Normalize admin API docs and install defaults

This commit is contained in:
Boen_Shi 2026-06-29 14:05:27 +08:00
parent 43ddf589a3
commit 48ee2628d4
40 changed files with 564 additions and 370 deletions

View File

@ -39,14 +39,14 @@ DB_PASSWORD=root
创建数据库后执行:
```bash
php artisan quickquiz:install --admin-email=admin@quickquiz.local --admin-password=password --fresh
php artisan quickquiz:install --fresh
php artisan serve
```
默认管理员:
- 邮箱:`admin@quickquiz.local`
- 密码:`password`
- 邮箱:`admin@example.com`
- 密码:`123456`
## Frontend Setup

View File

@ -13,8 +13,8 @@ use Illuminate\Support\Facades\Storage;
final class QuickQuizInstall extends Command
{
protected $signature = 'quickquiz:install
{--admin-email=admin@quickquiz.local : 首个管理员邮箱}
{--admin-password=password : 首个管理员密码}
{--admin-email=admin@example.com : 首个管理员邮箱}
{--admin-password=123456 : 首个管理员密码}
{--fresh : 使用 migrate:fresh 重建数据库}';
protected $description = 'Install QuickQuiz by running migrations, seeders, and creating the first administrator.';

View File

@ -18,7 +18,7 @@ use Illuminate\Support\Str;
final class ClassController extends Controller
{
#[Apidoc\Title('班级列表')]
#[Apidoc\Url('/api/admin/classes')]
#[Apidoc\Url('/admin/classes')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:classes'])]
public function index(Request $request): JsonResponse
@ -32,7 +32,7 @@ final class ClassController extends Controller
}
#[Apidoc\Title('创建班级')]
#[Apidoc\Url('/api/admin/classes')]
#[Apidoc\Url('/admin/classes')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:classes'])]
public function store(Request $request): JsonResponse
@ -52,7 +52,7 @@ final class ClassController extends Controller
}
#[Apidoc\Title('分配成员')]
#[Apidoc\Url('/api/admin/classes/{class}/members')]
#[Apidoc\Url('/admin/classes/{class}/members')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:classes'])]
public function addMember(Request $request, mixed $class): JsonResponse

View File

@ -17,7 +17,7 @@ use Illuminate\Http\Request;
final class LogController extends Controller
{
#[Apidoc\Title('日志列表')]
#[Apidoc\Url('/api/admin/logs')]
#[Apidoc\Url('/admin/logs')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:logs'])]
public function index(Request $request): JsonResponse

View File

@ -23,7 +23,7 @@ final class PaperController extends Controller
use AuthorizesOwnedResources;
#[Apidoc\Title('试卷列表')]
#[Apidoc\Url('/api/admin/papers')]
#[Apidoc\Url('/admin/papers')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:papers'])]
public function index(Request $request): JsonResponse
@ -37,7 +37,7 @@ final class PaperController extends Controller
}
#[Apidoc\Title('试卷详情')]
#[Apidoc\Url('/api/admin/papers/{paper}')]
#[Apidoc\Url('/admin/papers/{paper}')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:papers'])]
public function show(Request $request, mixed $paper): JsonResponse
@ -49,7 +49,7 @@ final class PaperController extends Controller
}
#[Apidoc\Title('创建固定试卷')]
#[Apidoc\Url('/api/admin/papers')]
#[Apidoc\Url('/admin/papers')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:papers'])]
public function store(Request $request): JsonResponse
@ -87,7 +87,7 @@ final class PaperController extends Controller
}
#[Apidoc\Title('更新固定试卷')]
#[Apidoc\Url('/api/admin/papers/{paper}')]
#[Apidoc\Url('/admin/papers/{paper}')]
#[Apidoc\Method('PUT')]
#[Apidoc\RouteMiddleware(['permission:papers'])]
public function update(Request $request, mixed $paper): JsonResponse
@ -118,7 +118,7 @@ final class PaperController extends Controller
}
#[Apidoc\Title('删除固定试卷')]
#[Apidoc\Url('/api/admin/papers/{paper}')]
#[Apidoc\Url('/admin/papers/{paper}')]
#[Apidoc\Method('DELETE')]
#[Apidoc\RouteMiddleware(['permission:papers'])]
public function destroy(Request $request, mixed $paper): JsonResponse

View File

@ -19,7 +19,7 @@ use Illuminate\Support\Facades\DB;
final class PermissionController extends Controller
{
#[Apidoc\Title('权限菜单列表')]
#[Apidoc\Url('/api/admin/permissions')]
#[Apidoc\Url('/admin/permissions')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:permissions'])]
public function index(): JsonResponse
@ -36,7 +36,7 @@ final class PermissionController extends Controller
}
#[Apidoc\Title('保存角色权限')]
#[Apidoc\Url('/api/admin/roles/{role}/permissions')]
#[Apidoc\Url('/admin/roles/{role}/permissions')]
#[Apidoc\Method('PUT')]
#[Apidoc\RouteMiddleware(['permission:permissions'])]
public function syncRole(Request $request, string $role): JsonResponse

View File

@ -24,7 +24,7 @@ final class QuestionBankController extends Controller
use AuthorizesOwnedResources;
#[Apidoc\Title('题库列表')]
#[Apidoc\Url('/api/admin/banks')]
#[Apidoc\Url('/admin/banks')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:banks'])]
public function index(Request $request): JsonResponse
@ -38,7 +38,7 @@ final class QuestionBankController extends Controller
}
#[Apidoc\Title('创建题库')]
#[Apidoc\Url('/api/admin/banks')]
#[Apidoc\Url('/admin/banks')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:banks.create'])]
public function store(Request $request): JsonResponse
@ -63,7 +63,7 @@ final class QuestionBankController extends Controller
}
#[Apidoc\Title('更新题库')]
#[Apidoc\Url('/api/admin/banks/{bank}')]
#[Apidoc\Url('/admin/banks/{bank}')]
#[Apidoc\Method('PUT')]
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
public function update(Request $request, mixed $bank): JsonResponse
@ -83,7 +83,7 @@ final class QuestionBankController extends Controller
}
#[Apidoc\Title('删除题库')]
#[Apidoc\Url('/api/admin/banks/{bank}')]
#[Apidoc\Url('/admin/banks/{bank}')]
#[Apidoc\Method('DELETE')]
#[Apidoc\RouteMiddleware(['permission:banks.delete'])]
public function destroy(Request $request, mixed $bank): JsonResponse
@ -103,7 +103,7 @@ final class QuestionBankController extends Controller
}
#[Apidoc\Title('题库授权')]
#[Apidoc\Url('/api/admin/banks/{bank}/shares')]
#[Apidoc\Url('/admin/banks/{bank}/shares')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:banks.share'])]
public function share(Request $request, mixed $bank): JsonResponse
@ -133,7 +133,7 @@ final class QuestionBankController extends Controller
}
#[Apidoc\Title('题库导出')]
#[Apidoc\Url('/api/admin/banks/{bank}/export')]
#[Apidoc\Url('/admin/banks/{bank}/export')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:questions.export'])]
public function export(Request $request, mixed $bank): JsonResponse

View File

@ -24,7 +24,7 @@ final class QuestionController extends Controller
use AuthorizesOwnedResources;
#[Apidoc\Title('题目列表')]
#[Apidoc\Url('/api/admin/questions')]
#[Apidoc\Url('/admin/questions')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:questions'])]
public function index(Request $request): JsonResponse
@ -50,7 +50,7 @@ final class QuestionController extends Controller
}
#[Apidoc\Title('创建题目')]
#[Apidoc\Url('/api/admin/questions')]
#[Apidoc\Url('/admin/questions')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
public function store(Request $request, QuestionImportService $service): JsonResponse
@ -78,7 +78,7 @@ final class QuestionController extends Controller
}
#[Apidoc\Title('批量导入题目')]
#[Apidoc\Url('/api/admin/banks/{bank}/imports')]
#[Apidoc\Url('/admin/banks/{bank}/imports')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
public function import(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
@ -103,7 +103,7 @@ final class QuestionController extends Controller
}
#[Apidoc\Title('校验导入题目')]
#[Apidoc\Url('/api/admin/banks/{bank}/imports/validate')]
#[Apidoc\Url('/admin/banks/{bank}/imports/validate')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
public function validateImport(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
@ -117,14 +117,14 @@ final class QuestionController extends Controller
$prepared = $service->prepareUploadedFile($request->file('file'));
return ApiResponse::success([
...$service->validateRows($prepared['rows']),
...$service->validateRows($prepared['rows'], $bank),
'type' => $prepared['type'],
'file_path' => $prepared['path'],
], '校验完成');
}
#[Apidoc\Title('提交已校验题目')]
#[Apidoc\Url('/api/admin/banks/{bank}/imports/rows')]
#[Apidoc\Url('/admin/banks/{bank}/imports/rows')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
public function importRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
@ -137,7 +137,7 @@ final class QuestionController extends Controller
'file_path' => ['nullable', 'string'],
]);
$validation = $service->validateRows($data['rows']);
$validation = $service->validateRows($data['rows'], $bank);
if (! $validation['valid']) {
return ApiResponse::success($validation, '校验未通过');
}
@ -148,7 +148,7 @@ final class QuestionController extends Controller
}
#[Apidoc\Title('校验已编辑题目')]
#[Apidoc\Url('/api/admin/banks/{bank}/imports/rows/validate')]
#[Apidoc\Url('/admin/banks/{bank}/imports/rows/validate')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:questions.import'])]
public function validateRows(Request $request, mixed $bank, QuestionImportService $service): JsonResponse
@ -159,7 +159,7 @@ final class QuestionController extends Controller
'rows' => ['required', 'array'],
]);
return ApiResponse::success($service->validateRows($data['rows']), '校验完成');
return ApiResponse::success($service->validateRows($data['rows'], $bank), '校验完成');
}
private function resolveBank(mixed $bank): QuestionBank
@ -172,7 +172,7 @@ final class QuestionController extends Controller
}
#[Apidoc\Title('更新题目状态')]
#[Apidoc\Url('/api/admin/questions/{question}')]
#[Apidoc\Url('/admin/questions/{question}')]
#[Apidoc\Method('PUT')]
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
public function update(Request $request, mixed $question): JsonResponse
@ -220,7 +220,7 @@ final class QuestionController extends Controller
}
#[Apidoc\Title('删除题目')]
#[Apidoc\Url('/api/admin/questions/{question}')]
#[Apidoc\Url('/admin/questions/{question}')]
#[Apidoc\Method('DELETE')]
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
public function destroy(Request $request, mixed $question): JsonResponse

View File

@ -23,7 +23,7 @@ use Illuminate\Support\Facades\Storage;
final class ReportController extends Controller
{
#[Apidoc\Title('报表概览')]
#[Apidoc\Url('/api/admin/reports/overview')]
#[Apidoc\Url('/admin/reports/overview')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function overview(): JsonResponse
@ -43,7 +43,7 @@ final class ReportController extends Controller
}
#[Apidoc\Title('练习趋势')]
#[Apidoc\Url('/api/admin/reports/trends')]
#[Apidoc\Url('/admin/reports/trends')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function trends(): JsonResponse
@ -59,7 +59,7 @@ final class ReportController extends Controller
}
#[Apidoc\Title('题目错误率')]
#[Apidoc\Url('/api/admin/reports/question-errors')]
#[Apidoc\Url('/admin/reports/question-errors')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function questionErrors(Request $request): JsonResponse
@ -75,7 +75,7 @@ final class ReportController extends Controller
}
#[Apidoc\Title('班级排行')]
#[Apidoc\Url('/api/admin/reports/class-ranking')]
#[Apidoc\Url('/admin/reports/class-ranking')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function classRanking(Request $request): JsonResponse
@ -107,7 +107,7 @@ final class ReportController extends Controller
}
#[Apidoc\Title('题库和分类掌握度')]
#[Apidoc\Url('/api/admin/reports/mastery')]
#[Apidoc\Url('/admin/reports/mastery')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function mastery(Request $request): JsonResponse
@ -143,7 +143,7 @@ final class ReportController extends Controller
}
#[Apidoc\Title('报表导出')]
#[Apidoc\Url('/api/admin/reports/export')]
#[Apidoc\Url('/admin/reports/export')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function export(Request $request): JsonResponse

View File

@ -18,7 +18,7 @@ use Illuminate\Http\Request;
final class SettingController extends Controller
{
#[Apidoc\Title('配置列表')]
#[Apidoc\Url('/api/admin/settings')]
#[Apidoc\Url('/admin/settings')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:settings'])]
public function index(): JsonResponse
@ -27,7 +27,7 @@ final class SettingController extends Controller
}
#[Apidoc\Title('保存配置')]
#[Apidoc\Url('/api/admin/settings')]
#[Apidoc\Url('/admin/settings')]
#[Apidoc\Method('PUT')]
#[Apidoc\RouteMiddleware(['permission:settings'])]
public function update(Request $request): JsonResponse

View File

@ -22,7 +22,7 @@ final class TaxonomyController extends Controller
use AuthorizesOwnedResources;
#[Apidoc\Title('分类列表')]
#[Apidoc\Url('/api/admin/banks/{bank}/categories')]
#[Apidoc\Url('/admin/banks/{bank}/categories')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:questions'])]
public function categories(Request $request, mixed $bank): JsonResponse
@ -34,7 +34,7 @@ final class TaxonomyController extends Controller
}
#[Apidoc\Title('创建分类')]
#[Apidoc\Url('/api/admin/banks/{bank}/categories')]
#[Apidoc\Url('/admin/banks/{bank}/categories')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
public function createCategory(Request $request, mixed $bank): JsonResponse
@ -54,7 +54,7 @@ final class TaxonomyController extends Controller
}
#[Apidoc\Title('标签列表')]
#[Apidoc\Url('/api/admin/banks/{bank}/tags')]
#[Apidoc\Url('/admin/banks/{bank}/tags')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:questions'])]
public function tags(Request $request, mixed $bank): JsonResponse
@ -66,7 +66,7 @@ final class TaxonomyController extends Controller
}
#[Apidoc\Title('创建标签')]
#[Apidoc\Url('/api/admin/banks/{bank}/tags')]
#[Apidoc\Url('/admin/banks/{bank}/tags')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:banks.update'])]
public function createTag(Request $request, mixed $bank): JsonResponse

View File

@ -20,7 +20,7 @@ use Illuminate\Support\Str;
final class UserController extends Controller
{
#[Apidoc\Title('用户列表')]
#[Apidoc\Url('/api/admin/users')]
#[Apidoc\Url('/admin/users')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:users'])]
public function index(Request $request): JsonResponse
@ -37,7 +37,7 @@ final class UserController extends Controller
}
#[Apidoc\Title('创建用户')]
#[Apidoc\Url('/api/admin/users')]
#[Apidoc\Url('/admin/users')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:users.create'])]
public function store(Request $request): JsonResponse
@ -55,7 +55,7 @@ final class UserController extends Controller
}
#[Apidoc\Title('更新用户')]
#[Apidoc\Url('/api/admin/users/{user}')]
#[Apidoc\Url('/admin/users/{user}')]
#[Apidoc\Method('PUT')]
#[Apidoc\RouteMiddleware(['permission:users.update'])]
public function update(Request $request, mixed $user): JsonResponse
@ -89,7 +89,7 @@ final class UserController extends Controller
}
#[Apidoc\Title('邀请码列表')]
#[Apidoc\Url('/api/admin/invite-codes')]
#[Apidoc\Url('/admin/invite-codes')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:users'])]
public function invites(Request $request): JsonResponse
@ -98,16 +98,24 @@ final class UserController extends Controller
}
#[Apidoc\Title('创建邀请码')]
#[Apidoc\Url('/api/admin/invite-codes')]
#[Apidoc\Url('/admin/invite-codes')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:users.create'])]
public function createInvite(Request $request): JsonResponse
{
$data = $request->validate([
'role' => ['required', 'in:teacher,user'],
'assigned_name' => ['nullable', 'string', 'max:50'],
'max_uses' => ['required', 'integer', 'min:1', 'max:10000'],
'expires_at' => ['nullable', 'date'],
]);
$data['assigned_name'] = isset($data['assigned_name']) ? trim((string) $data['assigned_name']) : null;
if ($data['assigned_name'] === '') {
$data['assigned_name'] = null;
}
if ($data['assigned_name'] !== null) {
$data['max_uses'] = 1;
}
$invite = InviteCode::create($data + [
'created_by' => $request->user()->id,

View File

@ -19,7 +19,7 @@ use Illuminate\Http\Request;
final class ClassroomController extends Controller
{
#[Apidoc\Title('我的班级')]
#[Apidoc\Url('/api/app/classes')]
#[Apidoc\Url('/app/classes')]
#[Apidoc\Method('GET')]
public function myClasses(Request $request): JsonResponse
{
@ -32,7 +32,7 @@ final class ClassroomController extends Controller
}
#[Apidoc\Title('通过班级码加入')]
#[Apidoc\Url('/api/app/classes/join')]
#[Apidoc\Url('/app/classes/join')]
#[Apidoc\Method('POST')]
public function join(Request $request): JsonResponse
{
@ -44,7 +44,7 @@ final class ClassroomController extends Controller
}
#[Apidoc\Title('可学习资源')]
#[Apidoc\Url('/api/app/resources')]
#[Apidoc\Url('/app/resources')]
#[Apidoc\Method('GET')]
public function resources(Request $request, LearningAccessService $access): JsonResponse
{

View File

@ -25,7 +25,7 @@ use Tymon\JWTAuth\Facades\JWTAuth;
final class QuizController extends Controller
{
#[Apidoc\Title('开始背题/刷题/抽题')]
#[Apidoc\Url('/api/app/banks/{bank}/attempts')]
#[Apidoc\Url('/app/banks/{bank}/attempts')]
#[Apidoc\Method('POST')]
public function startBank(Request $request, mixed $bank, QuizService $service, LearningAccessService $access): JsonResponse
{
@ -45,8 +45,19 @@ final class QuizController extends Controller
return ApiResponse::success($service->startPractice($this->currentUser($request), $bank, $data['mode'], $data), '已开始');
}
#[Apidoc\Title('题库标签')]
#[Apidoc\Url('/app/banks/{bank}/tags')]
#[Apidoc\Method('GET')]
public function tags(Request $request, mixed $bank, LearningAccessService $access): JsonResponse
{
$bank = $this->resolveBank($bank);
abort_if(! $access->canAccessBank($this->currentUser($request), $bank), 403);
return ApiResponse::success($bank->tags()->orderBy('name')->get());
}
#[Apidoc\Title('开始整卷测试')]
#[Apidoc\Url('/api/app/papers/{paper}/attempts')]
#[Apidoc\Url('/app/papers/{paper}/attempts')]
#[Apidoc\Method('POST')]
public function startPaper(Request $request, mixed $paper, QuizService $service, LearningAccessService $access): JsonResponse
{
@ -57,7 +68,7 @@ final class QuizController extends Controller
}
#[Apidoc\Title('继续作答')]
#[Apidoc\Url('/api/app/attempts/{attempt}')]
#[Apidoc\Url('/app/attempts/{attempt}')]
#[Apidoc\Method('GET')]
public function show(Request $request, mixed $attempt): JsonResponse
{
@ -68,7 +79,7 @@ final class QuizController extends Controller
}
#[Apidoc\Title('提交单题答案')]
#[Apidoc\Url('/api/app/attempts/{attempt}/answer')]
#[Apidoc\Url('/app/attempts/{attempt}/answer')]
#[Apidoc\Method('POST')]
public function answer(Request $request, mixed $attempt, QuizService $service): JsonResponse
{
@ -86,7 +97,7 @@ final class QuizController extends Controller
}
#[Apidoc\Title('保存作答位置')]
#[Apidoc\Url('/api/app/attempts/{attempt}/position')]
#[Apidoc\Url('/app/attempts/{attempt}/position')]
#[Apidoc\Method('PUT')]
public function updatePosition(Request $request, mixed $attempt): JsonResponse
{
@ -107,7 +118,7 @@ final class QuizController extends Controller
}
#[Apidoc\Title('交卷')]
#[Apidoc\Url('/api/app/attempts/{attempt}/submit')]
#[Apidoc\Url('/app/attempts/{attempt}/submit')]
#[Apidoc\Method('POST')]
public function submit(Request $request, mixed $attempt, QuizService $service): JsonResponse
{
@ -117,7 +128,7 @@ final class QuizController extends Controller
}
#[Apidoc\Title('错题列表')]
#[Apidoc\Url('/api/app/wrong-questions')]
#[Apidoc\Url('/app/wrong-questions')]
#[Apidoc\Method('GET')]
public function wrongQuestions(Request $request): JsonResponse
{
@ -136,7 +147,7 @@ final class QuizController extends Controller
}
#[Apidoc\Title('收藏和笔记')]
#[Apidoc\Url('/api/app/favorites')]
#[Apidoc\Url('/app/favorites')]
#[Apidoc\Method('POST')]
public function favorite(Request $request): JsonResponse
{

View File

@ -13,10 +13,7 @@ use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tymon\JWTAuth\Facades\JWTAuth;
@ -25,7 +22,7 @@ use Tymon\JWTAuth\Facades\JWTAuth;
final class AuthController extends Controller
{
#[Apidoc\Title('邀请码注册')]
#[Apidoc\Url('/api/auth/register')]
#[Apidoc\Url('/auth/register')]
#[Apidoc\Method('POST')]
public function register(Request $request): JsonResponse
{
@ -40,6 +37,9 @@ final class AuthController extends Controller
if (! $invite || ! $invite->available()) {
throw ValidationException::withMessages(['invite_code' => '邀请码无效']);
}
if ($invite->assigned_name !== null && trim($data['name']) !== $invite->assigned_name) {
throw ValidationException::withMessages(['name' => '姓名与邀请码指定使用人不一致']);
}
$user = User::create([
'name' => $data['name'],
@ -55,7 +55,7 @@ final class AuthController extends Controller
}
#[Apidoc\Title('验证码')]
#[Apidoc\Url('/api/auth/captcha')]
#[Apidoc\Url('/auth/captcha')]
#[Apidoc\Method('GET')]
public function captcha(Request $request): JsonResponse
{
@ -69,7 +69,7 @@ final class AuthController extends Controller
}
#[Apidoc\Title('登录')]
#[Apidoc\Url('/api/auth/login')]
#[Apidoc\Url('/auth/login')]
#[Apidoc\Method('POST')]
public function login(Request $request): JsonResponse
{
@ -120,7 +120,7 @@ final class AuthController extends Controller
}
#[Apidoc\Title('刷新Token')]
#[Apidoc\Url('/api/auth/refresh')]
#[Apidoc\Url('/auth/refresh')]
#[Apidoc\Method('POST')]
public function refresh(): JsonResponse
{
@ -132,7 +132,7 @@ final class AuthController extends Controller
}
#[Apidoc\Title('当前用户')]
#[Apidoc\Url('/api/auth/me')]
#[Apidoc\Url('/auth/me')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['jwt.auth'])]
public function me(Request $request): JsonResponse
@ -141,7 +141,7 @@ final class AuthController extends Controller
}
#[Apidoc\Title('退出登录')]
#[Apidoc\Url('/api/auth/logout')]
#[Apidoc\Url('/auth/logout')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['jwt.auth'])]
public function logout(): JsonResponse
@ -151,60 +151,6 @@ final class AuthController extends Controller
return ApiResponse::success(null, '已退出');
}
#[Apidoc\Title('发送找回密码邮件')]
#[Apidoc\Url('/api/auth/forgot-password')]
#[Apidoc\Method('POST')]
public function forgotPassword(Request $request): JsonResponse
{
$data = $request->validate(['email' => ['required', 'email']]);
$user = User::query()->where('email', $data['email'])->first();
if (! $user) {
return ApiResponse::success(null, '如果邮箱存在,系统会发送重置邮件');
}
$token = Password::broker()->createToken($user);
if (config('mail.default') !== 'smtp' || ! config('mail.mailers.smtp.host')) {
OperationLog::create([
'user_id' => $user->id,
'action' => 'auth.password_reset_token_created',
'payload' => ['token' => $token],
]);
return ApiResponse::success(['token' => $token], '邮件未配置,已返回重置 token');
}
Mail::raw("QuickQuiz 密码重置 Token{$token}", fn ($message) => $message->to($user->email)->subject('QuickQuiz 密码重置'));
return ApiResponse::success(null, '重置邮件已发送');
}
#[Apidoc\Title('重置密码')]
#[Apidoc\Url('/api/auth/reset-password')]
#[Apidoc\Method('POST')]
public function resetPassword(Request $request): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email'],
'token' => ['required', 'string'],
'password' => ['required', 'string', 'min:6', 'confirmed'],
]);
$status = Password::broker()->reset($data, function (User $user, string $password): void {
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
});
if ($status !== Password::PASSWORD_RESET) {
return ApiResponse::error('重置失败', 422, 422, ['status' => $status]);
}
return ApiResponse::success(null, '密码已重置');
}
private function tokenPayload(User $user): array
{
return [

View File

@ -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,
], '安装完成');
}
}

View File

@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
final class InviteCode extends Model
{
protected $fillable = ['created_by', 'code', 'role', 'max_uses', 'used_count', 'expires_at', 'is_active'];
protected $fillable = ['created_by', 'code', 'role', 'assigned_name', 'max_uses', 'used_count', 'expires_at', 'is_active'];
protected $casts = [
'expires_at' => 'datetime',

View File

@ -68,14 +68,38 @@ final class QuestionImportService
/**
* @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 = [];
$duplicates = [];
$seenHashes = [];
foreach ($rows as $index => $row) {
try {
$this->normalizeQuestionRow($row, $index + 1);
$normalized = $this->normalizeQuestionRow($row, $index + 1);
$hash = $this->dedupHash($normalized['content'], $normalized['options']);
if (isset($seenHashes[$hash])) {
$duplicates[] = [
'row' => $index + 1,
'message' => "与本次上传第 {$seenHashes[$hash]} 行重复,确认导入时会跳过",
'content' => $normalized['content'],
'type' => $normalized['type'],
];
continue;
}
$seenHashes[$hash] = $index + 1;
if ($bank !== null && $this->isDuplicate($bank, $normalized)) {
$duplicates[] = [
'row' => $index + 1,
'message' => '与题库已有题目重复,确认导入时会跳过',
'content' => $normalized['content'],
'type' => $normalized['type'],
];
}
} catch (ValidationException $exception) {
$errors[] = [
'row' => $index + 1,
@ -88,6 +112,8 @@ final class QuestionImportService
'valid' => $errors === [],
'rows' => $rows,
'errors' => $errors,
'duplicates' => $duplicates,
'importable_count' => max(0, count($rows) - count($errors) - count($duplicates)),
];
}
@ -110,19 +136,36 @@ final class QuestionImportService
$report = [];
$success = 0;
$skipped = 0;
$seenHashes = [];
foreach ($rows as $index => $row) {
$normalized = $this->normalizeQuestionRow($row, $index + 1);
$hash = $this->dedupHash($normalized['content'], $normalized['options']);
$exists = Question::query()
->where('question_bank_id', $bank->id)
->where('dedup_hash', $hash)
->exists();
if ($exists) {
if (isset($seenHashes[$hash])) {
$skipped++;
$report[] = ['row' => $index + 1, 'status' => 'skipped', 'message' => '重复题目已跳过'];
$report[] = [
'row' => $index + 1,
'status' => 'skipped',
'message' => "与本次导入第 {$seenHashes[$hash]} 行重复,已跳过",
'content' => $normalized['content'],
'type' => $normalized['type'],
];
continue;
}
$seenHashes[$hash] = $index + 1;
if ($this->isDuplicate($bank, $normalized)) {
$skipped++;
$report[] = [
'row' => $index + 1,
'status' => 'skipped',
'message' => '与题库已有题目重复,已跳过',
'content' => $normalized['content'],
'type' => $normalized['type'],
];
continue;
}
@ -149,7 +192,13 @@ final class QuestionImportService
}
$success++;
$report[] = ['row' => $index + 1, 'status' => 'success', 'message' => '导入成功'];
$report[] = [
'row' => $index + 1,
'status' => 'success',
'message' => '导入成功',
'content' => $normalized['content'],
'type' => $normalized['type'],
];
}
$job->update([
@ -288,6 +337,17 @@ final class QuestionImportService
], JSON_UNESCAPED_UNICODE));
}
/**
* @param array{content:string,options:array<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
* @return array<int, array<string, mixed>>

View File

@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
apiPrefix: '',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
@ -22,6 +23,6 @@ return Application::configure(basePath: dirname(__DIR__))
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
fn (Request $request) => $request->is('auth/*', 'admin/*', 'app/*', 'health'),
);
})->create();

View File

@ -105,7 +105,7 @@ return [
|
*/
'ttl' => env('JWT_TTL', 60),
'ttl' => env('JWT_TTL', 1440),
/*
|--------------------------------------------------------------------------

View File

@ -130,6 +130,7 @@ return new class extends Migration
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('code')->unique();
$table->string('role')->default('user');
$table->string('assigned_name', 50)->nullable();
$table->unsignedInteger('max_uses')->default(1);
$table->unsignedInteger('used_count')->default(0);
$table->timestamp('expires_at')->nullable();

View File

@ -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');
});
}
};

View File

@ -19,12 +19,12 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$admin = User::query()->firstOrCreate(
['email' => 'admin@quickquiz.local'],
['email' => 'admin@example.com'],
[
'name' => '系统管理员',
'role' => 'admin',
'is_active' => true,
'password' => Hash::make('password'),
'password' => Hash::make('123456'),
],
);

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>QuickQuiz</title>
</head>
<body>
<div id="app"></div>

View File

@ -50,6 +50,8 @@ export function validateQuestionImport(bankId: number, file: File) {
valid: boolean
rows: Array<Record<string, unknown>>
errors: Array<{ row: number; message: string }>
duplicates: Array<{ row: number; message: string; content: string; type: string }>
importable_count: number
type: string
file_path: string
}>(`/api/admin/banks/${bankId}/imports/validate`, form)
@ -70,6 +72,8 @@ export function validateQuestionRows(bankId: number, payload: {
valid: boolean
rows: Array<Record<string, unknown>>
errors: Array<{ row: number; message: string }>
duplicates: Array<{ row: number; message: string; content: string; type: string }>
importable_count: number
}>(`/api/admin/banks/${bankId}/imports/rows/validate`, payload)
}
@ -162,7 +166,7 @@ export function fetchInvites(params?: Record<string, unknown>) {
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)
}

View File

@ -35,24 +35,3 @@ export function me() {
export function captcha() {
return apiGet<{ captcha: string; expires_in: number }>('/api/auth/captcha')
}
export function forgotPassword(email: string) {
return apiPost<{ token?: string }>('/api/auth/forgot-password', { email })
}
export function resetPassword(payload: {
email: string
token: string
password: string
password_confirmation: string
}) {
return apiPost('/api/auth/reset-password', payload)
}
export function installStatus() {
return apiGet<{ installed: boolean; database: string }>('/api/install/status')
}
export function runInstall(payload: { admin_email: string; admin_password: string; fresh?: boolean }) {
return apiPost('/api/install/run', payload)
}

View File

@ -19,8 +19,12 @@ http.interceptors.response.use(
(response) => response.data,
async (error) => {
const auth = useAuthStore()
if (error.response?.status === 401 && auth.token) {
if (error.response?.status === 401) {
auth.clearSession()
if (!window.location.hash.startsWith('#/login')) {
const redirect = window.location.hash.slice(1) || '/'
window.location.href = `/#/login?redirect=${encodeURIComponent(redirect)}`
}
}
return Promise.reject(error)
},

View File

@ -5,6 +5,10 @@ export function fetchResources() {
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>) {
return apiPost<QuizAttempt>(`/api/app/banks/${bankId}/attempts`, payload)
}

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
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'
const route = useRoute()
@ -24,6 +25,20 @@ const menus = [
{ path: '/admin/logs', label: '操作日志', icon: Tickets, permission: 'logs' },
]
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>
<template>
@ -48,6 +63,7 @@ const visibleMenus = computed(() => menus.filter((item) => auth.can(item.permiss
<p class="muted text-sm">管理题库导入题目查看学习数据</p>
</div>
<ElButton type="primary" plain @click="router.push('/quiz')">进入学习端</ElButton>
<ElButton :icon="SwitchButton" plain @click="logout">退出登录</ElButton>
</header>
<section class="admin-content">
<RouterView />

View File

@ -1,8 +1,25 @@
<script setup lang="ts">
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 auth = useAuthStore()
async function logout() {
try {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消',
})
} catch {
return
}
auth.clearSession()
await router.replace('/login')
}
</script>
<template>
@ -12,7 +29,10 @@ const router = useRouter()
<Collection class="w-5 h-5" />
<span>QuickQuiz</span>
</button>
<div class="quiz-actions">
<ElButton :icon="Setting" circle @click="router.push('/admin')" />
<ElButton :icon="SwitchButton" circle @click="logout" />
</div>
</header>
<RouterView />
</div>
@ -47,4 +67,10 @@ const router = useRouter()
font-weight: 800;
cursor: pointer;
}
.quiz-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@ -1,12 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
export const router = createRouter({
history: createWebHistory(),
history: createWebHashHistory(),
routes: [
{ path: '/login', component: () => import('@/views/LoginView.vue') },
{ path: '/register', component: () => import('@/views/RegisterView.vue') },
{ path: '/install', component: () => import('@/views/InstallView.vue') },
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),

View File

@ -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>

View File

@ -3,7 +3,7 @@ import { reactive, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { captcha, forgotPassword } from '@/api/auth'
import { captcha } from '@/api/auth'
const router = useRouter()
const auth = useAuthStore()
@ -11,8 +11,8 @@ const loading = shallowRef(false)
const captchaText = shallowRef('')
const captchaRequired = shallowRef(false)
const form = reactive({
email: 'admin@quickquiz.local',
password: 'password',
email: '',
password: '',
captcha: '',
})
@ -38,10 +38,6 @@ async function loadCaptcha() {
captchaText.value = response.data.captcha
}
async function handleForgotPassword() {
const response = await forgotPassword(form.email)
ElMessage.success(response.data.token ? `重置 token${response.data.token}` : '重置邮件已发送')
}
</script>
<template>
@ -68,8 +64,6 @@ async function handleForgotPassword() {
<ElButton type="primary" :loading="loading" class="w-full" @click="submit">登录</ElButton>
<div class="login-links">
<RouterLink to="/register">邀请码注册</RouterLink>
<button type="button" @click="handleForgotPassword">忘记密码</button>
<RouterLink to="/install">安装向导</RouterLink>
</div>
</ElForm>
</section>

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import { reactive, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { register } from '@/api/auth'
const route = useRoute()
const router = useRouter()
const loading = shallowRef(false)
const form = reactive({
@ -11,7 +12,7 @@ const form = reactive({
email: '',
password: '',
password_confirmation: '',
invite_code: '',
invite_code: String(route.query.invite_code ?? route.query.code ?? route.query.invite ?? ''),
})
async function submit() {
@ -20,6 +21,10 @@ async function submit() {
await register(form)
ElMessage.success('注册成功,请登录')
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 {
loading.value = false
}

View File

@ -18,6 +18,14 @@ import {
} from '@/api/admin'
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 dialogVisible = shallowRef(false)
const uploadVisible = shallowRef(false)
@ -30,8 +38,11 @@ const shareUserIds = shallowRef<number[]>([])
const shareClassIds = shallowRef<number[]>([])
const uploadFiles = shallowRef<UploadUserFile[]>([])
const uploading = shallowRef(false)
const parsed = shallowRef(false)
const importRows = shallowRef<Array<Record<string, unknown>>>([])
const importErrors = shallowRef<Array<{ row: number; message: string }>>([])
const importDuplicates = shallowRef<ImportReportRow[]>([])
const importableCount = shallowRef(0)
const importType = shallowRef('')
const importFilePath = shallowRef('')
const form = reactive({
@ -47,6 +58,13 @@ const visibilityLabels: Record<string, string> = {
public: '公开',
assigned: '指定用户/班级',
}
const questionTypeLabels: Record<string, string> = {
single: '单选',
multiple: '多选',
judge: '判断',
blank: '填空',
}
const hasImportReport = computed(() => importDuplicates.value.length > 0 || importErrors.value.length > 0)
async function loadBanks() {
loading.value = true
@ -111,12 +129,15 @@ async function handleUploadChange(file: UploadFile) {
const response = await validateQuestionImport(selectedBank.value.id, file.raw)
importRows.value = response.data.rows
importErrors.value = response.data.errors
importDuplicates.value = response.data.duplicates
importableCount.value = response.data.importable_count
importType.value = response.data.type
importFilePath.value = response.data.file_path
parsed.value = true
if (response.data.valid) {
await commitImportRows()
ElMessage.success('解析完成,请确认无误后再导入')
} else {
ElMessage.warning('导入格式需要修正,可编辑错误行后继续')
ElMessage.warning('解析完成,存在需要修正的题目')
}
} finally {
uploading.value = false
@ -152,8 +173,11 @@ function openUpload(row: QuestionBank) {
uploadFiles.value = []
importRows.value = []
importErrors.value = []
importDuplicates.value = []
importableCount.value = 0
importType.value = ''
importFilePath.value = ''
parsed.value = false
uploadVisible.value = true
}
@ -162,8 +186,11 @@ function abandonImport() {
uploadFiles.value = []
importRows.value = []
importErrors.value = []
importDuplicates.value = []
importableCount.value = 0
importType.value = ''
importFilePath.value = ''
parsed.value = false
}
function rowText(row: Record<string, unknown> | undefined) {
@ -184,6 +211,8 @@ async function commitImportRows() {
try {
const validation = await validateQuestionRows(selectedBank.value.id, { rows: importRows.value })
importErrors.value = validation.data.errors
importDuplicates.value = validation.data.duplicates
importableCount.value = validation.data.importable_count
importRows.value = validation.data.rows
if (!validation.data.valid) {
ElMessage.warning('仍有题目未通过校验,请修正后再导入')
@ -201,12 +230,56 @@ async function commitImportRows() {
uploadFiles.value = []
importRows.value = []
importErrors.value = []
importDuplicates.value = []
importableCount.value = 0
parsed.value = false
await loadBanks()
} finally {
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) {
await exportBank(row.id)
ElMessage.success('题库已导出到 storage/app/exports')
@ -322,8 +395,52 @@ onMounted(loadBanks)
</div>
</template>
</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">
<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 class="import-error-head">
<strong> {{ error.row }} </strong>
@ -339,7 +456,9 @@ onMounted(loadBanks)
</section>
<template #footer>
<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>
</ElDialog>
@ -364,6 +483,63 @@ onMounted(loadBanks)
</template>
<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 {
display: grid;
gap: 12px;
@ -386,4 +562,12 @@ onMounted(loadBanks)
color: var(--qq-muted);
font-size: 13px;
}
.duplicate-content {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,5 +1,5 @@
<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 { Delete, Edit } from '@element-plus/icons-vue'
import { createInvite, createUser, fetchInvites, fetchUsers, updateUser } from '@/api/admin'
@ -13,7 +13,7 @@ const inviteDialog = shallowRef(false)
const detailMode = shallowRef<'create' | 'edit'>('create')
const editingUserId = shallowRef<number | null>(null)
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 roleLabels: Record<string, string> = {
@ -22,6 +22,15 @@ const roleLabels: Record<string, string> = {
user: '用户',
}
watch(
() => inviteForm.assigned_name,
(name) => {
if (name.trim()) {
inviteForm.max_uses = 1
}
},
)
async function load() {
loading.value = true
try {
@ -87,10 +96,47 @@ async function resetPassword(row: User) {
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() {
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('邀请码已创建')
inviteDialog.value = false
inviteForm.assigned_name = ''
inviteForm.max_uses = 1
await load()
}
@ -140,8 +186,19 @@ onMounted(load)
{{ roleLabels[String(row.role)] || row.role }}
</template>
</ElTableColumn>
<ElTableColumn prop="assigned_name" label="指定姓名" min-width="120">
<template #default="{ row }">
{{ row.assigned_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="max_uses" 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>
</ElTabPane>
</ElTabs>
@ -176,8 +233,22 @@ onMounted(load)
<ElOption label="用户" value="user" />
</ElSelect>
</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>
<template #footer><ElButton type="primary" @click="saveInvite">生成</ElButton></template>
</ElDialog>
</template>
<style scoped>
.invite-tip {
margin-left: 10px;
color: var(--qq-muted);
font-size: 12px;
}
</style>

View File

@ -94,6 +94,28 @@ async function go(index: number) {
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) {
const target = event.target as HTMLElement | null
if (target?.closest('input, textarea, .el-drawer, .quiz-footer')) return
@ -151,6 +173,7 @@ onMounted(async () => {
}, 10000)
window.addEventListener('beforeunload', savePositionOnUnload)
window.addEventListener('pagehide', savePositionOnUnload)
window.addEventListener('keydown', handleKeyboardNavigation)
} finally {
loadingAttempt.value = false
}
@ -165,6 +188,7 @@ onBeforeUnmount(() => {
}
window.removeEventListener('beforeunload', savePositionOnUnload)
window.removeEventListener('pagehide', savePositionOnUnload)
window.removeEventListener('keydown', handleKeyboardNavigation)
})
onBeforeRouteLeave(async () => {

View File

@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { fetchTags } from '@/api/admin'
import { startBankAttempt, startPaperAttempt } from '@/api/quiz'
import { fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz'
import type { Paper, QuestionBank } from '@/types/api'
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.types = []
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
}

View File

@ -20,7 +20,11 @@ export default defineConfig({
],
server: {
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',
},
},

View File

@ -61,33 +61,6 @@ final class AuthFlowTest extends TestCase
->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
{
$user = User::factory()->create([