Compare commits
281
Commits
ae32732cf6
...
26f05d1d76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26f05d1d76 | ||
|
|
47d4679984 | ||
|
|
70983a12a5 | ||
|
|
d1c6b8c201 | ||
|
|
0591eda56f | ||
|
|
e172ffc0b3 | ||
|
|
1f6935e31e | ||
|
|
2588ea42fd | ||
|
|
fd0fffb185 | ||
|
|
4497af940f | ||
|
|
663fb8d1dc | ||
|
|
9c652a6b3d | ||
|
|
29bb89a257 | ||
|
|
681c6ff4f4 | ||
|
|
5bb8a1eaa3 | ||
|
|
c12c77ac7e | ||
|
|
3102e1178a | ||
|
|
a15b3d9c76 | ||
|
|
05ecf6bebf | ||
|
|
54d9861bd8 | ||
|
|
596ceec880 | ||
|
|
6f99688667 | ||
|
|
4b32330b60 | ||
|
|
1566a4e275 | ||
|
|
f7817d8c26 | ||
|
|
53a0759507 | ||
|
|
e2b3f61863 | ||
|
|
2a93c4d19c | ||
|
|
1e160d192b | ||
|
|
7a72c94238 | ||
|
|
efb535eb3d | ||
|
|
819b7a82f4 | ||
|
|
0180f6e803 | ||
|
|
80aa091602 | ||
|
|
983e87403c | ||
|
|
5aaba42068 | ||
|
|
5ecf8caf83 | ||
|
|
2b216878ca | ||
|
|
77aca640ac | ||
|
|
9a8e5ad48e | ||
|
|
4af9ae90c9 | ||
|
|
f63f7cbbce | ||
|
|
21e3a7f39b | ||
|
|
f255367b08 | ||
|
|
edbf66874b | ||
|
|
e8992edabc | ||
|
|
f3eed84f7c | ||
|
|
b5e0421fd4 | ||
|
|
c0084462eb | ||
|
|
5fced6b4c2 | ||
|
|
2e6e3aacec | ||
|
|
64dc1c9234 | ||
|
|
2abf95888b | ||
|
|
eff635fb49 | ||
|
|
3136be2df7 | ||
|
|
efecef4e98 | ||
|
|
f025ebfa2e | ||
|
|
375a78b852 | ||
|
|
dd249c8753 | ||
|
|
7af5e3117d | ||
|
|
45b40f13ad | ||
|
|
567da331f4 | ||
|
|
e9781fdada | ||
|
|
266f042fb2 | ||
|
|
6591ca6574 | ||
|
|
ed8e016e30 | ||
|
|
b7f3c9c96d | ||
|
|
2fa90738e7 | ||
|
|
35ee7d69dd | ||
|
|
b22eb85f3a | ||
|
|
1d01415771 | ||
|
|
36fcf8427f | ||
|
|
754eea951c | ||
|
|
58f462bb3b | ||
|
|
3697078fc3 | ||
|
|
17e816761f | ||
|
|
93c816f3ab | ||
|
|
8a594446b6 | ||
|
|
134e2e8713 | ||
|
|
d628e94519 | ||
|
|
96e5bfd959 | ||
|
|
74bdef2236 | ||
|
|
b4c73411d6 | ||
|
|
eda968c250 | ||
|
|
7f0bfd67fa | ||
|
|
501f314aff | ||
|
|
6fb3bc0198 | ||
|
|
e4359e8093 | ||
|
|
40f361cf91 | ||
|
|
f2ddd4bc93 | ||
|
|
2179ff1daa | ||
|
|
a7b1a58449 | ||
|
|
8d510e19bb | ||
|
|
e539f9450a | ||
|
|
91e1b2b48b | ||
|
|
f16104b011 | ||
|
|
85e1681017 | ||
|
|
3c721224c8 | ||
|
|
67b1baede8 | ||
|
|
a97a40475a | ||
|
|
1df211c760 | ||
|
|
e9596e28a3 | ||
|
|
8db17aba12 | ||
|
|
9ff2edeecf | ||
|
|
35dbfda52c | ||
|
|
ecce21aaaf | ||
|
|
951e53c1dd | ||
|
|
f26b06877c | ||
|
|
1744c405a5 | ||
|
|
5f95b88914 | ||
|
|
83537face1 | ||
|
|
6495b77c8b | ||
|
|
597f1f0f93 | ||
|
|
4b58cee204 | ||
|
|
942332b6bd | ||
|
|
a7cfa66952 | ||
|
|
0636ad6f57 | ||
|
|
e0f1cdb337 | ||
|
|
88d6ffaf53 | ||
|
|
83cd62a110 | ||
|
|
4e95a2fad0 | ||
|
|
dd52e3e1f9 | ||
|
|
3c6b616d81 | ||
|
|
0dd5cbeee9 | ||
|
|
d68ef60ab9 | ||
|
|
98d8099b5d | ||
|
|
040e4e3253 | ||
|
|
7e65340f63 | ||
|
|
2bd9382c8c | ||
|
|
a7aa4f63ac | ||
|
|
9c577f9bc1 | ||
|
|
016e070fb9 | ||
|
|
0ee8b0d6ea | ||
|
|
334b2d77b1 | ||
|
|
b4bfc7706c | ||
|
|
f7e9d39bc2 | ||
|
|
46347ff99b | ||
|
|
65022968a5 | ||
|
|
04bb023c2e | ||
|
|
725fad4a55 | ||
|
|
4774c05809 | ||
|
|
33d75bf83a | ||
|
|
7b7f6ea81d | ||
|
|
7da4becf4f | ||
|
|
b6dc79b298 | ||
|
|
78a2d84335 | ||
|
|
564f0b3f21 | ||
|
|
18a27f82fd | ||
|
|
30f4195a8a | ||
|
|
ded044ca57 | ||
|
|
9386f2e152 | ||
|
|
44944944ad | ||
|
|
8f331419e7 | ||
|
|
73f09a9c99 | ||
|
|
aa0e71ce94 | ||
|
|
b77f90559a | ||
|
|
a1828a0aba | ||
|
|
cba1c5adb9 | ||
|
|
095626b857 | ||
|
|
8ce1c87dff | ||
|
|
912095b295 | ||
|
|
baa44acbee | ||
|
|
1ff887c94f | ||
|
|
33e4ead2c5 | ||
|
|
2ad2a971c0 | ||
|
|
616bb2bfec | ||
|
|
4caa3b061a | ||
|
|
4556eb71d8 | ||
|
|
4beab17b70 | ||
|
|
69c3a36957 | ||
|
|
a41ba2e85a | ||
|
|
330f69b21c | ||
|
|
b1b5a45bac | ||
|
|
dd9233a6d6 | ||
|
|
d7f33bfca2 | ||
|
|
ea1b114dd2 | ||
|
|
b1fcecb9d4 | ||
|
|
cce7920dcc | ||
|
|
696f1adf3f | ||
|
|
c8171a5bf2 | ||
|
|
33ed22434b | ||
|
|
14e7b2ba0d | ||
|
|
6eb63696c6 | ||
|
|
9eb18fedd4 | ||
|
|
16fb09021d | ||
|
|
a76b2f9bc9 | ||
|
|
e4e7507f85 | ||
|
|
3618b5a4b2 | ||
|
|
a89f6e8d70 | ||
|
|
42444fe3ab | ||
|
|
e10bc77978 | ||
|
|
5f8c96c1da | ||
|
|
32a14a9dd5 | ||
|
|
01e10388e4 | ||
|
|
a2d0f42ae6 | ||
|
|
544a046365 | ||
|
|
5e11796edd | ||
|
|
82623d141e | ||
|
|
6a5b480724 | ||
|
|
c202e0ea09 | ||
|
|
a48ea2cabe | ||
|
|
ebab966207 | ||
|
|
e39faea99a | ||
|
|
8b22e91f78 | ||
|
|
81aea950c6 | ||
|
|
00ce38eae3 | ||
|
|
73290291b3 | ||
|
|
af7139bf25 | ||
|
|
b1593b1c8e | ||
|
|
6d6c69cf37 | ||
|
|
838a703023 | ||
|
|
ea5393242a | ||
|
|
20bf212152 | ||
|
|
f4fa930423 | ||
|
|
24880e62ad | ||
|
|
d2d4c2974e | ||
|
|
c2988b6f28 | ||
|
|
2cd28f3a8e | ||
|
|
e4f1b31212 | ||
|
|
3716fec110 | ||
|
|
4cd73d9af8 | ||
|
|
bf94b0922c | ||
|
|
e6b7fe3848 | ||
|
|
175e4c0d68 | ||
|
|
dce79ef27d | ||
|
|
9fe551a42c | ||
|
|
fbc2d19b70 | ||
|
|
e7cbc71418 | ||
|
|
2af1109ced | ||
|
|
f91b4ab856 | ||
|
|
9ed8dda14c | ||
|
|
e04605c03e | ||
|
|
a7d8663f4d | ||
|
|
f01f8a174d | ||
|
|
586727cb99 | ||
|
|
350e8a7a3b | ||
|
|
bc360d868e | ||
|
|
a577fa4133 | ||
|
|
28abe1b429 | ||
|
|
81706fc48d | ||
|
|
8993455dfb | ||
|
|
1a2977974f | ||
|
|
186f957a5f | ||
|
|
ee57f1138b | ||
|
|
24db30ccec | ||
|
|
db87a1d675 | ||
|
|
e0f8235c4f | ||
|
|
84ffe1280b | ||
|
|
80ed033049 | ||
|
|
2d0dba9549 | ||
|
|
75c6f1dbd4 | ||
|
|
01ae14b445 | ||
|
|
056a40b31f | ||
|
|
382222314d | ||
|
|
377ac9b834 | ||
|
|
c0771bbc5d | ||
|
|
eec8c3d0ac | ||
|
|
fad4b2b867 | ||
|
|
df971ea418 | ||
|
|
9a289d8c39 | ||
|
|
942c1fb59d | ||
|
|
a6c0a5681f | ||
|
|
b3371676cb | ||
|
|
7957a717e3 | ||
|
|
010b05db38 | ||
|
|
06368bb8ec | ||
|
|
eed4f3a64c | ||
|
|
97907da0ba | ||
|
|
8c4e111db1 | ||
|
|
45eaa3f5fa | ||
|
|
ae160fa996 | ||
|
|
a0feeea600 | ||
|
|
af87c4990b | ||
|
|
93fbfd6683 | ||
|
|
5847bb4398 | ||
|
|
697cd50544 | ||
|
|
fc2126f364 | ||
|
|
73ad304863 | ||
|
|
e275da04ee | ||
|
|
d934111354 | ||
|
|
3b26fe4adc |
No files matched your search
@@ -1,29 +0,0 @@
|
|||||||
name: Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: debian
|
|
||||||
remote_port: 22
|
|
||||||
- name: school
|
|
||||||
remote_port: 8822
|
|
||||||
steps:
|
|
||||||
- name: Deploy to ${{ matrix.name }}
|
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.HOST }}
|
|
||||||
port: ${{ matrix.remote_port }}
|
|
||||||
username: root
|
|
||||||
key: ${{ secrets.KEY }}
|
|
||||||
script: sh /root/WebPreview/backend.sh
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: debian
|
||||||
|
build_command: build
|
||||||
|
remote_port: 22
|
||||||
|
- name: school
|
||||||
|
build_command: build:staging
|
||||||
|
remote_port: 8822
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/package-lock.json
|
||||||
|
|
||||||
|
- name: Build client
|
||||||
|
working-directory: client
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
npm run ${{ matrix.build_command }}
|
||||||
|
env:
|
||||||
|
CI: false
|
||||||
|
|
||||||
|
- name: Upload client to ${{ matrix.name }}
|
||||||
|
uses: easingthemes/ssh-deploy@main
|
||||||
|
with:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.KEY }}
|
||||||
|
REMOTE_HOST: ${{ secrets.HOST }}
|
||||||
|
REMOTE_PORT: ${{ matrix.remote_port }}
|
||||||
|
ARGS: "-avzr --delete"
|
||||||
|
SOURCE: client/dist/
|
||||||
|
REMOTE_USER: root
|
||||||
|
TARGET: /root/WebPreview/build
|
||||||
|
|
||||||
|
- name: Update server on ${{ matrix.name }}
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST }}
|
||||||
|
port: ${{ matrix.remote_port }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.KEY }}
|
||||||
|
script: |
|
||||||
|
cd /root/WebPreview
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose up -d --build
|
||||||
+17
-5
@@ -1,8 +1,20 @@
|
|||||||
venv
|
# 运行时数据(服务器上由 docker compose 挂载)
|
||||||
.venv
|
|
||||||
**/__pycache__
|
|
||||||
db.sqlite3
|
|
||||||
.env
|
.env
|
||||||
|
/data/
|
||||||
|
/build/
|
||||||
|
/media/
|
||||||
|
/redis/
|
||||||
|
/sqlite/
|
||||||
|
|
||||||
media/
|
# 本地工具目录
|
||||||
|
.agents/
|
||||||
|
.codex
|
||||||
|
.deepseek/
|
||||||
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 旧 Django 后端在本地留下的文件(虚拟环境、旧 worktree 等)
|
||||||
|
/api/
|
||||||
@@ -1 +0,0 @@
|
|||||||
3.13
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
WebPreview is a web-based tutorial/challenge platform where users view tutorials and submit HTML/CSS/JS code for challenges. It has a Bun backend (`server/`) and a Vue 3 frontend (`client/`) in one repository, deployed via Docker Compose with Caddy as reverse proxy.
|
||||||
|
|
||||||
|
The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1–2 GB). It keeps Django's exact database schema (table names, column types, UTC `YYYY-MM-DD HH:MM:SS.ffffff` timestamps, 32-char hex UUIDs, `pbkdf2_sha256$...` password hashes) so old data loads unchanged. The Django code is in git history before that change.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **`server/`** — Bun + **Hono** HTTP API, Bun's native WebSocket, **`bun:sqlite`** (single SQLite file, WAL). No Redis, no Postgres.
|
||||||
|
- `src/index.ts` — `Bun.serve`: WebSocket upgrades go to `ws.ts`, everything else to the Hono app
|
||||||
|
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, Origin check on writes, session middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
|
||||||
|
- `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions
|
||||||
|
- `src/auth.ts` — Django-compatible password hashing, sessions in table `auth_session` (cookie `sessionid`), `requireUser/requireAdmin/requireSuper` (401 / 403)
|
||||||
|
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt`
|
||||||
|
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes, done by hand), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
|
||||||
|
- `src/ws.ts` — `/ws/prompt/<task_id>/` (code generation chat) and `/ws/guidance/<task_id>/` (prompt coaching); messages per connection are processed serially; on disconnect the pending user message is deleted and the LLM request aborted
|
||||||
|
- `scripts/` — one-off CLIs: `migrate-from-postgres.ts`, `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
|
||||||
|
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
||||||
|
|
||||||
|
- **`client/`** — Vue 3 + TypeScript SPA built with **Rsbuild**
|
||||||
|
- UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`)
|
||||||
|
- `src/api.ts` — Axios-based API client; 401 clears login and goes home, 403 alerts and goes home
|
||||||
|
- `src/router.ts` — Vue Router with auth guards
|
||||||
|
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
||||||
|
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
||||||
|
|
||||||
|
- **`Caddyfile`** — serves SPA from `build/` and `/media`, proxies `/api*` and `/ws*` to the backend
|
||||||
|
- **`docker-compose.yaml`** — `webpreview-web` (Caddy) and `webpreview-api` (Bun); `webpreview-db` (Postgres) exists only under the `migrate` profile for the one-time data migration
|
||||||
|
- **`.github/workflows/deploy.yaml`** — on push to `main`: build client, rsync to server `build/`, then `git pull && docker compose up -d --build` on the server
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh # backend (bun --watch) + frontend together
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server (from `server/`)
|
||||||
|
```bash
|
||||||
|
bun run dev # dev server on :8000 with auto-reload
|
||||||
|
bun test # API + unit tests (in-memory SQLite, LLM disabled)
|
||||||
|
bun run typecheck
|
||||||
|
bun scripts/create-superuser.ts <username> <password>
|
||||||
|
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
||||||
|
bun scripts/recalculate-scores.ts [--dry-run]
|
||||||
|
```
|
||||||
|
Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/data/db.sqlite3`, uploads go to the repo-root `media/`.
|
||||||
|
|
||||||
|
### Client (from `client/`)
|
||||||
|
```bash
|
||||||
|
npm run start # Dev server (Rsbuild) on :3000
|
||||||
|
npm run build # Production build
|
||||||
|
npm run fmt # Format with Prettier
|
||||||
|
```
|
||||||
|
Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`).
|
||||||
|
|
||||||
|
### Docker (from project root)
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username> <password>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Technical Details
|
||||||
|
|
||||||
|
- **Schema changes**: there is no migration framework. Edit the DDL in `src/db.ts` for new tables; for altering existing tables add an idempotent step guarded by `PRAGMA user_version`
|
||||||
|
- **SQL style**: raw SQL with named params (`$id`), `strict: true` so params are passed without the `$` prefix (`{ id }`). Wrap multi-statement writes in `tx()`. Deleting tasks/submissions must go through `services/cascade.ts`
|
||||||
|
- **API compatibility**: response shapes, status codes and trailing-slash tolerance (`strict: false`) mirror the old django-ninja API, so the client needed no changes besides user management. Paginated endpoints take `page` / `page_size` (default 10, max 100) and return `{ items, count }`
|
||||||
|
- **Task `display` field**: integer used as URL slug for tutorials/challenges/creations (unique per task type), not the DB `id`
|
||||||
|
- **Submission / conversation IDs**: UUIDs in the API, 32-char hex in the DB
|
||||||
|
- Language/locale is Chinese; timestamps are stored in UTC and returned as ISO strings ending in `Z`
|
||||||
|
- Client build output is served by Caddy from `build/` in production
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
:80 {
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
handle_path /* {
|
||||||
|
root * /usr/share/caddy
|
||||||
|
file_server
|
||||||
|
try_files {path} /
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /media/* {
|
||||||
|
root * /media
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /api* {
|
||||||
|
reverse_proxy webpreview-api:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /ws* {
|
||||||
|
reverse_proxy webpreview-api:8000
|
||||||
|
}
|
||||||
|
}
|
||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
FROM python:3.12.2-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 配置apk使用清华镜像源
|
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories
|
|
||||||
|
|
||||||
# 安装构建依赖
|
|
||||||
RUN apk add --no-cache build-base
|
|
||||||
|
|
||||||
# 复制依赖文件
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# 使用清华镜像源安装依赖
|
|
||||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
|
|
||||||
&& pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# 最终阶段
|
|
||||||
FROM python:3.12.2-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 从builder阶段复制Python包
|
|
||||||
COPY --from=builder /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
|
|
||||||
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
|
||||||
|
|
||||||
# 复制应用代码
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN chmod +x /app/entrypoint.sh
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
||||||
Whitespace-only changes.
@@ -1,24 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth.admin import UserAdmin
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
admin.site.site_header = "前端开发网站管理"
|
|
||||||
admin.site.site_index = "前端开发网站管理"
|
|
||||||
admin.site.site_title = "前端开发网站管理"
|
|
||||||
|
|
||||||
admin.site.unregister(Group)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
|
||||||
class AdminAccount(UserAdmin):
|
|
||||||
fieldsets = ((None, {"fields": ("username", "password", "role", "is_active")}),)
|
|
||||||
add_fieldsets = (
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
{"fields": ("username", "password1", "password2", "role")},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
list_display = ("username", "role", "is_active")
|
|
||||||
list_filter = ("role", "is_active")
|
|
||||||
search_fields = ("username",)
|
|
||||||
-147
@@ -1,147 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
from django.contrib.auth import authenticate, login, logout
|
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from ninja import Router
|
|
||||||
from ninja.pagination import paginate
|
|
||||||
from ninja.errors import HttpError
|
|
||||||
from .schemas import (
|
|
||||||
BatchUsersIn,
|
|
||||||
ClassStudentEntry,
|
|
||||||
UserListSchema,
|
|
||||||
UserRegistrationSchema,
|
|
||||||
UserLoginSchema,
|
|
||||||
)
|
|
||||||
from .models import RoleChoices, User
|
|
||||||
from .decorators import super_required
|
|
||||||
from django.db import transaction
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
def generate_password(length=6):
|
|
||||||
"""生成更安全的随机密码"""
|
|
||||||
alphabet = string.digits[2:] # 只使用2-9的数字
|
|
||||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
|
||||||
def user_register(request, payload: UserRegistrationSchema):
|
|
||||||
if User.objects.filter(username=payload.username).exists():
|
|
||||||
raise HttpError(400, "用户已存在")
|
|
||||||
User.objects.create_user(
|
|
||||||
username=payload.username,
|
|
||||||
email=payload.email,
|
|
||||||
password=payload.password,
|
|
||||||
)
|
|
||||||
return {"message": "创建成功"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
def user_login(request, payload: UserLoginSchema):
|
|
||||||
user = authenticate(username=payload.username, password=payload.password)
|
|
||||||
if user:
|
|
||||||
login(request, user)
|
|
||||||
return {"username": user.username, "role": user.role}
|
|
||||||
else:
|
|
||||||
raise HttpError(401, "账号密码错误")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
@login_required
|
|
||||||
def user_logout(request):
|
|
||||||
logout(request)
|
|
||||||
return {"message": "退出成功"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile")
|
|
||||||
def my_profile(request):
|
|
||||||
# 首页获取用户状态
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
return {"username": request.user.get_username(), "role": request.user.role}
|
|
||||||
else:
|
|
||||||
return {"username": "", "role": RoleChoices.NORMAL}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response=List[UserListSchema])
|
|
||||||
@super_required
|
|
||||||
@paginate
|
|
||||||
def list(request, username: str, role: str = None):
|
|
||||||
# 用户列表
|
|
||||||
users = User.objects.filter(username__icontains=username)
|
|
||||||
if role:
|
|
||||||
users = users.filter(role=role)
|
|
||||||
return [UserListSchema.get(user) for user in users]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
|
||||||
@super_required
|
|
||||||
@transaction.atomic
|
|
||||||
def batch_create(request, payload: BatchUsersIn):
|
|
||||||
prefix = "web"
|
|
||||||
usernames = []
|
|
||||||
users_to_create = []
|
|
||||||
|
|
||||||
# 生成用户名列表
|
|
||||||
for name in payload.names:
|
|
||||||
username = prefix + payload.classname + name
|
|
||||||
usernames.append(username)
|
|
||||||
|
|
||||||
# 检查是否存在重复用户名
|
|
||||||
existing_users = User.objects.filter(username__in=usernames)
|
|
||||||
if existing_users.exists():
|
|
||||||
raise HttpError(400, "有些用户已经存在,创建失败")
|
|
||||||
|
|
||||||
# 批量创建用户
|
|
||||||
for username in usernames:
|
|
||||||
password = generate_password()
|
|
||||||
user = User(username=username, classname=payload.classname)
|
|
||||||
user.set_password(password)
|
|
||||||
users_to_create.append(user)
|
|
||||||
|
|
||||||
# 使用 bulk_create 批量保存
|
|
||||||
User.objects.bulk_create(users_to_create, ignore_conflicts=True)
|
|
||||||
|
|
||||||
# 返回创建的用户信息
|
|
||||||
return {"message": "批量创建成功"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/active/{id}")
|
|
||||||
@super_required
|
|
||||||
def toggle_user_is_active(request, id: int):
|
|
||||||
# 封号和解封
|
|
||||||
try:
|
|
||||||
user = User.objects.get(id=id)
|
|
||||||
user.is_active = not user.is_active
|
|
||||||
user.save()
|
|
||||||
return {
|
|
||||||
"message": f"{user.username} {'解封' if user.is_active else '封号'}成功"
|
|
||||||
}
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise HttpError(404, "查无此人")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/classes", response=List[str])
|
|
||||||
def list_classes(request):
|
|
||||||
"""返回所有不重复的非空班级名列表,按字典序升序"""
|
|
||||||
return (
|
|
||||||
User.objects.filter(classname__gt="")
|
|
||||||
.values_list("classname", flat=True)
|
|
||||||
.distinct()
|
|
||||||
.order_by("classname")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/names", response=List[ClassStudentEntry])
|
|
||||||
def list_names_by_class(request, classname: str):
|
|
||||||
"""返回指定班级的学生姓名和用户名列表"""
|
|
||||||
prefix = "web" + classname
|
|
||||||
users = User.objects.filter(
|
|
||||||
classname=classname,
|
|
||||||
username__startswith=prefix,
|
|
||||||
).order_by("username")
|
|
||||||
return [
|
|
||||||
ClassStudentEntry(name=u.username[len(prefix):], username=u.username)
|
|
||||||
for u in users
|
|
||||||
]
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AccountConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "account"
|
|
||||||
verbose_name = "用户"
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from ninja import NinjaAPI
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Callable, List, Any
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
|
||||||
from .models import User, RoleChoices
|
|
||||||
|
|
||||||
api = NinjaAPI()
|
|
||||||
|
|
||||||
|
|
||||||
def _require(roles: List[RoleChoices]) -> Callable:
|
|
||||||
def check_role(user: User) -> bool:
|
|
||||||
return user.is_authenticated and hasattr(user, "role") and user.role in roles
|
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
|
||||||
@wraps(func)
|
|
||||||
@login_required
|
|
||||||
@user_passes_test(check_role, login_url=None)
|
|
||||||
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
|
|
||||||
return func(request, *args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
admin_required = _require([RoleChoices.ADMIN, RoleChoices.SUPER])
|
|
||||||
super_required = _require([RoleChoices.SUPER])
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-03-07 01:32
|
|
||||||
|
|
||||||
import django.contrib.auth.models
|
|
||||||
import django.contrib.auth.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auth', '0012_alter_user_first_name_max_length'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='User',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
||||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
||||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
|
||||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
|
||||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
||||||
('role', models.CharField(choices=[('super', '超级管理员'), ('admin', '管理员'), ('normal', '普通')], default='normal', max_length=20, verbose_name='权限')),
|
|
||||||
('raw_password', models.CharField(blank=True, max_length=20, null=True, verbose_name='明文密码')),
|
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': '用户',
|
|
||||||
'verbose_name_plural': '用户',
|
|
||||||
'ordering': ('-id',),
|
|
||||||
},
|
|
||||||
managers=[
|
|
||||||
('objects', django.contrib.auth.models.UserManager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Profile',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('total_score', models.IntegerField(default=0)),
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.1 on 2026-03-09 11:55
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('account', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='profile',
|
|
||||||
name='total_score',
|
|
||||||
field=models.FloatField(default=0.0),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0.1 on 2026-03-18 02:33
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('account', '0002_alter_profile_total_score'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='classname',
|
|
||||||
field=models.CharField(blank=True, default='', max_length=50, verbose_name='班级'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Generated by Django 6.0.1 on 2026-03-18 10:38
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('account', '0003_user_classname'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='Profile',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
Whitespace-only changes.
@@ -1,50 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
|
|
||||||
|
|
||||||
class RoleChoices(models.TextChoices):
|
|
||||||
SUPER = "super", "超级管理员"
|
|
||||||
ADMIN = "admin", "管理员"
|
|
||||||
NORMAL = "normal", "普通"
|
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
|
||||||
first_name = None
|
|
||||||
last_name = None
|
|
||||||
|
|
||||||
role = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=RoleChoices.choices,
|
|
||||||
default=RoleChoices.NORMAL,
|
|
||||||
verbose_name="权限",
|
|
||||||
)
|
|
||||||
raw_password = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="明文密码",
|
|
||||||
)
|
|
||||||
classname = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
blank=True,
|
|
||||||
default="",
|
|
||||||
verbose_name="班级",
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self.username:
|
|
||||||
if self.is_superuser:
|
|
||||||
self.role = RoleChoices.SUPER
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def set_password(self, raw_password):
|
|
||||||
super().set_password(raw_password)
|
|
||||||
self.raw_password = raw_password
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ("-id",)
|
|
||||||
verbose_name = "用户"
|
|
||||||
verbose_name_plural = verbose_name
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from typing import List
|
|
||||||
from ninja import Schema, ModelSchema
|
|
||||||
from pydantic import EmailStr, Field
|
|
||||||
|
|
||||||
from .models import User, RoleChoices
|
|
||||||
|
|
||||||
|
|
||||||
class UserListSchema(ModelSchema):
|
|
||||||
@classmethod
|
|
||||||
def get(cls, obj):
|
|
||||||
raw_password = obj.raw_password if obj.role != RoleChoices.SUPER else ""
|
|
||||||
return cls(
|
|
||||||
id=obj.id,
|
|
||||||
username=obj.username,
|
|
||||||
raw_password=raw_password,
|
|
||||||
role=obj.role,
|
|
||||||
date_joined=obj.date_joined,
|
|
||||||
last_login=obj.last_login,
|
|
||||||
is_active=obj.is_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"raw_password",
|
|
||||||
"role",
|
|
||||||
"date_joined",
|
|
||||||
"last_login",
|
|
||||||
"is_active",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class UserRegistrationSchema(Schema):
|
|
||||||
username: str
|
|
||||||
email: EmailStr
|
|
||||||
password: str = Field(min_length=6, max_length=20)
|
|
||||||
|
|
||||||
|
|
||||||
class UserLoginSchema(Schema):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class BatchUsersIn(Schema):
|
|
||||||
names: List[str]
|
|
||||||
classname: str
|
|
||||||
|
|
||||||
|
|
||||||
class ClassStudentEntry(Schema):
|
|
||||||
name: str
|
|
||||||
username: str
|
|
||||||
Whitespace-only changes.
-30
@@ -1,30 +0,0 @@
|
|||||||
"""
|
|
||||||
ASGI config for api project.
|
|
||||||
|
|
||||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
|
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
django_asgi_app = get_asgi_application()
|
|
||||||
|
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
||||||
from channels.auth import AuthMiddlewareStack
|
|
||||||
from channels.security.websocket import AllowedHostsOriginValidator
|
|
||||||
from prompt.url import websocket_urlpatterns
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
|
||||||
{
|
|
||||||
"http": django_asgi_app,
|
|
||||||
"websocket": AllowedHostsOriginValidator(
|
|
||||||
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
-212
@@ -1,212 +0,0 @@
|
|||||||
"""
|
|
||||||
Django settings for api project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 5.1.6.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.1/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/5.1/ref/settings/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
DEBUG = True
|
|
||||||
|
|
||||||
DEV = os.getenv("ENV") != "production"
|
|
||||||
|
|
||||||
if DEV:
|
|
||||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
|
|
||||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:3000"]
|
|
||||||
else:
|
|
||||||
ALLOWED_HOSTS = ["web.xuyue.cc", "10.13.114.114"]
|
|
||||||
CSRF_TRUSTED_ORIGINS = ["https://web.xuyue.cc", "http://10.13.114.114:91"]
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"daphne",
|
|
||||||
"channels",
|
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.messages",
|
|
||||||
"django.contrib.staticfiles",
|
|
||||||
"ninja",
|
|
||||||
"corsheaders",
|
|
||||||
"django_extensions",
|
|
||||||
"account",
|
|
||||||
"task",
|
|
||||||
"submission",
|
|
||||||
"prompt",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"django.middleware.security.SecurityMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = "api.urls"
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "api.wsgi.application"
|
|
||||||
ASGI_APPLICATION = "api.asgi.application"
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
|
||||||
|
|
||||||
DEV_DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PROD_DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"NAME": os.getenv("POSTGRES_DB"),
|
|
||||||
"USER": os.getenv("POSTGRES_USER"),
|
|
||||||
"PASSWORD": os.getenv("POSTGRES_PASSWORD"),
|
|
||||||
"HOST": os.getenv("POSTGRES_HOST"),
|
|
||||||
"PORT": os.getenv("POSTGRES_PORT", "5432"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
PROD_CACHES = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "django_redis.cache.RedisCache",
|
|
||||||
"LOCATION": os.getenv("REDIS_URL"),
|
|
||||||
"OPTIONS": {
|
|
||||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if DEV:
|
|
||||||
DATABASES = DEV_DATABASES
|
|
||||||
else:
|
|
||||||
# DATABASES = DEV_DATABASES
|
|
||||||
DATABASES = PROD_DATABASES
|
|
||||||
# 使用 Redis 作为会话存储
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
|
||||||
SESSION_CACHE_ALIAS = "default"
|
|
||||||
# 配置缓存
|
|
||||||
CACHES = PROD_CACHES
|
|
||||||
|
|
||||||
# WebSocket 的缓存
|
|
||||||
CHANNEL_LAYERS = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
|
||||||
"CONFIG": {
|
|
||||||
"hosts": [(os.getenv("REDIS_HOST"), 6379)],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
# {
|
|
||||||
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
||||||
# },
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
||||||
"OPTIONS": {"min_length": 6},
|
|
||||||
},
|
|
||||||
# {
|
|
||||||
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
# },
|
|
||||||
# {
|
|
||||||
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
# },
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = "zh-hans"
|
|
||||||
|
|
||||||
TIME_ZONE = "Asia/Shanghai"
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = "django_static/"
|
|
||||||
|
|
||||||
STATIC_ROOT = BASE_DIR / "static"
|
|
||||||
|
|
||||||
# Default primary key field type
|
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
||||||
|
|
||||||
AUTH_USER_MODEL = "account.User"
|
|
||||||
|
|
||||||
if DEV:
|
|
||||||
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
|
|
||||||
else:
|
|
||||||
CORS_ALLOWED_ORIGINS = ["https://web.xuyue.cc"]
|
|
||||||
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
|
||||||
|
|
||||||
NINJA_PAGINATION_CLASS = "ninja.pagination.PageNumberPagination"
|
|
||||||
|
|
||||||
NINJA_PAGINATION_PER_PAGE = 10
|
|
||||||
|
|
||||||
MEDIA_URL = "/media/"
|
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
|
||||||
|
|
||||||
# LLM Configuration
|
|
||||||
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
|
|
||||||
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com")
|
|
||||||
LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-v4-flash")
|
|
||||||
|
|
||||||
# ARK (Volcengine) LLM Configuration
|
|
||||||
ARK_API_KEY = os.environ.get("ARK_API_KEY", "")
|
|
||||||
ARK_BASE_URL = os.environ.get("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
"""
|
|
||||||
URL configuration for api project.
|
|
||||||
|
|
||||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
||||||
https://docs.djangoproject.com/en/5.1/topics/http/urls/
|
|
||||||
Examples:
|
|
||||||
Function views
|
|
||||||
1. Add an import: from my_app import views
|
|
||||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
||||||
Class-based views
|
|
||||||
1. Add an import: from other_app.views import Home
|
|
||||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
||||||
Including another URLconf
|
|
||||||
1. Import the include() function: from django.urls import include, path
|
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.urls import path
|
|
||||||
from django.conf import settings
|
|
||||||
from django.conf.urls.static import static
|
|
||||||
from ninja import NinjaAPI
|
|
||||||
|
|
||||||
api = NinjaAPI()
|
|
||||||
|
|
||||||
api.add_router("account/", "account.api.router")
|
|
||||||
api.add_router("tutorial/", "task.tutorial.router")
|
|
||||||
api.add_router("challenge/", "task.challenge.router")
|
|
||||||
api.add_router("submission/", "submission.api.router")
|
|
||||||
api.add_router("upload/", "utils.upload.router")
|
|
||||||
api.add_router("assets/", "task.assets.router")
|
|
||||||
api.add_router("prompt/", "prompt.api.router")
|
|
||||||
|
|
||||||
|
|
||||||
apis = [
|
|
||||||
path("admin/", admin.site.urls),
|
|
||||||
path("api/", api.urls),
|
|
||||||
]
|
|
||||||
|
|
||||||
urlpatterns = apis + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
"""
|
|
||||||
WSGI config for api project.
|
|
||||||
|
|
||||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'api.settings')
|
|
||||||
|
|
||||||
application = get_wsgi_application()
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
chrome >= 90
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
PUBLIC_WEB_URL=http://localhost:8000
|
||||||
|
PUBLIC_BASE_URL=http://localhost:8000/api
|
||||||
|
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=df542e305f27dee6
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
PUBLIC_WEB_URL=https://web.xuyue.cc
|
||||||
|
PUBLIC_BASE_URL=https://web.xuyue.cc/api
|
||||||
|
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=df542e305f27dee6
|
||||||
|
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
PUBLIC_WEB_URL=http://10.13.114.114:91
|
||||||
|
PUBLIC_BASE_URL=http://10.13.114.114:91/api
|
||||||
|
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=df542e305f27dee6
|
||||||
|
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
.worktrees/
|
||||||
|
*.local
|
||||||
|
components.d.ts
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
semi=false
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
web preview
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hans-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="shortcut icon" href="/noto--honeybee.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>前端开发学习平台</title>
|
||||||
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
<script>
|
||||||
|
window.localStorage.setItem("maxkbMaskTip", true)
|
||||||
|
</script>
|
||||||
|
<% if (process.env.PUBLIC_MAXKB_URL) { %>
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
||||||
|
></script>
|
||||||
|
<% } %>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3323
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.3.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "rsbuild dev",
|
||||||
|
"build": "rsbuild build",
|
||||||
|
"build:staging": "rsbuild build --env-mode staging",
|
||||||
|
"fmt": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@vueuse/core": "^14.4.0",
|
||||||
|
"axios": "^1.19.0",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
|
"github-markdown-css": "^5.9.0",
|
||||||
|
"highlight.js": "^11.12.0",
|
||||||
|
"marked": "^18.0.9",
|
||||||
|
"marked-alert": "^2.1.2",
|
||||||
|
"marked-code-preview": "^1.3.7",
|
||||||
|
"marked-highlight": "^2.2.4",
|
||||||
|
"md-editor-v3": "^6.5.6",
|
||||||
|
"naive-ui": "^2.44.1",
|
||||||
|
"vue": "^3.5.41",
|
||||||
|
"vue-codemirror": "^6.1.1",
|
||||||
|
"vue-router": "^5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify/vue": "^5.0.1",
|
||||||
|
"@rsbuild/core": "^2.1.13",
|
||||||
|
"@rsbuild/plugin-vue": "^2.0.1",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"core-js": "^3.50.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"unplugin-vue-components": "^32.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>初级网络工程师岗位要求</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #000;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">初级网络工程师岗位要求</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">1. 学历要求</div>
|
||||||
|
<div class="content">
|
||||||
|
通常要求计算机科学、信息技术、通信工程等相关专业的大专或本科学历。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">2. 技术技能</div>
|
||||||
|
<div class="content">
|
||||||
|
<div>熟悉OSI模型、TCP/IP协议、路由与交换等基础概念。</div>
|
||||||
|
<div>能够配置和管理路由器、交换机、防火墙等网络设备。</div>
|
||||||
|
<div>掌握常见网络协议,如HTTP/HTTPS、FTP、DNS、DHCP等。</div>
|
||||||
|
<div>具备基本的网络故障诊断和解决能力。</div>
|
||||||
|
<div>了解基础的网络安全知识,如防火墙配置、VPN、ACL等。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">3. 工具使用</div>
|
||||||
|
<div class="content">
|
||||||
|
<div>熟悉如Wireshark、Nagios、PRTG等工具。</div>
|
||||||
|
<div>了解如Ansible、Puppet等自动化工具。</div>
|
||||||
|
<div>熟悉Windows和Linux操作系统的基本操作。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">4. 软技能</div>
|
||||||
|
<div class="content">
|
||||||
|
<div>能够清晰表达技术问题并与团队有效沟通。</div>
|
||||||
|
<div>具备快速学习新技术的能力。</div>
|
||||||
|
<div>能够在团队中协作完成任务。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">5. 认证要求</div>
|
||||||
|
<div class="content">持有CCNA、HCIA等初级网络认证者优先。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">6. 工作经验</div>
|
||||||
|
<div class="content">通常要求1-2年相关经验,优秀应届生也可考虑。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">7. 其他要求</div>
|
||||||
|
<div class="content">
|
||||||
|
<div>具备较强的工作责任心和抗压能力。</div>
|
||||||
|
<div>能够编写技术文档和报告。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">总结</div>
|
||||||
|
<div class="content">
|
||||||
|
初级网络工程师需具备扎实的网络基础知识、设备配置能力、故障排查技能及一定的网络安全知识,同时具备良好的沟通和学习能力,持有相关认证者更具竞争力。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
File renamed without changes.
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Monaco";
|
||||||
|
src: url(/Monaco.ttf);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineConfig } from "@rsbuild/core"
|
||||||
|
import { pluginVue } from "@rsbuild/plugin-vue"
|
||||||
|
import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
|
||||||
|
import Components from "unplugin-vue-components/rspack"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [pluginVue()],
|
||||||
|
html: {
|
||||||
|
template: "./index.html",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
include: [/node_modules[\\/]marked[\\/]/],
|
||||||
|
entry: {
|
||||||
|
index: "./src/main.ts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
target: "web",
|
||||||
|
polyfill: "usage",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
rspack: {
|
||||||
|
plugins: [
|
||||||
|
Components({
|
||||||
|
resolvers: [NaiveUiResolver()],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/media": {
|
||||||
|
target: process.env.PUBLIC_WEB_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/ws": {
|
||||||
|
target: process.env.PUBLIC_WEB_URL,
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { dateZhCN, zhCN } from "naive-ui"
|
||||||
|
import type { GlobalThemeOverrides } from "naive-ui"
|
||||||
|
import Login from "./components/Login.vue"
|
||||||
|
import { onMounted, watch } from "vue"
|
||||||
|
import { Account } from "./api"
|
||||||
|
import { authed, user } from "./store/user"
|
||||||
|
import { STORAGE_KEY } from "./utils/const"
|
||||||
|
import hljs from "highlight.js/lib/core"
|
||||||
|
|
||||||
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
|
common: {
|
||||||
|
borderRadius: "6px",
|
||||||
|
borderRadiusSmall: "4px",
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await Account.getMyProfile()
|
||||||
|
user.loaded = true
|
||||||
|
user.username = data.username
|
||||||
|
user.role = data.role
|
||||||
|
} catch {
|
||||||
|
user.loaded = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(authed, (v) => {
|
||||||
|
if (v) {
|
||||||
|
localStorage.setItem(STORAGE_KEY.LOGIN, "true")
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY.LOGIN)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-config-provider
|
||||||
|
class="myContainer"
|
||||||
|
:locale="zhCN"
|
||||||
|
:date-locale="dateZhCN"
|
||||||
|
:hljs="hljs"
|
||||||
|
:theme-overrides="themeOverrides"
|
||||||
|
>
|
||||||
|
<n-modal-provider>
|
||||||
|
<n-message-provider :max="1">
|
||||||
|
<n-dialog-provider>
|
||||||
|
<router-view></router-view>
|
||||||
|
<Login />
|
||||||
|
</n-dialog-provider>
|
||||||
|
</n-message-provider>
|
||||||
|
</n-modal-provider>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.myContainer {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
import { router } from "./router"
|
||||||
|
import type {
|
||||||
|
TutorialIn,
|
||||||
|
ChallengeIn,
|
||||||
|
FlagType,
|
||||||
|
SubmissionOut,
|
||||||
|
PromptMessage,
|
||||||
|
PromptHistoryItem,
|
||||||
|
TaskStatsOut,
|
||||||
|
TaskAsset,
|
||||||
|
AwardSection,
|
||||||
|
AwardManageIn,
|
||||||
|
AwardManageOut,
|
||||||
|
AwardItemIn,
|
||||||
|
AwardItemUpdateIn,
|
||||||
|
AwardItemManageOut,
|
||||||
|
GradebookOut,
|
||||||
|
GradebookQuery,
|
||||||
|
ShowcaseSubmissionLookupOut,
|
||||||
|
ShowcaseDetail,
|
||||||
|
PromptRound,
|
||||||
|
RandomRatingItem,
|
||||||
|
CreationSlim,
|
||||||
|
CreationDetail,
|
||||||
|
CreationIn,
|
||||||
|
} from "./utils/type"
|
||||||
|
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
xsrfCookieName: "xsrfCookieName",
|
||||||
|
xsrfHeaderName: "X-CSRFTOKEN",
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(res) => {
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (err.response) {
|
||||||
|
switch (err.response.status) {
|
||||||
|
case 401: // 未授权
|
||||||
|
localStorage.removeItem(STORAGE_KEY.LOGIN)
|
||||||
|
router.push("/")
|
||||||
|
break
|
||||||
|
case 403: // 禁止访问
|
||||||
|
alert("权限不够,禁止访问")
|
||||||
|
router.push("/")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.error("出现错误:", err.response.status, err.response.data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("出现错误:", err.message)
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Account = {
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
const res = await http.post("/account/login", { username, password })
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
const res = await http.post("/account/logout")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMyProfile() {
|
||||||
|
const res = await http.get("/account/profile")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(query: { username: string; page: number }) {
|
||||||
|
const res = await http.get("/account/list", {
|
||||||
|
params: query,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(payload: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
role: string
|
||||||
|
classname: string
|
||||||
|
}) {
|
||||||
|
const res = await http.post("/account/create", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: number,
|
||||||
|
payload: {
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
role?: string
|
||||||
|
classname?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const res = await http.put(`/account/${id}`, payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleActive(id: number) {
|
||||||
|
const res = await http.put(`/account/active/${id}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchCreate(payload: { classname: string; names: string[] }) {
|
||||||
|
const res = await http.post("/account/batch", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listClasses(): Promise<string[]> {
|
||||||
|
const res = await http.get("/account/classes")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listNamesByClass(
|
||||||
|
classname: string,
|
||||||
|
): Promise<{ name: string; username: string }[]> {
|
||||||
|
const res = await http.get("/account/names", { params: { classname } })
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tutorial = {
|
||||||
|
async list() {
|
||||||
|
const res = await http.get("/tutorial/list")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createOrUpdate(payload: TutorialIn) {
|
||||||
|
const res = await http.post("/tutorial/", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async togglePublic(display: number) {
|
||||||
|
const res = await http.put(`/tutorial/public/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(display: number) {
|
||||||
|
await http.delete(`/tutorial/${display}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(display: number) {
|
||||||
|
const res = await http.get(`/tutorial/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listDisplay() {
|
||||||
|
const res = await http.get("/tutorial/display")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Challenge = {
|
||||||
|
async list() {
|
||||||
|
const res = await http.get("/challenge/list")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createOrUpdate(payload: ChallengeIn) {
|
||||||
|
const res = await http.post("/challenge/", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async togglePublic(display: number) {
|
||||||
|
const res = await http.put(`/challenge/public/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(display: number) {
|
||||||
|
await http.delete(`/challenge/${display}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(display: number) {
|
||||||
|
const res = await http.get(`/challenge/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listDisplay() {
|
||||||
|
const res = await http.get("/challenge/display")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Creation = {
|
||||||
|
async listMine(): Promise<CreationSlim[]> {
|
||||||
|
const res = await http.get("/creation/mine")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(payload: CreationIn): Promise<CreationDetail> {
|
||||||
|
const res = await http.post("/creation/", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(display: number): Promise<CreationDetail> {
|
||||||
|
const res = await http.get(`/creation/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(display: number, payload: CreationIn): Promise<CreationDetail> {
|
||||||
|
const res = await http.put(`/creation/${display}`, payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCode(
|
||||||
|
display: number,
|
||||||
|
code: { html: string; css: string; js: string },
|
||||||
|
): Promise<CreationDetail> {
|
||||||
|
const res = await http.put(`/creation/${display}/code`, code)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(display: number) {
|
||||||
|
const res = await http.delete(`/creation/${display}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Submission = {
|
||||||
|
async create(
|
||||||
|
taskId: number,
|
||||||
|
code: {
|
||||||
|
html?: string
|
||||||
|
css?: string
|
||||||
|
js?: string
|
||||||
|
prompt?: string
|
||||||
|
},
|
||||||
|
messageId?: number,
|
||||||
|
) {
|
||||||
|
const { prompt, ...rest } = code
|
||||||
|
const data = {
|
||||||
|
task_id: taskId,
|
||||||
|
...rest,
|
||||||
|
prompt: prompt || null,
|
||||||
|
message_id: messageId ?? null,
|
||||||
|
}
|
||||||
|
const res = await http.post("/submission/", data)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(query: {
|
||||||
|
page: number
|
||||||
|
page_size?: number
|
||||||
|
username?: string
|
||||||
|
user_id?: number
|
||||||
|
flag?: string | null
|
||||||
|
zone?: string
|
||||||
|
task_id?: number
|
||||||
|
task_type?: string
|
||||||
|
score_min?: number
|
||||||
|
score_max_exclusive?: number
|
||||||
|
score_lt_threshold?: number
|
||||||
|
ordering?: string
|
||||||
|
grouped?: boolean
|
||||||
|
}) {
|
||||||
|
const res = await http.get("/submission", {
|
||||||
|
params: query,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listByUserTask(userId: number, taskId: number) {
|
||||||
|
const res = await http.get("/submission/by-user-task", {
|
||||||
|
params: { user_id: userId, task_id: taskId },
|
||||||
|
})
|
||||||
|
return res.data as SubmissionOut[]
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string) {
|
||||||
|
const res = await http.get("/submission/" + id)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPromptChain(id: string): Promise<PromptRound[]> {
|
||||||
|
const res = await http.get(`/submission/${id}/prompt-chain`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const res = await http.delete("/submission/" + id)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementView(id: string) {
|
||||||
|
await http.post(`/submission/${id}/view`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateScore(id: string, score: number) {
|
||||||
|
const res = await http.put(`/submission/${id}/score`, { score })
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async randomForRating(excludeId?: string) {
|
||||||
|
const res = await http.get("/submission/random-for-rating/", {
|
||||||
|
params: excludeId ? { exclude_id: excludeId } : {},
|
||||||
|
})
|
||||||
|
return res.data as RandomRatingItem | null
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateFlag(id: string, flag: FlagType) {
|
||||||
|
const res = await http.put(`/submission/${id}/flag`, { flag })
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearAllFlags() {
|
||||||
|
const res = await http.delete(`/submission/flags`)
|
||||||
|
return res.data as { cleared: number }
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
|
||||||
|
const params: Record<string, string | number> = {}
|
||||||
|
if (classname) params.classname = classname
|
||||||
|
const res = await http.get(`/submission/stats/${taskId}`, { params })
|
||||||
|
return res.data as TaskStatsOut
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradebookParams(query: GradebookQuery) {
|
||||||
|
const params: Record<string, string | boolean> = {
|
||||||
|
classname: query.classname,
|
||||||
|
}
|
||||||
|
if (query.task_type) params.task_type = query.task_type
|
||||||
|
if (query.username) params.username = query.username
|
||||||
|
if (query.include_all_tasks) params.include_all_tasks = true
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromDisposition(
|
||||||
|
disposition: string | undefined,
|
||||||
|
fallback: string,
|
||||||
|
) {
|
||||||
|
const match = disposition?.match(/filename\*=UTF-8''([^;]+)/)
|
||||||
|
return match ? decodeURIComponent(match[1]) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Gradebook = {
|
||||||
|
async get(query: GradebookQuery): Promise<GradebookOut> {
|
||||||
|
const res = await http.get("/submission/gradebook/", {
|
||||||
|
params: gradebookParams(query),
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadCsv(query: GradebookQuery) {
|
||||||
|
const res = await http.get("/submission/gradebook/export/", {
|
||||||
|
params: gradebookParams(query),
|
||||||
|
responseType: "blob",
|
||||||
|
})
|
||||||
|
const blob = new Blob([res.data], { type: "text/csv;charset=utf-8" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filenameFromDisposition(
|
||||||
|
res.headers["content-disposition"],
|
||||||
|
`gradebook-${query.classname}.csv`,
|
||||||
|
)
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Prompt = {
|
||||||
|
async listConversations(taskId?: number, userId?: number) {
|
||||||
|
const params: Record<string, number> = {}
|
||||||
|
if (taskId) params.task_id = taskId
|
||||||
|
if (userId) params.user_id = userId
|
||||||
|
return (await http.get("/prompt/conversations/", { params })).data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listHistory(taskId: number): Promise<PromptHistoryItem[]> {
|
||||||
|
const res = await http.get(`/prompt/history/${taskId}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessages(conversationId: string): Promise<PromptMessage[]> {
|
||||||
|
return (
|
||||||
|
await http.get<PromptMessage[]>(
|
||||||
|
`/prompt/conversations/${conversationId}/messages/`,
|
||||||
|
)
|
||||||
|
).data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessagesByUserTask(
|
||||||
|
taskId: number,
|
||||||
|
userId: number,
|
||||||
|
): Promise<PromptMessage[]> {
|
||||||
|
const convs = await this.listConversations(taskId, userId)
|
||||||
|
if (!convs.length) return []
|
||||||
|
return this.getMessages(convs[0].id)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMessagePair(
|
||||||
|
messageId: number,
|
||||||
|
): Promise<{ deleted: boolean; submission_deleted: boolean }> {
|
||||||
|
const res = await http.delete(`/prompt/messages/${messageId}/pair`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Helper = {
|
||||||
|
async upload(file: File) {
|
||||||
|
const form = new window.FormData()
|
||||||
|
form.append("image", file)
|
||||||
|
const res = await http.post("/upload/", form, {
|
||||||
|
headers: { "content-type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
return !!res.data.url ? res.data.url : ""
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Showcase = {
|
||||||
|
async list(): Promise<AwardSection[]> {
|
||||||
|
const res = await http.get("/submission/showcase/")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listManageAwards(): Promise<AwardManageOut[]> {
|
||||||
|
const res = await http.get("/submission/showcase/manage/awards")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAward(payload: AwardManageIn): Promise<AwardManageOut> {
|
||||||
|
const res = await http.post("/submission/showcase/manage/awards", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAward(
|
||||||
|
id: number,
|
||||||
|
payload: AwardManageIn,
|
||||||
|
): Promise<AwardManageOut> {
|
||||||
|
const res = await http.put(
|
||||||
|
`/submission/showcase/manage/awards/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAward(id: number) {
|
||||||
|
const res = await http.delete(`/submission/showcase/manage/awards/${id}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAwardItems(id: number): Promise<AwardItemManageOut[]> {
|
||||||
|
const res = await http.get(`/submission/showcase/manage/awards/${id}/items`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async findSubmissionForAward(
|
||||||
|
submissionId: string,
|
||||||
|
): Promise<ShowcaseSubmissionLookupOut> {
|
||||||
|
const res = await http.get(
|
||||||
|
`/submission/showcase/manage/submissions/${submissionId}`,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async addAwardItem(
|
||||||
|
id: number,
|
||||||
|
payload: AwardItemIn,
|
||||||
|
): Promise<AwardItemManageOut> {
|
||||||
|
const res = await http.post(
|
||||||
|
`/submission/showcase/manage/awards/${id}/items`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAwardItem(
|
||||||
|
itemId: number,
|
||||||
|
payload: AwardItemUpdateIn,
|
||||||
|
): Promise<AwardItemManageOut> {
|
||||||
|
const res = await http.put(
|
||||||
|
`/submission/showcase/manage/items/${itemId}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAwardItem(itemId: number) {
|
||||||
|
const res = await http.delete(`/submission/showcase/manage/items/${itemId}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshAwardItem(itemId: number): Promise<AwardItemManageOut> {
|
||||||
|
const res = await http.put(
|
||||||
|
`/submission/showcase/manage/items/${itemId}/refresh`,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDetail(submissionId: string): Promise<ShowcaseDetail> {
|
||||||
|
const res = await http.get(`/submission/showcase/${submissionId}/`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPromptChain(submissionId: string): Promise<PromptRound[]> {
|
||||||
|
const res = await http.get(
|
||||||
|
`/submission/showcase/${submissionId}/prompt-chain/`,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskAssets = {
|
||||||
|
async listChallenge(display: number): Promise<TaskAsset[]> {
|
||||||
|
return (await http.get<TaskAsset[]>(`/assets/challenge/${display}`)).data
|
||||||
|
},
|
||||||
|
async uploadChallenge(
|
||||||
|
display: number,
|
||||||
|
name: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<TaskAsset> {
|
||||||
|
const form = new window.FormData()
|
||||||
|
form.append("name", name)
|
||||||
|
form.append("file", file)
|
||||||
|
return (
|
||||||
|
await http.post<TaskAsset>(`/assets/challenge/${display}`, form, {
|
||||||
|
headers: { "content-type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
},
|
||||||
|
async deleteChallenge(display: number, name: string) {
|
||||||
|
return (await http.delete(`/assets/challenge/${display}/${name}`)).data
|
||||||
|
},
|
||||||
|
async listTutorial(display: number): Promise<TaskAsset[]> {
|
||||||
|
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
||||||
|
},
|
||||||
|
async uploadTutorial(
|
||||||
|
display: number,
|
||||||
|
name: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<TaskAsset> {
|
||||||
|
const form = new window.FormData()
|
||||||
|
form.append("name", name)
|
||||||
|
form.append("file", file)
|
||||||
|
return (
|
||||||
|
await http.post<TaskAsset>(`/assets/tutorial/${display}`, form, {
|
||||||
|
headers: { "content-type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
},
|
||||||
|
async deleteTutorial(display: number, name: string) {
|
||||||
|
return (await http.delete(`/assets/tutorial/${display}/${name}`)).data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
preset="card"
|
||||||
|
title="登录"
|
||||||
|
style="width: 420px"
|
||||||
|
v-model:show="loginModal"
|
||||||
|
>
|
||||||
|
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
|
||||||
|
<n-tab-pane name="student" tab="学生登录">
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="班级">
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedClass"
|
||||||
|
:options="classOptions"
|
||||||
|
placeholder="选择班级"
|
||||||
|
:loading="classesLoading"
|
||||||
|
@update:value="onClassChange"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="姓名">
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedUsername"
|
||||||
|
:options="nameOptions"
|
||||||
|
placeholder="选择姓名"
|
||||||
|
:loading="namesLoading"
|
||||||
|
:disabled="!selectedClass || namesLoading"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="密码">
|
||||||
|
<n-input
|
||||||
|
type="password"
|
||||||
|
v-model:value="studentPassword"
|
||||||
|
name="password"
|
||||||
|
@keyup.enter="submitStudent"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-alert
|
||||||
|
type="error"
|
||||||
|
v-if="classesError"
|
||||||
|
class="message"
|
||||||
|
title="加载班级列表失败,请刷新重试"
|
||||||
|
/>
|
||||||
|
<n-alert
|
||||||
|
type="error"
|
||||||
|
v-if="namesError"
|
||||||
|
class="message"
|
||||||
|
title="加载姓名列表失败,请重新选择班级"
|
||||||
|
/>
|
||||||
|
<n-alert
|
||||||
|
type="error"
|
||||||
|
v-if="showStudentError"
|
||||||
|
class="message"
|
||||||
|
title="登录失败,请检查密码"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
:loading="studentLoading"
|
||||||
|
:disabled="!selectedClass || !selectedUsername || !studentPassword"
|
||||||
|
@click="submitStudent"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</n-button>
|
||||||
|
</n-form>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="admin" tab="管理员登录">
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="用户名">
|
||||||
|
<n-input v-model:value="adminName" name="username" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="密码">
|
||||||
|
<n-input
|
||||||
|
type="password"
|
||||||
|
v-model:value="adminPassword"
|
||||||
|
name="password"
|
||||||
|
@keyup.enter="submitAdmin"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-alert
|
||||||
|
type="error"
|
||||||
|
v-if="showAdminError"
|
||||||
|
class="message"
|
||||||
|
title="登录失败,请检查用户名和密码"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
:loading="adminLoading"
|
||||||
|
:disabled="!adminName || !adminPassword"
|
||||||
|
@click="submitAdmin"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</n-button>
|
||||||
|
</n-form>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, onMounted } from "vue"
|
||||||
|
import { Account } from "../api"
|
||||||
|
import { loginModal } from "../store/modal"
|
||||||
|
import { user } from "../store/user"
|
||||||
|
import { router } from "../router"
|
||||||
|
|
||||||
|
// Tab state
|
||||||
|
const activeTab = ref("student")
|
||||||
|
|
||||||
|
// Student tab state
|
||||||
|
const selectedClass = ref<string | null>(null)
|
||||||
|
const selectedUsername = ref<string | null>(null)
|
||||||
|
const studentPassword = ref("")
|
||||||
|
const studentLoading = ref(false)
|
||||||
|
const showStudentError = ref(false)
|
||||||
|
|
||||||
|
// Classes data
|
||||||
|
const classes = ref<string[]>([])
|
||||||
|
const classesLoading = ref(false)
|
||||||
|
const classesError = ref(false)
|
||||||
|
const classOptions = computed(() =>
|
||||||
|
classes.value.map((c) => ({ label: c, value: c })),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Names data
|
||||||
|
const names = ref<{ name: string; username: string }[]>([])
|
||||||
|
const namesLoading = ref(false)
|
||||||
|
const namesError = ref(false)
|
||||||
|
const nameOptions = computed(() =>
|
||||||
|
names.value.map((n) => ({ label: n.name, value: n.username })),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin tab state
|
||||||
|
const adminName = ref("")
|
||||||
|
const adminPassword = ref("")
|
||||||
|
const adminLoading = ref(false)
|
||||||
|
const showAdminError = ref(false)
|
||||||
|
|
||||||
|
// Load classes on mount (cached — not reloaded on tab switch)
|
||||||
|
onMounted(async () => {
|
||||||
|
classesLoading.value = true
|
||||||
|
classesError.value = false
|
||||||
|
try {
|
||||||
|
classes.value = await Account.listClasses()
|
||||||
|
} catch {
|
||||||
|
classesError.value = true
|
||||||
|
} finally {
|
||||||
|
classesLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTabChange() {
|
||||||
|
// Reset student tab state
|
||||||
|
selectedClass.value = null
|
||||||
|
selectedUsername.value = null
|
||||||
|
studentPassword.value = ""
|
||||||
|
showStudentError.value = false
|
||||||
|
namesError.value = false
|
||||||
|
names.value = []
|
||||||
|
// Reset admin tab state
|
||||||
|
adminName.value = ""
|
||||||
|
adminPassword.value = ""
|
||||||
|
showAdminError.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClassChange(classname: string) {
|
||||||
|
selectedUsername.value = null
|
||||||
|
names.value = []
|
||||||
|
namesError.value = false
|
||||||
|
namesLoading.value = true
|
||||||
|
try {
|
||||||
|
names.value = await Account.listNamesByClass(classname)
|
||||||
|
} catch {
|
||||||
|
namesError.value = true
|
||||||
|
} finally {
|
||||||
|
namesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitStudent() {
|
||||||
|
if (!selectedUsername.value || !studentPassword.value) return
|
||||||
|
studentLoading.value = true
|
||||||
|
showStudentError.value = false
|
||||||
|
try {
|
||||||
|
const data = await Account.login(
|
||||||
|
selectedUsername.value,
|
||||||
|
studentPassword.value,
|
||||||
|
)
|
||||||
|
user.username = data.username
|
||||||
|
user.role = data.role
|
||||||
|
user.loaded = true
|
||||||
|
loginModal.value = false
|
||||||
|
router.replace(
|
||||||
|
window.location.pathname + window.location.search + window.location.hash,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
showStudentError.value = true
|
||||||
|
} finally {
|
||||||
|
studentLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdmin() {
|
||||||
|
if (!adminName.value || !adminPassword.value) return
|
||||||
|
adminLoading.value = true
|
||||||
|
showAdminError.value = false
|
||||||
|
try {
|
||||||
|
const data = await Account.login(adminName.value, adminPassword.value)
|
||||||
|
user.username = data.username
|
||||||
|
user.role = data.role
|
||||||
|
user.loaded = true
|
||||||
|
loginModal.value = false
|
||||||
|
router.replace(
|
||||||
|
window.location.pathname + window.location.search + window.location.hash,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
showAdminError.value = true
|
||||||
|
} finally {
|
||||||
|
adminLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="external-panel">
|
||||||
|
<div class="content-area">
|
||||||
|
<div class="field-label">提示词</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="promptText"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||||
|
placeholder="粘贴你发给外部 AI 的提示词..."
|
||||||
|
/>
|
||||||
|
<div class="code-field">
|
||||||
|
<div class="field-label" style="margin-top: 12px">完整的代码</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="rawCode"
|
||||||
|
type="textarea"
|
||||||
|
class="code-input"
|
||||||
|
placeholder="粘贴完整的前端代码..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="splitResult" class="split-result">
|
||||||
|
<n-tag size="small" type="success"
|
||||||
|
>HTML · {{ splitResult.html.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
<n-tag size="small" type="info"
|
||||||
|
>CSS · {{ splitResult.css.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
<n-tag size="small" type="warning"
|
||||||
|
>JS · {{ splitResult.js.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<n-button :disabled="!rawCode.trim()" @click="applyPreview"
|
||||||
|
>应用预览</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!splitResult || !promptText.trim()"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { html, css, js } from "../../store/editors"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
|
||||||
|
const props = defineProps<{ taskId: number }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: []
|
||||||
|
}>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const promptText = ref("")
|
||||||
|
const rawCode = ref("")
|
||||||
|
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
watch(rawCode, () => {
|
||||||
|
splitResult.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
function splitHtml(raw: string): { html: string; css: string; js: string } {
|
||||||
|
let result = raw
|
||||||
|
const cssBlocks: string[] = []
|
||||||
|
const jsBlocks: string[] = []
|
||||||
|
|
||||||
|
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
|
||||||
|
cssBlocks.push(content.trim())
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
result = result.replace(
|
||||||
|
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||||
|
(_, content) => {
|
||||||
|
jsBlocks.push(content.trim())
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: result.trim(),
|
||||||
|
css: cssBlocks.join("\n\n"),
|
||||||
|
js: jsBlocks.join("\n\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreview() {
|
||||||
|
const result = splitHtml(rawCode.value)
|
||||||
|
splitResult.value = result
|
||||||
|
html.value = result.html
|
||||||
|
css.value = result.css
|
||||||
|
js.value = result.js
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!splitResult.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await Submission.create(props.taskId, {
|
||||||
|
html: splitResult.value.html,
|
||||||
|
css: splitResult.value.css,
|
||||||
|
js: splitResult.value.js,
|
||||||
|
prompt: promptText.value.trim(),
|
||||||
|
})
|
||||||
|
emit("submitted")
|
||||||
|
message.success("提交成功")
|
||||||
|
promptText.value = ""
|
||||||
|
rawCode.value = ""
|
||||||
|
splitResult.value = null
|
||||||
|
} catch {
|
||||||
|
message.error("提交失败,请重试")
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.external-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-field {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input :deep(.n-input__textarea) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input :deep(.n-input__textarea-el) {
|
||||||
|
height: 100%;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-result {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="isOpen"
|
||||||
|
preset="card"
|
||||||
|
title="调教提示词"
|
||||||
|
style="width: min(560px, 92vw)"
|
||||||
|
:mask-closable="!streaming"
|
||||||
|
@update:show="onModalClose"
|
||||||
|
>
|
||||||
|
<div class="guidance-body">
|
||||||
|
<div class="guidance-messages" ref="messagesRef">
|
||||||
|
<div
|
||||||
|
v-for="(msg, i) in messages"
|
||||||
|
:key="msg.id ?? i"
|
||||||
|
class="guidance-msg"
|
||||||
|
:class="msg.role"
|
||||||
|
>
|
||||||
|
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
|
||||||
|
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="streaming" class="guidance-msg assistant">
|
||||||
|
<div class="msg-role">AI</div>
|
||||||
|
<div v-if="!displayStreamingContent" class="typing-indicator">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="msg-content"
|
||||||
|
v-html="renderMarkdown(displayStreamingContent)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isReady" class="ready-hint">
|
||||||
|
<Icon icon="lucide:check-circle" :width="16" />
|
||||||
|
提示词已优化,可以生成代码了!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="guidance-input">
|
||||||
|
<n-input
|
||||||
|
v-model:value="draftPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||||
|
placeholder="修改你的提示词后发送..."
|
||||||
|
:disabled="streaming"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
/>
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
||||||
|
<div>
|
||||||
|
<n-button
|
||||||
|
v-if="streaming"
|
||||||
|
type="error"
|
||||||
|
size="small"
|
||||||
|
@click="stopGuidance"
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-else
|
||||||
|
size="small"
|
||||||
|
:disabled="!draftPrompt.trim() || !connected"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<n-button
|
||||||
|
:type="isReady ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
:disabled="!draftPrompt.trim()"
|
||||||
|
@click="generate"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:play" :width="14" />
|
||||||
|
</template>
|
||||||
|
生成代码
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
import {
|
||||||
|
messages,
|
||||||
|
streaming,
|
||||||
|
streamingContent,
|
||||||
|
isReady,
|
||||||
|
isOpen,
|
||||||
|
connected,
|
||||||
|
initialPrompt,
|
||||||
|
sendGuidance,
|
||||||
|
stopGuidance,
|
||||||
|
closeGuidance,
|
||||||
|
} from "../../store/guidance"
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
generate: [prompt: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const draftPrompt = ref("")
|
||||||
|
const messagesRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const displayStreamingContent = computed(() =>
|
||||||
|
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const text = draftPrompt.value.trim()
|
||||||
|
if (!text || streaming.value) return
|
||||||
|
sendGuidance(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate() {
|
||||||
|
const text = draftPrompt.value.trim()
|
||||||
|
if (!text) return
|
||||||
|
emit("generate", text)
|
||||||
|
closeGuidance()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModalClose(show: boolean) {
|
||||||
|
if (!show && !streaming.value) closeGuidance()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (val) => {
|
||||||
|
if (val) draftPrompt.value = initialPrompt.value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([() => messages.value.length, streamingContent], () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesRef.value) {
|
||||||
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.guidance-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-messages {
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-msg .msg-role {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-msg.user .msg-role {
|
||||||
|
color: #2080f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-msg.assistant .msg-role {
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-msg.assistant .msg-content {
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-left: 3px solid #2080f0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-msg.user .msg-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-content :deep(p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #18a058;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f0fff4;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #b8e8cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2080f0;
|
||||||
|
animation: bounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<template>
|
||||||
|
<div class="history-panel">
|
||||||
|
<n-flex
|
||||||
|
class="history-toolbar"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
:wrap="false"
|
||||||
|
>
|
||||||
|
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
quaternary
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:loading="loading"
|
||||||
|
aria-label="刷新历史对话"
|
||||||
|
@click="load(true)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:refresh-cw" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
刷新历史对话
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin v-if="loading" class="state" />
|
||||||
|
<n-empty
|
||||||
|
v-else-if="!items.length"
|
||||||
|
class="state"
|
||||||
|
description="暂无历史对话"
|
||||||
|
/>
|
||||||
|
<n-scrollbar v-else class="history-scrollbar">
|
||||||
|
<n-flex vertical :size="12" class="history-list">
|
||||||
|
<n-card
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.assistant_message_id"
|
||||||
|
class="history-card"
|
||||||
|
:class="{
|
||||||
|
'is-selected':
|
||||||
|
selectedAssistantMessageId === item.assistant_message_id,
|
||||||
|
}"
|
||||||
|
size="small"
|
||||||
|
:bordered="true"
|
||||||
|
hoverable
|
||||||
|
:embedded="selectedAssistantMessageId === item.assistant_message_id"
|
||||||
|
:content-style="{ padding: 0 }"
|
||||||
|
@click="selectItem(item)"
|
||||||
|
>
|
||||||
|
<n-flex
|
||||||
|
class="history-main"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
:wrap="false"
|
||||||
|
>
|
||||||
|
<n-flex align="center" :wrap="false" :size="6">
|
||||||
|
<n-tag
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
:bordered="false"
|
||||||
|
:type="
|
||||||
|
selectedAssistantMessageId === item.assistant_message_id
|
||||||
|
? 'success'
|
||||||
|
: 'default'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
#{{ index + 1 }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:type="item.source === 'manual' ? 'info' : 'success'"
|
||||||
|
>
|
||||||
|
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center" :wrap="false" :size="4">
|
||||||
|
<n-tag
|
||||||
|
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
正在预览
|
||||||
|
</n-tag>
|
||||||
|
<n-text depth="3">
|
||||||
|
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
|
||||||
|
</n-text>
|
||||||
|
<n-tooltip placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
quaternary
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:loading="deletingId === item.assistant_message_id"
|
||||||
|
:disabled="deletingId !== null"
|
||||||
|
@click="deleteItem(item, $event)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:trash-2" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
删除这条历史对话
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<div
|
||||||
|
class="prompt-markdown markdown-body"
|
||||||
|
v-html="renderMarkdown(item.prompt)"
|
||||||
|
/>
|
||||||
|
<div class="thumbnail" aria-label="页面缩略图">
|
||||||
|
<iframe
|
||||||
|
v-if="item.hasPage"
|
||||||
|
title="页面缩略图"
|
||||||
|
loading="lazy"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
tabindex="-1"
|
||||||
|
:srcdoc="item.previewDoc"
|
||||||
|
/>
|
||||||
|
<n-empty v-else size="small" description="未生成页面" />
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
</n-scrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
import { Prompt } from "../../api"
|
||||||
|
import type { PromptHistoryItem } from "../../utils/type"
|
||||||
|
import { parseTime } from "../../utils/helper"
|
||||||
|
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: number
|
||||||
|
active: boolean
|
||||||
|
assetBaseUrl?: string
|
||||||
|
refreshKey?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [code: { html: string; css: string; js: string }]
|
||||||
|
deleted: [assistantMessageId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type HistoryViewItem = PromptHistoryItem & {
|
||||||
|
hasPage: boolean
|
||||||
|
previewDoc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = ref<HistoryViewItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedAssistantMessageId = ref<number | null>(null)
|
||||||
|
const deletingId = ref<number | null>(null)
|
||||||
|
let loadedTaskId = 0
|
||||||
|
let pendingRefresh = false
|
||||||
|
|
||||||
|
const naiveMessage = useMessage()
|
||||||
|
|
||||||
|
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
||||||
|
const html = item.code_html ?? ""
|
||||||
|
const css = item.code_css ?? ""
|
||||||
|
const js = item.code_js ?? ""
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
hasPage: !!(html.trim() || css.trim() || js.trim()),
|
||||||
|
previewDoc: buildPreviewDocument({
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
js,
|
||||||
|
assetBaseUrl: props.assetBaseUrl,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(item: HistoryViewItem, e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (deletingId.value !== null) return
|
||||||
|
deletingId.value = item.assistant_message_id
|
||||||
|
try {
|
||||||
|
await Prompt.deleteMessagePair(item.assistant_message_id)
|
||||||
|
items.value = items.value.filter(
|
||||||
|
(i) => i.assistant_message_id !== item.assistant_message_id,
|
||||||
|
)
|
||||||
|
if (selectedAssistantMessageId.value === item.assistant_message_id) {
|
||||||
|
selectedAssistantMessageId.value = null
|
||||||
|
}
|
||||||
|
emit("deleted", item.assistant_message_id)
|
||||||
|
naiveMessage.success("已删除")
|
||||||
|
} catch (error: any) {
|
||||||
|
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: HistoryViewItem) {
|
||||||
|
selectedAssistantMessageId.value = item.assistant_message_id
|
||||||
|
emit("select", {
|
||||||
|
html: item.code_html ?? "",
|
||||||
|
css: item.code_css ?? "",
|
||||||
|
js: item.code_js ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(force = true) {
|
||||||
|
if (!props.taskId || loading.value) return
|
||||||
|
if (!force && loadedTaskId === props.taskId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const taskChanged = loadedTaskId !== props.taskId
|
||||||
|
const data = await Prompt.listHistory(props.taskId)
|
||||||
|
items.value = data.map(toViewItem)
|
||||||
|
if (
|
||||||
|
taskChanged ||
|
||||||
|
!items.value.some(
|
||||||
|
(item) =>
|
||||||
|
item.assistant_message_id === selectedAssistantMessageId.value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
selectedAssistantMessageId.value = null
|
||||||
|
}
|
||||||
|
loadedTaskId = props.taskId
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.active, props.taskId] as const,
|
||||||
|
([active]) => {
|
||||||
|
if (active) {
|
||||||
|
if (pendingRefresh) {
|
||||||
|
pendingRefresh = false
|
||||||
|
load(true)
|
||||||
|
} else {
|
||||||
|
load(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.refreshKey,
|
||||||
|
() => {
|
||||||
|
if (props.active) load(true)
|
||||||
|
else pendingRefresh = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.active) load(false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.history-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-toolbar {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.history-card.is-selected {
|
||||||
|
--n-color: #f7fffa;
|
||||||
|
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card.is-selected .history-main {
|
||||||
|
background: #eefaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-main {
|
||||||
|
padding: 10px 12px;
|
||||||
|
transition: background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(p),
|
||||||
|
.prompt-markdown :deep(ul),
|
||||||
|
.prompt-markdown :deep(ol),
|
||||||
|
.prompt-markdown :deep(blockquote),
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
padding: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(code) {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
height: 160px;
|
||||||
|
border-top: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
background: #f7f7f9;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card.is-selected .thumbnail {
|
||||||
|
border-top-color: #d8f1e2;
|
||||||
|
background: #f4fbf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prompt-panel">
|
||||||
|
<div class="messages" ref="messagesRef">
|
||||||
|
<div
|
||||||
|
v-for="(pair, pi) in pairs"
|
||||||
|
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
||||||
|
class="message-pair"
|
||||||
|
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
||||||
|
>
|
||||||
|
<!-- Delete button: only shown on hover when pair has assistant id and not streaming -->
|
||||||
|
<button
|
||||||
|
v-if="pair.assistantMsg?.id && !streaming"
|
||||||
|
class="pair-delete-btn"
|
||||||
|
@click="deletePair(pair.assistantMsg!.id!)"
|
||||||
|
title="删除这轮对话及关联提交"
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:trash-2" :width="14" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- User message -->
|
||||||
|
<div class="message user">
|
||||||
|
<div class="message-role">我</div>
|
||||||
|
<div
|
||||||
|
class="message-content"
|
||||||
|
v-html="renderContent(pair.userMsg)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assistant message -->
|
||||||
|
<div v-if="pair.assistantMsg" class="message assistant">
|
||||||
|
<div class="message-role">AI</div>
|
||||||
|
<div
|
||||||
|
class="message-content"
|
||||||
|
v-html="renderContent(pair.assistantMsg)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-if="allowSubmit && hasCode(pair.assistantMsg)"
|
||||||
|
class="message-actions"
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
:disabled="pair.assistantMsg.submitted"
|
||||||
|
:loading="submittingId === pair.assistantMsg.id"
|
||||||
|
@click="submitVersion(pair.assistantMsg)"
|
||||||
|
>
|
||||||
|
{{ pair.assistantMsg.submitted ? "已提交" : "提交此版本" }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Streaming indicator -->
|
||||||
|
<div v-if="streaming" class="message assistant">
|
||||||
|
<div class="message-role">AI</div>
|
||||||
|
<div v-if="!streamingContent" class="typing-indicator">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="message-content"
|
||||||
|
v-html="renderMarkdown(streamingContent)"
|
||||||
|
></div>
|
||||||
|
<div class="streaming-hint">AI 正在生成中…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||||
|
<div class="input-area">
|
||||||
|
<n-flex
|
||||||
|
class="prompt-input-row"
|
||||||
|
align="flex-start"
|
||||||
|
:wrap="false"
|
||||||
|
:size="8"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
v-model:value="input"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||||
|
placeholder="描述你想要的网页效果..."
|
||||||
|
:disabled="streaming"
|
||||||
|
style="flex: 1; min-width: 0"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||||
|
style="flex-shrink: 0"
|
||||||
|
title="调教提示词"
|
||||||
|
aria-label="调教提示词"
|
||||||
|
@click="startGuidance"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:lightbulb" :width="16" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedModel"
|
||||||
|
:options="modelOptions"
|
||||||
|
style="width: 150px"
|
||||||
|
:disabled="streaming"
|
||||||
|
/>
|
||||||
|
<n-button v-if="streaming" type="error" @click="stopPrompt">
|
||||||
|
停止
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-else
|
||||||
|
type="primary"
|
||||||
|
:disabled="!input.trim() || !connected"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick, computed } from "vue"
|
||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import GuidancePanel from "./GuidancePanel.vue"
|
||||||
|
import { openGuidance } from "../../store/guidance"
|
||||||
|
import {
|
||||||
|
messages,
|
||||||
|
streaming,
|
||||||
|
streamingContent,
|
||||||
|
connected,
|
||||||
|
sendPrompt,
|
||||||
|
stopPrompt,
|
||||||
|
currentTaskId,
|
||||||
|
removeMessagePair,
|
||||||
|
markMessageSubmitted,
|
||||||
|
} from "../../store/prompt"
|
||||||
|
import { Prompt, Submission } from "../../api"
|
||||||
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
|
||||||
|
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ deleted: []; submitted: [] }>()
|
||||||
|
|
||||||
|
const input = ref("")
|
||||||
|
const messagesRef = ref<HTMLElement>()
|
||||||
|
const naiveMessage = useMessage()
|
||||||
|
|
||||||
|
const modelOptions = [
|
||||||
|
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" },
|
||||||
|
{ label: "DeepSeek", value: "deepseek-v4-flash" },
|
||||||
|
{ label: "DeepSeek(思考)", value: "deepseek-v4-flash-thinking" },
|
||||||
|
]
|
||||||
|
const selectedModel = useStorage("prompt-model", "deepseek-v4-flash")
|
||||||
|
|
||||||
|
// Group messages into user+assistant pairs
|
||||||
|
const submittingId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const pairs = computed(() => {
|
||||||
|
const result: Array<{
|
||||||
|
userMsg: { role: string; content: string; id?: number }
|
||||||
|
assistantMsg: {
|
||||||
|
role: string
|
||||||
|
content: string
|
||||||
|
id?: number
|
||||||
|
code?: any
|
||||||
|
submitted?: boolean
|
||||||
|
} | null
|
||||||
|
index: number
|
||||||
|
}> = []
|
||||||
|
const msgs = messages.value
|
||||||
|
let i = 0
|
||||||
|
while (i < msgs.length) {
|
||||||
|
if (msgs[i].role === "user") {
|
||||||
|
const assistantMsg =
|
||||||
|
msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
|
||||||
|
result.push({ userMsg: msgs[i], assistantMsg, index: i })
|
||||||
|
i += assistantMsg ? 2 : 1
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function hasCode(msg: { code?: any } | null): boolean {
|
||||||
|
if (!msg?.code) return false
|
||||||
|
return !!(msg.code.html || msg.code.css || msg.code.js)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitVersion(msg: {
|
||||||
|
id?: number
|
||||||
|
code?: { html: string | null; css: string | null; js: string | null }
|
||||||
|
}) {
|
||||||
|
if (!msg.id || !msg.code || !currentTaskId.value) return
|
||||||
|
submittingId.value = msg.id
|
||||||
|
try {
|
||||||
|
await Submission.create(
|
||||||
|
currentTaskId.value,
|
||||||
|
{
|
||||||
|
html: msg.code.html ?? "",
|
||||||
|
css: msg.code.css ?? "",
|
||||||
|
js: msg.code.js ?? "",
|
||||||
|
},
|
||||||
|
msg.id,
|
||||||
|
)
|
||||||
|
markMessageSubmitted(msg.id)
|
||||||
|
naiveMessage.success("提交成功")
|
||||||
|
emit("submitted")
|
||||||
|
} catch {
|
||||||
|
naiveMessage.error("提交失败,请重试")
|
||||||
|
} finally {
|
||||||
|
submittingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePair(assistantMsgId: number) {
|
||||||
|
try {
|
||||||
|
await Prompt.deleteMessagePair(assistantMsgId)
|
||||||
|
removeMessagePair(assistantMsgId)
|
||||||
|
naiveMessage.success("已删除")
|
||||||
|
emit("deleted")
|
||||||
|
} catch (error: any) {
|
||||||
|
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const text = input.value.trim()
|
||||||
|
if (!text || streaming.value) return
|
||||||
|
sendPrompt(text, selectedModel.value)
|
||||||
|
input.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGuidance() {
|
||||||
|
const text = input.value.trim()
|
||||||
|
if (!text || !currentTaskId.value) return
|
||||||
|
openGuidance(currentTaskId.value, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGuidanceGenerate(finalPrompt: string) {
|
||||||
|
sendPrompt(finalPrompt, selectedModel.value)
|
||||||
|
input.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(msg: { role: string; content: string }): string {
|
||||||
|
return renderMarkdown(msg.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([() => messages.value.length, streamingContent], () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesRef.value) {
|
||||||
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prompt-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-role {
|
||||||
|
color: #2080f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-role {
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(pre) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #18a058;
|
||||||
|
animation: bounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
margin-top: 4px;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-pair {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-pair .message {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-delete-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #bbb;
|
||||||
|
line-height: 1;
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-delete-btn:hover {
|
||||||
|
color: #e03e3e;
|
||||||
|
border-color: #e03e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-pair.has-delete:hover .pair-delete-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
preset="card"
|
||||||
|
:show="show"
|
||||||
|
title="等待 AI 回复时,给同学的作品打个分吧"
|
||||||
|
style="width: 90vw; max-width: 960px"
|
||||||
|
@update:show="onUpdateShow"
|
||||||
|
>
|
||||||
|
<n-text
|
||||||
|
v-if="current"
|
||||||
|
depth="3"
|
||||||
|
style="font-size: 12px; display: block; margin-bottom: 8px"
|
||||||
|
>
|
||||||
|
{{ current.task_title }} · 提交者 {{ current.username }}
|
||||||
|
</n-text>
|
||||||
|
<div class="preview-wrapper">
|
||||||
|
<iframe class="preview-iframe" :srcdoc="previewContent"></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="rate-row">
|
||||||
|
<n-rate
|
||||||
|
v-if="current"
|
||||||
|
:key="current.submission_id"
|
||||||
|
:size="32"
|
||||||
|
@update:value="onRate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n-text
|
||||||
|
depth="3"
|
||||||
|
style="font-size: 11px; display: block; text-align: center; margin-top: 8px"
|
||||||
|
>
|
||||||
|
打分后自动换下一个作品;AI 回复完成后弹窗会自动关闭
|
||||||
|
</n-text>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onUnmounted } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
import { streaming } from "../../store/prompt"
|
||||||
|
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||||
|
import type { RandomRatingItem } from "../../utils/type"
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const current = ref<RandomRatingItem | null>(null)
|
||||||
|
const previewContent = ref("")
|
||||||
|
let reshowTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearReshowTimer() {
|
||||||
|
if (reshowTimer !== null) {
|
||||||
|
clearTimeout(reshowTimer)
|
||||||
|
reshowTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndShow() {
|
||||||
|
try {
|
||||||
|
const item = await Submission.randomForRating(current.value?.submission_id)
|
||||||
|
if (item) {
|
||||||
|
current.value = item
|
||||||
|
previewContent.value = buildPreviewDocument({
|
||||||
|
html: item.html ?? "",
|
||||||
|
css: item.css ?? "",
|
||||||
|
js: item.js ?? "",
|
||||||
|
})
|
||||||
|
show.value = true
|
||||||
|
} else {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "获取作品失败,请重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRate(score: number) {
|
||||||
|
if (!current.value) return
|
||||||
|
try {
|
||||||
|
await Submission.updateScore(current.value.submission_id, score)
|
||||||
|
message.success("感谢评分!")
|
||||||
|
await fetchAndShow()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateShow(value: boolean) {
|
||||||
|
show.value = value
|
||||||
|
if (!value) {
|
||||||
|
clearReshowTimer()
|
||||||
|
if (streaming.value) {
|
||||||
|
reshowTimer = setTimeout(fetchAndShow, 30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
streaming,
|
||||||
|
(val) => {
|
||||||
|
clearReshowTimer()
|
||||||
|
if (val) {
|
||||||
|
fetchAndShow()
|
||||||
|
} else {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearReshowTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.preview-wrapper {
|
||||||
|
height: 70vh;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<MdEditor v-bind="props" v-model="modelValue" @upload-img="uploadImg" />
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { MdEditor } from "md-editor-v3"
|
||||||
|
//@ts-ignore
|
||||||
|
import "md-editor-v3/lib/style.css"
|
||||||
|
import { Helper } from "../../api"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const modelValue = defineModel<string>()
|
||||||
|
|
||||||
|
const props = defineProps()
|
||||||
|
|
||||||
|
const uploadImg = async (files: File[], callback: (urls: string[]) => void) => {
|
||||||
|
try {
|
||||||
|
const res = await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
const path = await Helper.upload(file)
|
||||||
|
if (!path) {
|
||||||
|
message.error("图片上传失败")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
callback(res.filter((url) => url !== ""))
|
||||||
|
} catch (err) {
|
||||||
|
message.error("图片上传失败")
|
||||||
|
callback([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex>
|
||||||
|
<n-button secondary v-if="user.role !== Role.Super" @click="edit">
|
||||||
|
修改
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
type="error"
|
||||||
|
v-if="user.role !== Role.Super && user.is_active"
|
||||||
|
@click="toggleUserActive"
|
||||||
|
>
|
||||||
|
封号
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
type="primary"
|
||||||
|
v-if="user.role !== Role.Super && !user.is_active"
|
||||||
|
@click="toggleUserActive"
|
||||||
|
>
|
||||||
|
解封
|
||||||
|
</n-button>
|
||||||
|
<UserFormModal
|
||||||
|
v-model:show="showEdit"
|
||||||
|
:user="user"
|
||||||
|
@saved="emits('reload')"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Account } from "../../api"
|
||||||
|
import { Role, type User } from "../../utils/type"
|
||||||
|
import UserFormModal from "./UserFormModal.vue"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emits = defineEmits(["reload"])
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const showEdit = ref(false)
|
||||||
|
|
||||||
|
function edit() {
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUserActive() {
|
||||||
|
const data = await Account.toggleActive(props.user.id)
|
||||||
|
message.success(data.message)
|
||||||
|
emits("reload")
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
style="width: 360px"
|
||||||
|
:mask-closable="false"
|
||||||
|
preset="card"
|
||||||
|
:title="user ? `修改 ${user.username}` : '新建用户'"
|
||||||
|
:show="show"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-form label-placement="left" label-width="60">
|
||||||
|
<n-form-item label="用户名">
|
||||||
|
<n-input v-model:value="form.username" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="密码">
|
||||||
|
<n-input
|
||||||
|
v-model:value="form.password"
|
||||||
|
:placeholder="user ? '不修改请留空' : '6 到 20 位'"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="权限">
|
||||||
|
<n-select v-model:value="form.role" :options="roles" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="班级">
|
||||||
|
<n-input v-model:value="form.classname" placeholder="可不填" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!form.username || (!user && !form.password)"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, ref, watch } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Account } from "../../api"
|
||||||
|
import { Role, type User } from "../../utils/type"
|
||||||
|
|
||||||
|
const props = defineProps<{ show: boolean; user?: User | null }>()
|
||||||
|
const emit = defineEmits(["update:show", "saved"])
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
role: Role.Normal,
|
||||||
|
classname: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
{ label: "普通用户", value: Role.Normal },
|
||||||
|
{ label: "管理员", value: Role.Admin },
|
||||||
|
]
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (!show) return
|
||||||
|
form.username = props.user?.username ?? ""
|
||||||
|
form.password = ""
|
||||||
|
form.role = props.user?.role ?? Role.Normal
|
||||||
|
form.classname = props.user?.classname ?? ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = props.user
|
||||||
|
? await Account.update(props.user.id, {
|
||||||
|
username: form.username,
|
||||||
|
role: form.role,
|
||||||
|
classname: form.classname,
|
||||||
|
...(form.password ? { password: form.password } : {}),
|
||||||
|
})
|
||||||
|
: await Account.create({ ...form })
|
||||||
|
message.success(data.message)
|
||||||
|
emit("update:show", false)
|
||||||
|
emit("saved")
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
message.error(
|
||||||
|
typeof detail === "string" ? detail : (detail?.[0]?.msg ?? "保存失败"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { EditorView } from "@codemirror/view"
|
||||||
|
import { Codemirror } from "vue-codemirror"
|
||||||
|
import { css } from "@codemirror/lang-css"
|
||||||
|
import { javascript } from "@codemirror/lang-javascript"
|
||||||
|
import { html } from "@codemirror/lang-html"
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const styleTheme = EditorView.baseTheme({
|
||||||
|
"& .cm-scroller": {
|
||||||
|
"font-family": "Monaco",
|
||||||
|
},
|
||||||
|
"&.cm-editor.cm-focused": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||||
|
"font-family": "Monaco",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const bgColors: Record<string, string> = {
|
||||||
|
html: "#fff6f3",
|
||||||
|
css: "#f3f6ff",
|
||||||
|
js: "#fffdf0",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
language?: "html" | "css" | "js"
|
||||||
|
fontSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
language: "html",
|
||||||
|
fontSize: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const code = defineModel<string>("value")
|
||||||
|
|
||||||
|
const lang = computed(() => {
|
||||||
|
if (props.language === "html") return html()
|
||||||
|
if (props.language === "css") return css()
|
||||||
|
return javascript()
|
||||||
|
})
|
||||||
|
|
||||||
|
const bgTheme = computed(() => {
|
||||||
|
const bg = bgColors[props.language] ?? "#ffffff"
|
||||||
|
return EditorView.theme({
|
||||||
|
"&": { backgroundColor: bg },
|
||||||
|
".cm-gutters": { backgroundColor: bg },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const extensions = computed(() => [styleTheme, bgTheme.value, lang.value])
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Codemirror
|
||||||
|
v-model="code"
|
||||||
|
indentWithTab
|
||||||
|
:extensions="extensions"
|
||||||
|
:tabSize="4"
|
||||||
|
:style="{ height: '100%', fontSize: props.fontSize + 'px' }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="editors-root">
|
||||||
|
<n-tabs
|
||||||
|
:value="tab"
|
||||||
|
:class="`tab-active-${tab}`"
|
||||||
|
pane-class="pane"
|
||||||
|
style="height: 100%"
|
||||||
|
type="card"
|
||||||
|
@update:value="changeTab"
|
||||||
|
>
|
||||||
|
<n-tab-pane name="html" tab="HTML">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:html"></Icon>
|
||||||
|
<span>HTML</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<Editor v-model:value="html" :font-size="size" language="html" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="css" tab="CSS">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:css"></Icon>
|
||||||
|
<span>CSS</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<Editor v-model:value="css" :font-size="size" language="css" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="js" tab="JS">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:javascript"></Icon>
|
||||||
|
<span>JS</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<Editor v-model:value="js" :font-size="size" language="js" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="actions" tab="选项">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
|
||||||
|
<span>选项</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-flex class="wrapper" vertical>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span class="label">重置</span>
|
||||||
|
<n-button @click="reset('html')">HTML</n-button>
|
||||||
|
<n-button @click="reset('css')">CSS</n-button>
|
||||||
|
<n-button @click="reset('js')">JS</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span class="label">字号</span>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
||||||
|
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
|
||||||
|
调小
|
||||||
|
</n-button>
|
||||||
|
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
||||||
|
调大
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span class="label">预加载</span>
|
||||||
|
<n-tag type="success">Normalize.css</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-tab-pane>
|
||||||
|
<template #suffix>
|
||||||
|
<Toolbar
|
||||||
|
:submit-loading="submitLoading"
|
||||||
|
@format="format"
|
||||||
|
@submit="formatAndSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import prettier from "prettier/standalone"
|
||||||
|
import * as htmlParser from "prettier/parser-html"
|
||||||
|
import * as cssParser from "prettier/parser-postcss"
|
||||||
|
import * as babelParser from "prettier/parser-babel"
|
||||||
|
import * as estreeParser from "prettier/plugins/estree"
|
||||||
|
import Editor from "./Editor.vue"
|
||||||
|
import Toolbar from "./Toolbar.vue"
|
||||||
|
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||||
|
import { taskId } from "../../store/task"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||||
|
import { h, ref } from "vue"
|
||||||
|
|
||||||
|
const dialog = useDialog()
|
||||||
|
const message = useMessage()
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
|
||||||
|
function changeTab(name: string) {
|
||||||
|
tab.value = name
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(num: number) {
|
||||||
|
size.value = num
|
||||||
|
}
|
||||||
|
|
||||||
|
async function formatCode() {
|
||||||
|
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
|
||||||
|
prettier.format(html.value, {
|
||||||
|
parser: "html",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(css.value, {
|
||||||
|
parser: "css",
|
||||||
|
plugins: [cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(js.value, {
|
||||||
|
parser: "babel",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [babelParser, estreeParser],
|
||||||
|
tabWidth: 2,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
html.value = htmlFormatted
|
||||||
|
css.value = cssFormatted
|
||||||
|
js.value = jsFormatted
|
||||||
|
}
|
||||||
|
|
||||||
|
async function format() {
|
||||||
|
try {
|
||||||
|
await formatCode()
|
||||||
|
} catch (err: any) {
|
||||||
|
dialog.error({
|
||||||
|
title: "格式化失败",
|
||||||
|
content: () => h(NCode, { code: err.message }),
|
||||||
|
style: { width: "auto" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSubmit() {
|
||||||
|
try {
|
||||||
|
await Submission.create(taskId.value, {
|
||||||
|
html: html.value,
|
||||||
|
css: css.value,
|
||||||
|
js: js.value,
|
||||||
|
})
|
||||||
|
message.success("提交成功")
|
||||||
|
} catch (err) {
|
||||||
|
message.error("提交失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function formatAndSubmit() {
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
await formatCode()
|
||||||
|
await doSubmit()
|
||||||
|
} catch (err: any) {
|
||||||
|
dialog.warning({
|
||||||
|
title: "代码整理失败",
|
||||||
|
content: () => h(NCode, { code: err.message }),
|
||||||
|
positiveText: "忽略并提交",
|
||||||
|
negativeText: "取消",
|
||||||
|
style: { width: "auto" },
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await doSubmit()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.pane {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
||||||
|
background-color: #fff6f3 !important;
|
||||||
|
}
|
||||||
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
|
||||||
|
background-color: #f3f6ff !important;
|
||||||
|
}
|
||||||
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
|
||||||
|
background-color: #fffdf0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editors-root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tab-active-html .n-tab-pane) {
|
||||||
|
background-color: #fff6f3;
|
||||||
|
}
|
||||||
|
:deep(.tab-active-css .n-tab-pane) {
|
||||||
|
background-color: #f3f6ff;
|
||||||
|
}
|
||||||
|
:deep(.tab-active-js .n-tab-pane) {
|
||||||
|
background-color: #fffdf0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
|
<div>
|
||||||
|
<n-text class="titleText">预览</n-text>
|
||||||
|
<n-text v-if="!!submission.id" depth="3">
|
||||||
|
({{ submission.view_count || 0 }})
|
||||||
|
</n-text>
|
||||||
|
</div>
|
||||||
|
<n-flex>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary @click="cycleLayout">
|
||||||
|
<template #icon>
|
||||||
|
<Icon :icon="layoutIcon" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ layoutLabel }}
|
||||||
|
</n-tooltip>
|
||||||
|
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||||
|
<n-button quaternary @click="open">全屏</n-button>
|
||||||
|
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
|
||||||
|
<n-button
|
||||||
|
quaternary
|
||||||
|
v-if="props.showCodeButton"
|
||||||
|
@click="emits('showCode')"
|
||||||
|
>
|
||||||
|
代码
|
||||||
|
</n-button>
|
||||||
|
<n-button quaternary v-if="props.submissionId" @click="copyLink">
|
||||||
|
链接
|
||||||
|
</n-button>
|
||||||
|
<n-flex v-if="!!submission.id" align="center">
|
||||||
|
<n-button quaternary @click="emits('showCode')">代码</n-button>
|
||||||
|
<n-popover v-if="submission.my_score === 0">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button secondary type="primary">打分</n-button>
|
||||||
|
</template>
|
||||||
|
<n-rate :size="30" @update:value="updateScore" />
|
||||||
|
</n-popover>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||||
|
<iframe class="iframe" :srcdoc="previewContent"></iframe>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { watchDebounced } from "@vueuse/core"
|
||||||
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
import { submission } from "../../store/submission"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import copy from "copy-text-to-clipboard"
|
||||||
|
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
assetBaseUrl?: string
|
||||||
|
submissionId?: string
|
||||||
|
showCodeButton?: boolean
|
||||||
|
clearable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
||||||
|
|
||||||
|
type Layout = "desktop" | "mobile" | "tablet"
|
||||||
|
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
||||||
|
const layoutConfig: Record<
|
||||||
|
Layout,
|
||||||
|
{ icon: string; label: string; width: string }
|
||||||
|
> = {
|
||||||
|
desktop: {
|
||||||
|
icon: "material-symbols:desktop-windows-outline",
|
||||||
|
label: "桌面",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
icon: "material-symbols:smartphone-outline",
|
||||||
|
label: "移动端 (375px)",
|
||||||
|
width: "375px",
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
icon: "material-symbols:tablet-outline",
|
||||||
|
label: "平板 (768px)",
|
||||||
|
width: "768px",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const layoutIndex = ref(0)
|
||||||
|
const layoutIcon = computed(
|
||||||
|
() => layoutConfig[layouts[layoutIndex.value]!].icon,
|
||||||
|
)
|
||||||
|
const layoutLabel = computed(
|
||||||
|
() => layoutConfig[layouts[layoutIndex.value]!].label,
|
||||||
|
)
|
||||||
|
const iframeWrapperStyle = computed(() => ({
|
||||||
|
maxWidth: layoutConfig[layouts[layoutIndex.value]!].width,
|
||||||
|
}))
|
||||||
|
function cycleLayout() {
|
||||||
|
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const showDL = computed(() => props.html || props.css || props.js)
|
||||||
|
const previewContent = ref("")
|
||||||
|
|
||||||
|
function getContent() {
|
||||||
|
return buildPreviewDocument({
|
||||||
|
html: props.html,
|
||||||
|
css: props.css,
|
||||||
|
js: props.js,
|
||||||
|
assetBaseUrl: props.assetBaseUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview() {
|
||||||
|
previewContent.value = getContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const content = getContent()
|
||||||
|
const blob = new Blob([content], { type: "text/html" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = "index.html"
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (props.submissionId) {
|
||||||
|
const data = router.resolve({
|
||||||
|
name: "submission",
|
||||||
|
params: { id: props.submissionId },
|
||||||
|
})
|
||||||
|
window.open(data.href, "_blank")
|
||||||
|
} else {
|
||||||
|
const newTab = window.open("about:blank", "_blank")
|
||||||
|
if (!newTab) return
|
||||||
|
newTab.document.open()
|
||||||
|
newTab.document.write(getContent())
|
||||||
|
newTab.document.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
emits("clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
copy(`${document.location.origin}/submission/${props.submissionId}`)
|
||||||
|
message.success("该提交的链接已复制")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateScore(score: number) {
|
||||||
|
try {
|
||||||
|
const res = await Submission.updateScore(submission.value.id, score)
|
||||||
|
message.success(res.message)
|
||||||
|
submission.value.my_score = score
|
||||||
|
emits("afterScore")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response.data.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||||
|
preview,
|
||||||
|
{
|
||||||
|
debounce: 500,
|
||||||
|
maxWait: 1000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
onMounted(preview)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.title {
|
||||||
|
height: 43px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid rgb(239, 239, 245);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleText {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iframe-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
transition: max-width 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex align="center" class="corner">
|
||||||
|
<n-button quaternary v-if="!show" @click="showTutorial"> 教程 </n-button>
|
||||||
|
<template v-if="user.loaded && authed">
|
||||||
|
<n-button quaternary @click="emit('format')">整理</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
:disabled="submitDisabled"
|
||||||
|
:loading="submitLoading"
|
||||||
|
@click="emit('submit')"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
<n-dropdown :options="menu" @select="clickMenu">
|
||||||
|
<n-button>{{ user.username }}</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
</template>
|
||||||
|
<n-button
|
||||||
|
v-if="user.loaded && !authed"
|
||||||
|
@click="handleLogin"
|
||||||
|
secondary
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, h } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { authed, roleAdmin, roleNormal, user } from "../../store/user"
|
||||||
|
import { loginModal } from "../../store/modal"
|
||||||
|
import { show, panelSize } from "../../store/panel"
|
||||||
|
import { taskId } from "../../store/task"
|
||||||
|
import { Account } from "../../api"
|
||||||
|
import { Role } from "../../utils/type"
|
||||||
|
import { router } from "../../router"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
submitLoading: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits(["format", "submit"])
|
||||||
|
|
||||||
|
const submitDisabled = computed(() => {
|
||||||
|
return taskId.value === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const menu = computed(() => [
|
||||||
|
{
|
||||||
|
label: "后台管理",
|
||||||
|
key: "dashboard",
|
||||||
|
show: !roleNormal.value,
|
||||||
|
icon: () =>
|
||||||
|
h(Icon, {
|
||||||
|
icon: "streamline-emojis:robot-face-1",
|
||||||
|
width: 20,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "我的提交",
|
||||||
|
key: "submissions",
|
||||||
|
icon: () =>
|
||||||
|
h(Icon, {
|
||||||
|
icon: "streamline-emojis:bar-chart",
|
||||||
|
width: 20,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "退出账号",
|
||||||
|
key: "logout",
|
||||||
|
icon: () =>
|
||||||
|
h(Icon, {
|
||||||
|
icon: "streamline-emojis:hot-beverage-2",
|
||||||
|
width: 20,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function showTutorial() {
|
||||||
|
show.value = true
|
||||||
|
panelSize.value = 2 / 5
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickMenu(name: string) {
|
||||||
|
switch (name) {
|
||||||
|
case "dashboard": {
|
||||||
|
const route = roleAdmin.value ? "challenge-editor" : "tutorial-editor"
|
||||||
|
router.push({ name: route, params: { display: 0 } })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "submissions":
|
||||||
|
router.push({
|
||||||
|
name: "submissions",
|
||||||
|
params: { page: 1 },
|
||||||
|
query: { username: user.username },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case "logout":
|
||||||
|
handleLogout()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin() {
|
||||||
|
loginModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await Account.logout()
|
||||||
|
user.username = ""
|
||||||
|
user.role = Role.Normal
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.corner {
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
style="width: 90vw; max-width: 1400px"
|
||||||
|
@update:show="$emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex justify="start" align="center">
|
||||||
|
<span>提示词</span>
|
||||||
|
<n-tooltip placement="bottom-start">
|
||||||
|
<template #trigger>
|
||||||
|
<Icon icon="lucide:info" :width="20" style="color: #aaa" />
|
||||||
|
</template>
|
||||||
|
<div style="line-height: 2">
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
|
||||||
|
L1
|
||||||
|
</span>
|
||||||
|
— 记忆:复述或识别知识点
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[2], fontWeight: 'bold' }">
|
||||||
|
L2
|
||||||
|
</span>
|
||||||
|
— 理解:用自己的话解释概念
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[3], fontWeight: 'bold' }">
|
||||||
|
L3
|
||||||
|
</span>
|
||||||
|
— 应用:将知识用于新情境
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[4], fontWeight: 'bold' }">
|
||||||
|
L4
|
||||||
|
</span>
|
||||||
|
— 分析:拆解结构、找出规律
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[5], fontWeight: 'bold' }">
|
||||||
|
L5
|
||||||
|
</span>
|
||||||
|
— 评价:基于标准作出判断
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[6], fontWeight: 'bold' }">
|
||||||
|
L6
|
||||||
|
</span>
|
||||||
|
— 创造:整合信息产出新成果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!loading && rounds.length === 0"
|
||||||
|
description="暂无对话记录"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
style="
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
height: 75vh;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
border-right: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(round, index) in rounds"
|
||||||
|
:key="index"
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
"
|
||||||
|
@click="selectedRound = index"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: selectedRound === index ? '#2080f0' : '#c2d5fb',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: '2px',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
|
||||||
|
border:
|
||||||
|
selectedRound === index
|
||||||
|
? '1px solid #2080f0'
|
||||||
|
: '1px solid #e0e0e0',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="prompt-markdown markdown-body"
|
||||||
|
:class="{
|
||||||
|
'is-collapsed':
|
||||||
|
isPromptLong(round.question) && !isExpanded(index),
|
||||||
|
}"
|
||||||
|
v-html="renderMarkdown(round.question)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="round.source"
|
||||||
|
:style="{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '1px 5px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background:
|
||||||
|
round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
|
||||||
|
color:
|
||||||
|
round.source === 'conversation' ? '#2060c0' : '#888',
|
||||||
|
fontWeight: 500,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}"
|
||||||
|
>{{
|
||||||
|
round.source === "conversation" ? "对话" : "手动"
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="round.prompt_level"
|
||||||
|
:style="{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: levelColors[round.prompt_level],
|
||||||
|
}"
|
||||||
|
>L{{ round.prompt_level }}</span
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
v-if="isPromptLong(round.question)"
|
||||||
|
text
|
||||||
|
size="tiny"
|
||||||
|
type="primary"
|
||||||
|
@click.stop="toggleExpanded(index)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
isExpanded(index)
|
||||||
|
? 'lucide:chevron-up'
|
||||||
|
: 'lucide:chevron-down'
|
||||||
|
"
|
||||||
|
:width="12"
|
||||||
|
/>
|
||||||
|
{{ isExpanded(index) ? "收起" : "展开" }}
|
||||||
|
</n-button>
|
||||||
|
<n-popconfirm
|
||||||
|
v-if="round.assistantMsgId && canDelete"
|
||||||
|
:show-icon="false"
|
||||||
|
@positive-click="deleteRound(index)"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
size="tiny"
|
||||||
|
type="error"
|
||||||
|
style="margin-left: 2px"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:trash-2" :width="12" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
确定删除这一轮?
|
||||||
|
</n-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||||
|
<div style="font-weight: bold; font-size: 13px; color: #555">
|
||||||
|
第 {{ selectedRound + 1 }} 轮网页
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-if="selectedPageHtml"
|
||||||
|
:srcdoc="selectedPageHtml"
|
||||||
|
:key="selectedRound"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
style="
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { marked } from "marked"
|
||||||
|
import { Prompt, Submission } from "../../api"
|
||||||
|
import type { PromptRound } from "../../utils/type"
|
||||||
|
import { user, roleSuper } from "../../store/user"
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
submissionId: string
|
||||||
|
username?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canDelete = computed(
|
||||||
|
() =>
|
||||||
|
roleSuper.value || (!!props.username && props.username === user.username),
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{ "update:show": [value: boolean] }>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedRound = ref(0)
|
||||||
|
const expandedRounds = ref<Set<number>>(new Set())
|
||||||
|
type ChainRound = Omit<PromptRound, "source"> & {
|
||||||
|
source: string | null
|
||||||
|
assistantMsgId: number | null
|
||||||
|
}
|
||||||
|
const rounds = ref<ChainRound[]>([])
|
||||||
|
|
||||||
|
async function deleteRound(index: number) {
|
||||||
|
const round = rounds.value[index]
|
||||||
|
if (!round.assistantMsgId) return
|
||||||
|
try {
|
||||||
|
await Prompt.deleteMessagePair(round.assistantMsgId)
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadMessages()
|
||||||
|
if (selectedRound.value >= rounds.value.length) {
|
||||||
|
selectedRound.value = Math.max(0, rounds.value.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelColors: Record<number, string> = {
|
||||||
|
1: "#aaa",
|
||||||
|
2: "#6aab9c",
|
||||||
|
3: "#4a9ade",
|
||||||
|
4: "#c47ab8",
|
||||||
|
5: "#e0a040",
|
||||||
|
6: "#e05c5c",
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPageHtml = computed(() => {
|
||||||
|
const round = rounds.value[selectedRound.value]
|
||||||
|
if (!round?.html) return null
|
||||||
|
const style = round.css ? `<style>${round.css}</style>` : ""
|
||||||
|
const script = round.js ? `<script>${round.js}<\/script>` : ""
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderMarkdown(text: string): string {
|
||||||
|
return marked.parse(text) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromptLong(text: string): boolean {
|
||||||
|
return text.length > 220 || text.split(/\r?\n/).length > 4
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(index: number): boolean {
|
||||||
|
return expandedRounds.value.has(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(index: number) {
|
||||||
|
const next = new Set(expandedRounds.value)
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index)
|
||||||
|
} else {
|
||||||
|
next.add(index)
|
||||||
|
}
|
||||||
|
expandedRounds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
if (!props.submissionId) return
|
||||||
|
loading.value = true
|
||||||
|
rounds.value = []
|
||||||
|
selectedRound.value = 0
|
||||||
|
expandedRounds.value = new Set()
|
||||||
|
try {
|
||||||
|
const data = await Submission.getPromptChain(props.submissionId)
|
||||||
|
rounds.value = data.map((round) => ({
|
||||||
|
...round,
|
||||||
|
source: round.source ?? null,
|
||||||
|
assistantMsgId: round.assistant_msg_id ?? null,
|
||||||
|
}))
|
||||||
|
const last = rounds.value.length - 1
|
||||||
|
if (last >= 0) selectedRound.value = last
|
||||||
|
} catch {
|
||||||
|
rounds.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.submissionId] as const,
|
||||||
|
([visible]) => {
|
||||||
|
if (visible) loadMessages()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prompt-markdown {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown.is-collapsed {
|
||||||
|
position: relative;
|
||||||
|
max-height: 126px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(p),
|
||||||
|
.prompt-markdown :deep(ul),
|
||||||
|
.prompt-markdown :deep(ol),
|
||||||
|
.prompt-markdown :deep(blockquote),
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
padding: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(code) {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
preset="card"
|
||||||
|
:show="show"
|
||||||
|
style="max-width: 60%"
|
||||||
|
@update:show="$emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span>前端代码</span>
|
||||||
|
<n-button tertiary @click="$emit('copy-to-editor')"
|
||||||
|
>复制到编辑框</n-button
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-tabs animated type="segment">
|
||||||
|
<n-tab-pane name="html" tab="html">
|
||||||
|
<n-code :code="html" language="html" word-wrap />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="css" tab="css">
|
||||||
|
<n-code :code="css" language="css" word-wrap />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane v-if="!!js" name="js" tab="js">
|
||||||
|
<n-code :code="js" language="js" word-wrap />
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"update:show": [value: boolean]
|
||||||
|
"copy-to-editor": []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<n-spin v-if="loading" size="small" style="padding: 12px" />
|
||||||
|
<n-data-table
|
||||||
|
v-else-if="items"
|
||||||
|
:columns="subColumns"
|
||||||
|
:data="items"
|
||||||
|
size="small"
|
||||||
|
striped
|
||||||
|
:row-key="(r: SubmissionOut) => r.id"
|
||||||
|
:row-props="rowProps"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h } from "vue"
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NDataTable,
|
||||||
|
NPopconfirm,
|
||||||
|
NSpin,
|
||||||
|
type DataTableColumn,
|
||||||
|
} from "naive-ui"
|
||||||
|
import type { SubmissionOut } from "../../utils/type"
|
||||||
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
|
import { parseTime } from "../../utils/helper"
|
||||||
|
import { user, roleSuper } from "../../store/user"
|
||||||
|
import { submission } from "../../store/submission"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
row: SubmissionOut
|
||||||
|
items: SubmissionOut[] | undefined
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: string]
|
||||||
|
delete: [row: SubmissionOut, parentId: string]
|
||||||
|
"show-chain": [submissionId: string, username: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
|
||||||
|
|
||||||
|
function rowProps(r: SubmissionOut) {
|
||||||
|
return {
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
onClick: () => emit("select", r.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClassName(r: SubmissionOut) {
|
||||||
|
return submission.value.id === r.id ? "row-active" : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
|
||||||
|
{
|
||||||
|
title: "时间",
|
||||||
|
key: "created",
|
||||||
|
width: 160,
|
||||||
|
render: (r) => parseTime(r.created, "YYYY-MM-DD HH:mm:ss"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "得分",
|
||||||
|
key: "score",
|
||||||
|
width: 80,
|
||||||
|
render: (r) => {
|
||||||
|
const myScore = r.my_score > 0 ? String(r.my_score) : "-"
|
||||||
|
const avgScore = r.score > 0 ? r.score.toFixed(2) : "-"
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
||||||
|
[
|
||||||
|
h("span", avgScore),
|
||||||
|
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: isChallenge.value ? 110 : 60,
|
||||||
|
render: (r: SubmissionOut) =>
|
||||||
|
h("div", { style: { display: "flex", gap: "8px" } }, [
|
||||||
|
...(isChallenge.value
|
||||||
|
? [
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
type: "primary",
|
||||||
|
onClick: (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit("show-chain", r.id, r.username)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() => "查看",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(r.username === user.username || roleSuper.value
|
||||||
|
? [
|
||||||
|
h(
|
||||||
|
NPopconfirm,
|
||||||
|
{ onPositiveClick: () => emit("delete", r, props.row.id) },
|
||||||
|
{
|
||||||
|
trigger: () =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
type: "error",
|
||||||
|
size: "small",
|
||||||
|
onClick: (e: Event) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
() => "删除",
|
||||||
|
),
|
||||||
|
default: () => "确定删除这次提交?",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
} as DataTableColumn<SubmissionOut>,
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<n-popover v-if="isAdmin" trigger="click">
|
||||||
|
<template #trigger>
|
||||||
|
<span :style="dotStyle" />
|
||||||
|
</template>
|
||||||
|
<n-space vertical size="small">
|
||||||
|
<n-button
|
||||||
|
v-for="opt in FLAG_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
text
|
||||||
|
@click="$emit('update:flag', opt.value)"
|
||||||
|
>
|
||||||
|
<span style="display: flex; align-items: center; gap: 6px">
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: opt.color,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
{{ opt.label }}
|
||||||
|
</span>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="flag"
|
||||||
|
text
|
||||||
|
block
|
||||||
|
type="error"
|
||||||
|
@click="$emit('update:flag', null)"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-popover>
|
||||||
|
<span v-else :style="dotStyle" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import type { FlagType } from "../../utils/type"
|
||||||
|
|
||||||
|
const FLAG_OPTIONS: {
|
||||||
|
value: NonNullable<FlagType>
|
||||||
|
color: string
|
||||||
|
label: string
|
||||||
|
}[] = [
|
||||||
|
{ value: "red", color: "#e03030", label: "值得展示" },
|
||||||
|
{ value: "blue", color: "#2080f0", label: "需要讲解" },
|
||||||
|
{ value: "green", color: "#18a058", label: "优秀作品" },
|
||||||
|
{ value: "yellow", color: "#f0a020", label: "需要改进" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flag: FlagType
|
||||||
|
isAdmin: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ "update:flag": [value: FlagType] }>()
|
||||||
|
|
||||||
|
const dotStyle = computed(() => {
|
||||||
|
const match = FLAG_OPTIONS.find((f) => f.value === props.flag)
|
||||||
|
return {
|
||||||
|
display: "inline-block",
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: match ? match.color : "transparent",
|
||||||
|
border: match ? "none" : "1px dashed #ccc",
|
||||||
|
cursor: props.isAdmin ? "pointer" : "default",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex>
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:bordered="false"
|
||||||
|
type="primary"
|
||||||
|
v-if="props.submission.task_type === 'tutorial'"
|
||||||
|
>
|
||||||
|
教程
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
:bordered="false"
|
||||||
|
size="small"
|
||||||
|
type="error"
|
||||||
|
v-if="props.submission.task_type === 'challenge'"
|
||||||
|
>
|
||||||
|
挑战
|
||||||
|
</n-tag>
|
||||||
|
<n-button text @click="open">{{ props.submission.task_title }}</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import type { SubmissionOut } from "../../utils/type"
|
||||||
|
import { goHome } from "../../utils/helper"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
submission: SubmissionOut
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
function open() {
|
||||||
|
goHome(router, props.submission.task_type, props.submission.task_display)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
|
||||||
|
<n-empty v-if="!challenges.length">暂无挑战,敬请期待</n-empty>
|
||||||
|
<n-flex v-else vertical :size="12">
|
||||||
|
<n-card
|
||||||
|
v-for="item in challenges"
|
||||||
|
:key="item.display"
|
||||||
|
hoverable
|
||||||
|
:class="['challenge-card', { submitted: item.submitted }]"
|
||||||
|
@click="select(item)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex align="center" :size="6">
|
||||||
|
<span v-if="item.submitted" class="check-icon">✓</span>
|
||||||
|
<span :class="{ 'submitted-title': item.submitted }">{{
|
||||||
|
item.title
|
||||||
|
}}</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-flex :size="6">
|
||||||
|
<n-tag type="warning" size="small">{{ item.score }} 分</n-tag>
|
||||||
|
<n-tag v-if="item.pass_score != null" size="small"
|
||||||
|
>及格 {{ item.pass_score }} 分</n-tag
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-text depth="3" class="challenge-author">
|
||||||
|
出题人:{{ item.author_name || "未设置" }}
|
||||||
|
</n-text>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Challenge } from "../../api"
|
||||||
|
import { taskTab } from "../../store/task"
|
||||||
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
|
import type { ChallengeSlim } from "../../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const challenges = ref<ChallengeSlim[]>([])
|
||||||
|
|
||||||
|
function select(item: ChallengeSlim) {
|
||||||
|
router.push({ name: "home-challenge", params: { display: item.display } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
challenges.value = await Challenge.listDisplay()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-card.submitted {
|
||||||
|
background-color: #f6ffed;
|
||||||
|
border-color: #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: #52c41a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-title {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-author {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex vertical>
|
||||||
|
<n-button size="small" @click="showUpload = true">图片素材</n-button>
|
||||||
|
<n-flex v-if="assets.length" wrap>
|
||||||
|
<n-card
|
||||||
|
v-for="asset in assets"
|
||||||
|
:key="asset.name"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<template #cover>
|
||||||
|
<n-image
|
||||||
|
:src="asset.url"
|
||||||
|
style="width: 100%; height: 100px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<n-flex align="center" justify="space-between">
|
||||||
|
<n-text style="font-size: 12px; word-break: break-all">
|
||||||
|
{{ asset.name }}
|
||||||
|
</n-text>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
quaternary
|
||||||
|
type="error"
|
||||||
|
@click="remove(asset.name)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="material-symbols:close-rounded" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showUpload"
|
||||||
|
preset="card"
|
||||||
|
title="上传素材"
|
||||||
|
style="width: 400px"
|
||||||
|
>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="文件名(如 1.png)">
|
||||||
|
<n-input v-model:value="uploadName" placeholder="1.png" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="选择文件">
|
||||||
|
<n-upload :max="1" @change="onFileChange" :default-upload="false">
|
||||||
|
<n-button>选择文件</n-button>
|
||||||
|
</n-upload>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="showUpload = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!uploadName || !uploadFile"
|
||||||
|
:loading="uploading"
|
||||||
|
@click="upload"
|
||||||
|
>
|
||||||
|
上传
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import type { UploadFileInfo } from "naive-ui"
|
||||||
|
import { TaskAssets } from "../../api"
|
||||||
|
import type { TaskAsset } from "../../utils/type"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskType: "challenge" | "tutorial"
|
||||||
|
display: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const assets = ref<TaskAsset[]>([])
|
||||||
|
const showUpload = ref(false)
|
||||||
|
const uploadName = ref("")
|
||||||
|
const uploadFile = ref<File | null>(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.display) return
|
||||||
|
try {
|
||||||
|
assets.value =
|
||||||
|
props.taskType === "challenge"
|
||||||
|
? await TaskAssets.listChallenge(props.display)
|
||||||
|
: await TaskAssets.listTutorial(props.display)
|
||||||
|
} catch {
|
||||||
|
assets.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange({ file }: { file: UploadFileInfo }) {
|
||||||
|
if (file.status === "pending") {
|
||||||
|
uploadFile.value = file.file ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!uploadName.value || !uploadFile.value) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const asset =
|
||||||
|
props.taskType === "challenge"
|
||||||
|
? await TaskAssets.uploadChallenge(
|
||||||
|
props.display,
|
||||||
|
uploadName.value,
|
||||||
|
uploadFile.value,
|
||||||
|
)
|
||||||
|
: await TaskAssets.uploadTutorial(
|
||||||
|
props.display,
|
||||||
|
uploadName.value,
|
||||||
|
uploadFile.value,
|
||||||
|
)
|
||||||
|
const idx = assets.value.findIndex((a) => a.name === asset.name)
|
||||||
|
if (idx >= 0) assets.value[idx] = asset
|
||||||
|
else assets.value.push(asset)
|
||||||
|
message.success(`${asset.name} 上传成功`)
|
||||||
|
showUpload.value = false
|
||||||
|
uploadName.value = ""
|
||||||
|
uploadFile.value = null
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.detail ?? "上传失败")
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(name: string) {
|
||||||
|
try {
|
||||||
|
props.taskType === "challenge"
|
||||||
|
? await TaskAssets.deleteChallenge(props.display, name)
|
||||||
|
: await TaskAssets.deleteTutorial(props.display, name)
|
||||||
|
assets.value = assets.value.filter((a) => a.name !== name)
|
||||||
|
message.success("删除成功")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.detail ?? "删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.display, load, { immediate: true })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
taskTab === TASK_TYPE.Tutorial
|
||||||
|
? 'twemoji:books'
|
||||||
|
: 'twemoji:crossed-swords'
|
||||||
|
"
|
||||||
|
:width="20"
|
||||||
|
></Icon>
|
||||||
|
<n-tabs
|
||||||
|
style="width: 150px"
|
||||||
|
type="segment"
|
||||||
|
animated
|
||||||
|
:value="taskTab"
|
||||||
|
@update:value="changeTab"
|
||||||
|
>
|
||||||
|
<n-tab name="tutorial" tab="教程"></n-tab>
|
||||||
|
<n-tab name="challenge" tab="挑战"></n-tab>
|
||||||
|
</n-tabs>
|
||||||
|
<template v-if="!hideNav">
|
||||||
|
<n-button text @click="prev()" :disabled="prevDisabled()">
|
||||||
|
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
|
||||||
|
</n-button>
|
||||||
|
<span v-if="progressText" class="progress-text">
|
||||||
|
{{ progressText }}
|
||||||
|
</span>
|
||||||
|
<n-button text @click="next()" :disabled="nextDisabled()">
|
||||||
|
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex>
|
||||||
|
<n-tooltip
|
||||||
|
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="showAssets = true">
|
||||||
|
<Icon :width="16" icon="lucide:image"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
素材
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="authed" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
@click="
|
||||||
|
$router.push({ name: 'submissions', params: { page: 1 } })
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:list"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
提交记录
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="authed" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="$router.push({ name: 'creation-list' })">
|
||||||
|
<Icon :width="16" icon="lucide:sparkles"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
自由创作
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="authed" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="$router.push({ name: 'showcase' })">
|
||||||
|
<Icon :width="16" icon="lucide:award"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
创意工坊
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="roleSuper" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="edit">
|
||||||
|
<Icon :width="16" icon="lucide:edit"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
编辑
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="$emit('hide')">
|
||||||
|
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
关闭
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
|
||||||
|
<ChallengeList v-else />
|
||||||
|
</div>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showAssets"
|
||||||
|
preset="card"
|
||||||
|
title="图片素材"
|
||||||
|
style="width: 570px"
|
||||||
|
>
|
||||||
|
<n-flex wrap>
|
||||||
|
<n-card
|
||||||
|
v-for="asset in tutorialAssets"
|
||||||
|
:key="asset.name"
|
||||||
|
:title="asset.name"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<template #cover>
|
||||||
|
<n-image
|
||||||
|
:src="asset.url"
|
||||||
|
style="width: 100%; height: 100px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
import {
|
||||||
|
step,
|
||||||
|
tutorialIds,
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
prevDisabled,
|
||||||
|
nextDisabled,
|
||||||
|
} from "../../store/tutorial"
|
||||||
|
import { authed, roleSuper } from "../../store/user"
|
||||||
|
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
|
import { TaskAssets } from "../../api"
|
||||||
|
import type { TaskAsset } from "../../utils/type"
|
||||||
|
import ChallengeList from "./ChallengeList.vue"
|
||||||
|
import TutorialContent from "./TutorialContent.vue"
|
||||||
|
|
||||||
|
const tutorialAssets = ref<TaskAsset[]>([])
|
||||||
|
const showAssets = ref(false)
|
||||||
|
|
||||||
|
async function loadTutorialAssets(display: number) {
|
||||||
|
if (!display) {
|
||||||
|
tutorialAssets.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
tutorialAssets.value = await TaskAssets.listTutorial(display)
|
||||||
|
} catch {
|
||||||
|
tutorialAssets.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(step, loadTutorialAssets, { immediate: true })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 路由同步:初始执行 + watch 响应 SPA 内部导航
|
||||||
|
function syncRoute(routeName: string) {
|
||||||
|
if (routeName.startsWith("home-tutorial")) {
|
||||||
|
taskTab.value = TASK_TYPE.Tutorial
|
||||||
|
if (route.params.display) step.value = Number(route.params.display)
|
||||||
|
} else if (routeName.startsWith("home-challenge")) {
|
||||||
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
|
if (route.params.display)
|
||||||
|
challengeDisplay.value = Number(route.params.display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncRoute(route.name as string)
|
||||||
|
watch(() => route.name as string, syncRoute)
|
||||||
|
|
||||||
|
defineEmits(["hide"])
|
||||||
|
|
||||||
|
const hideNav = computed(
|
||||||
|
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
const progressText = computed(() => {
|
||||||
|
const ids = tutorialIds.value
|
||||||
|
if (!ids.length) return ""
|
||||||
|
const i = ids.indexOf(step.value)
|
||||||
|
return i === -1 ? "" : `${i + 1} / ${ids.length}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function changeTab(v: TASK_TYPE) {
|
||||||
|
taskId.value = 0
|
||||||
|
taskTab.value = v
|
||||||
|
if (v === TASK_TYPE.Tutorial) {
|
||||||
|
router.push(
|
||||||
|
step.value
|
||||||
|
? { name: "home-tutorial", params: { display: step.value } }
|
||||||
|
: { name: "home-tutorial-list" },
|
||||||
|
)
|
||||||
|
} else if (v === TASK_TYPE.Challenge) {
|
||||||
|
router.push({ name: "home-challenge-list" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit() {
|
||||||
|
const name =
|
||||||
|
taskTab.value === TASK_TYPE.Tutorial
|
||||||
|
? "tutorial-editor"
|
||||||
|
: "challenge-editor"
|
||||||
|
const display =
|
||||||
|
taskTab.value === TASK_TYPE.Tutorial ? step.value : challengeDisplay.value
|
||||||
|
router.push({ name, params: { display } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 43px;
|
||||||
|
padding: 0 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgb(239, 239, 245);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
@update:show="$emit('update:show', $event)"
|
||||||
|
preset="card"
|
||||||
|
title="提交统计"
|
||||||
|
style="max-width: 660px"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<div style="max-height: 75vh; overflow-y: auto">
|
||||||
|
<!-- 初始加载 -->
|
||||||
|
<template v-if="!stats && loading">
|
||||||
|
<n-flex justify="center" style="padding: 40px">
|
||||||
|
<n-spin size="large" />
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="stats">
|
||||||
|
<!-- 班级筛选 -->
|
||||||
|
<n-flex
|
||||||
|
align="center"
|
||||||
|
style="margin-bottom: 16px; flex-wrap: wrap; gap: 6px"
|
||||||
|
>
|
||||||
|
<span style="color: #666; font-size: 12px">班级筛选:</span>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:type="selectedClass === null ? 'primary' : 'default'"
|
||||||
|
@click="selectClass(null)"
|
||||||
|
>全部</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
v-for="c in stats.classes"
|
||||||
|
:key="c"
|
||||||
|
size="small"
|
||||||
|
:type="selectedClass === c ? 'primary' : 'default'"
|
||||||
|
@click="selectClass(c)"
|
||||||
|
>{{ c }}</n-button
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<!-- 关键指标 -->
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(metric, i) in metrics"
|
||||||
|
:key="metric.label"
|
||||||
|
:style="{
|
||||||
|
padding: '12px 8px',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRight: i < metrics.length - 1 ? '1px solid #eee' : 'none',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: metric.color,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ metric.value }}
|
||||||
|
</div>
|
||||||
|
<div style="color: #888; font-size: 11px; margin-top: 2px">
|
||||||
|
{{ metric.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未提交名单(可折叠) -->
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff8f8;
|
||||||
|
"
|
||||||
|
@click="showUnsubmitted = !showUnsubmitted"
|
||||||
|
>
|
||||||
|
<n-flex align="center" :size="6">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: #d03050;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
<span style="font-weight: 600; color: #d03050; font-size: 12px"
|
||||||
|
>未提交({{ stats.unsubmitted_count }}人)</span
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
showUnsubmitted
|
||||||
|
? 'lucide:chevron-down'
|
||||||
|
: 'lucide:chevron-right'
|
||||||
|
"
|
||||||
|
:width="14"
|
||||||
|
style="color: #aaa"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showUnsubmitted"
|
||||||
|
style="
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff8f8;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<n-tag
|
||||||
|
v-for="u in stats.unsubmitted_users"
|
||||||
|
:key="u.username"
|
||||||
|
size="small"
|
||||||
|
:bordered="true"
|
||||||
|
style="border-color: #ffd0d0; background: #fff"
|
||||||
|
>{{ displayName(u.username, u.classname) }}</n-tag
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="!stats.unsubmitted_users.length"
|
||||||
|
style="color: #aaa; font-size: 12px"
|
||||||
|
>暂无</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未打分名单(可折叠) -->
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fffaf5;
|
||||||
|
"
|
||||||
|
@click="showUnrated = !showUnrated"
|
||||||
|
>
|
||||||
|
<n-flex align="center" :size="6">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: #e07800;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
<span style="font-weight: 600; color: #e07800; font-size: 12px"
|
||||||
|
>未打分({{ stats.unrated_count }}人)</span
|
||||||
|
>
|
||||||
|
<span style="font-size: 11px; color: #aaa"
|
||||||
|
>— 还没给任何提交打过分</span
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'
|
||||||
|
"
|
||||||
|
:width="14"
|
||||||
|
style="color: #aaa"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showUnrated"
|
||||||
|
style="
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fffaf5;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<n-tag
|
||||||
|
v-for="u in stats.unrated_users"
|
||||||
|
:key="u.username"
|
||||||
|
size="small"
|
||||||
|
:bordered="true"
|
||||||
|
style="border-color: #ffd0a0; background: #fff"
|
||||||
|
>{{ displayName(u.username, u.classname) }}</n-tag
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="!stats.unrated_users.length"
|
||||||
|
style="color: #aaa; font-size: 12px"
|
||||||
|
>暂无</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提交次数分布 -->
|
||||||
|
<div style="margin-bottom: 12px">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
提交次数分布
|
||||||
|
<span style="font-size: 11px; color: #aaa; font-weight: 400"
|
||||||
|
>(已提交的 {{ stats.submitted_count }} 人)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<div
|
||||||
|
v-for="bucket in countBuckets"
|
||||||
|
:key="bucket.label"
|
||||||
|
:style="{
|
||||||
|
flex: 1,
|
||||||
|
border: `1px solid ${bucket.borderColor}`,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '10px 8px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: bucket.bg,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: bucket.color,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ bucket.value }}
|
||||||
|
</div>
|
||||||
|
<div style="color: #aaa; font-size: 11px; margin-top: 2px">
|
||||||
|
{{ bucket.label }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #e8e8e8;
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 4px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
background: bucket.color,
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
width: bucketPct(bucket.value),
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div style="color: #bbb; font-size: 10px; margin-top: 3px">
|
||||||
|
{{ bucketPct(bucket.value) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标记统计 -->
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
标记统计
|
||||||
|
</div>
|
||||||
|
<n-flex :size="8" style="flex-wrap: wrap">
|
||||||
|
<div
|
||||||
|
v-for="flag in flagBadges"
|
||||||
|
:key="flag.label"
|
||||||
|
:style="{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
background: flag.bg,
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: `1px solid ${flag.border}`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
width: '7px',
|
||||||
|
height: '7px',
|
||||||
|
background: flag.color,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'inline-block',
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
<span :style="{ color: flag.color, fontSize: '12px' }">{{
|
||||||
|
flag.label
|
||||||
|
}}</span>
|
||||||
|
<span style="font-weight: 700; font-size: 13px; color: #333">{{
|
||||||
|
flag.value
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 人气前五 -->
|
||||||
|
<div style="margin-top: 12px">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
人气前五
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="stats.top_viewed.length === 0"
|
||||||
|
style="color: #aaa; font-size: 12px"
|
||||||
|
>
|
||||||
|
暂无
|
||||||
|
</div>
|
||||||
|
<div v-else style="display: flex; flex-direction: column; gap: 6px">
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in stats.top_viewed"
|
||||||
|
:key="item.submission_id"
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fafafa;
|
||||||
|
"
|
||||||
|
@click="viewSubmission(item.submission_id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
i < 3 ? ['#f0a020', '#888', '#a07040'][i] : '#ddd',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '700',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}"
|
||||||
|
>{{ i + 1 }}</span
|
||||||
|
>
|
||||||
|
<span style="flex: 1; font-size: 13px; color: #333">
|
||||||
|
{{ displayName(item.username, item.classname) }}
|
||||||
|
</span>
|
||||||
|
<span style="font-size: 12px; color: #2080f0; font-weight: 600">
|
||||||
|
{{ item.view_count }} 次
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, watch } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
import type { TaskStatsOut } from "../../utils/type"
|
||||||
|
|
||||||
|
const props = defineProps<{ taskId: number; show: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: "update:show", v: boolean): void }>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const stats = ref<TaskStatsOut | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedClass = ref<string | null>(null)
|
||||||
|
const showUnsubmitted = ref(false)
|
||||||
|
const showUnrated = ref(false)
|
||||||
|
|
||||||
|
async function load(classname?: string) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
stats.value = await Submission.getStats(props.taskId, classname)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(username: string, classname: string) {
|
||||||
|
const prefix = "web" + classname
|
||||||
|
return username.startsWith(prefix) ? username.slice(prefix.length) : username
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectClass(c: string | null) {
|
||||||
|
selectedClass.value = c
|
||||||
|
load(c ?? undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewSubmission(id: string) {
|
||||||
|
const { href } = router.resolve({ name: "submission", params: { id } })
|
||||||
|
window.open(href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketPct(value: number) {
|
||||||
|
const total = stats.value?.submitted_count ?? 0
|
||||||
|
if (!total) return "0%"
|
||||||
|
return Math.round((value / total) * 100) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = computed(() => {
|
||||||
|
if (!stats.value) return []
|
||||||
|
return [
|
||||||
|
{ label: "已提交", value: stats.value.submitted_count, color: "#18a058" },
|
||||||
|
{ label: "未提交", value: stats.value.unsubmitted_count, color: "#d03050" },
|
||||||
|
{
|
||||||
|
label: "平均分",
|
||||||
|
value: stats.value.average_score?.toFixed(1) ?? "—",
|
||||||
|
color: "#2080f0",
|
||||||
|
},
|
||||||
|
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const countBuckets = computed(() => {
|
||||||
|
if (!stats.value) return []
|
||||||
|
const d = stats.value.submission_count_distribution
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "4 次+",
|
||||||
|
value: d.count_4_plus,
|
||||||
|
color: "#f0a020",
|
||||||
|
bg: "#fffbf0",
|
||||||
|
borderColor: "#ffe0a0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "3 次",
|
||||||
|
value: d.count_3,
|
||||||
|
color: "#18a058",
|
||||||
|
bg: "#f0fff4",
|
||||||
|
borderColor: "#c8e8d0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "2 次",
|
||||||
|
value: d.count_2,
|
||||||
|
color: "#2080f0",
|
||||||
|
bg: "#f0f7ff",
|
||||||
|
borderColor: "#d0e8ff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "仅 1 次",
|
||||||
|
value: d.count_1,
|
||||||
|
color: "#888",
|
||||||
|
bg: "#fafafa",
|
||||||
|
borderColor: "#e0e0e0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreBars = computed(() => {
|
||||||
|
if (!stats.value) return []
|
||||||
|
const d = stats.value.score_distribution
|
||||||
|
const vals = [d.range_1_2, d.range_2_3, d.range_3_4, d.range_4_5, d.range_5]
|
||||||
|
const max = Math.max(...vals, 1)
|
||||||
|
const colors = ["#d03050", "#f0a020", "#2080f0", "#18a058", "#18a058"]
|
||||||
|
const labels = ["★", "★★", "★★★", "★★★★", "★★★★★"]
|
||||||
|
return vals.map((v, i) => ({
|
||||||
|
value: v,
|
||||||
|
label: labels[i],
|
||||||
|
color: colors[i],
|
||||||
|
height: Math.max(Math.round((v / max) * 68), 3) + "px",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const flagBadges = computed(() => {
|
||||||
|
if (!stats.value) return []
|
||||||
|
const f = stats.value.flag_stats
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "值得展示",
|
||||||
|
value: f.red,
|
||||||
|
color: "#d03050",
|
||||||
|
bg: "#fff0f0",
|
||||||
|
border: "#ffd0d0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "需要讲解",
|
||||||
|
value: f.blue,
|
||||||
|
color: "#2080f0",
|
||||||
|
bg: "#f0f7ff",
|
||||||
|
border: "#c8deff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "优秀作品",
|
||||||
|
value: f.green,
|
||||||
|
color: "#18a058",
|
||||||
|
bg: "#f0fff4",
|
||||||
|
border: "#b8e8c8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "需要改进",
|
||||||
|
value: f.yellow,
|
||||||
|
color: "#f0a020",
|
||||||
|
bg: "#fffbf0",
|
||||||
|
border: "#ffe8a0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load when modal opens
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(val) => {
|
||||||
|
if (val && props.taskId) {
|
||||||
|
selectedClass.value = null
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="markdown-body" v-html="content" ref="$content" />
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref, useTemplateRef, watch } from "vue"
|
||||||
|
import { marked } from "marked"
|
||||||
|
import copyFn from "copy-text-to-clipboard"
|
||||||
|
import { css, html, js, tab } from "../../store/editors"
|
||||||
|
import { Tutorial } from "../../api"
|
||||||
|
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
||||||
|
import { taskId, assetBaseUrl } from "../../store/task"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
renderer: {
|
||||||
|
code({ text, lang }) {
|
||||||
|
const language = lang?.toLowerCase() ?? "html"
|
||||||
|
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||||
|
<div class="codeblock-action">
|
||||||
|
<span class="lang">${language.toUpperCase()}</span>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="action-btn" data-action="copy">复制</button>
|
||||||
|
<button class="action-btn" data-action="replace">替换</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre><code class="language-${language}">${text}</code></pre>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
link({ href, text }) {
|
||||||
|
return `<a href="${href}" target="_blank">${text}</a>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const content = ref("")
|
||||||
|
const $content = useTemplateRef<any>("$content")
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const data = await Tutorial.get(step.value)
|
||||||
|
taskId.value = data.task_ptr
|
||||||
|
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||||
|
html.value = data.example_html ?? ""
|
||||||
|
css.value = data.example_css ?? ""
|
||||||
|
js.value = data.example_js ?? ""
|
||||||
|
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||||
|
content.value = await marked.parse(merged, { async: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(btn: HTMLButtonElement, done: string, original: string) {
|
||||||
|
btn.textContent = done
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = original
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCodeActions() {
|
||||||
|
$content.value?.addEventListener("click", (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
|
||||||
|
"[data-action]",
|
||||||
|
)
|
||||||
|
if (!btn) return
|
||||||
|
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
||||||
|
const lang = wrapper.dataset.lang ?? "html"
|
||||||
|
const code = wrapper.querySelector("code")?.textContent ?? ""
|
||||||
|
|
||||||
|
if (btn.dataset.action === "copy") {
|
||||||
|
copyFn(code)
|
||||||
|
flash(btn, "已复制", "复制")
|
||||||
|
} else if (btn.dataset.action === "replace") {
|
||||||
|
tab.value = lang
|
||||||
|
if (lang === "html") html.value = code
|
||||||
|
if (lang === "css") css.value = code
|
||||||
|
if (lang === "js") js.value = code
|
||||||
|
flash(btn, "已替换", "替换")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await loadTutorials()
|
||||||
|
if (!tutorialIds.value.length) {
|
||||||
|
content.value = "暂无教程"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupCodeActions()
|
||||||
|
init()
|
||||||
|
})
|
||||||
|
watch(step, (v) => {
|
||||||
|
router.push({ name: "home-tutorial", params: { display: v } })
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.markdown-body {
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
.markdown-body pre {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre code {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: Monaco;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .codeblock-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .codeblock-wrapper pre {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family:
|
||||||
|
v-sans,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif,
|
||||||
|
"Apple Color Emoji",
|
||||||
|
"Segoe UI Emoji",
|
||||||
|
"Segoe UI Symbol";
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .lang {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .action-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock-action .action-btn:hover {
|
||||||
|
border-color: #18a058;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly PUBLIC_WEB_URL: string
|
||||||
|
readonly PUBLIC_BASE_URL: string
|
||||||
|
readonly PUBLIC_MAXKB_URL: string
|
||||||
|
readonly PUBLIC_ICONIFY_URL: string
|
||||||
|
readonly PUBLIC_WS_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d0d0d0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #b0b0b0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createApp } from "vue"
|
||||||
|
import { create } from "naive-ui"
|
||||||
|
import App from "./App.vue"
|
||||||
|
import "./global.css"
|
||||||
|
import { addAPIProvider } from "@iconify/vue"
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
import "github-markdown-css/github-markdown-light.css"
|
||||||
|
|
||||||
|
import { marked } from "marked"
|
||||||
|
import alert from "marked-alert"
|
||||||
|
import { markedHighlight } from "marked-highlight"
|
||||||
|
import preview from "marked-code-preview"
|
||||||
|
import { alertVariants } from "./utils/const"
|
||||||
|
|
||||||
|
import hljs from "highlight.js/lib/core"
|
||||||
|
import xml from "highlight.js/lib/languages/xml"
|
||||||
|
import css from "highlight.js/lib/languages/css"
|
||||||
|
import javascript from "highlight.js/lib/languages/javascript"
|
||||||
|
import plaintext from "highlight.js/lib/languages/plaintext"
|
||||||
|
//@ts-ignore
|
||||||
|
import "highlight.js/styles/github.min.css"
|
||||||
|
import { router } from "./router"
|
||||||
|
|
||||||
|
hljs.registerLanguage("html", xml)
|
||||||
|
hljs.registerLanguage("css", css)
|
||||||
|
hljs.registerLanguage("js", javascript)
|
||||||
|
hljs.registerLanguage("javascript", javascript)
|
||||||
|
hljs.registerLanguage("plaintext", plaintext)
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
marked.use(
|
||||||
|
markedHighlight({
|
||||||
|
langPrefix: "hljs language-",
|
||||||
|
highlight(code, lang) {
|
||||||
|
const language = hljs.getLanguage(lang) ? lang : "plaintext"
|
||||||
|
return hljs.highlight(code, { language }).value
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
marked.use(alert({ variants: alertVariants }))
|
||||||
|
|
||||||
|
const template = `<div class="markedown-body-preview">{preview}</div>`
|
||||||
|
marked.use(preview({ template }))
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const naive = create()
|
||||||
|
|
||||||
|
app.use(naive)
|
||||||
|
app.use(router)
|
||||||
|
app.mount("#app")
|
||||||
|
|
||||||
|
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||||
|
addAPIProvider("", {
|
||||||
|
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
<template>
|
||||||
|
<div class="challenge-layout">
|
||||||
|
<div class="challenge-sider">
|
||||||
|
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||||
|
<template #prefix>
|
||||||
|
<n-button text @click="back" style="margin: 0 8px">
|
||||||
|
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<n-flex style="margin: 0 8px">
|
||||||
|
<n-button
|
||||||
|
v-if="roleAdmin || roleSuper"
|
||||||
|
text
|
||||||
|
@click="showStats = true"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="authed"
|
||||||
|
text
|
||||||
|
@click="
|
||||||
|
$router.push({ name: 'submissions', params: { page: 1 } })
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:list" />
|
||||||
|
</n-button>
|
||||||
|
<n-button v-if="roleSuper" text @click="edit">
|
||||||
|
<Icon :width="16" icon="lucide:edit" />
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||||
|
<div class="desc-pane">
|
||||||
|
<div class="challenge-meta">
|
||||||
|
<n-flex align="center" justify="space-between">
|
||||||
|
<n-text depth="3">
|
||||||
|
出题人:{{ challengeAuthor || "未设置" }}
|
||||||
|
</n-text>
|
||||||
|
<n-flex align="center" size="small">
|
||||||
|
<n-button
|
||||||
|
v-if="assets.length"
|
||||||
|
size="small"
|
||||||
|
@click="showAssets = true"
|
||||||
|
>
|
||||||
|
看素材
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="exampleCode"
|
||||||
|
size="small"
|
||||||
|
@click="previewExample"
|
||||||
|
>
|
||||||
|
看示例
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="markdown-body content no-select"
|
||||||
|
v-html="challengeContent"
|
||||||
|
ref="$desc"
|
||||||
|
@copy.prevent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||||
|
<PromptPanel
|
||||||
|
@deleted="historyRefreshKey++"
|
||||||
|
@submitted="historyRefreshKey++"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="external" tab="手动提交" display-directive="show">
|
||||||
|
<ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
||||||
|
<PromptHistoryPanel
|
||||||
|
:task-id="taskId"
|
||||||
|
:active="activeTab === 'history'"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
:refresh-key="historyRefreshKey"
|
||||||
|
@select="previewHistoryItem"
|
||||||
|
@deleted="removeMessagePair"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
|
<div class="challenge-content">
|
||||||
|
<Preview
|
||||||
|
:html="html"
|
||||||
|
:css="css"
|
||||||
|
:js="js"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
show-code-button
|
||||||
|
clearable
|
||||||
|
@showCode="showCode = true"
|
||||||
|
@clear="clearAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
||||||
|
<RandomRatingModal v-if="authed" />
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showAssets"
|
||||||
|
preset="card"
|
||||||
|
title="素材"
|
||||||
|
style="width: 500px"
|
||||||
|
>
|
||||||
|
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||||
|
<n-gi v-for="asset in assets" :key="asset.name">
|
||||||
|
<n-card size="small" :title="asset.name">
|
||||||
|
<n-image
|
||||||
|
:src="asset.url"
|
||||||
|
style="width: 100%; height: 100px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-modal>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showCode"
|
||||||
|
preset="card"
|
||||||
|
title="代码"
|
||||||
|
style="width: 700px"
|
||||||
|
>
|
||||||
|
<n-tabs type="line">
|
||||||
|
<n-tab-pane name="html" tab="HTML">
|
||||||
|
<n-code :code="html" language="html" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="css" tab="CSS">
|
||||||
|
<n-code :code="css" language="css" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="js" tab="JS">
|
||||||
|
<n-code :code="js" language="javascript" />
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { marked, type MarkedOptions } from "marked"
|
||||||
|
import copyFn from "copy-text-to-clipboard"
|
||||||
|
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||||
|
import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
|
||||||
|
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||||
|
import Preview from "../components/editor/Preview.vue"
|
||||||
|
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||||
|
import RandomRatingModal from "../components/ai/RandomRatingModal.vue"
|
||||||
|
import { Challenge, Submission, TaskAssets } from "../api"
|
||||||
|
import type { TaskAsset } from "../utils/type"
|
||||||
|
import { html, css, js } from "../store/editors"
|
||||||
|
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import { authed, roleAdmin, roleSuper } from "../store/user"
|
||||||
|
import {
|
||||||
|
connectPrompt,
|
||||||
|
disconnectPrompt,
|
||||||
|
streaming,
|
||||||
|
setOnCodeComplete,
|
||||||
|
removeMessagePair,
|
||||||
|
markMessageSubmitted,
|
||||||
|
} from "../store/prompt"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const challengeRenderer = new marked.Renderer()
|
||||||
|
challengeRenderer.code = ({ text, lang }) => {
|
||||||
|
const language = lang?.toLowerCase() ?? "text"
|
||||||
|
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||||
|
<div class="codeblock-action">
|
||||||
|
<span class="lang">${language.toUpperCase()}</span>
|
||||||
|
<button class="action-btn" data-action="copy">复制</button>
|
||||||
|
</div>
|
||||||
|
<pre><code class="language-${language}">${text}</code></pre>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
challengeRenderer.link = ({ href, text }) =>
|
||||||
|
`<a href="${href}" target="_blank">${text}</a>`
|
||||||
|
|
||||||
|
function setupCodeCopy() {
|
||||||
|
$desc.value?.addEventListener("click", (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||||
|
if (!btn) return
|
||||||
|
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
||||||
|
const code = wrapper.querySelector("code")?.textContent ?? ""
|
||||||
|
copyFn(code)
|
||||||
|
btn.textContent = "已复制"
|
||||||
|
setTimeout(() => { btn.textContent = "复制" }, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = ref("desc")
|
||||||
|
const challengeContent = ref("")
|
||||||
|
const challengeAuthor = ref("")
|
||||||
|
const $desc = useTemplateRef<HTMLElement>("$desc")
|
||||||
|
const showCode = ref(false)
|
||||||
|
const showStats = ref(false)
|
||||||
|
const showAssets = ref(false)
|
||||||
|
const assets = ref<TaskAsset[]>([])
|
||||||
|
const historyRefreshKey = ref(0)
|
||||||
|
const exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
|
||||||
|
|
||||||
|
const assetBaseUrl = computed(
|
||||||
|
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(streaming, (val) => {
|
||||||
|
if (val) activeTab.value = "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadChallenge() {
|
||||||
|
const display = Number(route.params.display)
|
||||||
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
|
challengeDisplay.value = display
|
||||||
|
const [data, fetchedAssets] = await Promise.all([
|
||||||
|
Challenge.get(display),
|
||||||
|
TaskAssets.listChallenge(display),
|
||||||
|
])
|
||||||
|
taskId.value = data.task_ptr
|
||||||
|
challengeAuthor.value = data.author_name ?? ""
|
||||||
|
if (data.example_html || data.example_css || data.example_js) {
|
||||||
|
exampleCode.value = {
|
||||||
|
html: data.example_html ?? "",
|
||||||
|
css: data.example_css ?? "",
|
||||||
|
js: data.example_js ?? "",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exampleCode.value = null
|
||||||
|
}
|
||||||
|
challengeContent.value = await marked.parse(data.content, {
|
||||||
|
async: true,
|
||||||
|
renderer: challengeRenderer,
|
||||||
|
} as MarkedOptions)
|
||||||
|
assets.value = fetchedAssets
|
||||||
|
if (!authed.value) return
|
||||||
|
connectPrompt(data.task_ptr)
|
||||||
|
setOnCodeComplete(async (code, messageId) => {
|
||||||
|
try {
|
||||||
|
await Submission.create(
|
||||||
|
taskId.value,
|
||||||
|
{
|
||||||
|
html: code.html ?? "",
|
||||||
|
css: code.css ?? "",
|
||||||
|
js: code.js ?? "",
|
||||||
|
},
|
||||||
|
messageId,
|
||||||
|
)
|
||||||
|
markMessageSubmitted(messageId)
|
||||||
|
historyRefreshKey.value++
|
||||||
|
message.success("已自动提交本次对话生成的代码")
|
||||||
|
} catch {
|
||||||
|
message.error("自动提交失败,请稍后重试")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit() {
|
||||||
|
router.push({
|
||||||
|
name: "challenge-editor",
|
||||||
|
params: { display: route.params.display },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewExample() {
|
||||||
|
if (!exampleCode.value) return
|
||||||
|
html.value = exampleCode.value.html
|
||||||
|
css.value = exampleCode.value.css
|
||||||
|
js.value = exampleCode.value.js
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
html.value = ""
|
||||||
|
css.value = ""
|
||||||
|
js.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
||||||
|
html.value = code.html
|
||||||
|
css.value = code.css
|
||||||
|
js.value = code.js
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
disconnectPrompt()
|
||||||
|
taskId.value = 0
|
||||||
|
router.push({ name: "home-challenge-list" })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupCodeCopy()
|
||||||
|
loadChallenge()
|
||||||
|
})
|
||||||
|
onUnmounted(disconnectPrompt)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.challenge-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider {
|
||||||
|
width: 40%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-content {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs :deep(.n-tab-pane) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-pane {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-meta {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-select {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.challenge-sider .codeblock-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-wrapper pre {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-wrapper pre code {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: Monaco, monospace;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-action {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-action .lang {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .action-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .action-btn:hover {
|
||||||
|
border-color: #18a058;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<template>
|
||||||
|
<n-grid x-gap="10" :cols="8">
|
||||||
|
<n-gi :span="2" class="col">
|
||||||
|
<n-flex vertical class="list">
|
||||||
|
<n-card
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.display"
|
||||||
|
@click="show(item.display)"
|
||||||
|
class="card"
|
||||||
|
:header-style="{
|
||||||
|
backgroundColor:
|
||||||
|
item.display === challenge.display
|
||||||
|
? 'rgba(24, 160, 80, 0.1)'
|
||||||
|
: '',
|
||||||
|
}"
|
||||||
|
:embedded="!item.is_public"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
:disabled="!roleSuper"
|
||||||
|
@click.stop="togglePublic(item.display)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
width="24"
|
||||||
|
:icon="
|
||||||
|
item.is_public
|
||||||
|
? 'twemoji:check-mark-button'
|
||||||
|
: 'twemoji:locked'
|
||||||
|
"
|
||||||
|
></Icon>
|
||||||
|
</n-button>
|
||||||
|
<span>【{{ item.display }}】{{ item.title }}</span>
|
||||||
|
<n-tag size="small" type="warning">{{ item.score }}分</n-tag>
|
||||||
|
<n-tag size="small"
|
||||||
|
>出题人 {{ item.author_name || "未设置" }}</n-tag
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button text type="default" @click="remove(item.display)">
|
||||||
|
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
<n-button @click="createNew">新建</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<n-gi :span="6" class="col">
|
||||||
|
<n-flex vertical>
|
||||||
|
<n-form inline :show-feedback="false">
|
||||||
|
<n-form-item label="序号" label-placement="left">
|
||||||
|
<n-input-number
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="challenge.display"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="标题" label-placement="left">
|
||||||
|
<n-input v-model:value="challenge.title" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="分数" label-placement="left">
|
||||||
|
<n-input-number
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="challenge.score"
|
||||||
|
:min="0"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="公开" label-placement="left">
|
||||||
|
<n-switch v-model:value="challenge.is_public" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="出题人" label-placement="left">
|
||||||
|
<n-text depth="3">{{ challenge.author_name || "未设置" }}</n-text>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label-placement="left">
|
||||||
|
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-flex>
|
||||||
|
<TaskAssetManager
|
||||||
|
v-if="challenge.display"
|
||||||
|
task-type="challenge"
|
||||||
|
:display="challenge.display"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
v-if="challenge.display"
|
||||||
|
size="small"
|
||||||
|
@click="showExampleModal = true"
|
||||||
|
>
|
||||||
|
示例代码
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<MarkdownEditor
|
||||||
|
style="height: calc(100vh - 100px)"
|
||||||
|
v-model="challenge.content"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showExampleModal"
|
||||||
|
preset="card"
|
||||||
|
title="示例代码(点击「看示例」时显示效果)"
|
||||||
|
style="width: 640px"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
v-model:value="rawCode"
|
||||||
|
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
||||||
|
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||||
|
/>
|
||||||
|
<n-flex v-if="splitResult" style="margin-top: 8px">
|
||||||
|
<n-tag size="small" type="success"
|
||||||
|
>HTML · {{ splitResult.html.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
<n-tag size="small" type="info"
|
||||||
|
>CSS · {{ splitResult.css.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
<n-tag size="small" type="warning"
|
||||||
|
>JS · {{ splitResult.js.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Challenge } from "../api"
|
||||||
|
import { roleSuper } from "../store/user"
|
||||||
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
|
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||||
|
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const confirm = useDialog()
|
||||||
|
|
||||||
|
const list = ref<ChallengeSlim[]>([])
|
||||||
|
const showExampleModal = ref(false)
|
||||||
|
const rawCode = ref("")
|
||||||
|
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||||
|
|
||||||
|
function splitHtml(raw: string) {
|
||||||
|
let result = raw
|
||||||
|
const cssBlocks: string[] = []
|
||||||
|
const jsBlocks: string[] = []
|
||||||
|
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
||||||
|
cssBlocks.push(c.trim())
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
result = result.replace(
|
||||||
|
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||||
|
(_, c) => {
|
||||||
|
jsBlocks.push(c.trim())
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
html: result.trim(),
|
||||||
|
css: cssBlocks.join("\n\n"),
|
||||||
|
js: jsBlocks.join("\n\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(rawCode, (val) => {
|
||||||
|
if (!val.trim()) {
|
||||||
|
splitResult.value = null
|
||||||
|
challenge.example_html = null
|
||||||
|
challenge.example_css = null
|
||||||
|
challenge.example_js = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const split = splitHtml(val)
|
||||||
|
splitResult.value = split
|
||||||
|
challenge.example_html = split.html || null
|
||||||
|
challenge.example_css = split.css || null
|
||||||
|
challenge.example_js = split.js || null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showExampleModal, (visible) => {
|
||||||
|
if (!visible) return
|
||||||
|
const parts: string[] = []
|
||||||
|
if (challenge.example_css)
|
||||||
|
parts.push(`<style>\n${challenge.example_css}\n</style>`)
|
||||||
|
if (challenge.example_html) parts.push(challenge.example_html)
|
||||||
|
if (challenge.example_js)
|
||||||
|
parts.push(`<script>\n${challenge.example_js}\n<\/script>`)
|
||||||
|
rawCode.value = parts.join("\n\n")
|
||||||
|
})
|
||||||
|
const challenge = reactive({
|
||||||
|
display: 0,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
score: 0,
|
||||||
|
is_public: false,
|
||||||
|
author_name: "",
|
||||||
|
example_html: null as string | null,
|
||||||
|
example_css: null as string | null,
|
||||||
|
example_js: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => challenge.display && challenge.title && challenge.content,
|
||||||
|
)
|
||||||
|
async function getContent() {
|
||||||
|
list.value = await Challenge.list()
|
||||||
|
const display = Number(route.params.display)
|
||||||
|
const target = list.value.find((item) => item.display === display)
|
||||||
|
if (target) {
|
||||||
|
show(display)
|
||||||
|
} else if (list.value.length > 0) {
|
||||||
|
show(list.value[0].display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
challenge.display = list.value[list.value.length - 1]?.display ?? 0 + 1
|
||||||
|
challenge.title = ""
|
||||||
|
challenge.content = ""
|
||||||
|
challenge.score = 0
|
||||||
|
challenge.is_public = false
|
||||||
|
challenge.author_name = ""
|
||||||
|
challenge.example_html = null
|
||||||
|
challenge.example_css = null
|
||||||
|
challenge.example_js = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
await Challenge.createOrUpdate(challenge)
|
||||||
|
message.success("提交成功")
|
||||||
|
challenge.display = 0
|
||||||
|
challenge.title = ""
|
||||||
|
challenge.content = ""
|
||||||
|
challenge.score = 0
|
||||||
|
challenge.is_public = false
|
||||||
|
challenge.author_name = ""
|
||||||
|
challenge.example_html = null
|
||||||
|
challenge.example_css = null
|
||||||
|
challenge.example_js = null
|
||||||
|
await getContent()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response.data.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(display: number) {
|
||||||
|
confirm.warning({
|
||||||
|
title: "警告",
|
||||||
|
content: "你确定要删除吗?",
|
||||||
|
positiveText: "确定",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await Challenge.remove(display)
|
||||||
|
message.success("删除成功")
|
||||||
|
getContent()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(display: number) {
|
||||||
|
router.push({ name: "challenge-editor", params: { display } })
|
||||||
|
const item = await Challenge.get(display)
|
||||||
|
challenge.display = item.display
|
||||||
|
challenge.title = item.title
|
||||||
|
challenge.content = item.content
|
||||||
|
challenge.score = item.score
|
||||||
|
challenge.is_public = item.is_public
|
||||||
|
challenge.author_name = item.author_name ?? ""
|
||||||
|
challenge.example_html = item.example_html ?? null
|
||||||
|
challenge.example_css = item.example_css ?? null
|
||||||
|
challenge.example_js = item.example_js ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublic(display: number) {
|
||||||
|
const data = await Challenge.togglePublic(display)
|
||||||
|
message.success(data.message)
|
||||||
|
list.value = list.value.map((item) => {
|
||||||
|
if (item.display === display) item.is_public = !item.is_public
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getContent)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.list {
|
||||||
|
overflow: auto;
|
||||||
|
height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<div class="creation-layout">
|
||||||
|
<div class="creation-sider">
|
||||||
|
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||||
|
<template #prefix>
|
||||||
|
<n-button text @click="back" style="margin: 0 8px">
|
||||||
|
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<n-flex style="margin: 0 8px">
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text :loading="savingCode" @click="saveCode(true)">
|
||||||
|
<Icon :width="16" icon="lucide:save" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
保存当前作品
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-tab-pane name="topic" tab="我的主题" display-directive="show">
|
||||||
|
<div class="topic-pane">
|
||||||
|
<n-form-item label="主题名称" :show-feedback="false">
|
||||||
|
<n-input
|
||||||
|
v-model:value="title"
|
||||||
|
placeholder="给你的主题起个名字"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item
|
||||||
|
label="主题描述"
|
||||||
|
:show-feedback="false"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
v-model:value="content"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 8 }"
|
||||||
|
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-flex style="margin-top: 16px">
|
||||||
|
<n-button
|
||||||
|
:loading="savingTopic"
|
||||||
|
:disabled="!title.trim()"
|
||||||
|
@click="saveTopic"
|
||||||
|
>
|
||||||
|
保存主题
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!content.trim() || streaming"
|
||||||
|
@click="generate"
|
||||||
|
>
|
||||||
|
用这个主题生成
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-text depth="3" class="topic-hint">
|
||||||
|
自由创作只属于你自己,不会进入提交记录和评分。
|
||||||
|
</n-text>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||||
|
<PromptPanel :allow-submit="false" @deleted="historyRefreshKey++" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
||||||
|
<PromptHistoryPanel
|
||||||
|
:task-id="taskId"
|
||||||
|
:active="activeTab === 'history'"
|
||||||
|
:refresh-key="historyRefreshKey"
|
||||||
|
@select="previewHistoryItem"
|
||||||
|
@deleted="removeMessagePair"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
|
<div class="creation-content">
|
||||||
|
<Preview
|
||||||
|
:html="html"
|
||||||
|
:css="css"
|
||||||
|
:js="js"
|
||||||
|
show-code-button
|
||||||
|
clearable
|
||||||
|
@showCode="showCode = true"
|
||||||
|
@clear="clearAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showCode"
|
||||||
|
preset="card"
|
||||||
|
title="代码"
|
||||||
|
style="width: 700px"
|
||||||
|
>
|
||||||
|
<n-tabs type="line">
|
||||||
|
<n-tab-pane name="html" tab="HTML">
|
||||||
|
<n-code :code="html" language="html" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="css" tab="CSS">
|
||||||
|
<n-code :code="css" language="css" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="js" tab="JS">
|
||||||
|
<n-code :code="js" language="javascript" />
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||||
|
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||||
|
import Preview from "../components/editor/Preview.vue"
|
||||||
|
import { Creation } from "../api"
|
||||||
|
import { html, css, js } from "../store/editors"
|
||||||
|
import {
|
||||||
|
connectPrompt,
|
||||||
|
disconnectPrompt,
|
||||||
|
removeMessagePair,
|
||||||
|
sendPrompt,
|
||||||
|
setOnCodeComplete,
|
||||||
|
streaming,
|
||||||
|
} from "../store/prompt"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const display = Number(route.params.display)
|
||||||
|
const activeTab = ref("topic")
|
||||||
|
const taskId = ref(0)
|
||||||
|
const title = ref("")
|
||||||
|
const content = ref("")
|
||||||
|
const showCode = ref(false)
|
||||||
|
const savingTopic = ref(false)
|
||||||
|
const savingCode = ref(false)
|
||||||
|
const historyRefreshKey = ref(0)
|
||||||
|
|
||||||
|
watch(streaming, (val) => {
|
||||||
|
if (val) activeTab.value = "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await Creation.get(display)
|
||||||
|
taskId.value = data.task_id
|
||||||
|
title.value = data.title
|
||||||
|
content.value = data.content
|
||||||
|
html.value = data.html
|
||||||
|
css.value = data.css
|
||||||
|
js.value = data.js
|
||||||
|
connectPrompt(data.task_id)
|
||||||
|
setOnCodeComplete(() => saveCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTopic() {
|
||||||
|
if (!title.value.trim()) return
|
||||||
|
savingTopic.value = true
|
||||||
|
try {
|
||||||
|
await Creation.update(display, {
|
||||||
|
title: title.value.trim(),
|
||||||
|
content: content.value,
|
||||||
|
})
|
||||||
|
message.success("主题已保存")
|
||||||
|
} catch {
|
||||||
|
message.error("保存失败,请重试")
|
||||||
|
} finally {
|
||||||
|
savingTopic.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCode(notify = false) {
|
||||||
|
savingCode.value = true
|
||||||
|
try {
|
||||||
|
await Creation.saveCode(display, {
|
||||||
|
html: html.value,
|
||||||
|
css: css.value,
|
||||||
|
js: js.value,
|
||||||
|
})
|
||||||
|
if (notify) message.success("作品已保存")
|
||||||
|
} catch {
|
||||||
|
if (notify) message.error("保存失败,请重试")
|
||||||
|
} finally {
|
||||||
|
savingCode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate() {
|
||||||
|
const text = content.value.trim()
|
||||||
|
if (!text || streaming.value) return
|
||||||
|
activeTab.value = "chat"
|
||||||
|
sendPrompt(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
html.value = ""
|
||||||
|
css.value = ""
|
||||||
|
js.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
||||||
|
html.value = code.html
|
||||||
|
css.value = code.css
|
||||||
|
js.value = code.js
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
disconnectPrompt()
|
||||||
|
router.push({ name: "creation-list" })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
onUnmounted(disconnectPrompt)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.creation-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-sider {
|
||||||
|
width: 40%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-content {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs :deep(.n-tab-pane) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-pane {
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<main class="creation-home">
|
||||||
|
<n-flex
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-end"
|
||||||
|
style="margin-bottom: 32px"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<n-flex align="center" :size="8">
|
||||||
|
<n-button text @click="$router.push({ name: 'home' })">
|
||||||
|
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
||||||
|
</n-button>
|
||||||
|
<n-h2 style="margin: 0">自由创作</n-h2>
|
||||||
|
</n-flex>
|
||||||
|
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
|
||||||
|
</div>
|
||||||
|
<n-button type="primary" @click="openCreate">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:plus" :width="16" />
|
||||||
|
</template>
|
||||||
|
新建主题
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!loading && !creations.length"
|
||||||
|
description="还没有创作,点右上角新建一个主题吧"
|
||||||
|
style="margin-top: 72px"
|
||||||
|
/>
|
||||||
|
<div v-else class="card-grid">
|
||||||
|
<n-card
|
||||||
|
v-for="item in creations"
|
||||||
|
:key="item.display"
|
||||||
|
class="creation-card"
|
||||||
|
hoverable
|
||||||
|
@click="open(item)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-ellipsis style="max-width: 100%">{{ item.title }}</n-ellipsis>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-flex :size="4" align="center">
|
||||||
|
<n-tag v-if="item.has_code" type="success" size="small">
|
||||||
|
已有作品
|
||||||
|
</n-tag>
|
||||||
|
<n-button text @click.stop="remove(item)">
|
||||||
|
<Icon icon="lucide:trash-2" :width="15" />
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-ellipsis :line-clamp="2" class="creation-desc">
|
||||||
|
{{ item.content || "还没写主题描述" }}
|
||||||
|
</n-ellipsis>
|
||||||
|
<template #footer>
|
||||||
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
更新于 {{ formatTime(item.modified) }}
|
||||||
|
</n-text>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showCreate"
|
||||||
|
preset="card"
|
||||||
|
title="新建主题"
|
||||||
|
style="width: 520px"
|
||||||
|
>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="主题名称">
|
||||||
|
<n-input
|
||||||
|
v-model:value="form.title"
|
||||||
|
placeholder="例如:我的番茄钟"
|
||||||
|
maxlength="100"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="主题描述(可以之后再补充)">
|
||||||
|
<n-input
|
||||||
|
v-model:value="form.content"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||||
|
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="flex-end">
|
||||||
|
<n-button @click="showCreate = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:loading="creating"
|
||||||
|
:disabled="!form.title.trim()"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
创建并开始
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Creation } from "../api"
|
||||||
|
import type { CreationSlim } from "../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const creating = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const creations = ref<CreationSlim[]>([])
|
||||||
|
const form = reactive({ title: "", content: "" })
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return new Date(value).toLocaleString("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(item: CreationSlim) {
|
||||||
|
router.push({ name: "creation", params: { display: item.display } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
form.title = ""
|
||||||
|
form.content = ""
|
||||||
|
showCreate.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const title = form.title.trim()
|
||||||
|
if (!title) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const created = await Creation.create({ title, content: form.content })
|
||||||
|
showCreate.value = false
|
||||||
|
router.push({ name: "creation", params: { display: created.display } })
|
||||||
|
} catch {
|
||||||
|
message.error("创建失败,请重试")
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(item: CreationSlim) {
|
||||||
|
dialog.warning({
|
||||||
|
title: "删除主题",
|
||||||
|
content: `确定删除「${item.title}」吗?和它相关的对话也会一起删除。`,
|
||||||
|
positiveText: "删除",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await Creation.remove(item.display)
|
||||||
|
creations.value = creations.value.filter(
|
||||||
|
(c) => c.display !== item.display,
|
||||||
|
)
|
||||||
|
message.success("已删除")
|
||||||
|
} catch {
|
||||||
|
message.error("删除失败,请重试")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
creations.value = await Creation.listMine()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.creation-home {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex class="container" :wrap="false">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="back-btn" @click="() => goHome($router, taskTab, step)">
|
||||||
|
← 返回
|
||||||
|
</div>
|
||||||
|
<n-divider style="margin: 8px 0" />
|
||||||
|
<div
|
||||||
|
v-for="item in menu"
|
||||||
|
:key="item.label"
|
||||||
|
:class="['nav-item', { active: $route.name === item.route.name }]"
|
||||||
|
@click="$router.push(item.route)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<router-view></router-view>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { taskTab } from "../store/task"
|
||||||
|
import { step } from "../store/tutorial"
|
||||||
|
import { roleAdmin, roleSuper } from "../store/user"
|
||||||
|
import { goHome } from "../utils/helper"
|
||||||
|
|
||||||
|
const menu = computed(() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: "教程",
|
||||||
|
route: { name: "tutorial-editor", params: { display: step.value } },
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "挑战",
|
||||||
|
route: { name: "challenge-editor", params: { display: 0 } },
|
||||||
|
show: roleAdmin.value || roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "用户",
|
||||||
|
route: { name: "user-manage", params: { page: 1 } },
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "工坊",
|
||||||
|
route: { name: "showcase-manage" },
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "成绩",
|
||||||
|
route: { name: "gradebook" },
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "提交",
|
||||||
|
route: { name: "submissions", params: { page: 1 } },
|
||||||
|
show: roleAdmin.value || roleSuper.value,
|
||||||
|
},
|
||||||
|
].filter((item) => item.show),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 110px;
|
||||||
|
min-width: 110px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-right: 1px solid #efeff5;
|
||||||
|
background: #fafafa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background-color: #e8f8f0;
|
||||||
|
color: #18a058;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex
|
||||||
|
vertical
|
||||||
|
:size="12"
|
||||||
|
style="height: 100%; min-width: 0; box-sizing: border-box; padding: 10px 10px 10px 0; overflow: hidden;"
|
||||||
|
>
|
||||||
|
<n-flex class="toolbar" align="center" justify="space-between" style="flex-shrink: 0;">
|
||||||
|
<n-flex align="center" :size="8" wrap style="min-width: 0;">
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.classname"
|
||||||
|
class="class-select"
|
||||||
|
:options="classOptions"
|
||||||
|
placeholder="班级"
|
||||||
|
:loading="classesLoading"
|
||||||
|
/>
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.task_type"
|
||||||
|
class="type-select"
|
||||||
|
:options="taskTypeOptions"
|
||||||
|
/>
|
||||||
|
<n-input
|
||||||
|
v-model:value="query.username"
|
||||||
|
class="search-input"
|
||||||
|
clearable
|
||||||
|
placeholder="学生搜索"
|
||||||
|
/>
|
||||||
|
<n-switch v-model:value="query.include_all_tasks">
|
||||||
|
<template #checked>全部有提交任务</template>
|
||||||
|
<template #unchecked>只看计入任务</template>
|
||||||
|
</n-switch>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center" :size="8">
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
title="刷新"
|
||||||
|
:disabled="!query.classname"
|
||||||
|
:loading="loading"
|
||||||
|
@click="loadGradebook"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:refresh-cw" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
:disabled="!query.classname || !gradebook"
|
||||||
|
:loading="exporting"
|
||||||
|
@click="exportCsv"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:download" :width="15" />
|
||||||
|
</template>
|
||||||
|
导出 CSV
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
||||||
|
{{ loadError }}
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-flex v-if="gradebook" align="center" :size="8" style="flex-shrink: 0;">
|
||||||
|
<n-tag size="small">学生 {{ gradebook.student_count }}</n-tag>
|
||||||
|
<n-tag size="small">任务 {{ gradebook.task_count }}</n-tag>
|
||||||
|
<n-tag size="small" type="success">
|
||||||
|
计入 {{ gradebook.included_task_count }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag size="small">
|
||||||
|
覆盖门槛 {{ gradebook.coverage_threshold_count }} 人
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-data-table
|
||||||
|
size="small"
|
||||||
|
striped
|
||||||
|
flex-height
|
||||||
|
:loading="loading"
|
||||||
|
:columns="columns"
|
||||||
|
:data="rows"
|
||||||
|
:row-key="(row: GradebookRow) => row.user_id"
|
||||||
|
:scroll-x="scrollX"
|
||||||
|
style="flex: 1; min-height: 0;"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { watchDebounced } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NTag,
|
||||||
|
NText,
|
||||||
|
useMessage,
|
||||||
|
type DataTableColumn,
|
||||||
|
} from "naive-ui"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Account, Gradebook } from "../api"
|
||||||
|
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||||
|
import type {
|
||||||
|
GradebookCell,
|
||||||
|
GradebookOut,
|
||||||
|
GradebookQuery,
|
||||||
|
GradebookRow,
|
||||||
|
GradebookTask,
|
||||||
|
GradebookTaskType,
|
||||||
|
} from "../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const classesLoading = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const loadError = ref("")
|
||||||
|
const gradebook = ref<GradebookOut | null>(null)
|
||||||
|
const classes = ref<string[]>([])
|
||||||
|
|
||||||
|
const query = reactive<GradebookQuery>({
|
||||||
|
classname: "",
|
||||||
|
task_type: "",
|
||||||
|
username: "",
|
||||||
|
include_all_tasks: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "教程", value: "tutorial" },
|
||||||
|
{ label: "挑战", value: "challenge" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const classOptions = computed(() =>
|
||||||
|
classes.value.map((classname) => ({ label: classname, value: classname })),
|
||||||
|
)
|
||||||
|
const rows = computed(() => gradebook.value?.rows ?? [])
|
||||||
|
const scrollX = computed(() => 1040 + (gradebook.value?.tasks.length ?? 0) * 96)
|
||||||
|
|
||||||
|
function formatScore(value: number | null) {
|
||||||
|
if (value === null) return "-"
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskTitle(task: GradebookTask) {
|
||||||
|
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战"
|
||||||
|
return `${typeLabel}${task.display}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubmission(cell: GradebookCell) {
|
||||||
|
if (!cell.submitted || !cell.submission_id) return
|
||||||
|
const { href } = router.resolve({
|
||||||
|
name: "submission",
|
||||||
|
params: { id: cell.submission_id },
|
||||||
|
})
|
||||||
|
window.open(href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradeTagType(grade: string) {
|
||||||
|
if (grade === "A") return "success"
|
||||||
|
if (grade === "B") return "info"
|
||||||
|
if (grade === "C") return "default"
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskHeader(task: GradebookTask) {
|
||||||
|
return h("div", { class: ["task-header", { muted: !task.included }] }, [
|
||||||
|
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
|
||||||
|
h("div", { class: "task-meta" }, [
|
||||||
|
h("span", `${Math.round(task.coverage * 100)}%`),
|
||||||
|
task.included
|
||||||
|
? null
|
||||||
|
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScore(row: GradebookRow, task: GradebookTask) {
|
||||||
|
const cell = row.scores[task.id]
|
||||||
|
if (!cell || !cell.submitted) {
|
||||||
|
return h(
|
||||||
|
NText,
|
||||||
|
{ class: "missing-cell", type: "error" },
|
||||||
|
{ default: () => "缺交" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
type: task.included ? "primary" : "default",
|
||||||
|
class: ["score-link", { muted: !task.included }],
|
||||||
|
onClick: (event: MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
openSubmission(cell)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ default: () => formatScore(cell.score) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMissingCount(value: number) {
|
||||||
|
if (value <= 0) return "0"
|
||||||
|
return h(NText, { type: "error" }, { default: () => String(value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
|
||||||
|
const tasks = gradebook.value?.tasks ?? []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "排名",
|
||||||
|
key: "rank",
|
||||||
|
width: 60,
|
||||||
|
fixed: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "平时等级",
|
||||||
|
key: "grade",
|
||||||
|
width: 80,
|
||||||
|
fixed: "left",
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: gradeTagType(row.grade) },
|
||||||
|
{ default: () => row.grade },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "考核等级",
|
||||||
|
key: "assessment_grade",
|
||||||
|
width: 80,
|
||||||
|
fixed: "left",
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: gradeTagType(row.assessment_grade) },
|
||||||
|
{ default: () => row.assessment_grade },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "考核分数",
|
||||||
|
key: "assessment_score",
|
||||||
|
width: 80,
|
||||||
|
fixed: "left",
|
||||||
|
render: (row) => row.assessment_score,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "学生",
|
||||||
|
key: "username",
|
||||||
|
width: 80,
|
||||||
|
fixed: "left",
|
||||||
|
render: (row) => {
|
||||||
|
const studentName = displayGradebookStudentName(row)
|
||||||
|
return h(NText, { title: studentName }, { default: () => studentName })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...tasks.map((task) => ({
|
||||||
|
title: () => renderTaskHeader(task),
|
||||||
|
key: `task-${task.id}`,
|
||||||
|
width: 96,
|
||||||
|
align: "center" as const,
|
||||||
|
className: task.included ? "" : "excluded-task-column",
|
||||||
|
render: (row: GradebookRow) => renderScore(row, task),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: "教程合计",
|
||||||
|
key: "tutorial_total",
|
||||||
|
width: 92,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.tutorial_total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "挑战合计",
|
||||||
|
key: "challenge_total",
|
||||||
|
width: 92,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.challenge_total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "总分",
|
||||||
|
key: "total_score",
|
||||||
|
width: 82,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.total_score),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "平均",
|
||||||
|
key: "average_score",
|
||||||
|
width: 82,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.average_score),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "已交",
|
||||||
|
key: "submitted_task_count",
|
||||||
|
width: 70,
|
||||||
|
fixed: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "缺交",
|
||||||
|
key: "missing_task_count",
|
||||||
|
width: 70,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => renderMissingCount(row.missing_task_count),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadClasses() {
|
||||||
|
classesLoading.value = true
|
||||||
|
try {
|
||||||
|
classes.value = await Account.listClasses()
|
||||||
|
if (!query.classname && classes.value.length > 0) {
|
||||||
|
query.classname = classes.value[0]
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
classesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGradebook() {
|
||||||
|
if (!query.classname) {
|
||||||
|
gradebook.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ""
|
||||||
|
try {
|
||||||
|
gradebook.value = await Gradebook.get(query)
|
||||||
|
classes.value = gradebook.value.classes
|
||||||
|
} catch (err: any) {
|
||||||
|
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!query.classname) return
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
await Gradebook.downloadCsv(query)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "导出失败")
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [query.classname, query.task_type, query.include_all_tasks],
|
||||||
|
() => loadGradebook(),
|
||||||
|
)
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => query.username,
|
||||||
|
() => loadGradebook(),
|
||||||
|
{ debounce: 400, maxWait: 1000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadClasses()
|
||||||
|
await loadGradebook()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.class-select {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-select {
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header.muted {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
display: flex;
|
||||||
|
min-height: 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-cell {
|
||||||
|
color: #d03050;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-link.muted {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.excluded-task-column) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-select,
|
||||||
|
.type-select,
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<main class="showcase">
|
||||||
|
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;">
|
||||||
|
<div>
|
||||||
|
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2>
|
||||||
|
<n-text depth="3">优秀作品展示</n-text>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!loading && awards.length === 0"
|
||||||
|
description="暂无展示作品"
|
||||||
|
style="margin-top: 72px;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="section in awards"
|
||||||
|
:key="section.id"
|
||||||
|
style="margin-bottom: 48px;"
|
||||||
|
>
|
||||||
|
<n-flex vertical :size="4" style="margin-bottom: 16px;">
|
||||||
|
<n-h3 style="margin: 0;">{{ section.name }}</n-h3>
|
||||||
|
<n-text v-if="section.description" depth="3" style="font-size: 13px;">
|
||||||
|
{{ section.description }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<n-card
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.submission_id"
|
||||||
|
class="work-card"
|
||||||
|
content-style="padding: 0;"
|
||||||
|
hoverable
|
||||||
|
@click="openDetail(item)"
|
||||||
|
>
|
||||||
|
<div class="card-preview">
|
||||||
|
<iframe
|
||||||
|
:srcdoc="buildSrcdoc(item)"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
scrolling="no"
|
||||||
|
class="preview-iframe"
|
||||||
|
/>
|
||||||
|
<div class="preview-overlay" />
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<n-flex justify="space-between" align="center" :wrap="false">
|
||||||
|
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;">
|
||||||
|
{{ item.username }}
|
||||||
|
</n-ellipsis>
|
||||||
|
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
|
||||||
|
<n-flex align="center" :size="3">
|
||||||
|
<Icon icon="lucide:star" :width="13" />
|
||||||
|
<n-text style="font-size: 12px; color: #666;">
|
||||||
|
{{ item.score.toFixed(1) }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center" :size="3">
|
||||||
|
<Icon icon="lucide:eye" :width="13" />
|
||||||
|
<n-text style="font-size: 12px; color: #666;">
|
||||||
|
{{ item.view_count }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-ellipsis
|
||||||
|
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: #888;"
|
||||||
|
>
|
||||||
|
{{ item.task_title }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</n-spin>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Showcase } from "../api"
|
||||||
|
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
const awards = ref<AwardSection[]>([])
|
||||||
|
|
||||||
|
function buildSrcdoc(item: ShowcaseItem): string {
|
||||||
|
const css = item.css ? `<style>${item.css}</style>` : ""
|
||||||
|
const js = item.js ? `<script>${item.js}<\/script>` : ""
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(item: ShowcaseItem) {
|
||||||
|
const { href } = router.resolve({
|
||||||
|
name: "submission",
|
||||||
|
params: { id: item.submission_id },
|
||||||
|
})
|
||||||
|
window.open(href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
awards.value = await Showcase.list()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.showcase {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card {
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-preview {
|
||||||
|
position: relative;
|
||||||
|
height: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-iframe {
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
border: none;
|
||||||
|
transform: scale(0.5);
|
||||||
|
transform-origin: top left;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
<template>
|
||||||
|
<n-layout has-sider style="height: 100%;">
|
||||||
|
<n-layout-sider
|
||||||
|
:width="260"
|
||||||
|
bordered
|
||||||
|
content-style="overflow: auto; height: 100%;"
|
||||||
|
style="background: #fafafa;"
|
||||||
|
>
|
||||||
|
<n-flex class="panel-header" justify="space-between" align="center">
|
||||||
|
<n-text strong>奖项</n-text>
|
||||||
|
<n-button size="small" secondary title="新建奖项" @click="startCreate">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:plus" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin :show="awardsLoading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!awardsLoading && awards.length === 0"
|
||||||
|
description="暂无奖项"
|
||||||
|
size="small"
|
||||||
|
style="margin-top: 40px;"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-for="award in awards"
|
||||||
|
:key="award.id"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'award-row',
|
||||||
|
{ active: currentAwardId === award.id && !creating },
|
||||||
|
]"
|
||||||
|
@click="selectAward(award)"
|
||||||
|
>
|
||||||
|
<n-ellipsis style="flex: 1; min-width: 0; font-size: 14px; font-weight: 500;">
|
||||||
|
{{ award.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
<n-flex align="center" :size="6" style="flex-shrink: 0; color: #777; font-size: 12px;">
|
||||||
|
<n-tag v-if="!award.is_active" size="small">停用</n-tag>
|
||||||
|
<span>{{ award.item_count }} 件</span>
|
||||||
|
</n-flex>
|
||||||
|
</button>
|
||||||
|
</n-spin>
|
||||||
|
</n-layout-sider>
|
||||||
|
|
||||||
|
<n-layout content-style="padding: 12px; overflow: auto; height: 100%; box-sizing: border-box;">
|
||||||
|
<n-form
|
||||||
|
:model="awardDraft"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="82"
|
||||||
|
style="max-width: 1100px;"
|
||||||
|
>
|
||||||
|
<n-grid :cols="4" :x-gap="12" :y-gap="8" responsive="screen">
|
||||||
|
<n-form-item-gi :span="2" label="名称">
|
||||||
|
<n-input v-model:value="awardDraft.name" placeholder="奖项名称" />
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi label="排序">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="awardDraft.sort_order"
|
||||||
|
:show-button="false"
|
||||||
|
style="width: 120px;"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi label="启用">
|
||||||
|
<n-switch v-model:value="awardDraft.is_active" />
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi :span="2" label="简介">
|
||||||
|
<n-input
|
||||||
|
v-model:value="awardDraft.description"
|
||||||
|
placeholder="可留空"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi label="作品排序">
|
||||||
|
<n-select
|
||||||
|
v-model:value="awardDraft.item_ordering"
|
||||||
|
:options="orderingOptions"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi>
|
||||||
|
<n-flex justify="end" style="width: 100%;">
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!canSaveAward"
|
||||||
|
:loading="savingAward"
|
||||||
|
@click="saveAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:save" :width="15" />
|
||||||
|
</template>
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="currentAwardId && !creating"
|
||||||
|
tertiary
|
||||||
|
type="error"
|
||||||
|
:loading="deletingAward"
|
||||||
|
@click="deleteCurrentAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:trash-2" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-form-item-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<n-divider />
|
||||||
|
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-bottom: 10px;">
|
||||||
|
<n-text strong>已授奖作品</n-text>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
:disabled="!currentAwardId || creating"
|
||||||
|
@click="openAddWorkModal"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:plus" :width="15" />
|
||||||
|
</template>
|
||||||
|
添加作品
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
secondary
|
||||||
|
title="刷新"
|
||||||
|
:disabled="!currentAwardId || creating"
|
||||||
|
:loading="itemsLoading"
|
||||||
|
@click="loadAwardItems"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:refresh-cw" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table
|
||||||
|
size="small"
|
||||||
|
striped
|
||||||
|
:columns="itemColumns"
|
||||||
|
:data="awardItems"
|
||||||
|
:loading="itemsLoading"
|
||||||
|
:row-key="(row: AwardItemManageOut) => row.id"
|
||||||
|
style="max-width: 1100px;"
|
||||||
|
/>
|
||||||
|
</n-layout>
|
||||||
|
</n-layout>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="addWorkModalVisible"
|
||||||
|
preset="card"
|
||||||
|
title="添加作品"
|
||||||
|
style="width: min(640px, calc(100vw - 32px));"
|
||||||
|
>
|
||||||
|
<n-flex vertical :size="12">
|
||||||
|
<n-input-group>
|
||||||
|
<n-input
|
||||||
|
v-model:value="lookupSubmissionId"
|
||||||
|
clearable
|
||||||
|
placeholder="提交 ID"
|
||||||
|
@keyup.enter="findSubmissionForAward"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!lookupSubmissionId.trim()"
|
||||||
|
:loading="lookupLoading"
|
||||||
|
@click="findSubmissionForAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:search" :width="15" />
|
||||||
|
</template>
|
||||||
|
查找
|
||||||
|
</n-button>
|
||||||
|
</n-input-group>
|
||||||
|
|
||||||
|
<n-alert v-if="lookupError" type="error">
|
||||||
|
{{ lookupError }}
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-flex v-if="submissionCandidate" vertical :size="12">
|
||||||
|
<n-descriptions :column="2" size="small" bordered>
|
||||||
|
<n-descriptions-item label="提交者">
|
||||||
|
{{ submissionCandidate.username }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="任务">
|
||||||
|
{{ submissionCandidate.task_title }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="得分">
|
||||||
|
{{
|
||||||
|
submissionCandidate.score > 0
|
||||||
|
? submissionCandidate.score.toFixed(2)
|
||||||
|
: "-"
|
||||||
|
}}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="浏览">
|
||||||
|
{{ submissionCandidate.view_count }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="提示词">
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:type="
|
||||||
|
submissionCandidate.has_prompt_chain ? 'success' : 'default'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ submissionCandidate.has_prompt_chain ? "有" : "无" }}
|
||||||
|
</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="状态">
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:type="candidateAlreadyAwarded ? 'default' : 'info'"
|
||||||
|
>
|
||||||
|
{{ candidateAlreadyAwarded ? "已添加" : "可添加" }}
|
||||||
|
</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
<n-flex justify="end" style="width: 100%;">
|
||||||
|
<n-button secondary @click="clearSubmissionLookup">清空</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="candidateAlreadyAwarded"
|
||||||
|
:loading="addingCandidate"
|
||||||
|
@click="addCandidateToAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon
|
||||||
|
:icon="candidateAlreadyAwarded ? 'lucide:check' : 'lucide:plus'"
|
||||||
|
:width="15"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
{{ candidateAlreadyAwarded ? "已添加" : "添加到奖项" }}
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, reactive, ref } from "vue"
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NButtonGroup,
|
||||||
|
NInputNumber,
|
||||||
|
NTag,
|
||||||
|
useMessage,
|
||||||
|
type DataTableColumn,
|
||||||
|
} from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Showcase } from "../api"
|
||||||
|
import type {
|
||||||
|
AwardItemManageOut,
|
||||||
|
AwardManageIn,
|
||||||
|
AwardManageOut,
|
||||||
|
ItemOrdering,
|
||||||
|
ShowcaseSubmissionLookupOut,
|
||||||
|
} from "../utils/type"
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const awards = ref<AwardManageOut[]>([])
|
||||||
|
const awardItems = ref<AwardItemManageOut[]>([])
|
||||||
|
const currentAwardId = ref<number | null>(null)
|
||||||
|
const creating = ref(false)
|
||||||
|
const awardsLoading = ref(false)
|
||||||
|
const itemsLoading = ref(false)
|
||||||
|
const savingAward = ref(false)
|
||||||
|
const deletingAward = ref(false)
|
||||||
|
const updatingItemIds = ref(new Set<number>())
|
||||||
|
const refreshingItemIds = ref(new Set<number>())
|
||||||
|
const addWorkModalVisible = ref(false)
|
||||||
|
const lookupSubmissionId = ref("")
|
||||||
|
const lookupLoading = ref(false)
|
||||||
|
const lookupError = ref("")
|
||||||
|
const submissionCandidate = ref<ShowcaseSubmissionLookupOut | null>(null)
|
||||||
|
const addingCandidate = ref(false)
|
||||||
|
|
||||||
|
const awardDraft = reactive<AwardManageIn>(defaultAward())
|
||||||
|
|
||||||
|
const orderingOptions: { label: string; value: ItemOrdering }[] = [
|
||||||
|
{ label: "手动排序", value: "manual" },
|
||||||
|
{ label: "授奖时间", value: "awarded_at" },
|
||||||
|
{ label: "评分", value: "score" },
|
||||||
|
{ label: "浏览量", value: "view_count" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const canSaveAward = computed(() => awardDraft.name.trim().length > 0)
|
||||||
|
const awardedSubmissionIds = computed(
|
||||||
|
() => new Set(awardItems.value.map((item) => item.submission_id)),
|
||||||
|
)
|
||||||
|
const candidateAlreadyAwarded = computed(
|
||||||
|
() =>
|
||||||
|
!!submissionCandidate.value &&
|
||||||
|
awardedSubmissionIds.value.has(submissionCandidate.value.submission_id),
|
||||||
|
)
|
||||||
|
const nextSortOrder = computed(() => {
|
||||||
|
if (awardItems.value.length === 0) return 0
|
||||||
|
return Math.max(...awardItems.value.map((item) => item.sort_order)) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemColumns: DataTableColumn<AwardItemManageOut>[] = [
|
||||||
|
{
|
||||||
|
title: "排序",
|
||||||
|
key: "sort_order",
|
||||||
|
width: 92,
|
||||||
|
render: (row) =>
|
||||||
|
h(NInputNumber, {
|
||||||
|
value: row.sort_order,
|
||||||
|
min: 0,
|
||||||
|
size: "small",
|
||||||
|
showButton: false,
|
||||||
|
class: "table-number-input",
|
||||||
|
loading: updatingItemIds.value.has(row.id),
|
||||||
|
"onUpdate:value": (value: number | null) =>
|
||||||
|
updateItemOrder(row, value ?? 0),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "提交者",
|
||||||
|
key: "username",
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "任务",
|
||||||
|
key: "task_title",
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "得分",
|
||||||
|
key: "score",
|
||||||
|
width: 72,
|
||||||
|
render: (row) => (row.score > 0 ? row.score.toFixed(2) : "-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "浏览",
|
||||||
|
key: "view_count",
|
||||||
|
width: 72,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "提示词",
|
||||||
|
key: "has_prompt_chain",
|
||||||
|
width: 88,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: row.has_prompt_chain ? "success" : "default" },
|
||||||
|
{ default: () => (row.has_prompt_chain ? "有" : "无") },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
key: "is_stale",
|
||||||
|
width: 96,
|
||||||
|
render: (row) =>
|
||||||
|
row.is_stale
|
||||||
|
? h(NTag, { size: "small", type: "warning" }, { default: () => "有新提交" })
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "actions",
|
||||||
|
width: 100,
|
||||||
|
render: (row) =>
|
||||||
|
h(NButtonGroup, { size: "small" }, {
|
||||||
|
default: () => [
|
||||||
|
row.is_stale
|
||||||
|
? h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
secondary: true,
|
||||||
|
type: "warning",
|
||||||
|
title: "切换到最新提交",
|
||||||
|
loading: refreshingItemIds.value.has(row.id),
|
||||||
|
onClick: () => refreshAwardItem(row),
|
||||||
|
},
|
||||||
|
{ icon: () => h(Icon, { icon: "lucide:refresh-cw", width: 14 }) },
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
tertiary: true,
|
||||||
|
type: "error",
|
||||||
|
title: "移除",
|
||||||
|
onClick: () => removeAwardItem(row),
|
||||||
|
},
|
||||||
|
{ icon: () => h(Icon, { icon: "lucide:trash-2", width: 15 }) },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function defaultAward(): AwardManageIn {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort_order: 0,
|
||||||
|
is_active: true,
|
||||||
|
item_ordering: "manual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignDraft(award: AwardManageIn) {
|
||||||
|
Object.assign(awardDraft, {
|
||||||
|
name: award.name,
|
||||||
|
description: award.description,
|
||||||
|
sort_order: award.sort_order,
|
||||||
|
is_active: award.is_active,
|
||||||
|
item_ordering: award.item_ordering,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
currentAwardId.value = null
|
||||||
|
creating.value = true
|
||||||
|
awardItems.value = []
|
||||||
|
assignDraft(defaultAward())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAwards() {
|
||||||
|
awardsLoading.value = true
|
||||||
|
try {
|
||||||
|
awards.value = await Showcase.listManageAwards()
|
||||||
|
} finally {
|
||||||
|
awardsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAward(award: AwardManageOut) {
|
||||||
|
currentAwardId.value = award.id
|
||||||
|
creating.value = false
|
||||||
|
assignDraft(award)
|
||||||
|
await loadAwardItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAward() {
|
||||||
|
if (!canSaveAward.value) return
|
||||||
|
const payload: AwardManageIn = {
|
||||||
|
...awardDraft,
|
||||||
|
name: awardDraft.name.trim(),
|
||||||
|
description: awardDraft.description.trim(),
|
||||||
|
}
|
||||||
|
savingAward.value = true
|
||||||
|
try {
|
||||||
|
const saved =
|
||||||
|
currentAwardId.value && !creating.value
|
||||||
|
? await Showcase.updateAward(currentAwardId.value, payload)
|
||||||
|
: await Showcase.createAward(payload)
|
||||||
|
await loadAwards()
|
||||||
|
const next = awards.value.find((award) => award.id === saved.id)
|
||||||
|
if (next) await selectAward(next)
|
||||||
|
message.success("已保存")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "保存失败")
|
||||||
|
} finally {
|
||||||
|
savingAward.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentAward() {
|
||||||
|
if (!currentAwardId.value || creating.value) return
|
||||||
|
if (!window.confirm("确定删除这个奖项?")) return
|
||||||
|
deletingAward.value = true
|
||||||
|
try {
|
||||||
|
await Showcase.deleteAward(currentAwardId.value)
|
||||||
|
await loadAwards()
|
||||||
|
if (awards.value.length > 0) await selectAward(awards.value[0])
|
||||||
|
else startCreate()
|
||||||
|
message.success("已删除")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "删除失败")
|
||||||
|
} finally {
|
||||||
|
deletingAward.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAwardItems() {
|
||||||
|
if (!currentAwardId.value || creating.value) {
|
||||||
|
awardItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemsLoading.value = true
|
||||||
|
try {
|
||||||
|
awardItems.value = await Showcase.listAwardItems(currentAwardId.value)
|
||||||
|
} finally {
|
||||||
|
itemsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUpdatingItem(id: number, loading: boolean) {
|
||||||
|
const next = new Set(updatingItemIds.value)
|
||||||
|
if (loading) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
updatingItemIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRefreshingItem(id: number, loading: boolean) {
|
||||||
|
const next = new Set(refreshingItemIds.value)
|
||||||
|
if (loading) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
refreshingItemIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAwardItem(row: AwardItemManageOut) {
|
||||||
|
setRefreshingItem(row.id, true)
|
||||||
|
try {
|
||||||
|
const updated = await Showcase.refreshAwardItem(row.id)
|
||||||
|
const idx = awardItems.value.findIndex((item) => item.id === row.id)
|
||||||
|
if (idx !== -1) awardItems.value[idx] = updated
|
||||||
|
message.success("已切换到最新提交")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "切换失败")
|
||||||
|
} finally {
|
||||||
|
setRefreshingItem(row.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateItemOrder(row: AwardItemManageOut, sortOrder: number) {
|
||||||
|
if (row.sort_order === sortOrder) return
|
||||||
|
row.sort_order = sortOrder
|
||||||
|
setUpdatingItem(row.id, true)
|
||||||
|
try {
|
||||||
|
await Showcase.updateAwardItem(row.id, { sort_order: sortOrder })
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "排序更新失败")
|
||||||
|
await loadAwardItems()
|
||||||
|
} finally {
|
||||||
|
setUpdatingItem(row.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAwardItem(row: AwardItemManageOut) {
|
||||||
|
if (!window.confirm("确定移除这个作品?")) return
|
||||||
|
try {
|
||||||
|
await Showcase.deleteAwardItem(row.id)
|
||||||
|
awardItems.value = awardItems.value.filter((item) => item.id !== row.id)
|
||||||
|
await loadAwards()
|
||||||
|
message.success("已移除")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "移除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSubmissionLookup() {
|
||||||
|
lookupSubmissionId.value = ""
|
||||||
|
lookupError.value = ""
|
||||||
|
submissionCandidate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddWorkModal() {
|
||||||
|
if (!currentAwardId.value || creating.value) return
|
||||||
|
clearSubmissionLookup()
|
||||||
|
addWorkModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSubmissionForAward() {
|
||||||
|
const submissionId = lookupSubmissionId.value.trim()
|
||||||
|
if (!submissionId) return
|
||||||
|
lookupLoading.value = true
|
||||||
|
lookupError.value = ""
|
||||||
|
submissionCandidate.value = null
|
||||||
|
try {
|
||||||
|
submissionCandidate.value =
|
||||||
|
await Showcase.findSubmissionForAward(submissionId)
|
||||||
|
} catch (err: any) {
|
||||||
|
lookupError.value = err.response?.data?.detail ?? "没有找到这个提交"
|
||||||
|
} finally {
|
||||||
|
lookupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCandidateToAward() {
|
||||||
|
const candidate = submissionCandidate.value
|
||||||
|
if (
|
||||||
|
!currentAwardId.value ||
|
||||||
|
creating.value ||
|
||||||
|
!candidate ||
|
||||||
|
candidateAlreadyAwarded.value
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addingCandidate.value = true
|
||||||
|
try {
|
||||||
|
const item = await Showcase.addAwardItem(currentAwardId.value, {
|
||||||
|
submission_id: candidate.submission_id,
|
||||||
|
sort_order: nextSortOrder.value,
|
||||||
|
})
|
||||||
|
awardItems.value = [...awardItems.value, item]
|
||||||
|
await loadAwards()
|
||||||
|
message.success("已添加作品")
|
||||||
|
addWorkModalVisible.value = false
|
||||||
|
clearSubmissionLookup()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "添加作品失败")
|
||||||
|
} finally {
|
||||||
|
addingCandidate.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAwards()
|
||||||
|
if (awards.value.length > 0) await selectAward(awards.value[0])
|
||||||
|
else startCreate()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #efeff5;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: transparent;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award-row:hover {
|
||||||
|
background: #f2f5f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award-row.active {
|
||||||
|
background: #e8f8f0;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.table-number-input) {
|
||||||
|
width: 76px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, useTemplateRef } from "vue"
|
||||||
|
import { Submission } from "../api"
|
||||||
|
import type { SubmissionAll } from "../utils/type"
|
||||||
|
import { buildPreviewDocument } from "../utils/previewDocument"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const submission: SubmissionAll = await Submission.get(props.id)
|
||||||
|
Submission.incrementView(props.id)
|
||||||
|
|
||||||
|
if (!iframe.value) return
|
||||||
|
const doc = iframe.value.contentDocument
|
||||||
|
if (doc) {
|
||||||
|
doc.open()
|
||||||
|
doc.write(
|
||||||
|
buildPreviewDocument({
|
||||||
|
html: submission.html,
|
||||||
|
css: submission.css,
|
||||||
|
js: submission.js,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
doc.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<iframe class="iframe" ref="iframe"></iframe>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.iframe {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<template>
|
||||||
|
<n-split
|
||||||
|
class="container"
|
||||||
|
direction="horizontal"
|
||||||
|
:default-size="0.5"
|
||||||
|
:min="0.2"
|
||||||
|
:max="0.8"
|
||||||
|
>
|
||||||
|
<template #1>
|
||||||
|
<n-flex
|
||||||
|
vertical
|
||||||
|
style="height: 100%; padding-right: 10px; overflow: hidden"
|
||||||
|
>
|
||||||
|
<n-flex align="center" justify="end">
|
||||||
|
<n-select
|
||||||
|
:value="query.flag"
|
||||||
|
style="width: 100px"
|
||||||
|
clearable
|
||||||
|
placeholder="标记"
|
||||||
|
:options="flagFilterOptions"
|
||||||
|
@update:value="handleFlagSelect"
|
||||||
|
/>
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.zone"
|
||||||
|
style="width: 100px"
|
||||||
|
clearable
|
||||||
|
placeholder="从夯到拉"
|
||||||
|
:options="[
|
||||||
|
{ label: '夯爆了', value: 'featured' },
|
||||||
|
{ label: 'NPC', value: 'pending' },
|
||||||
|
{ label: '拉完了', value: 'low' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<n-input
|
||||||
|
style="width: 120px"
|
||||||
|
v-model:value="query.username"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<n-pagination
|
||||||
|
v-model:page="query.page"
|
||||||
|
:page-size="10"
|
||||||
|
:item-count="count"
|
||||||
|
simple
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
style="padding: 0 10px"
|
||||||
|
title="刷新"
|
||||||
|
@click="init"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:refresh-cw" />
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table
|
||||||
|
flex-height
|
||||||
|
striped
|
||||||
|
:columns="columns"
|
||||||
|
:data="data"
|
||||||
|
:row-key="(row: SubmissionOut) => row.id"
|
||||||
|
:expanded-row-keys="expandedKeys"
|
||||||
|
@update:expanded-row-keys="handleExpand"
|
||||||
|
:row-props="rowProps"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
style="flex: 1; min-height: 0"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<template #2>
|
||||||
|
<div style="height: 100%; padding-left: 10px">
|
||||||
|
<Preview
|
||||||
|
v-if="submission.id"
|
||||||
|
:html="html"
|
||||||
|
:css="css"
|
||||||
|
:js="js"
|
||||||
|
:submission-id="submission.id"
|
||||||
|
@after-score="afterScore"
|
||||||
|
@show-code="codeModal = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-split>
|
||||||
|
|
||||||
|
<CodeModal
|
||||||
|
v-model:show="codeModal"
|
||||||
|
:html="html"
|
||||||
|
:css="css"
|
||||||
|
:js="js"
|
||||||
|
@copy-to-editor="copyToEditor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChainModal
|
||||||
|
v-model:show="chainModal"
|
||||||
|
:submission-id="chainSubmissionId"
|
||||||
|
:username="chainUsername"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||||
|
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Submission } from "../api"
|
||||||
|
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||||
|
import { parseTime } from "../utils/helper"
|
||||||
|
import { goHome } from "../utils/helper"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import { watchDebounced } from "@vueuse/core"
|
||||||
|
import { useRouter, useRoute } from "vue-router"
|
||||||
|
|
||||||
|
import Preview from "../components/editor/Preview.vue"
|
||||||
|
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
||||||
|
import CodeModal from "../components/submissions/CodeModal.vue"
|
||||||
|
import ChainModal from "../components/submissions/ChainModal.vue"
|
||||||
|
import FlagCell from "../components/submissions/FlagCell.vue"
|
||||||
|
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
||||||
|
|
||||||
|
import { submission } from "../store/submission"
|
||||||
|
import { taskTab, challengeDisplay } from "../store/task"
|
||||||
|
import { step } from "../store/tutorial"
|
||||||
|
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
||||||
|
import { roleAdmin, roleSuper, user } from "../store/user"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
|
const data = ref<SubmissionOut[]>([])
|
||||||
|
const count = ref(0)
|
||||||
|
const query = reactive({
|
||||||
|
page: Number(route.params.page),
|
||||||
|
username: (Array.isArray(route.query.username)
|
||||||
|
? ""
|
||||||
|
: (route.query.username ?? "")) as string,
|
||||||
|
flag: null as string | null,
|
||||||
|
zone: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前选中提交的代码
|
||||||
|
const html = computed(() => submission.value.html)
|
||||||
|
const css = computed(() => submission.value.css)
|
||||||
|
const js = computed(() => submission.value.js)
|
||||||
|
|
||||||
|
// Modal 状态
|
||||||
|
const codeModal = ref(false)
|
||||||
|
const chainModal = ref(false)
|
||||||
|
const chainSubmissionId = ref<string>("")
|
||||||
|
const chainUsername = ref<string>("")
|
||||||
|
|
||||||
|
// 展开行
|
||||||
|
const expandedKeys = ref<string[]>([])
|
||||||
|
const expandedData = reactive(new Map<string, SubmissionOut[]>())
|
||||||
|
const expandedLoading = reactive(new Set<string>())
|
||||||
|
|
||||||
|
// 管理员判断
|
||||||
|
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
|
||||||
|
|
||||||
|
// Flag 过滤选项
|
||||||
|
const flagFilterOptions = computed(() => {
|
||||||
|
const opts = [
|
||||||
|
{ label: "红旗", value: "red" },
|
||||||
|
{ label: "蓝旗", value: "blue" },
|
||||||
|
{ label: "绿旗", value: "green" },
|
||||||
|
{ label: "黄旗", value: "yellow" },
|
||||||
|
{ label: "全部", value: "any" },
|
||||||
|
]
|
||||||
|
if (isAdmin.value) opts.push({ label: "清除", value: "_clear_all" })
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleFlagSelect(value: string | null) {
|
||||||
|
if (value === "_clear_all") {
|
||||||
|
clearAllFlags()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query.flag = value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFlag(row: SubmissionOut, flag: FlagType) {
|
||||||
|
await Submission.updateFlag(row.id, flag)
|
||||||
|
row.flag = flag
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAllFlags() {
|
||||||
|
await Submission.clearAllFlags()
|
||||||
|
data.value = data.value.map((d) => ({ ...d, flag: null }))
|
||||||
|
query.flag = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function showChain(submissionId: string, username: string) {
|
||||||
|
chainSubmissionId.value = submissionId
|
||||||
|
chainUsername.value = username
|
||||||
|
chainModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns: DataTableColumn<SubmissionOut>[] = [
|
||||||
|
{
|
||||||
|
type: "expand",
|
||||||
|
expandable: () => true,
|
||||||
|
renderExpand: (row) =>
|
||||||
|
h(ExpandedSubTable, {
|
||||||
|
row,
|
||||||
|
items: expandedData.get(row.id),
|
||||||
|
loading: expandedLoading.has(row.id),
|
||||||
|
onSelect: (id) => getSubmissionByID(id),
|
||||||
|
onDelete: (r, parentId) => handleDelete(r, parentId),
|
||||||
|
"onShow-chain": (submissionId, username) =>
|
||||||
|
showChain(submissionId, username),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "flag",
|
||||||
|
width: 50,
|
||||||
|
render: (row) =>
|
||||||
|
h(FlagCell, {
|
||||||
|
flag: row.flag ?? null,
|
||||||
|
isAdmin: isAdmin.value,
|
||||||
|
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "zone",
|
||||||
|
width: 42,
|
||||||
|
render: (row) => {
|
||||||
|
const map: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; type: "success" | "default" | "warning" }
|
||||||
|
> = {
|
||||||
|
featured: { label: "夯", type: "success" },
|
||||||
|
pending: { label: "N", type: "default" },
|
||||||
|
low: { label: "拉", type: "warning" },
|
||||||
|
}
|
||||||
|
if (!row.zone || !map[row.zone]) return null
|
||||||
|
const { label, type } = map[row.zone]
|
||||||
|
return h(NTag, { size: "small", round: true, type }, () => label)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "时间",
|
||||||
|
key: "created",
|
||||||
|
width: 110,
|
||||||
|
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "提交者",
|
||||||
|
key: "user",
|
||||||
|
width: 80,
|
||||||
|
render: (row) => row.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "任务",
|
||||||
|
key: "task_title",
|
||||||
|
render: (row) => h(TaskTitle, { submission: row }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "得分",
|
||||||
|
key: "score",
|
||||||
|
width: 70,
|
||||||
|
render: (row) => {
|
||||||
|
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
|
||||||
|
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
||||||
|
[
|
||||||
|
h("span", avgScore),
|
||||||
|
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "次数",
|
||||||
|
key: "submit_count",
|
||||||
|
width: 60,
|
||||||
|
render: (row) => row.submit_count || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "浏览",
|
||||||
|
key: "view_count",
|
||||||
|
width: 60,
|
||||||
|
render: (row) => row.view_count || "-",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function handleExpand(keys: (string | number)[]) {
|
||||||
|
const strKeys = keys.map(String)
|
||||||
|
const newKey = strKeys.find((k) => !expandedKeys.value.includes(k))
|
||||||
|
expandedKeys.value = strKeys
|
||||||
|
if (newKey) {
|
||||||
|
const row = data.value.find((d) => d.id === newKey)
|
||||||
|
if (row && !expandedData.has(newKey)) {
|
||||||
|
expandedLoading.add(newKey)
|
||||||
|
try {
|
||||||
|
const items = await Submission.listByUserTask(row.userid, row.task_id)
|
||||||
|
expandedData.set(newKey, items)
|
||||||
|
} finally {
|
||||||
|
expandedLoading.delete(newKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: SubmissionOut, parentId: string) {
|
||||||
|
try {
|
||||||
|
await Submission.delete(row.id)
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const items = expandedData.get(parentId)
|
||||||
|
if (items)
|
||||||
|
expandedData.set(
|
||||||
|
parentId,
|
||||||
|
items.filter((d) => d.id !== row.id),
|
||||||
|
)
|
||||||
|
if (submission.value.id === row.id) submission.value.id = ""
|
||||||
|
const res = await Submission.list(query)
|
||||||
|
data.value = res.items
|
||||||
|
count.value = res.count
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowProps(row: SubmissionOut) {
|
||||||
|
return {
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
onClick: () => {
|
||||||
|
getSubmissionByID(row.id)
|
||||||
|
handleExpand(
|
||||||
|
expandedKeys.value.includes(row.id)
|
||||||
|
? expandedKeys.value.filter((k) => k !== row.id)
|
||||||
|
: [...expandedKeys.value, row.id],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClassName(row: SubmissionOut) {
|
||||||
|
return submission.value.id === row.id ? "row-active" : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
expandedKeys.value = []
|
||||||
|
expandedData.clear()
|
||||||
|
const res = await Submission.list(query)
|
||||||
|
data.value = res.items
|
||||||
|
count.value = res.count
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSubmissionByID(id: string) {
|
||||||
|
submission.value = await Submission.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterScore() {
|
||||||
|
data.value = data.value.map((d) => {
|
||||||
|
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
||||||
|
return d
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToEditor() {
|
||||||
|
eHtml.value = html.value
|
||||||
|
eCss.value = css.value
|
||||||
|
eJs.value = js.value
|
||||||
|
goHome(router, submission.value.task_type, submission.value.task_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => query.page,
|
||||||
|
(v) => {
|
||||||
|
init()
|
||||||
|
router.push({ params: { page: v } })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watchDebounced(
|
||||||
|
() => query.username,
|
||||||
|
() => {
|
||||||
|
query.page = 1
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
{ debounce: 500, maxWait: 1000 },
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => query.flag,
|
||||||
|
() => {
|
||||||
|
query.page = 1
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => query.zone,
|
||||||
|
() => {
|
||||||
|
query.page = 1
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
onUnmounted(() => {
|
||||||
|
submission.value = {
|
||||||
|
id: "",
|
||||||
|
userid: 0,
|
||||||
|
username: "",
|
||||||
|
task_id: 0,
|
||||||
|
task_display: 0,
|
||||||
|
task_title: "",
|
||||||
|
task_type: TASK_TYPE.Tutorial,
|
||||||
|
score: 0,
|
||||||
|
my_score: 0,
|
||||||
|
html: "",
|
||||||
|
css: "",
|
||||||
|
js: "",
|
||||||
|
submit_count: 0,
|
||||||
|
view_count: 0,
|
||||||
|
created: new Date(),
|
||||||
|
modified: new Date(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.row-active td) {
|
||||||
|
background-color: rgba(24, 160, 80, 0.1) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<n-grid x-gap="10" :cols="8">
|
||||||
|
<n-gi :span="2" class="col">
|
||||||
|
<n-flex vertical class="list">
|
||||||
|
<n-card
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.display"
|
||||||
|
@click="show(item.display)"
|
||||||
|
class="card"
|
||||||
|
:header-style="{
|
||||||
|
backgroundColor:
|
||||||
|
item.display === tutorial.display ? 'rgba(24, 160, 80, 0.1)' : '',
|
||||||
|
}"
|
||||||
|
:embedded="!item.is_public"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-button text @click.stop="togglePublic(item.display)">
|
||||||
|
<Icon
|
||||||
|
width="24"
|
||||||
|
:icon="
|
||||||
|
item.is_public
|
||||||
|
? 'twemoji:check-mark-button'
|
||||||
|
: 'twemoji:locked'
|
||||||
|
"
|
||||||
|
></Icon>
|
||||||
|
</n-button>
|
||||||
|
<span>【{{ item.display }}】{{ item.title }}</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button text type="default" @click="remove(item.display)">
|
||||||
|
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
<n-button @click="createNew">新建</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-gi>
|
||||||
|
|
||||||
|
<n-gi :span="6" class="col">
|
||||||
|
<n-flex vertical>
|
||||||
|
<n-form inline :show-feedback="false">
|
||||||
|
<n-form-item label="序号" label-placement="left">
|
||||||
|
<n-input-number
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="tutorial.display"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="标题" label-placement="left">
|
||||||
|
<n-input v-model:value="tutorial.title" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="公开" label-placement="left">
|
||||||
|
<n-switch v-model:value="tutorial.is_public" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label-placement="left">
|
||||||
|
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-flex>
|
||||||
|
<TaskAssetManager
|
||||||
|
v-if="tutorial.display"
|
||||||
|
task-type="tutorial"
|
||||||
|
:display="tutorial.display"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
v-if="tutorial.display"
|
||||||
|
size="small"
|
||||||
|
@click="showExampleModal = true"
|
||||||
|
>
|
||||||
|
示例代码
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<MarkdownEditor
|
||||||
|
style="height: calc(100vh - 100px)"
|
||||||
|
v-model="tutorial.content"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showExampleModal"
|
||||||
|
preset="card"
|
||||||
|
title="示例代码(加载教程时自动填入编辑器)"
|
||||||
|
style="width: 640px"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
v-model:value="rawCode"
|
||||||
|
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
||||||
|
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||||
|
/>
|
||||||
|
<n-flex v-if="splitResult" style="margin-top: 8px">
|
||||||
|
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
|
||||||
|
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
|
||||||
|
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Tutorial } from "../api"
|
||||||
|
import type { TutorialSlim } from "../utils/type"
|
||||||
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
|
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||||
|
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const confirm = useDialog()
|
||||||
|
|
||||||
|
const list = ref<TutorialSlim[]>([])
|
||||||
|
const showExampleModal = ref(false)
|
||||||
|
const rawCode = ref("")
|
||||||
|
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||||
|
|
||||||
|
function splitHtml(raw: string) {
|
||||||
|
let result = raw
|
||||||
|
const cssBlocks: string[] = []
|
||||||
|
const jsBlocks: string[] = []
|
||||||
|
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
||||||
|
cssBlocks.push(c.trim())
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
result = result.replace(
|
||||||
|
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||||
|
(_, c) => {
|
||||||
|
jsBlocks.push(c.trim())
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
html: result.trim(),
|
||||||
|
css: cssBlocks.join("\n\n"),
|
||||||
|
js: jsBlocks.join("\n\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(rawCode, (val) => {
|
||||||
|
if (!val.trim()) {
|
||||||
|
splitResult.value = null
|
||||||
|
tutorial.example_html = null
|
||||||
|
tutorial.example_css = null
|
||||||
|
tutorial.example_js = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const split = splitHtml(val)
|
||||||
|
splitResult.value = split
|
||||||
|
tutorial.example_html = split.html || null
|
||||||
|
tutorial.example_css = split.css || null
|
||||||
|
tutorial.example_js = split.js || null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showExampleModal, (visible) => {
|
||||||
|
if (!visible) return
|
||||||
|
const parts: string[] = []
|
||||||
|
if (tutorial.example_css) parts.push(`<style>\n${tutorial.example_css}\n</style>`)
|
||||||
|
if (tutorial.example_html) parts.push(tutorial.example_html)
|
||||||
|
if (tutorial.example_js) parts.push(`<script>\n${tutorial.example_js}\n<\/script>`)
|
||||||
|
rawCode.value = parts.join("\n\n")
|
||||||
|
})
|
||||||
|
const tutorial = reactive({
|
||||||
|
display: 0,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
is_public: false,
|
||||||
|
example_html: null as string | null,
|
||||||
|
example_css: null as string | null,
|
||||||
|
example_js: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => tutorial.display && tutorial.title && tutorial.content,
|
||||||
|
)
|
||||||
|
async function getContent() {
|
||||||
|
list.value = await Tutorial.list()
|
||||||
|
const display = Number(route.params.display)
|
||||||
|
const target = list.value.find((item) => item.display === display)
|
||||||
|
if (target) {
|
||||||
|
show(display)
|
||||||
|
} else if (list.value.length > 0) {
|
||||||
|
show(list.value[0]!.display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
tutorial.display = list.value[list.value.length - 1]?.display ?? 0 + 1
|
||||||
|
tutorial.title = ""
|
||||||
|
tutorial.content = ""
|
||||||
|
tutorial.is_public = false
|
||||||
|
tutorial.example_html = null
|
||||||
|
tutorial.example_css = null
|
||||||
|
tutorial.example_js = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
await Tutorial.createOrUpdate(tutorial)
|
||||||
|
message.success("提交成功")
|
||||||
|
tutorial.display = 0
|
||||||
|
tutorial.title = ""
|
||||||
|
tutorial.content = ""
|
||||||
|
tutorial.is_public = false
|
||||||
|
tutorial.example_html = null
|
||||||
|
tutorial.example_css = null
|
||||||
|
tutorial.example_js = null
|
||||||
|
await getContent()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response.data.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(display: number) {
|
||||||
|
confirm.warning({
|
||||||
|
title: "警告",
|
||||||
|
content: "你确定要删除吗?",
|
||||||
|
positiveText: "确定",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await Tutorial.remove(display)
|
||||||
|
message.success("删除成功")
|
||||||
|
getContent()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(display: number) {
|
||||||
|
router.push({ name: "tutorial-editor", params: { display } })
|
||||||
|
const item = await Tutorial.get(display)
|
||||||
|
tutorial.display = item.display
|
||||||
|
tutorial.title = item.title
|
||||||
|
tutorial.content = item.content
|
||||||
|
tutorial.is_public = item.is_public
|
||||||
|
tutorial.example_html = item.example_html ?? null
|
||||||
|
tutorial.example_css = item.example_css ?? null
|
||||||
|
tutorial.example_js = item.example_js ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublic(display: number) {
|
||||||
|
const data = await Tutorial.togglePublic(display)
|
||||||
|
message.success(data.message)
|
||||||
|
list.value = list.value.map((item) => {
|
||||||
|
if (item.display === display) item.is_public = !item.is_public
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(getContent)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.list {
|
||||||
|
overflow: auto;
|
||||||
|
height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex vertical class="container">
|
||||||
|
<n-flex>
|
||||||
|
<div>
|
||||||
|
<n-input
|
||||||
|
style="width: 200px"
|
||||||
|
v-model:value="query.username"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<n-select
|
||||||
|
style="width: 120px"
|
||||||
|
v-model:value="query.role"
|
||||||
|
:options="roles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n-button @click="showCreate = true">新建一个</n-button>
|
||||||
|
<n-button @click="showBatch = true">批量新建</n-button>
|
||||||
|
<n-pagination
|
||||||
|
v-model:page="query.page"
|
||||||
|
:page-size="10"
|
||||||
|
:item-count="count"
|
||||||
|
simple
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
||||||
|
<UserFormModal v-model:show="showCreate" @saved="init" />
|
||||||
|
<n-modal
|
||||||
|
style="width: 300px"
|
||||||
|
:mask-closable="false"
|
||||||
|
preset="card"
|
||||||
|
title="批量新建 (前缀 web)"
|
||||||
|
v-model:show="showBatch"
|
||||||
|
>
|
||||||
|
<n-flex vertical>
|
||||||
|
<n-input
|
||||||
|
placeholder="班级"
|
||||||
|
v-model:value="classname"
|
||||||
|
:disabled="batchLoading"
|
||||||
|
/>
|
||||||
|
<n-input
|
||||||
|
rows="20"
|
||||||
|
type="textarea"
|
||||||
|
v-model:value="namesStr"
|
||||||
|
:disabled="batchLoading"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!(classname && !!names.length)"
|
||||||
|
:loading="batchLoading"
|
||||||
|
@click="batchCreateUsers"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-modal>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, reactive, ref, h, watch, computed } from "vue"
|
||||||
|
import { Account } from "../api"
|
||||||
|
import { parseTime } from "../utils/helper"
|
||||||
|
import { useMessage, type DataTableColumn } from "naive-ui"
|
||||||
|
import { getRole, Role, type User } from "../utils/type"
|
||||||
|
import UserActions from "../components/dashboard/UserActions.vue"
|
||||||
|
import UserFormModal from "../components/dashboard/UserFormModal.vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { watchDebounced } from "@vueuse/core"
|
||||||
|
|
||||||
|
const users = ref([])
|
||||||
|
const count = ref(0)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const query = reactive({
|
||||||
|
username: "",
|
||||||
|
page: Number(route.params.page),
|
||||||
|
role: "",
|
||||||
|
})
|
||||||
|
const showBatch = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const classname = ref("")
|
||||||
|
const namesStr = ref("")
|
||||||
|
const names = computed(() => namesStr.value.split("\n").filter((it) => !!it))
|
||||||
|
|
||||||
|
const batchLoading = ref(false)
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
{ label: "全部权限", value: "" },
|
||||||
|
{ label: "普通用户", value: Role.Normal },
|
||||||
|
{ label: "管理员", value: Role.Admin },
|
||||||
|
{ label: "超级管理员", value: Role.Super },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns: DataTableColumn<User>[] = [
|
||||||
|
{
|
||||||
|
title: "用户名",
|
||||||
|
key: "username",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "密码",
|
||||||
|
key: "raw_password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
key: "date_joined",
|
||||||
|
render: (row) => parseTime(row.date_joined, "YYYY-MM-DD HH:mm:ss"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "上次登录",
|
||||||
|
key: "last_login",
|
||||||
|
render: (row) =>
|
||||||
|
row.last_login
|
||||||
|
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||||
|
: "从未登录",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "班级",
|
||||||
|
key: "classname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "权限",
|
||||||
|
key: "role",
|
||||||
|
render: (row) => getRole(row.role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "选项",
|
||||||
|
key: "actions",
|
||||||
|
render: (row) => h(UserActions, { user: row, onReload: init }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function batchCreateUsers() {
|
||||||
|
if (!names.value.length) return
|
||||||
|
try {
|
||||||
|
batchLoading.value = true
|
||||||
|
const data = await Account.batchCreate({
|
||||||
|
classname: classname.value,
|
||||||
|
names: names.value,
|
||||||
|
})
|
||||||
|
batchLoading.value = false
|
||||||
|
message.success(data.message)
|
||||||
|
showBatch.value = false
|
||||||
|
query.page = 1
|
||||||
|
query.role = ""
|
||||||
|
init()
|
||||||
|
} catch (err: any) {
|
||||||
|
batchLoading.value = false
|
||||||
|
message.error(err.response?.data?.detail ?? "批量创建失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const data = await Account.list(query)
|
||||||
|
users.value = data.items
|
||||||
|
count.value = data.count
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => query.role,
|
||||||
|
() => {
|
||||||
|
query.page = 1
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watchDebounced(
|
||||||
|
() => query.username,
|
||||||
|
() => {
|
||||||
|
query.page = 1
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
{ debounce: 500, maxWait: 1000 },
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => query.page,
|
||||||
|
(v) => {
|
||||||
|
init()
|
||||||
|
router.push({ params: { page: v } })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 10px 10px 10px 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<n-split :size="panelSize" @update-size="changeSize" min="400px" max="900px">
|
||||||
|
<template #1>
|
||||||
|
<TaskPanel @hide="hide" />
|
||||||
|
</template>
|
||||||
|
<template #2>
|
||||||
|
<n-split direction="vertical" min="200px">
|
||||||
|
<template #1>
|
||||||
|
<Editors />
|
||||||
|
</template>
|
||||||
|
<template #2>
|
||||||
|
<Preview
|
||||||
|
:html="html"
|
||||||
|
:css="css"
|
||||||
|
:js="js"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-split>
|
||||||
|
</template>
|
||||||
|
</n-split>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useMagicKeys, whenever } from "@vueuse/core"
|
||||||
|
import Editors from "../components/editor/Editors.vue"
|
||||||
|
import Preview from "../components/editor/Preview.vue"
|
||||||
|
import TaskPanel from "../components/task/TaskPanel.vue"
|
||||||
|
import { show, panelSize } from "../store/panel"
|
||||||
|
import { html, css, js } from "../store/editors"
|
||||||
|
import { assetBaseUrl } from "../store/task"
|
||||||
|
|
||||||
|
const { ctrl_s } = useMagicKeys({
|
||||||
|
passive: false,
|
||||||
|
onEventFired(e) {
|
||||||
|
if (e.ctrlKey && e.key === "s" && e.type === "keydown") e.preventDefault()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { ctrl_r } = useMagicKeys({
|
||||||
|
passive: false,
|
||||||
|
onEventFired(e) {
|
||||||
|
if (e.ctrlKey && e.key === "r" && e.type === "keydown") e.preventDefault()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function changeSize(n: number) {
|
||||||
|
panelSize.value = n
|
||||||
|
if (n > 0) show.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
panelSize.value = 0
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
whenever(ctrl_s!, () => {})
|
||||||
|
whenever(ctrl_r!, () => {})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { createWebHistory, createRouter } from "vue-router"
|
||||||
|
import { loginModal } from "./store/modal"
|
||||||
|
|
||||||
|
import Workspace from "./pages/Workspace.vue"
|
||||||
|
import { STORAGE_KEY } from "./utils/const"
|
||||||
|
import { authed } from "./store/user"
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
|
||||||
|
{
|
||||||
|
path: "/tutorial",
|
||||||
|
name: "home-tutorial-list",
|
||||||
|
component: Workspace,
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tutorial/:display",
|
||||||
|
name: "home-tutorial",
|
||||||
|
component: Workspace,
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/challenge",
|
||||||
|
name: "home-challenge-list",
|
||||||
|
component: Workspace,
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/challenge/:display",
|
||||||
|
name: "home-challenge",
|
||||||
|
component: () => import("./pages/ChallengeDetail.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/submissions/:page",
|
||||||
|
name: "submissions",
|
||||||
|
component: () => import("./pages/Submissions.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/submission/:id",
|
||||||
|
name: "submission",
|
||||||
|
component: () => import("./pages/Submission.vue"),
|
||||||
|
props: true,
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/creation",
|
||||||
|
name: "creation-list",
|
||||||
|
component: () => import("./pages/CreationHome.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/creation/:display",
|
||||||
|
name: "creation",
|
||||||
|
component: () => import("./pages/CreationDetail.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/showcase",
|
||||||
|
name: "showcase",
|
||||||
|
component: () => import("./pages/Showcase.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
name: "dashboard",
|
||||||
|
component: () => import("./pages/Dashboard.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "tutorial/:display",
|
||||||
|
name: "tutorial-editor",
|
||||||
|
component: () => import("./pages/TutorialEditor.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "challenge/:display",
|
||||||
|
name: "challenge-editor",
|
||||||
|
component: () => import("./pages/ChallengeEditor.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "user-manage/:page",
|
||||||
|
name: "user-manage",
|
||||||
|
component: () => import("./pages/UserManage.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "showcase",
|
||||||
|
name: "showcase-manage",
|
||||||
|
component: () => import("./pages/ShowcaseManage.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "gradebook",
|
||||||
|
name: "gradebook",
|
||||||
|
component: () => import("./pages/Gradebook.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const isLoggedIn =
|
||||||
|
authed.value || localStorage.getItem(STORAGE_KEY.LOGIN) === "true"
|
||||||
|
if (to.meta.auth && !isLoggedIn) {
|
||||||
|
loginModal.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
|
import { STORAGE_KEY } from "../utils/const"
|
||||||
|
|
||||||
|
const defaultHTML = ``
|
||||||
|
const defaultCSS = ``
|
||||||
|
|
||||||
|
export const html = useStorage(STORAGE_KEY.HTML, defaultHTML)
|
||||||
|
export const css = useStorage(STORAGE_KEY.CSS, defaultCSS)
|
||||||
|
export const js = useStorage(STORAGE_KEY.JS, "")
|
||||||
|
|
||||||
|
export const tab = useStorage(STORAGE_KEY.TAB, "html")
|
||||||
|
export const size = useStorage(STORAGE_KEY.FONTSIZE, 24)
|
||||||
|
|
||||||
|
export function reset(lang: "html" | "css" | "js") {
|
||||||
|
if (lang === "html") {
|
||||||
|
html.value = defaultHTML
|
||||||
|
} else if (lang === "css") {
|
||||||
|
css.value = defaultCSS
|
||||||
|
} else {
|
||||||
|
js.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
import { WS_BASE_URL } from "../utils/const"
|
||||||
|
|
||||||
|
export interface GuidanceMessage {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messages = ref<GuidanceMessage[]>([])
|
||||||
|
export const connected = ref(false)
|
||||||
|
export const streaming = ref(false)
|
||||||
|
export const streamingContent = ref("")
|
||||||
|
export const isReady = ref(false)
|
||||||
|
export const isOpen = ref(false)
|
||||||
|
export const initialPrompt = ref("")
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let _taskId = 0
|
||||||
|
|
||||||
|
function setupHandlers(socket: WebSocket) {
|
||||||
|
socket.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
if (initialPrompt.value) {
|
||||||
|
sendToSocket(initialPrompt.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === "init") {
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
} else if (data.type === "stream") {
|
||||||
|
streaming.value = true
|
||||||
|
streamingContent.value += data.content
|
||||||
|
} else if (data.type === "complete") {
|
||||||
|
streaming.value = false
|
||||||
|
const content = streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||||
|
messages.value.push({ role: "assistant", content, id: data.message_id })
|
||||||
|
streamingContent.value = ""
|
||||||
|
if (data.is_ready) isReady.value = true
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
messages.value.push({ role: "assistant", content: data.content })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToSocket(content: string) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
streaming.value = true
|
||||||
|
messages.value.push({ role: "user", content })
|
||||||
|
ws.send(JSON.stringify({ type: "message", content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openGuidance(taskId: number, prompt: string) {
|
||||||
|
if (!taskId) return
|
||||||
|
|
||||||
|
_taskId = taskId
|
||||||
|
initialPrompt.value = prompt
|
||||||
|
messages.value = []
|
||||||
|
isReady.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
isOpen.value = true
|
||||||
|
|
||||||
|
if (ws) ws.close()
|
||||||
|
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${taskId}/`)
|
||||||
|
setupHandlers(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendGuidance(content: string) {
|
||||||
|
sendToSocket(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopGuidance() {
|
||||||
|
if (
|
||||||
|
messages.value.length > 0 &&
|
||||||
|
messages.value[messages.value.length - 1].role === "user"
|
||||||
|
) {
|
||||||
|
messages.value.pop()
|
||||||
|
}
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
if (_taskId) {
|
||||||
|
if (ws) ws.close()
|
||||||
|
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${_taskId}/`)
|
||||||
|
initialPrompt.value = ""
|
||||||
|
setupHandlers(ws)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGuidance() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
isOpen.value = false
|
||||||
|
messages.value = []
|
||||||
|
isReady.value = false
|
||||||
|
connected.value = false
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
initialPrompt.value = ""
|
||||||
|
_taskId = 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
export const loginModal = ref(false)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// client/src/store/panel.ts
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
export const show = ref(true)
|
||||||
|
export const panelSize = ref(2 / 5)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
import { WS_BASE_URL } from "../utils/const"
|
||||||
|
import { html, css, js } from "./editors"
|
||||||
|
|
||||||
|
export interface PromptMessage {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
id?: number // assistant message backend pk (for deletion)
|
||||||
|
code?: { html: string | null; css: string | null; js: string | null }
|
||||||
|
submitted?: boolean // whether this assistant message's code has been submitted
|
||||||
|
created?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messages = ref<PromptMessage[]>([])
|
||||||
|
export const conversationId = ref<string>("")
|
||||||
|
export const connected = ref(false)
|
||||||
|
export const streaming = ref(false)
|
||||||
|
export const streamingContent = ref("")
|
||||||
|
export const currentTaskId = ref(0)
|
||||||
|
let _onCodeComplete:
|
||||||
|
| ((
|
||||||
|
code: { html: string | null; css: string | null; js: string | null },
|
||||||
|
messageId: number
|
||||||
|
) => void)
|
||||||
|
| null = null
|
||||||
|
|
||||||
|
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
||||||
|
_onCodeComplete = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
|
||||||
|
export function connectPrompt(taskId: number) {
|
||||||
|
currentTaskId.value = taskId
|
||||||
|
if (ws) ws.close()
|
||||||
|
|
||||||
|
ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (data.type === "init") {
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
const alreadyLoaded = conversationId.value === data.conversation_id
|
||||||
|
conversationId.value = data.conversation_id
|
||||||
|
if (!alreadyLoaded) {
|
||||||
|
messages.value = data.messages || []
|
||||||
|
const lastAssistant = [...messages.value]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant" && m.code)
|
||||||
|
if (lastAssistant?.code) {
|
||||||
|
applyCode(lastAssistant.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.type === "stream") {
|
||||||
|
streaming.value = true
|
||||||
|
streamingContent.value += data.content
|
||||||
|
} else if (data.type === "complete") {
|
||||||
|
streaming.value = false
|
||||||
|
messages.value.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: streamingContent.value,
|
||||||
|
id: data.message_id,
|
||||||
|
code: data.code,
|
||||||
|
submitted: false,
|
||||||
|
})
|
||||||
|
streamingContent.value = ""
|
||||||
|
if (data.code) {
|
||||||
|
applyCode(data.code)
|
||||||
|
if (_onCodeComplete) {
|
||||||
|
_onCodeComplete(data.code, data.message_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
messages.value.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: data.content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectPrompt() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
messages.value = []
|
||||||
|
conversationId.value = ""
|
||||||
|
connected.value = false
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
currentTaskId.value = 0
|
||||||
|
_onCodeComplete = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPrompt(content: string, model: string = "") {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
streaming.value = true
|
||||||
|
messages.value.push({ role: "user", content })
|
||||||
|
ws.send(JSON.stringify({ type: "message", content, model }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPrompt() {
|
||||||
|
if (
|
||||||
|
messages.value.length > 0 &&
|
||||||
|
messages.value[messages.value.length - 1].role === "user"
|
||||||
|
) {
|
||||||
|
messages.value.pop()
|
||||||
|
}
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
if (currentTaskId.value) {
|
||||||
|
connectPrompt(currentTaskId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markMessageSubmitted(assistantMsgId: number) {
|
||||||
|
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
||||||
|
if (msg) msg.submitted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeMessagePair(assistantMsgId: number) {
|
||||||
|
const idx = messages.value.findIndex((m) => m.id === assistantMsgId)
|
||||||
|
if (idx >= 1) {
|
||||||
|
messages.value.splice(idx - 1, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCode(code: {
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
|
}) {
|
||||||
|
if (code.html !== null) html.value = code.html
|
||||||
|
if (code.css !== null) css.value = code.css
|
||||||
|
if (code.js !== null) js.value = code.js
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
import type { SubmissionAll } from "../utils/type"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
|
||||||
|
export const submission = ref<SubmissionAll>({
|
||||||
|
id: "",
|
||||||
|
userid: 0,
|
||||||
|
username: "",
|
||||||
|
task_id: 0,
|
||||||
|
task_display: 0, // 这是序号
|
||||||
|
task_title: "",
|
||||||
|
task_type: TASK_TYPE.Tutorial,
|
||||||
|
score: 0.0,
|
||||||
|
my_score: 0,
|
||||||
|
html: "",
|
||||||
|
css: "",
|
||||||
|
js: "",
|
||||||
|
created: new Date(),
|
||||||
|
modified: new Date(),
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ref } from "vue"
|
||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
|
||||||
|
const currentTask = window.location.pathname.startsWith("/challenge")
|
||||||
|
? TASK_TYPE.Challenge
|
||||||
|
: TASK_TYPE.Tutorial
|
||||||
|
|
||||||
|
export const taskTab = ref(currentTask)
|
||||||
|
export const taskId = ref(0)
|
||||||
|
export const challengeDisplay = useStorage("challenge-display", 0)
|
||||||
|
export const assetBaseUrl = ref("")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { Tutorial } from "../api"
|
||||||
|
|
||||||
|
export const step = useStorage("tutorial-step", 1)
|
||||||
|
export const tutorialIds = ref<number[]>([])
|
||||||
|
|
||||||
|
export async function loadTutorials(): Promise<void> {
|
||||||
|
tutorialIds.value = await Tutorial.listDisplay()
|
||||||
|
if (tutorialIds.value.length && !tutorialIds.value.includes(step.value)) {
|
||||||
|
step.value = tutorialIds.value[0] as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prevDisabled(): boolean {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
return i <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextDisabled(): boolean {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
return i === -1 || i === tutorialIds.value.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prev(): void {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
if (i > 0) step.value = tutorialIds.value[i - 1] as number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function next(): void {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
if (i !== -1 && i < tutorialIds.value.length - 1)
|
||||||
|
step.value = tutorialIds.value[i + 1] as number
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { computed, reactive } from "vue"
|
||||||
|
import { Role } from "../utils/type"
|
||||||
|
|
||||||
|
export const user = reactive({
|
||||||
|
loaded: false,
|
||||||
|
username: "",
|
||||||
|
role: Role.Normal,
|
||||||
|
})
|
||||||
|
export const authed = computed(() => !!user.username)
|
||||||
|
export const roleNormal = computed(
|
||||||
|
() => authed.value && user.role === Role.Normal,
|
||||||
|
)
|
||||||
|
export const roleAdmin = computed(
|
||||||
|
() => authed.value && user.role === Role.Admin,
|
||||||
|
)
|
||||||
|
export const roleSuper = computed(
|
||||||
|
() => authed.value && user.role === Role.Super,
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export const alertVariants = [
|
||||||
|
{
|
||||||
|
type: "note",
|
||||||
|
icon: '<svg class="octicon octicon-info mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>',
|
||||||
|
title: "注意",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tip",
|
||||||
|
icon: '<svg class="octicon octicon-light-bulb mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>',
|
||||||
|
title: "提示",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "important",
|
||||||
|
icon: '<svg class="octicon octicon-report mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>',
|
||||||
|
title: "重要",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "warning",
|
||||||
|
icon: '<svg class="octicon octicon-alert mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>',
|
||||||
|
title: "警告",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "caution",
|
||||||
|
icon: '<svg class="octicon octicon-stop mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>',
|
||||||
|
title: "小心",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const STORAGE_KEY = {
|
||||||
|
LOGIN: "web-isloggedin",
|
||||||
|
HTML: "web-html",
|
||||||
|
CSS: "web-css",
|
||||||
|
JS: "web-js",
|
||||||
|
TAB: "web-tab",
|
||||||
|
FONTSIZE: "web-fontsize",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
|
||||||
|
|
||||||
|
export const WS_BASE_URL =
|
||||||
|
import.meta.env.PUBLIC_WS_URL ||
|
||||||
|
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}`
|
||||||
|
|
||||||
|
export enum TASK_TYPE {
|
||||||
|
Tutorial = "tutorial",
|
||||||
|
Challenge = "challenge",
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface GradebookStudentIdentity {
|
||||||
|
username: string
|
||||||
|
classname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayGradebookStudentName(student: GradebookStudentIdentity) {
|
||||||
|
const generatedPrefix = `web${student.classname}`
|
||||||
|
if (!student.classname || !student.username.startsWith(generatedPrefix)) {
|
||||||
|
return student.username
|
||||||
|
}
|
||||||
|
return student.username.slice(generatedPrefix.length)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useDateFormat } from "@vueuse/core"
|
||||||
|
import { TASK_TYPE } from "./const"
|
||||||
|
|
||||||
|
export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
|
||||||
|
const time = useDateFormat(utc, format, { locales: "zh-CN" })
|
||||||
|
return time.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goHome(router: any, type: TASK_TYPE, display: number) {
|
||||||
|
if (type === TASK_TYPE.Tutorial) {
|
||||||
|
router.push({ name: "home-tutorial", params: { display } })
|
||||||
|
} else if (type === TASK_TYPE.Challenge) {
|
||||||
|
if (display) router.push({ name: "home-challenge", params: { display } })
|
||||||
|
else router.push({ name: "home-challenge-list" })
|
||||||
|
} else {
|
||||||
|
router.push({ name: "home" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { marked } from "marked"
|
||||||
|
|
||||||
|
export function renderMarkdown(text: string): string {
|
||||||
|
return marked.parse(text) as string
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface PreviewDocumentOptions {
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
assetBaseUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPreviewDocument({
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
js,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: PreviewDocumentOptions) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-Hans-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>预览</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
|
||||||
|
<style>${css}</style>
|
||||||
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${html}
|
||||||
|
<script>${js}<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user