Normalize admin API docs and install defaults
This commit is contained in:
parent
43ddf589a3
commit
48ee2628d4
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
QuickQuiz 是一个前后端分离题库系统。后端为 Laravel + MySQL + JWT + hg/apidoc 注解路由,前端为 Vue 3 + Vite + TypeScript + Element Plus + UnoCSS + Pinia。
|
QuickQuiz 是一个前后端分离题库系统。后端为 Laravel + MySQL + JWT + hg/apidoc 注解路由,前端为 Vue 3 + Vite + TypeScript + Element Plus + UnoCSS + Pinia。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- PHP 8.3+
|
- PHP 8.3+
|
||||||
- Composer 2+
|
- Composer 2+
|
||||||
@ -39,14 +39,14 @@ DB_PASSWORD=root
|
|||||||
创建数据库后执行:
|
创建数据库后执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan quickquiz:install --admin-email=admin@quickquiz.local --admin-password=password --fresh
|
php artisan quickquiz:install --fresh
|
||||||
php artisan serve
|
php artisan serve
|
||||||
```
|
```
|
||||||
|
|
||||||
默认管理员:
|
默认管理员:
|
||||||
|
|
||||||
- 邮箱:`admin@quickquiz.local`
|
- 邮箱:`admin@example.com`
|
||||||
- 密码:`password`
|
- 密码:`123456`
|
||||||
|
|
||||||
## Frontend Setup
|
## Frontend Setup
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,8 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
final class QuickQuizInstall extends Command
|
final class QuickQuizInstall extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'quickquiz:install
|
protected $signature = 'quickquiz:install
|
||||||
{--admin-email=admin@quickquiz.local : 首个管理员邮箱}
|
{--admin-email=admin@example.com : 首个管理员邮箱}
|
||||||
{--admin-password=password : 首个管理员密码}
|
{--admin-password=123456 : 首个管理员密码}
|
||||||
{--fresh : 使用 migrate:fresh 重建数据库}';
|
{--fresh : 使用 migrate:fresh 重建数据库}';
|
||||||
|
|
||||||
protected $description = 'Install QuickQuiz by running migrations, seeders, and creating the first administrator.';
|
protected $description = 'Install QuickQuiz by running migrations, seeders, and creating the first administrator.';
|
||||||
|
|||||||
@ -18,7 +18,7 @@ use Illuminate\Support\Str;
|
|||||||
final class ClassController extends Controller
|
final class ClassController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('班级列表')]
|
#[Apidoc\Title('班级列表')]
|
||||||
#[Apidoc\Url('/api/admin/classes')]
|
#[Apidoc\Url('/admin/classes')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@ -32,7 +32,7 @@ final class ClassController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建班级')]
|
#[Apidoc\Title('创建班级')]
|
||||||
#[Apidoc\Url('/api/admin/classes')]
|
#[Apidoc\Url('/admin/classes')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@ -52,7 +52,7 @@ final class ClassController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('分配成员')]
|
#[Apidoc\Title('分配成员')]
|
||||||
#[Apidoc\Url('/api/admin/classes/{class}/members')]
|
#[Apidoc\Url('/admin/classes/{class}/members')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
#[Apidoc\RouteMiddleware(['permission:classes'])]
|
||||||
public function addMember(Request $request, mixed $class): JsonResponse
|
public function addMember(Request $request, mixed $class): JsonResponse
|
||||||
|
|||||||
@ -17,7 +17,7 @@ use Illuminate\Http\Request;
|
|||||||
final class LogController extends Controller
|
final class LogController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('日志列表')]
|
#[Apidoc\Title('日志列表')]
|
||||||
#[Apidoc\Url('/api/admin/logs')]
|
#[Apidoc\Url('/admin/logs')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:logs'])]
|
#[Apidoc\RouteMiddleware(['permission:logs'])]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
|
|||||||
@ -23,7 +23,7 @@ final class PaperController extends Controller
|
|||||||
use AuthorizesOwnedResources;
|
use AuthorizesOwnedResources;
|
||||||
|
|
||||||
#[Apidoc\Title('试卷列表')]
|
#[Apidoc\Title('试卷列表')]
|
||||||
#[Apidoc\Url('/api/admin/papers')]
|
#[Apidoc\Url('/admin/papers')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@ -37,7 +37,7 @@ final class PaperController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('试卷详情')]
|
#[Apidoc\Title('试卷详情')]
|
||||||
#[Apidoc\Url('/api/admin/papers/{paper}')]
|
#[Apidoc\Url('/admin/papers/{paper}')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
||||||
public function show(Request $request, mixed $paper): JsonResponse
|
public function show(Request $request, mixed $paper): JsonResponse
|
||||||
@ -49,7 +49,7 @@ final class PaperController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建固定试卷')]
|
#[Apidoc\Title('创建固定试卷')]
|
||||||
#[Apidoc\Url('/api/admin/papers')]
|
#[Apidoc\Url('/admin/papers')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@ -87,7 +87,7 @@ final class PaperController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('更新固定试卷')]
|
#[Apidoc\Title('更新固定试卷')]
|
||||||
#[Apidoc\Url('/api/admin/papers/{paper}')]
|
#[Apidoc\Url('/admin/papers/{paper}')]
|
||||||
#[Apidoc\Method('PUT')]
|
#[Apidoc\Method('PUT')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
||||||
public function update(Request $request, mixed $paper): JsonResponse
|
public function update(Request $request, mixed $paper): JsonResponse
|
||||||
@ -118,7 +118,7 @@ final class PaperController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('删除固定试卷')]
|
#[Apidoc\Title('删除固定试卷')]
|
||||||
#[Apidoc\Url('/api/admin/papers/{paper}')]
|
#[Apidoc\Url('/admin/papers/{paper}')]
|
||||||
#[Apidoc\Method('DELETE')]
|
#[Apidoc\Method('DELETE')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
#[Apidoc\RouteMiddleware(['permission:papers'])]
|
||||||
public function destroy(Request $request, mixed $paper): JsonResponse
|
public function destroy(Request $request, mixed $paper): JsonResponse
|
||||||
|
|||||||
@ -19,7 +19,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
final class PermissionController extends Controller
|
final class PermissionController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('权限菜单列表')]
|
#[Apidoc\Title('权限菜单列表')]
|
||||||
#[Apidoc\Url('/api/admin/permissions')]
|
#[Apidoc\Url('/admin/permissions')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:permissions'])]
|
#[Apidoc\RouteMiddleware(['permission:permissions'])]
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
@ -36,7 +36,7 @@ final class PermissionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('保存角色权限')]
|
#[Apidoc\Title('保存角色权限')]
|
||||||
#[Apidoc\Url('/api/admin/roles/{role}/permissions')]
|
#[Apidoc\Url('/admin/roles/{role}/permissions')]
|
||||||
#[Apidoc\Method('PUT')]
|
#[Apidoc\Method('PUT')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:permissions'])]
|
#[Apidoc\RouteMiddleware(['permission:permissions'])]
|
||||||
public function syncRole(Request $request, string $role): JsonResponse
|
public function syncRole(Request $request, string $role): JsonResponse
|
||||||
|
|||||||
@ -24,7 +24,7 @@ final class QuestionBankController extends Controller
|
|||||||
use AuthorizesOwnedResources;
|
use AuthorizesOwnedResources;
|
||||||
|
|
||||||
#[Apidoc\Title('题库列表')]
|
#[Apidoc\Title('题库列表')]
|
||||||
#[Apidoc\Url('/api/admin/banks')]
|
#[Apidoc\Url('/admin/banks')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks'])]
|
#[Apidoc\RouteMiddleware(['permission:banks'])]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@ -38,7 +38,7 @@ final class QuestionBankController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建题库')]
|
#[Apidoc\Title('创建题库')]
|
||||||
#[Apidoc\Url('/api/admin/banks')]
|
#[Apidoc\Url('/admin/banks')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.create'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.create'])]
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@ -63,7 +63,7 @@ final class QuestionBankController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('更新题库')]
|
#[Apidoc\Title('更新题库')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}')]
|
#[Apidoc\Url('/admin/banks/{bank}')]
|
||||||
#[Apidoc\Method('PUT')]
|
#[Apidoc\Method('PUT')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
||||||
public function update(Request $request, mixed $bank): JsonResponse
|
public function update(Request $request, mixed $bank): JsonResponse
|
||||||
@ -83,7 +83,7 @@ final class QuestionBankController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('删除题库')]
|
#[Apidoc\Title('删除题库')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}')]
|
#[Apidoc\Url('/admin/banks/{bank}')]
|
||||||
#[Apidoc\Method('DELETE')]
|
#[Apidoc\Method('DELETE')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.delete'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.delete'])]
|
||||||
public function destroy(Request $request, mixed $bank): JsonResponse
|
public function destroy(Request $request, mixed $bank): JsonResponse
|
||||||
@ -103,7 +103,7 @@ final class QuestionBankController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('题库授权')]
|
#[Apidoc\Title('题库授权')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/shares')]
|
#[Apidoc\Url('/admin/banks/{bank}/shares')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.share'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.share'])]
|
||||||
public function share(Request $request, mixed $bank): JsonResponse
|
public function share(Request $request, mixed $bank): JsonResponse
|
||||||
@ -133,7 +133,7 @@ final class QuestionBankController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('题库导出')]
|
#[Apidoc\Title('题库导出')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/export')]
|
#[Apidoc\Url('/admin/banks/{bank}/export')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions.export'])]
|
#[Apidoc\RouteMiddleware(['permission:questions.export'])]
|
||||||
public function export(Request $request, mixed $bank): JsonResponse
|
public function export(Request $request, mixed $bank): JsonResponse
|
||||||
|
|||||||
@ -24,7 +24,7 @@ final class QuestionController extends Controller
|
|||||||
use AuthorizesOwnedResources;
|
use AuthorizesOwnedResources;
|
||||||
|
|
||||||
#[Apidoc\Title('题目列表')]
|
#[Apidoc\Title('题目列表')]
|
||||||
#[Apidoc\Url('/api/admin/questions')]
|
#[Apidoc\Url('/admin/questions')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions'])]
|
#[Apidoc\RouteMiddleware(['permission:questions'])]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@ -50,7 +50,7 @@ final class QuestionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建题目')]
|
#[Apidoc\Title('创建题目')]
|
||||||
#[Apidoc\Url('/api/admin/questions')]
|
#[Apidoc\Url('/admin/questions')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
||||||
public function store(Request $request, QuestionImportService $service): JsonResponse
|
public function store(Request $request, QuestionImportService $service): JsonResponse
|
||||||
@ -78,7 +78,7 @@ final class QuestionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('批量导入题目')]
|
#[Apidoc\Title('批量导入题目')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/imports')]
|
#[Apidoc\Url('/admin/banks/{bank}/imports')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
||||||
public function import(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
|
public function import(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
|
||||||
@ -103,7 +103,7 @@ final class QuestionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('校验导入题目')]
|
#[Apidoc\Title('校验导入题目')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/imports/validate')]
|
#[Apidoc\Url('/admin/banks/{bank}/imports/validate')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
||||||
public function validateImport(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
|
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'));
|
$prepared = $service->prepareUploadedFile($request->file('file'));
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
...$service->validateRows($prepared['rows']),
|
...$service->validateRows($prepared['rows'], $bank),
|
||||||
'type' => $prepared['type'],
|
'type' => $prepared['type'],
|
||||||
'file_path' => $prepared['path'],
|
'file_path' => $prepared['path'],
|
||||||
], '校验完成');
|
], '校验完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('提交已校验题目')]
|
#[Apidoc\Title('提交已校验题目')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/imports/rows')]
|
#[Apidoc\Url('/admin/banks/{bank}/imports/rows')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
||||||
public function importRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
|
public function importRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
|
||||||
@ -137,7 +137,7 @@ final class QuestionController extends Controller
|
|||||||
'file_path' => ['nullable', 'string'],
|
'file_path' => ['nullable', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$validation = $service->validateRows($data['rows']);
|
$validation = $service->validateRows($data['rows'], $bank);
|
||||||
if (! $validation['valid']) {
|
if (! $validation['valid']) {
|
||||||
return ApiResponse::success($validation, '校验未通过');
|
return ApiResponse::success($validation, '校验未通过');
|
||||||
}
|
}
|
||||||
@ -148,7 +148,7 @@ final class QuestionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('校验已编辑题目')]
|
#[Apidoc\Title('校验已编辑题目')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/imports/rows/validate')]
|
#[Apidoc\Url('/admin/banks/{bank}/imports/rows/validate')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
|
||||||
public function validateRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
|
public function validateRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
|
||||||
@ -159,7 +159,7 @@ final class QuestionController extends Controller
|
|||||||
'rows' => ['required', 'array'],
|
'rows' => ['required', 'array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return ApiResponse::success($service->validateRows($data['rows']), '校验完成');
|
return ApiResponse::success($service->validateRows($data['rows'], $bank), '校验完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveBank(mixed $bank): QuestionBank
|
private function resolveBank(mixed $bank): QuestionBank
|
||||||
@ -172,7 +172,7 @@ final class QuestionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('更新题目状态')]
|
#[Apidoc\Title('更新题目状态')]
|
||||||
#[Apidoc\Url('/api/admin/questions/{question}')]
|
#[Apidoc\Url('/admin/questions/{question}')]
|
||||||
#[Apidoc\Method('PUT')]
|
#[Apidoc\Method('PUT')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
||||||
public function update(Request $request, mixed $question): JsonResponse
|
public function update(Request $request, mixed $question): JsonResponse
|
||||||
@ -220,7 +220,7 @@ final class QuestionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('删除题目')]
|
#[Apidoc\Title('删除题目')]
|
||||||
#[Apidoc\Url('/api/admin/questions/{question}')]
|
#[Apidoc\Url('/admin/questions/{question}')]
|
||||||
#[Apidoc\Method('DELETE')]
|
#[Apidoc\Method('DELETE')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
||||||
public function destroy(Request $request, mixed $question): JsonResponse
|
public function destroy(Request $request, mixed $question): JsonResponse
|
||||||
|
|||||||
@ -23,7 +23,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
final class ReportController extends Controller
|
final class ReportController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('报表概览')]
|
#[Apidoc\Title('报表概览')]
|
||||||
#[Apidoc\Url('/api/admin/reports/overview')]
|
#[Apidoc\Url('/admin/reports/overview')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function overview(): JsonResponse
|
public function overview(): JsonResponse
|
||||||
@ -43,7 +43,7 @@ final class ReportController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('练习趋势')]
|
#[Apidoc\Title('练习趋势')]
|
||||||
#[Apidoc\Url('/api/admin/reports/trends')]
|
#[Apidoc\Url('/admin/reports/trends')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function trends(): JsonResponse
|
public function trends(): JsonResponse
|
||||||
@ -59,7 +59,7 @@ final class ReportController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('题目错误率')]
|
#[Apidoc\Title('题目错误率')]
|
||||||
#[Apidoc\Url('/api/admin/reports/question-errors')]
|
#[Apidoc\Url('/admin/reports/question-errors')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function questionErrors(Request $request): JsonResponse
|
public function questionErrors(Request $request): JsonResponse
|
||||||
@ -75,7 +75,7 @@ final class ReportController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('班级排行')]
|
#[Apidoc\Title('班级排行')]
|
||||||
#[Apidoc\Url('/api/admin/reports/class-ranking')]
|
#[Apidoc\Url('/admin/reports/class-ranking')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function classRanking(Request $request): JsonResponse
|
public function classRanking(Request $request): JsonResponse
|
||||||
@ -107,7 +107,7 @@ final class ReportController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('题库和分类掌握度')]
|
#[Apidoc\Title('题库和分类掌握度')]
|
||||||
#[Apidoc\Url('/api/admin/reports/mastery')]
|
#[Apidoc\Url('/admin/reports/mastery')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function mastery(Request $request): JsonResponse
|
public function mastery(Request $request): JsonResponse
|
||||||
@ -143,7 +143,7 @@ final class ReportController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('报表导出')]
|
#[Apidoc\Title('报表导出')]
|
||||||
#[Apidoc\Url('/api/admin/reports/export')]
|
#[Apidoc\Url('/admin/reports/export')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
#[Apidoc\RouteMiddleware(['permission:reports'])]
|
||||||
public function export(Request $request): JsonResponse
|
public function export(Request $request): JsonResponse
|
||||||
|
|||||||
@ -18,7 +18,7 @@ use Illuminate\Http\Request;
|
|||||||
final class SettingController extends Controller
|
final class SettingController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('配置列表')]
|
#[Apidoc\Title('配置列表')]
|
||||||
#[Apidoc\Url('/api/admin/settings')]
|
#[Apidoc\Url('/admin/settings')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:settings'])]
|
#[Apidoc\RouteMiddleware(['permission:settings'])]
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
@ -27,7 +27,7 @@ final class SettingController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('保存配置')]
|
#[Apidoc\Title('保存配置')]
|
||||||
#[Apidoc\Url('/api/admin/settings')]
|
#[Apidoc\Url('/admin/settings')]
|
||||||
#[Apidoc\Method('PUT')]
|
#[Apidoc\Method('PUT')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:settings'])]
|
#[Apidoc\RouteMiddleware(['permission:settings'])]
|
||||||
public function update(Request $request): JsonResponse
|
public function update(Request $request): JsonResponse
|
||||||
|
|||||||
@ -22,7 +22,7 @@ final class TaxonomyController extends Controller
|
|||||||
use AuthorizesOwnedResources;
|
use AuthorizesOwnedResources;
|
||||||
|
|
||||||
#[Apidoc\Title('分类列表')]
|
#[Apidoc\Title('分类列表')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/categories')]
|
#[Apidoc\Url('/admin/banks/{bank}/categories')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions'])]
|
#[Apidoc\RouteMiddleware(['permission:questions'])]
|
||||||
public function categories(Request $request, mixed $bank): JsonResponse
|
public function categories(Request $request, mixed $bank): JsonResponse
|
||||||
@ -34,7 +34,7 @@ final class TaxonomyController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建分类')]
|
#[Apidoc\Title('创建分类')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/categories')]
|
#[Apidoc\Url('/admin/banks/{bank}/categories')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
||||||
public function createCategory(Request $request, mixed $bank): JsonResponse
|
public function createCategory(Request $request, mixed $bank): JsonResponse
|
||||||
@ -54,7 +54,7 @@ final class TaxonomyController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('标签列表')]
|
#[Apidoc\Title('标签列表')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/tags')]
|
#[Apidoc\Url('/admin/banks/{bank}/tags')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:questions'])]
|
#[Apidoc\RouteMiddleware(['permission:questions'])]
|
||||||
public function tags(Request $request, mixed $bank): JsonResponse
|
public function tags(Request $request, mixed $bank): JsonResponse
|
||||||
@ -66,7 +66,7 @@ final class TaxonomyController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建标签')]
|
#[Apidoc\Title('创建标签')]
|
||||||
#[Apidoc\Url('/api/admin/banks/{bank}/tags')]
|
#[Apidoc\Url('/admin/banks/{bank}/tags')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
|
||||||
public function createTag(Request $request, mixed $bank): JsonResponse
|
public function createTag(Request $request, mixed $bank): JsonResponse
|
||||||
|
|||||||
@ -20,7 +20,7 @@ use Illuminate\Support\Str;
|
|||||||
final class UserController extends Controller
|
final class UserController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('用户列表')]
|
#[Apidoc\Title('用户列表')]
|
||||||
#[Apidoc\Url('/api/admin/users')]
|
#[Apidoc\Url('/admin/users')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:users'])]
|
#[Apidoc\RouteMiddleware(['permission:users'])]
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@ -37,7 +37,7 @@ final class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建用户')]
|
#[Apidoc\Title('创建用户')]
|
||||||
#[Apidoc\Url('/api/admin/users')]
|
#[Apidoc\Url('/admin/users')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:users.create'])]
|
#[Apidoc\RouteMiddleware(['permission:users.create'])]
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@ -55,7 +55,7 @@ final class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('更新用户')]
|
#[Apidoc\Title('更新用户')]
|
||||||
#[Apidoc\Url('/api/admin/users/{user}')]
|
#[Apidoc\Url('/admin/users/{user}')]
|
||||||
#[Apidoc\Method('PUT')]
|
#[Apidoc\Method('PUT')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:users.update'])]
|
#[Apidoc\RouteMiddleware(['permission:users.update'])]
|
||||||
public function update(Request $request, mixed $user): JsonResponse
|
public function update(Request $request, mixed $user): JsonResponse
|
||||||
@ -89,7 +89,7 @@ final class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('邀请码列表')]
|
#[Apidoc\Title('邀请码列表')]
|
||||||
#[Apidoc\Url('/api/admin/invite-codes')]
|
#[Apidoc\Url('/admin/invite-codes')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:users'])]
|
#[Apidoc\RouteMiddleware(['permission:users'])]
|
||||||
public function invites(Request $request): JsonResponse
|
public function invites(Request $request): JsonResponse
|
||||||
@ -98,16 +98,24 @@ final class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('创建邀请码')]
|
#[Apidoc\Title('创建邀请码')]
|
||||||
#[Apidoc\Url('/api/admin/invite-codes')]
|
#[Apidoc\Url('/admin/invite-codes')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['permission:users.create'])]
|
#[Apidoc\RouteMiddleware(['permission:users.create'])]
|
||||||
public function createInvite(Request $request): JsonResponse
|
public function createInvite(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'role' => ['required', 'in:teacher,user'],
|
'role' => ['required', 'in:teacher,user'],
|
||||||
|
'assigned_name' => ['nullable', 'string', 'max:50'],
|
||||||
'max_uses' => ['required', 'integer', 'min:1', 'max:10000'],
|
'max_uses' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||||
'expires_at' => ['nullable', 'date'],
|
'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 + [
|
$invite = InviteCode::create($data + [
|
||||||
'created_by' => $request->user()->id,
|
'created_by' => $request->user()->id,
|
||||||
|
|||||||
@ -19,7 +19,7 @@ use Illuminate\Http\Request;
|
|||||||
final class ClassroomController extends Controller
|
final class ClassroomController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('我的班级')]
|
#[Apidoc\Title('我的班级')]
|
||||||
#[Apidoc\Url('/api/app/classes')]
|
#[Apidoc\Url('/app/classes')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
public function myClasses(Request $request): JsonResponse
|
public function myClasses(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@ -32,7 +32,7 @@ final class ClassroomController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('通过班级码加入')]
|
#[Apidoc\Title('通过班级码加入')]
|
||||||
#[Apidoc\Url('/api/app/classes/join')]
|
#[Apidoc\Url('/app/classes/join')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function join(Request $request): JsonResponse
|
public function join(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@ -44,7 +44,7 @@ final class ClassroomController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('可学习资源')]
|
#[Apidoc\Title('可学习资源')]
|
||||||
#[Apidoc\Url('/api/app/resources')]
|
#[Apidoc\Url('/app/resources')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
public function resources(Request $request, LearningAccessService $access): JsonResponse
|
public function resources(Request $request, LearningAccessService $access): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@ -25,7 +25,7 @@ use Tymon\JWTAuth\Facades\JWTAuth;
|
|||||||
final class QuizController extends Controller
|
final class QuizController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('开始背题/刷题/抽题')]
|
#[Apidoc\Title('开始背题/刷题/抽题')]
|
||||||
#[Apidoc\Url('/api/app/banks/{bank}/attempts')]
|
#[Apidoc\Url('/app/banks/{bank}/attempts')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function startBank(Request $request, mixed $bank, QuizService $service, LearningAccessService $access): JsonResponse
|
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), '已开始');
|
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\Title('开始整卷测试')]
|
||||||
#[Apidoc\Url('/api/app/papers/{paper}/attempts')]
|
#[Apidoc\Url('/app/papers/{paper}/attempts')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function startPaper(Request $request, mixed $paper, QuizService $service, LearningAccessService $access): JsonResponse
|
public function startPaper(Request $request, mixed $paper, QuizService $service, LearningAccessService $access): JsonResponse
|
||||||
{
|
{
|
||||||
@ -57,7 +68,7 @@ final class QuizController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('继续作答')]
|
#[Apidoc\Title('继续作答')]
|
||||||
#[Apidoc\Url('/api/app/attempts/{attempt}')]
|
#[Apidoc\Url('/app/attempts/{attempt}')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
public function show(Request $request, mixed $attempt): JsonResponse
|
public function show(Request $request, mixed $attempt): JsonResponse
|
||||||
{
|
{
|
||||||
@ -68,7 +79,7 @@ final class QuizController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('提交单题答案')]
|
#[Apidoc\Title('提交单题答案')]
|
||||||
#[Apidoc\Url('/api/app/attempts/{attempt}/answer')]
|
#[Apidoc\Url('/app/attempts/{attempt}/answer')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function answer(Request $request, mixed $attempt, QuizService $service): JsonResponse
|
public function answer(Request $request, mixed $attempt, QuizService $service): JsonResponse
|
||||||
{
|
{
|
||||||
@ -86,7 +97,7 @@ final class QuizController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('保存作答位置')]
|
#[Apidoc\Title('保存作答位置')]
|
||||||
#[Apidoc\Url('/api/app/attempts/{attempt}/position')]
|
#[Apidoc\Url('/app/attempts/{attempt}/position')]
|
||||||
#[Apidoc\Method('PUT')]
|
#[Apidoc\Method('PUT')]
|
||||||
public function updatePosition(Request $request, mixed $attempt): JsonResponse
|
public function updatePosition(Request $request, mixed $attempt): JsonResponse
|
||||||
{
|
{
|
||||||
@ -107,7 +118,7 @@ final class QuizController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('交卷')]
|
#[Apidoc\Title('交卷')]
|
||||||
#[Apidoc\Url('/api/app/attempts/{attempt}/submit')]
|
#[Apidoc\Url('/app/attempts/{attempt}/submit')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function submit(Request $request, mixed $attempt, QuizService $service): JsonResponse
|
public function submit(Request $request, mixed $attempt, QuizService $service): JsonResponse
|
||||||
{
|
{
|
||||||
@ -117,7 +128,7 @@ final class QuizController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('错题列表')]
|
#[Apidoc\Title('错题列表')]
|
||||||
#[Apidoc\Url('/api/app/wrong-questions')]
|
#[Apidoc\Url('/app/wrong-questions')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
public function wrongQuestions(Request $request): JsonResponse
|
public function wrongQuestions(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@ -136,7 +147,7 @@ final class QuizController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('收藏和笔记')]
|
#[Apidoc\Title('收藏和笔记')]
|
||||||
#[Apidoc\Url('/api/app/favorites')]
|
#[Apidoc\Url('/app/favorites')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function favorite(Request $request): JsonResponse
|
public function favorite(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@ -13,10 +13,7 @@ use hg\apidoc\annotation as Apidoc;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Tymon\JWTAuth\Facades\JWTAuth;
|
use Tymon\JWTAuth\Facades\JWTAuth;
|
||||||
|
|
||||||
@ -25,7 +22,7 @@ use Tymon\JWTAuth\Facades\JWTAuth;
|
|||||||
final class AuthController extends Controller
|
final class AuthController extends Controller
|
||||||
{
|
{
|
||||||
#[Apidoc\Title('邀请码注册')]
|
#[Apidoc\Title('邀请码注册')]
|
||||||
#[Apidoc\Url('/api/auth/register')]
|
#[Apidoc\Url('/auth/register')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function register(Request $request): JsonResponse
|
public function register(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@ -40,6 +37,9 @@ final class AuthController extends Controller
|
|||||||
if (! $invite || ! $invite->available()) {
|
if (! $invite || ! $invite->available()) {
|
||||||
throw ValidationException::withMessages(['invite_code' => '邀请码无效']);
|
throw ValidationException::withMessages(['invite_code' => '邀请码无效']);
|
||||||
}
|
}
|
||||||
|
if ($invite->assigned_name !== null && trim($data['name']) !== $invite->assigned_name) {
|
||||||
|
throw ValidationException::withMessages(['name' => '姓名与邀请码指定使用人不一致']);
|
||||||
|
}
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
@ -55,7 +55,7 @@ final class AuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('验证码')]
|
#[Apidoc\Title('验证码')]
|
||||||
#[Apidoc\Url('/api/auth/captcha')]
|
#[Apidoc\Url('/auth/captcha')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
public function captcha(Request $request): JsonResponse
|
public function captcha(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@ -69,7 +69,7 @@ final class AuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('登录')]
|
#[Apidoc\Title('登录')]
|
||||||
#[Apidoc\Url('/api/auth/login')]
|
#[Apidoc\Url('/auth/login')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@ -120,7 +120,7 @@ final class AuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('刷新Token')]
|
#[Apidoc\Title('刷新Token')]
|
||||||
#[Apidoc\Url('/api/auth/refresh')]
|
#[Apidoc\Url('/auth/refresh')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
public function refresh(): JsonResponse
|
public function refresh(): JsonResponse
|
||||||
{
|
{
|
||||||
@ -132,7 +132,7 @@ final class AuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('当前用户')]
|
#[Apidoc\Title('当前用户')]
|
||||||
#[Apidoc\Url('/api/auth/me')]
|
#[Apidoc\Url('/auth/me')]
|
||||||
#[Apidoc\Method('GET')]
|
#[Apidoc\Method('GET')]
|
||||||
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
||||||
public function me(Request $request): JsonResponse
|
public function me(Request $request): JsonResponse
|
||||||
@ -141,7 +141,7 @@ final class AuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Apidoc\Title('退出登录')]
|
#[Apidoc\Title('退出登录')]
|
||||||
#[Apidoc\Url('/api/auth/logout')]
|
#[Apidoc\Url('/auth/logout')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
#[Apidoc\RouteMiddleware(['jwt.auth'])]
|
||||||
public function logout(): JsonResponse
|
public function logout(): JsonResponse
|
||||||
@ -151,60 +151,6 @@ final class AuthController extends Controller
|
|||||||
return ApiResponse::success(null, '已退出');
|
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
|
private function tokenPayload(User $user): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Support\ApiResponse;
|
|
||||||
use hg\apidoc\annotation as Apidoc;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
#[Apidoc\Group('安装')]
|
|
||||||
#[Apidoc\Title('安装向导')]
|
|
||||||
final class InstallController extends Controller
|
|
||||||
{
|
|
||||||
#[Apidoc\Title('安装状态')]
|
|
||||||
#[Apidoc\Url('/api/install/status')]
|
|
||||||
#[Apidoc\Method('GET')]
|
|
||||||
public function status(): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiResponse::success([
|
|
||||||
'installed' => 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,
|
|
||||||
], '安装完成');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
|
|
||||||
final class InviteCode extends 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 = [
|
protected $casts = [
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
|
|||||||
@ -68,14 +68,38 @@ final class QuestionImportService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array<string, mixed>> $rows
|
* @param array<int, array<string, mixed>> $rows
|
||||||
* @return array{valid:bool,rows:array<int, array<string, mixed>>,errors:array<int, array{row:int,message:string}>}
|
* @return array{valid:bool,rows:array<int, array<string, mixed>>,errors:array<int, array{row:int,message:string}>,duplicates:array<int, array{row:int,message:string,content:string,type:string}>,importable_count:int}
|
||||||
*/
|
*/
|
||||||
public function validateRows(array $rows): array
|
public function validateRows(array $rows, ?QuestionBank $bank = null): array
|
||||||
{
|
{
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
$duplicates = [];
|
||||||
|
$seenHashes = [];
|
||||||
foreach ($rows as $index => $row) {
|
foreach ($rows as $index => $row) {
|
||||||
try {
|
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) {
|
} catch (ValidationException $exception) {
|
||||||
$errors[] = [
|
$errors[] = [
|
||||||
'row' => $index + 1,
|
'row' => $index + 1,
|
||||||
@ -88,6 +112,8 @@ final class QuestionImportService
|
|||||||
'valid' => $errors === [],
|
'valid' => $errors === [],
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
|
'duplicates' => $duplicates,
|
||||||
|
'importable_count' => max(0, count($rows) - count($errors) - count($duplicates)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,19 +136,36 @@ final class QuestionImportService
|
|||||||
$report = [];
|
$report = [];
|
||||||
$success = 0;
|
$success = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
|
$seenHashes = [];
|
||||||
|
|
||||||
foreach ($rows as $index => $row) {
|
foreach ($rows as $index => $row) {
|
||||||
$normalized = $this->normalizeQuestionRow($row, $index + 1);
|
$normalized = $this->normalizeQuestionRow($row, $index + 1);
|
||||||
$hash = $this->dedupHash($normalized['content'], $normalized['options']);
|
$hash = $this->dedupHash($normalized['content'], $normalized['options']);
|
||||||
|
|
||||||
$exists = Question::query()
|
if (isset($seenHashes[$hash])) {
|
||||||
->where('question_bank_id', $bank->id)
|
|
||||||
->where('dedup_hash', $hash)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
$skipped++;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
@ -149,7 +192,13 @@ final class QuestionImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$success++;
|
$success++;
|
||||||
$report[] = ['row' => $index + 1, 'status' => 'success', 'message' => '导入成功'];
|
$report[] = [
|
||||||
|
'row' => $index + 1,
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => '导入成功',
|
||||||
|
'content' => $normalized['content'],
|
||||||
|
'type' => $normalized['type'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$job->update([
|
$job->update([
|
||||||
@ -288,6 +337,17 @@ final class QuestionImportService
|
|||||||
], JSON_UNESCAPED_UNICODE));
|
], JSON_UNESCAPED_UNICODE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{content:string,options:array<int, array{text:string,correct:bool}>} $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<int, array<int, mixed>> $rows
|
* @param array<int, array<int, mixed>> $rows
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
|
apiPrefix: '',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
@ -22,6 +23,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
fn (Request $request) => $request->is('api/*'),
|
fn (Request $request) => $request->is('auth/*', 'admin/*', 'app/*', 'health'),
|
||||||
);
|
);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@ -105,7 +105,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'ttl' => env('JWT_TTL', 60),
|
'ttl' => env('JWT_TTL', 1440),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@ -130,6 +130,7 @@ return new class extends Migration
|
|||||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->string('code')->unique();
|
$table->string('code')->unique();
|
||||||
$table->string('role')->default('user');
|
$table->string('role')->default('user');
|
||||||
|
$table->string('assigned_name', 50)->nullable();
|
||||||
$table->unsignedInteger('max_uses')->default(1);
|
$table->unsignedInteger('max_uses')->default(1);
|
||||||
$table->unsignedInteger('used_count')->default(0);
|
$table->unsignedInteger('used_count')->default(0);
|
||||||
$table->timestamp('expires_at')->nullable();
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('invite_codes', 'assigned_name')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('invite_codes', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -19,12 +19,12 @@ class DatabaseSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$admin = User::query()->firstOrCreate(
|
$admin = User::query()->firstOrCreate(
|
||||||
['email' => 'admin@quickquiz.local'],
|
['email' => 'admin@example.com'],
|
||||||
[
|
[
|
||||||
'name' => '系统管理员',
|
'name' => '系统管理员',
|
||||||
'role' => 'admin',
|
'role' => 'admin',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('123456'),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>QuickQuiz</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -50,6 +50,8 @@ export function validateQuestionImport(bankId: number, file: File) {
|
|||||||
valid: boolean
|
valid: boolean
|
||||||
rows: Array<Record<string, unknown>>
|
rows: Array<Record<string, unknown>>
|
||||||
errors: Array<{ row: number; message: string }>
|
errors: Array<{ row: number; message: string }>
|
||||||
|
duplicates: Array<{ row: number; message: string; content: string; type: string }>
|
||||||
|
importable_count: number
|
||||||
type: string
|
type: string
|
||||||
file_path: string
|
file_path: string
|
||||||
}>(`/api/admin/banks/${bankId}/imports/validate`, form)
|
}>(`/api/admin/banks/${bankId}/imports/validate`, form)
|
||||||
@ -70,6 +72,8 @@ export function validateQuestionRows(bankId: number, payload: {
|
|||||||
valid: boolean
|
valid: boolean
|
||||||
rows: Array<Record<string, unknown>>
|
rows: Array<Record<string, unknown>>
|
||||||
errors: Array<{ row: number; message: string }>
|
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)
|
}>(`/api/admin/banks/${bankId}/imports/rows/validate`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +166,7 @@ export function fetchInvites(params?: Record<string, unknown>) {
|
|||||||
return apiGet<PageData<Record<string, unknown>>>('/api/admin/invite-codes', params)
|
return apiGet<PageData<Record<string, unknown>>>('/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)
|
return apiPost('/api/admin/invite-codes', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,24 +35,3 @@ export function me() {
|
|||||||
export function captcha() {
|
export function captcha() {
|
||||||
return apiGet<{ captcha: string; expires_in: number }>('/api/auth/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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -19,8 +19,12 @@ http.interceptors.response.use(
|
|||||||
(response) => response.data,
|
(response) => response.data,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
if (error.response?.status === 401 && auth.token) {
|
if (error.response?.status === 401) {
|
||||||
auth.clearSession()
|
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)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,6 +5,10 @@ export function fetchResources() {
|
|||||||
return apiGet('/api/app/resources')
|
return apiGet('/api/app/resources')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchBankTags(bankId: number) {
|
||||||
|
return apiGet<Array<{ id: number; name: string }>>(`/api/app/banks/${bankId}/tags`)
|
||||||
|
}
|
||||||
|
|
||||||
export function startBankAttempt(bankId: number, payload: Record<string, unknown>) {
|
export function startBankAttempt(bankId: number, payload: Record<string, unknown>) {
|
||||||
return apiPost<QuizAttempt>(`/api/app/banks/${bankId}/attempts`, payload)
|
return apiPost<QuizAttempt>(`/api/app/banks/${bankId}/attempts`, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, shallowRef } from 'vue'
|
import { computed, shallowRef } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Collection, DataAnalysis, Files, House, Key, Menu, Notebook, OfficeBuilding, PriceTag, Setting, Tickets, User } from '@element-plus/icons-vue'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { Collection, DataAnalysis, Files, House, Key, Menu, Notebook, OfficeBuilding, PriceTag, Setting, SwitchButton, Tickets, User } from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -24,6 +25,20 @@ const menus = [
|
|||||||
{ path: '/admin/logs', label: '操作日志', icon: Tickets, permission: 'logs' },
|
{ path: '/admin/logs', label: '操作日志', icon: Tickets, permission: 'logs' },
|
||||||
]
|
]
|
||||||
const visibleMenus = computed(() => menus.filter((item) => auth.can(item.permission)))
|
const visibleMenus = computed(() => menus.filter((item) => auth.can(item.permission)))
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '退出',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.clearSession()
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -48,6 +63,7 @@ const visibleMenus = computed(() => menus.filter((item) => auth.can(item.permiss
|
|||||||
<p class="muted text-sm">管理题库、导入题目、查看学习数据</p>
|
<p class="muted text-sm">管理题库、导入题目、查看学习数据</p>
|
||||||
</div>
|
</div>
|
||||||
<ElButton type="primary" plain @click="router.push('/quiz')">进入学习端</ElButton>
|
<ElButton type="primary" plain @click="router.push('/quiz')">进入学习端</ElButton>
|
||||||
|
<ElButton :icon="SwitchButton" plain @click="logout">退出登录</ElButton>
|
||||||
</header>
|
</header>
|
||||||
<section class="admin-content">
|
<section class="admin-content">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|||||||
@ -1,8 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Collection, Setting } from '@element-plus/icons-vue'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { Collection, Setting, SwitchButton } from '@element-plus/icons-vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '退出',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.clearSession()
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -12,7 +29,10 @@ const router = useRouter()
|
|||||||
<Collection class="w-5 h-5" />
|
<Collection class="w-5 h-5" />
|
||||||
<span>QuickQuiz</span>
|
<span>QuickQuiz</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="quiz-actions">
|
||||||
<ElButton :icon="Setting" circle @click="router.push('/admin')" />
|
<ElButton :icon="Setting" circle @click="router.push('/admin')" />
|
||||||
|
<ElButton :icon="SwitchButton" circle @click="logout" />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
@ -47,4 +67,10 @@ const router = useRouter()
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quiz-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/login', component: () => import('@/views/LoginView.vue') },
|
{ path: '/login', component: () => import('@/views/LoginView.vue') },
|
||||||
{ path: '/register', component: () => import('@/views/RegisterView.vue') },
|
{ path: '/register', component: () => import('@/views/RegisterView.vue') },
|
||||||
{ path: '/install', component: () => import('@/views/InstallView.vue') },
|
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
component: () => import('@/layouts/AdminLayout.vue'),
|
component: () => import('@/layouts/AdminLayout.vue'),
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { reactive, shallowRef } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { runInstall } from '@/api/auth'
|
|
||||||
|
|
||||||
const loading = shallowRef(false)
|
|
||||||
const form = reactive({
|
|
||||||
admin_email: 'admin@quickquiz.local',
|
|
||||||
admin_password: 'password',
|
|
||||||
fresh: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await runInstall(form)
|
|
||||||
ElMessage.success('安装完成')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main class="install-page">
|
|
||||||
<section class="install-panel">
|
|
||||||
<h1>QuickQuiz 安装向导</h1>
|
|
||||||
<p>配置首个管理员并执行迁移与种子数据。数据库连接使用当前 `.env`。</p>
|
|
||||||
<ElForm :model="form" label-position="top">
|
|
||||||
<ElFormItem label="管理员邮箱">
|
|
||||||
<ElInput v-model="form.admin_email" />
|
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem label="管理员密码">
|
|
||||||
<ElInput v-model="form.admin_password" type="password" show-password />
|
|
||||||
</ElFormItem>
|
|
||||||
<ElCheckbox v-model="form.fresh">重建数据库</ElCheckbox>
|
|
||||||
<ElButton class="w-full mt-4" type="primary" :loading="loading" @click="submit">开始安装</ElButton>
|
|
||||||
</ElForm>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.install-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.install-panel {
|
|
||||||
width: min(520px, 100%);
|
|
||||||
padding: 28px;
|
|
||||||
border: 1px solid var(--qq-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.install-panel h1 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.install-panel p {
|
|
||||||
margin: 0 0 20px;
|
|
||||||
color: var(--qq-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -3,7 +3,7 @@ import { reactive, shallowRef } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { captcha, forgotPassword } from '@/api/auth'
|
import { captcha } from '@/api/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@ -11,8 +11,8 @@ const loading = shallowRef(false)
|
|||||||
const captchaText = shallowRef('')
|
const captchaText = shallowRef('')
|
||||||
const captchaRequired = shallowRef(false)
|
const captchaRequired = shallowRef(false)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: 'admin@quickquiz.local',
|
email: '',
|
||||||
password: 'password',
|
password: '',
|
||||||
captcha: '',
|
captcha: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -38,10 +38,6 @@ async function loadCaptcha() {
|
|||||||
captchaText.value = response.data.captcha
|
captchaText.value = response.data.captcha
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleForgotPassword() {
|
|
||||||
const response = await forgotPassword(form.email)
|
|
||||||
ElMessage.success(response.data.token ? `重置 token:${response.data.token}` : '重置邮件已发送')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -68,8 +64,6 @@ async function handleForgotPassword() {
|
|||||||
<ElButton type="primary" :loading="loading" class="w-full" @click="submit">登录</ElButton>
|
<ElButton type="primary" :loading="loading" class="w-full" @click="submit">登录</ElButton>
|
||||||
<div class="login-links">
|
<div class="login-links">
|
||||||
<RouterLink to="/register">邀请码注册</RouterLink>
|
<RouterLink to="/register">邀请码注册</RouterLink>
|
||||||
<button type="button" @click="handleForgotPassword">忘记密码</button>
|
|
||||||
<RouterLink to="/install">安装向导</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, shallowRef } from 'vue'
|
import { reactive, shallowRef } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { register } from '@/api/auth'
|
import { register } from '@/api/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = shallowRef(false)
|
const loading = shallowRef(false)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@ -11,7 +12,7 @@ const form = reactive({
|
|||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
password_confirmation: '',
|
password_confirmation: '',
|
||||||
invite_code: '',
|
invite_code: String(route.query.invite_code ?? route.query.code ?? route.query.invite ?? ''),
|
||||||
})
|
})
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@ -20,6 +21,10 @@ async function submit() {
|
|||||||
await register(form)
|
await register(form)
|
||||||
ElMessage.success('注册成功,请登录')
|
ElMessage.success('注册成功,请登录')
|
||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
|
} catch (error: any) {
|
||||||
|
const errors = error.response?.data?.errors
|
||||||
|
const firstError = errors ? Object.values(errors).flat().find(Boolean) : null
|
||||||
|
ElMessage.error(String(firstError ?? error.response?.data?.message ?? '注册失败,请检查填写信息'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,14 @@ import {
|
|||||||
} from '@/api/admin'
|
} from '@/api/admin'
|
||||||
import type { QuestionBank, SchoolClass, User } from '@/types/api'
|
import type { QuestionBank, SchoolClass, User } from '@/types/api'
|
||||||
|
|
||||||
|
type ImportReportRow = {
|
||||||
|
row?: number
|
||||||
|
status?: string
|
||||||
|
message?: string
|
||||||
|
content?: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
const loading = shallowRef(false)
|
const loading = shallowRef(false)
|
||||||
const dialogVisible = shallowRef(false)
|
const dialogVisible = shallowRef(false)
|
||||||
const uploadVisible = shallowRef(false)
|
const uploadVisible = shallowRef(false)
|
||||||
@ -30,8 +38,11 @@ const shareUserIds = shallowRef<number[]>([])
|
|||||||
const shareClassIds = shallowRef<number[]>([])
|
const shareClassIds = shallowRef<number[]>([])
|
||||||
const uploadFiles = shallowRef<UploadUserFile[]>([])
|
const uploadFiles = shallowRef<UploadUserFile[]>([])
|
||||||
const uploading = shallowRef(false)
|
const uploading = shallowRef(false)
|
||||||
|
const parsed = shallowRef(false)
|
||||||
const importRows = shallowRef<Array<Record<string, unknown>>>([])
|
const importRows = shallowRef<Array<Record<string, unknown>>>([])
|
||||||
const importErrors = shallowRef<Array<{ row: number; message: string }>>([])
|
const importErrors = shallowRef<Array<{ row: number; message: string }>>([])
|
||||||
|
const importDuplicates = shallowRef<ImportReportRow[]>([])
|
||||||
|
const importableCount = shallowRef(0)
|
||||||
const importType = shallowRef('')
|
const importType = shallowRef('')
|
||||||
const importFilePath = shallowRef('')
|
const importFilePath = shallowRef('')
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@ -47,6 +58,13 @@ const visibilityLabels: Record<string, string> = {
|
|||||||
public: '公开',
|
public: '公开',
|
||||||
assigned: '指定用户/班级',
|
assigned: '指定用户/班级',
|
||||||
}
|
}
|
||||||
|
const questionTypeLabels: Record<string, string> = {
|
||||||
|
single: '单选',
|
||||||
|
multiple: '多选',
|
||||||
|
judge: '判断',
|
||||||
|
blank: '填空',
|
||||||
|
}
|
||||||
|
const hasImportReport = computed(() => importDuplicates.value.length > 0 || importErrors.value.length > 0)
|
||||||
|
|
||||||
async function loadBanks() {
|
async function loadBanks() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -111,12 +129,15 @@ async function handleUploadChange(file: UploadFile) {
|
|||||||
const response = await validateQuestionImport(selectedBank.value.id, file.raw)
|
const response = await validateQuestionImport(selectedBank.value.id, file.raw)
|
||||||
importRows.value = response.data.rows
|
importRows.value = response.data.rows
|
||||||
importErrors.value = response.data.errors
|
importErrors.value = response.data.errors
|
||||||
|
importDuplicates.value = response.data.duplicates
|
||||||
|
importableCount.value = response.data.importable_count
|
||||||
importType.value = response.data.type
|
importType.value = response.data.type
|
||||||
importFilePath.value = response.data.file_path
|
importFilePath.value = response.data.file_path
|
||||||
|
parsed.value = true
|
||||||
if (response.data.valid) {
|
if (response.data.valid) {
|
||||||
await commitImportRows()
|
ElMessage.success('解析完成,请确认无误后再导入')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning('导入格式需要修正,可编辑错误行后继续')
|
ElMessage.warning('解析完成,存在需要修正的题目')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
@ -152,8 +173,11 @@ function openUpload(row: QuestionBank) {
|
|||||||
uploadFiles.value = []
|
uploadFiles.value = []
|
||||||
importRows.value = []
|
importRows.value = []
|
||||||
importErrors.value = []
|
importErrors.value = []
|
||||||
|
importDuplicates.value = []
|
||||||
|
importableCount.value = 0
|
||||||
importType.value = ''
|
importType.value = ''
|
||||||
importFilePath.value = ''
|
importFilePath.value = ''
|
||||||
|
parsed.value = false
|
||||||
uploadVisible.value = true
|
uploadVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,8 +186,11 @@ function abandonImport() {
|
|||||||
uploadFiles.value = []
|
uploadFiles.value = []
|
||||||
importRows.value = []
|
importRows.value = []
|
||||||
importErrors.value = []
|
importErrors.value = []
|
||||||
|
importDuplicates.value = []
|
||||||
|
importableCount.value = 0
|
||||||
importType.value = ''
|
importType.value = ''
|
||||||
importFilePath.value = ''
|
importFilePath.value = ''
|
||||||
|
parsed.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowText(row: Record<string, unknown> | undefined) {
|
function rowText(row: Record<string, unknown> | undefined) {
|
||||||
@ -184,6 +211,8 @@ async function commitImportRows() {
|
|||||||
try {
|
try {
|
||||||
const validation = await validateQuestionRows(selectedBank.value.id, { rows: importRows.value })
|
const validation = await validateQuestionRows(selectedBank.value.id, { rows: importRows.value })
|
||||||
importErrors.value = validation.data.errors
|
importErrors.value = validation.data.errors
|
||||||
|
importDuplicates.value = validation.data.duplicates
|
||||||
|
importableCount.value = validation.data.importable_count
|
||||||
importRows.value = validation.data.rows
|
importRows.value = validation.data.rows
|
||||||
if (!validation.data.valid) {
|
if (!validation.data.valid) {
|
||||||
ElMessage.warning('仍有题目未通过校验,请修正后再导入')
|
ElMessage.warning('仍有题目未通过校验,请修正后再导入')
|
||||||
@ -201,12 +230,56 @@ async function commitImportRows() {
|
|||||||
uploadFiles.value = []
|
uploadFiles.value = []
|
||||||
importRows.value = []
|
importRows.value = []
|
||||||
importErrors.value = []
|
importErrors.value = []
|
||||||
|
importDuplicates.value = []
|
||||||
|
importableCount.value = 0
|
||||||
|
parsed.value = false
|
||||||
await loadBanks()
|
await loadBanks()
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function importRowContent(row: Record<string, unknown> | undefined) {
|
||||||
|
const value = row?.content ?? row?.questionText ?? row?.['题干'] ?? row?.title ?? ''
|
||||||
|
return String(value ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function importRowType(row: Record<string, unknown> | undefined) {
|
||||||
|
const value = row?.type ?? row?.questionType ?? row?.['题型'] ?? ''
|
||||||
|
return String(value ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadImportReport() {
|
||||||
|
const header = ['行号', '类别', '题型', '题干', '说明']
|
||||||
|
const duplicateRows = importDuplicates.value.map((item) => [
|
||||||
|
item.row ?? '',
|
||||||
|
'重复',
|
||||||
|
questionTypeLabels[String(item.type ?? '')] ?? item.type ?? '',
|
||||||
|
item.content ?? '',
|
||||||
|
item.message ?? '',
|
||||||
|
])
|
||||||
|
const errorRows = importErrors.value.map((item) => {
|
||||||
|
const rawRow = importRows.value[item.row - 1]
|
||||||
|
const type = importRowType(rawRow)
|
||||||
|
|
||||||
|
return [
|
||||||
|
item.row,
|
||||||
|
'错误',
|
||||||
|
questionTypeLabels[type] ?? type,
|
||||||
|
importRowContent(rawRow),
|
||||||
|
item.message,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const escape = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
|
const csv = [header, ...duplicateRows, ...errorRows].map((row) => row.map(escape).join(',')).join('\n')
|
||||||
|
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' })
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = `quickquiz-import-check-report-${Date.now()}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExport(row: QuestionBank) {
|
async function handleExport(row: QuestionBank) {
|
||||||
await exportBank(row.id)
|
await exportBank(row.id)
|
||||||
ElMessage.success('题库已导出到 storage/app/exports')
|
ElMessage.success('题库已导出到 storage/app/exports')
|
||||||
@ -322,8 +395,52 @@ onMounted(loadBanks)
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElUpload>
|
</ElUpload>
|
||||||
|
<section v-if="parsed" class="import-preview">
|
||||||
|
<ElAlert
|
||||||
|
:type="importErrors.length > 0 ? 'warning' : 'success'"
|
||||||
|
:closable="false"
|
||||||
|
title="文件已解析,确认前不会写入题库。"
|
||||||
|
/>
|
||||||
|
<div class="import-summary">
|
||||||
|
<div>
|
||||||
|
<strong>{{ importRows.length }}</strong>
|
||||||
|
<span>解析题目</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ importableCount }}</strong>
|
||||||
|
<span>可导入</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ importDuplicates.length }}</strong>
|
||||||
|
<span>重复</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ importErrors.length }}</strong>
|
||||||
|
<span>错误</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasImportReport" class="import-report-actions">
|
||||||
|
<span>检测到重复或错误题目,可先下载报告核对。</span>
|
||||||
|
<ElButton type="primary" plain size="small" @click="downloadImportReport">下载校验报告</ElButton>
|
||||||
|
</div>
|
||||||
|
<section v-if="importDuplicates.length > 0" class="duplicate-preview">
|
||||||
|
<div class="import-section-title">
|
||||||
|
<strong>重复题目</strong>
|
||||||
|
<span>确认导入时会自动跳过</span>
|
||||||
|
</div>
|
||||||
|
<ElTable :data="importDuplicates" max-height="180">
|
||||||
|
<ElTableColumn prop="row" label="行号" width="72" />
|
||||||
|
<ElTableColumn prop="type" label="题型" width="86">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ questionTypeLabels[row.type] || row.type || '-' }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="content" label="题干" />
|
||||||
|
</ElTable>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
<section v-if="importErrors.length > 0" class="import-errors">
|
<section v-if="importErrors.length > 0" class="import-errors">
|
||||||
<ElAlert type="warning" :closable="false" title="以下题目未通过校验,请编辑修正后继续导入,或取消本次上传。" />
|
<ElAlert type="warning" :closable="false" title="以下题目未通过校验,请编辑修正后再确认导入,或放弃本次上传。" />
|
||||||
<div v-for="error in importErrors" :key="`${error.row}-${error.message}`" class="import-error-row">
|
<div v-for="error in importErrors" :key="`${error.row}-${error.message}`" class="import-error-row">
|
||||||
<div class="import-error-head">
|
<div class="import-error-head">
|
||||||
<strong>第 {{ error.row }} 题</strong>
|
<strong>第 {{ error.row }} 题</strong>
|
||||||
@ -339,7 +456,9 @@ onMounted(loadBanks)
|
|||||||
</section>
|
</section>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<ElButton @click="abandonImport">放弃本次上传</ElButton>
|
<ElButton @click="abandonImport">放弃本次上传</ElButton>
|
||||||
<ElButton v-if="importRows.length" type="primary" :loading="uploading" @click="commitImportRows">继续导入</ElButton>
|
<ElButton v-if="parsed" type="primary" :loading="uploading" :disabled="importRows.length === 0" @click="commitImportRows">
|
||||||
|
确认导入
|
||||||
|
</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElDialog>
|
</ElDialog>
|
||||||
|
|
||||||
@ -364,6 +483,63 @@ onMounted(loadBanks)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.import-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary > div {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--qq-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.64);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--qq-ink);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary span,
|
||||||
|
.import-section-title span {
|
||||||
|
color: var(--qq-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-report-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(217, 119, 6, 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 251, 235, 0.78);
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.import-errors {
|
.import-errors {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -386,4 +562,12 @@ onMounted(loadBanks)
|
|||||||
color: var(--qq-muted);
|
color: var(--qq-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.duplicate-content {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
import { computed, onMounted, reactive, shallowRef, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Delete, Edit } from '@element-plus/icons-vue'
|
import { Delete, Edit } from '@element-plus/icons-vue'
|
||||||
import { createInvite, createUser, fetchInvites, fetchUsers, updateUser } from '@/api/admin'
|
import { createInvite, createUser, fetchInvites, fetchUsers, updateUser } from '@/api/admin'
|
||||||
@ -13,7 +13,7 @@ const inviteDialog = shallowRef(false)
|
|||||||
const detailMode = shallowRef<'create' | 'edit'>('create')
|
const detailMode = shallowRef<'create' | 'edit'>('create')
|
||||||
const editingUserId = shallowRef<number | null>(null)
|
const editingUserId = shallowRef<number | null>(null)
|
||||||
const userForm = reactive({ name: '', email: '', password: '', role: 'user', is_active: true })
|
const userForm = reactive({ name: '', email: '', password: '', role: 'user', is_active: true })
|
||||||
const inviteForm = reactive({ role: 'user', max_uses: 1 })
|
const inviteForm = reactive({ role: 'user', assigned_name: '', max_uses: 1 })
|
||||||
|
|
||||||
const userDialogTitle = computed(() => (detailMode.value === 'create' ? '创建用户' : '编辑用户'))
|
const userDialogTitle = computed(() => (detailMode.value === 'create' ? '创建用户' : '编辑用户'))
|
||||||
const roleLabels: Record<string, string> = {
|
const roleLabels: Record<string, string> = {
|
||||||
@ -22,6 +22,15 @@ const roleLabels: Record<string, string> = {
|
|||||||
user: '用户',
|
user: '用户',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => inviteForm.assigned_name,
|
||||||
|
(name) => {
|
||||||
|
if (name.trim()) {
|
||||||
|
inviteForm.max_uses = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -87,10 +96,47 @@ async function resetPassword(row: User) {
|
|||||||
ElMessage.success('密码已重置')
|
ElMessage.success('密码已重置')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyText(text: string, message: string) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} else {
|
||||||
|
const input = document.createElement('textarea')
|
||||||
|
input.value = text
|
||||||
|
input.style.position = 'fixed'
|
||||||
|
input.style.opacity = '0'
|
||||||
|
document.body.appendChild(input)
|
||||||
|
input.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(input)
|
||||||
|
}
|
||||||
|
ElMessage.success(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerLink(code: string) {
|
||||||
|
return `${window.location.origin}/#/register?invite_code=${encodeURIComponent(code)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInviteCode(row: Record<string, unknown>) {
|
||||||
|
await copyText(String(row.code ?? ''), '邀请码已复制')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInviteLink(row: Record<string, unknown>) {
|
||||||
|
await copyText(registerLink(String(row.code ?? '')), '注册链接已复制')
|
||||||
|
}
|
||||||
|
|
||||||
async function saveInvite() {
|
async function saveInvite() {
|
||||||
await createInvite(inviteForm)
|
if (inviteForm.assigned_name.trim()) {
|
||||||
|
inviteForm.max_uses = 1
|
||||||
|
}
|
||||||
|
await createInvite({
|
||||||
|
role: inviteForm.role,
|
||||||
|
assigned_name: inviteForm.assigned_name.trim() || undefined,
|
||||||
|
max_uses: inviteForm.max_uses,
|
||||||
|
})
|
||||||
ElMessage.success('邀请码已创建')
|
ElMessage.success('邀请码已创建')
|
||||||
inviteDialog.value = false
|
inviteDialog.value = false
|
||||||
|
inviteForm.assigned_name = ''
|
||||||
|
inviteForm.max_uses = 1
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,8 +186,19 @@ onMounted(load)
|
|||||||
{{ roleLabels[String(row.role)] || row.role }}
|
{{ roleLabels[String(row.role)] || row.role }}
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="assigned_name" label="指定姓名" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.assigned_name || '-' }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn prop="max_uses" label="可用次数" />
|
<ElTableColumn prop="max_uses" label="可用次数" />
|
||||||
<ElTableColumn prop="used_count" label="已使用" />
|
<ElTableColumn prop="used_count" label="已使用" />
|
||||||
|
<ElTableColumn label="操作" width="220" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton size="small" @click="copyInviteCode(row)">复制分享码</ElButton>
|
||||||
|
<ElButton size="small" type="primary" plain @click="copyInviteLink(row)">复制分享链接</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
</ElTabPane>
|
</ElTabPane>
|
||||||
</ElTabs>
|
</ElTabs>
|
||||||
@ -176,8 +233,22 @@ onMounted(load)
|
|||||||
<ElOption label="用户" value="user" />
|
<ElOption label="用户" value="user" />
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem label="可用次数"><ElInputNumber v-model="inviteForm.max_uses" :min="1" /></ElFormItem>
|
<ElFormItem label="指定姓名">
|
||||||
|
<ElInput v-model="inviteForm.assigned_name" placeholder="可选;填写后邀请码只能该姓名注册" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="可用次数">
|
||||||
|
<ElInputNumber v-model="inviteForm.max_uses" :min="1" :disabled="Boolean(inviteForm.assigned_name.trim())" />
|
||||||
|
<span v-if="inviteForm.assigned_name.trim()" class="invite-tip">指定姓名时固定为 1 次</span>
|
||||||
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
<template #footer><ElButton type="primary" @click="saveInvite">生成</ElButton></template>
|
<template #footer><ElButton type="primary" @click="saveInvite">生成</ElButton></template>
|
||||||
</ElDialog>
|
</ElDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invite-tip {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--qq-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -94,6 +94,28 @@ async function go(index: number) {
|
|||||||
sheetOpen.value = false
|
sheetOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
return Boolean(target.closest('input, textarea, select, [contenteditable="true"], .el-input, .el-textarea, .el-select'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || sheetOpen.value || isEditableTarget(event.target)) return
|
||||||
|
|
||||||
|
const previousKeys = ['ArrowLeft', '[', ',']
|
||||||
|
const nextKeys = ['ArrowRight', ']', '.']
|
||||||
|
if (previousKeys.includes(event.key)) {
|
||||||
|
event.preventDefault()
|
||||||
|
void go(quiz.currentIndex - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextKeys.includes(event.key)) {
|
||||||
|
event.preventDefault()
|
||||||
|
void go(quiz.currentIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleTouchStart(event: TouchEvent) {
|
function handleTouchStart(event: TouchEvent) {
|
||||||
const target = event.target as HTMLElement | null
|
const target = event.target as HTMLElement | null
|
||||||
if (target?.closest('input, textarea, .el-drawer, .quiz-footer')) return
|
if (target?.closest('input, textarea, .el-drawer, .quiz-footer')) return
|
||||||
@ -151,6 +173,7 @@ onMounted(async () => {
|
|||||||
}, 10000)
|
}, 10000)
|
||||||
window.addEventListener('beforeunload', savePositionOnUnload)
|
window.addEventListener('beforeunload', savePositionOnUnload)
|
||||||
window.addEventListener('pagehide', savePositionOnUnload)
|
window.addEventListener('pagehide', savePositionOnUnload)
|
||||||
|
window.addEventListener('keydown', handleKeyboardNavigation)
|
||||||
} finally {
|
} finally {
|
||||||
loadingAttempt.value = false
|
loadingAttempt.value = false
|
||||||
}
|
}
|
||||||
@ -165,6 +188,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
window.removeEventListener('beforeunload', savePositionOnUnload)
|
window.removeEventListener('beforeunload', savePositionOnUnload)
|
||||||
window.removeEventListener('pagehide', savePositionOnUnload)
|
window.removeEventListener('pagehide', savePositionOnUnload)
|
||||||
|
window.removeEventListener('keydown', handleKeyboardNavigation)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeRouteLeave(async () => {
|
onBeforeRouteLeave(async () => {
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { fetchTags } from '@/api/admin'
|
import { fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz'
|
||||||
import { startBankAttempt, startPaperAttempt } from '@/api/quiz'
|
|
||||||
import type { Paper, QuestionBank } from '@/types/api'
|
import type { Paper, QuestionBank } from '@/types/api'
|
||||||
import { fetchResources } from '@/api/quiz'
|
import { fetchResources } from '@/api/quiz'
|
||||||
|
|
||||||
@ -50,7 +49,7 @@ async function openRandom(bank: QuestionBank) {
|
|||||||
randomForm.limit = Math.min(Math.max(bank.questions_count || 20, 1), 20)
|
randomForm.limit = Math.min(Math.max(bank.questions_count || 20, 1), 20)
|
||||||
randomForm.types = []
|
randomForm.types = []
|
||||||
randomForm.tag_ids = []
|
randomForm.tag_ids = []
|
||||||
tags.value = (await fetchTags(bank.id)).data as Array<{ id: number; name: string }>
|
tags.value = (await fetchBankTags(bank.id)).data as Array<{ id: number; name: string }>
|
||||||
randomDialogVisible.value = true
|
randomDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,11 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://127.0.0.1:8000',
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
'/apidoc': 'http://127.0.0.1:8000',
|
'/apidoc': 'http://127.0.0.1:8000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -61,33 +61,6 @@ final class AuthFlowTest extends TestCase
|
|||||||
->assertJsonPath('message', '已退出');
|
->assertJsonPath('message', '已退出');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_forgot_password_returns_token_when_mail_is_not_configured_and_resets_password(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'email' => 'reset@example.com',
|
|
||||||
'password' => Hash::make('old-password'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$token = $this->postJson('/api/auth/forgot-password', [
|
|
||||||
'email' => $user->email,
|
|
||||||
])->assertOk()
|
|
||||||
->assertJsonStructure(['data' => ['token']])
|
|
||||||
->json('data.token');
|
|
||||||
|
|
||||||
$this->postJson('/api/auth/reset-password', [
|
|
||||||
'email' => $user->email,
|
|
||||||
'token' => $token,
|
|
||||||
'password' => 'new-password',
|
|
||||||
'password_confirmation' => 'new-password',
|
|
||||||
])->assertOk()
|
|
||||||
->assertJsonPath('message', '密码已重置');
|
|
||||||
|
|
||||||
$this->postJson('/api/auth/login', [
|
|
||||||
'email' => $user->email,
|
|
||||||
'password' => 'new-password',
|
|
||||||
])->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_login_failures_require_captcha_after_limit(): void
|
public function test_login_failures_require_captcha_after_limit(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user