Compare commits
36
Commits
40f3063b91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489274ba0e | ||
|
|
e9127318cb | ||
|
|
5bdacc438d | ||
|
|
43c18cdb88 | ||
|
|
9fee688d95 | ||
|
|
9f3f7187e1 | ||
|
|
6b2ffbb3ec | ||
|
|
b765cbf1aa | ||
|
|
e06930c8f7 | ||
|
|
f41fda3c19 | ||
|
|
30630e8125 | ||
|
|
bc9da688ba | ||
|
|
915b48ebc0 | ||
|
|
54003fd404 | ||
|
|
6f0dd33de3 | ||
|
|
81e1b233ee | ||
|
|
1ba31844a9 | ||
|
|
51ee246a6f | ||
|
|
951a6e0826 | ||
|
|
4c9617caf1 | ||
|
|
3fb23dc3f3 | ||
|
|
6ad9be2b30 | ||
|
|
726906e3f6 | ||
|
|
06267fb0ff | ||
|
|
e4fb723544 | ||
|
|
0b3780e0cd | ||
|
|
75e20a3ae1 | ||
|
|
8653d411a6 | ||
|
|
df3f842dac | ||
|
|
9c6c01eba1 | ||
|
|
ff1eb2982e | ||
|
|
e47fac1732 | ||
|
|
ffdfbc2513 | ||
|
|
325474bdb7 | ||
|
|
bd84e3f8d1 | ||
|
|
03878f6cc2 |
No files matched your search
@@ -15,10 +15,11 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
|
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, auth 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/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; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
|
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `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/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `review` (mounted under `/submission/review`: teacher class × task wall — no `classname` means all classes —, students' peer works with `scope=class|all`), `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)
|
- Who can read a submission (`canView` in `routes/submission.ts`): staff, the owner, anyone if it's in the showcase, or any student who has submitted the same task (any class). Students' `GET /submission/` only lists their own. Peer works and the random-rating item are anonymous until the viewer rates them (`PUT /:id/score` returns the author). `flag` now only carries `present` (teacher's in-class presentation queue); the old four colors are no longer shown
|
||||||
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); messages per connection are processed serially; on disconnect the pending user message is deleted and the LLM request aborted
|
- `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; chat context is the last 20 messages with code kept only in the newest reply that has code. Chat replies are written as **做了什么 / 我替你定的 / 想一想** — the decisions the AI made for the student and one open question, to prompt the next round; a reply to a plain question has no code and the WebSocket sends `code: null`. For challenges the AI also gets the task brief (`challengeBrief`) as a checklist only (`BRIEF_RULES`): it builds just what the student asked for and asks about one missing requirement in “想一想”; creations get no brief. The “帮我写清楚” coach gets the task brief, recent prompts and the last AI explanation from `guidanceContext` in `services/prompt.ts`, and judges by what the student added beyond the brief, like the classifier), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background; the model sees the task brief and the previous prompt, temperature 0, prompts under 4 chars get no level), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
|
||||||
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
|
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted
|
||||||
|
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`, `prompt-stats.ts` (read-only before/after comparison of AI-chat behavior: rounds, levels, external-code share, “出问题了” / reflection usage)
|
||||||
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
- 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 **Vite**; Bun is the package manager and runtime (no Node/npm)
|
- **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
|
||||||
@@ -26,7 +27,13 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`)
|
- UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`)
|
||||||
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
|
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
|
||||||
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
|
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
|
||||||
- `src/router.ts` — Vue Router with auth guards
|
- `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar
|
||||||
|
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors (iframes stay white: they show students' pages). No `color-mix()` (Chrome 105). `store/theme.ts` holds the mode (auto / light / dark, toggled in the top bar) and sets `data-theme` on `<html>`; `styles/dark-content.css` is generated from the GitHub dark markdown and highlight.js styles (selectors prefixed with `:root[data-theme="dark"]`); CodeMirror uses One Dark highlighting in dark mode
|
||||||
|
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
|
||||||
|
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically; `SubmitModal` first asks an optional one-line reflection (最满意的改动和理由), stored in `submission_reflection` (`ON DELETE CASCADE`, returned by `GET /submission/:id` only to staff and the author, shown as “学生说” in `ReviewPanel`). In `PromptPanel`, “出问题了” is a three-field form (做了什么 / 想看到什么 / 实际看到了什么) joined into one prompt; a short vague complaint (“还是不行”) gets a one-time hint offering it. Pasted external-AI prompts need at least 8 characters Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
|
||||||
|
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
|
||||||
|
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K previous/next student (by key position), 1–5 rate, P present; “全部班级” groups the wall by class; `components/review/PeersDrawer.vue` is the students' “同学作品” (本班 / 全部班级)
|
||||||
|
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
|
||||||
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
- `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)
|
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
||||||
|
|
||||||
@@ -42,7 +49,7 @@ bun install # installs both workspaces into the single root bun.loc
|
|||||||
bun run dev # backend (bun --watch, :8000) + frontend (Vite, :3000) together
|
bun run dev # backend (bun --watch, :8000) + frontend (Vite, :3000) together
|
||||||
bun run build # client production build (build:staging for the school server)
|
bun run build # client production build (build:staging for the school server)
|
||||||
bun run test # server tests
|
bun run test # server tests
|
||||||
bun run typecheck # server typecheck
|
bun run typecheck # server (tsc 7) + client (vue-tsc) typecheck
|
||||||
bun run fmt # format the client
|
bun run fmt # format the client
|
||||||
```
|
```
|
||||||
Add a dependency from inside its workspace (`cd client && bun add x`); the lockfile is still the root one.
|
Add a dependency from inside its workspace (`cd client && bun add x`); the lockfile is still the root one.
|
||||||
@@ -55,6 +62,7 @@ bun run typecheck
|
|||||||
bun scripts/create-superuser.ts <username> <password>
|
bun scripts/create-superuser.ts <username> <password>
|
||||||
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
||||||
bun scripts/recalculate-scores.ts [--dry-run]
|
bun scripts/recalculate-scores.ts [--dry-run]
|
||||||
|
bun scripts/prompt-stats.ts <split-date> [--from date] [--to date] # dates in Beijing time
|
||||||
```
|
```
|
||||||
Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/data/db.sqlite3`, uploads go to the repo-root `media/`.
|
Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/data/db.sqlite3`, uploads go to the repo-root `media/`.
|
||||||
|
|
||||||
@@ -62,6 +70,7 @@ Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/d
|
|||||||
```bash
|
```bash
|
||||||
bun run dev # Dev server (Vite, run on the Bun runtime) on :3000
|
bun run dev # Dev server (Vite, run on the Bun runtime) on :3000
|
||||||
bun run build # Production build (bun run build:staging for the school server)
|
bun run build # Production build (bun run build:staging for the school server)
|
||||||
|
bun run typecheck # vue-tsc; the client pins TypeScript 5.9 because vue-tsc can't run on TS 7 (the server uses TS 7)
|
||||||
bun fmt # Format with Bun's built-in formatter (config in bunfig.toml)
|
bun fmt # Format with Bun's built-in formatter (config in bunfig.toml)
|
||||||
```
|
```
|
||||||
Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`).
|
Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`).
|
||||||
|
|||||||
@@ -12,13 +12,17 @@
|
|||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.12",
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/language": "6.12.3",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.1",
|
"@codemirror/view": "^6.41.1",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@vueuse/core": "^15.0.0",
|
"@vueuse/core": "^15.0.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.2",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"github-markdown-css": "^5.9.0",
|
"github-markdown-css": "^5.9.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"marked": "^18.1.1",
|
"marked": "^18.1.1",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
"marked-code-preview": "^1.3.7",
|
"marked-code-preview": "^1.3.7",
|
||||||
@@ -30,14 +34,14 @@
|
|||||||
"vue-router": "^5.4.0",
|
"vue-router": "^5.4.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.4",
|
|
||||||
"@vitejs/plugin-legacy": "^8.2.3",
|
"@vitejs/plugin-legacy": "^8.2.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "~5.9.3",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.3.5",
|
"vite": "^8.3.5",
|
||||||
|
"vue-tsc": "^3.3.13",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
@@ -293,6 +297,8 @@
|
|||||||
|
|
||||||
"@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
|
"@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
|
||||||
|
|
||||||
|
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
|
||||||
|
|
||||||
"@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
|
"@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
|
||||||
|
|
||||||
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
||||||
@@ -301,9 +307,7 @@
|
|||||||
|
|
||||||
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||||
|
|
||||||
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
"@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.3.0", "https://registry.npmjs.com/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", {}, "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A=="],
|
||||||
|
|
||||||
"@iconify/vue": ["@iconify/vue@5.0.4", "https://registry.npmjs.com/@iconify/vue/-/vue-5.0.4.tgz", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3.0.0" } }, "sha512-9yP5PjOv5G9vStHfyPtQ69Ay1CSNAC3AZTRDJ4LX3UMX7nEH0GO8OKhc3qhcrXp1KS2GnlLufGbFCwD+U4Fi5A=="],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
@@ -459,6 +463,12 @@
|
|||||||
|
|
||||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
||||||
|
|
||||||
|
"@volar/language-core": ["@volar/language-core@2.4.28", "https://registry.npmjs.com/@volar/language-core/-/language-core-2.4.28.tgz", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||||
|
|
||||||
|
"@volar/source-map": ["@volar/source-map@2.4.28", "https://registry.npmjs.com/@volar/source-map/-/source-map-2.4.28.tgz", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||||
|
|
||||||
|
"@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmjs.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||||
|
|
||||||
"@vue-macros/common": ["@vue-macros/common@3.1.4", "https://registry.npmjs.com/@vue-macros/common/-/common-3.1.4.tgz", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="],
|
"@vue-macros/common": ["@vue-macros/common@3.1.4", "https://registry.npmjs.com/@vue-macros/common/-/common-3.1.4.tgz", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="],
|
||||||
|
|
||||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
||||||
@@ -475,6 +485,8 @@
|
|||||||
|
|
||||||
"@vue/devtools-shared": ["@vue/devtools-shared@8.2.1", "https://registry.npmjs.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", {}, "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g=="],
|
"@vue/devtools-shared": ["@vue/devtools-shared@8.2.1", "https://registry.npmjs.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", {}, "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g=="],
|
||||||
|
|
||||||
|
"@vue/language-core": ["@vue/language-core@3.3.13", "https://registry.npmjs.com/@vue/language-core/-/language-core-3.3.13.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
||||||
|
|
||||||
"@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
"@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
||||||
|
|
||||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "https://registry.npmjs.com/@vue/runtime-core/-/runtime-core-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "https://registry.npmjs.com/@vue/runtime-core/-/runtime-core-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
||||||
@@ -497,6 +509,8 @@
|
|||||||
|
|
||||||
"agent-base": ["agent-base@6.0.2", "https://registry.npmjs.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
"agent-base": ["agent-base@6.0.2", "https://registry.npmjs.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||||
|
|
||||||
|
"alien-signals": ["alien-signals@3.2.1", "https://registry.npmjs.com/alien-signals/-/alien-signals-3.2.1.tgz", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"ast-kit": ["ast-kit@2.2.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-2.2.0.tgz", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
"ast-kit": ["ast-kit@2.2.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-2.2.0.tgz", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
||||||
@@ -673,6 +687,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-vue-next": ["lucide-vue-next@1.0.0", "https://registry.npmjs.com/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@1.4.4", "https://registry.npmjs.com/magic-string/-/magic-string-1.4.4.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ=="],
|
"magic-string": ["magic-string@1.4.4", "https://registry.npmjs.com/magic-string/-/magic-string-1.4.4.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ=="],
|
||||||
|
|
||||||
"magic-string-ast": ["magic-string-ast@1.0.3", "https://registry.npmjs.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
"magic-string-ast": ["magic-string-ast@1.0.3", "https://registry.npmjs.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
|
||||||
@@ -725,6 +741,8 @@
|
|||||||
|
|
||||||
"obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
"obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
|
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmjs.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "https://registry.npmjs.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "https://registry.npmjs.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
@@ -789,7 +807,7 @@
|
|||||||
|
|
||||||
"treemate": ["treemate@0.3.11", "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
"treemate": ["treemate@0.3.11", "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
"typescript": ["typescript@5.9.3", "https://registry.npmjs.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"uc.micro": ["uc.micro@2.1.0", "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
"uc.micro": ["uc.micro@2.1.0", "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||||
|
|
||||||
@@ -819,12 +837,16 @@
|
|||||||
|
|
||||||
"vooks": ["vooks@0.2.12", "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
"vooks": ["vooks@0.2.12", "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.2.0", "https://registry.npmjs.com/vscode-uri/-/vscode-uri-3.2.0.tgz", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||||
|
|
||||||
"vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
"vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
||||||
|
|
||||||
"vue-codemirror": ["vue-codemirror@6.1.1", "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", { "dependencies": { "@codemirror/commands": "6.x", "@codemirror/language": "6.x", "@codemirror/state": "6.x", "@codemirror/view": "6.x" }, "peerDependencies": { "codemirror": "6.x", "vue": "3.x" } }, "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg=="],
|
"vue-codemirror": ["vue-codemirror@6.1.1", "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", { "dependencies": { "@codemirror/commands": "6.x", "@codemirror/language": "6.x", "@codemirror/state": "6.x", "@codemirror/view": "6.x" }, "peerDependencies": { "codemirror": "6.x", "vue": "3.x" } }, "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg=="],
|
||||||
|
|
||||||
"vue-router": ["vue-router@5.4.0", "https://registry.npmjs.com/vue-router/-/vue-router-5.4.0.tgz", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="],
|
"vue-router": ["vue-router@5.4.0", "https://registry.npmjs.com/vue-router/-/vue-router-5.4.0.tgz", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="],
|
||||||
|
|
||||||
|
"vue-tsc": ["vue-tsc@3.3.13", "https://registry.npmjs.com/vue-tsc/-/vue-tsc-3.3.13.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
||||||
|
|
||||||
"vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
"vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
||||||
|
|
||||||
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
@@ -857,6 +879,8 @@
|
|||||||
|
|
||||||
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"@vue/language-core/picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
"ast-walker-scope/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
|
"ast-walker-scope/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
|
||||||
|
|
||||||
"ast-walker-scope/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
|
"ast-walker-scope/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
|
||||||
@@ -879,6 +903,8 @@
|
|||||||
|
|
||||||
"vue-router/unplugin": ["unplugin@3.5.0", "https://registry.npmjs.com/unplugin/-/unplugin-3.5.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-s9qc6Y8ChhQE9RZ9Se+znEtZA0oK31LmEBO9y97ixR3ErA8UwlzA4XNybg/41od8rQ8EKScVQM857NWnI+EWkQ=="],
|
"vue-router/unplugin": ["unplugin@3.5.0", "https://registry.npmjs.com/unplugin/-/unplugin-3.5.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-s9qc6Y8ChhQE9RZ9Se+znEtZA0oK31LmEBO9y97ixR3ErA8UwlzA4XNybg/41od8rQ8EKScVQM857NWnI+EWkQ=="],
|
||||||
|
|
||||||
|
"webpreview-server/typescript": ["typescript@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||||
|
|
||||||
"@babel/traverse/@babel/parser/@babel/types": ["@babel/types@7.29.11", "https://registry.npmjs.com/@babel/types/-/types-7.29.11.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
|
"@babel/traverse/@babel/parser/@babel/types": ["@babel/types@7.29.11", "https://registry.npmjs.com/@babel/types/-/types-7.29.11.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
|
||||||
|
|
||||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
|
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
PUBLIC_WEB_URL=https://web.xuyue.cc
|
PUBLIC_WEB_URL=https://web.xuyue.cc
|
||||||
PUBLIC_BASE_URL=https://web.xuyue.cc/api
|
PUBLIC_BASE_URL=https://web.xuyue.cc/api
|
||||||
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
PUBLIC_WEB_URL=http://10.13.114.114:91
|
PUBLIC_WEB_URL=http://10.13.114.114:91
|
||||||
PUBLIC_BASE_URL=http://10.13.114.114:91/api
|
PUBLIC_BASE_URL=http://10.13.114.114:91/api
|
||||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>前端开发学习平台</title>
|
<title>前端开发学习平台</title>
|
||||||
<link rel="stylesheet" href="/normalize.min.css" />
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
<link rel="stylesheet" href="/style.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+9
-4
@@ -7,19 +7,24 @@
|
|||||||
"dev": "bun --bun vite",
|
"dev": "bun --bun vite",
|
||||||
"build": "bun --bun vite build",
|
"build": "bun --bun vite build",
|
||||||
"build:staging": "bun --bun vite build --mode staging",
|
"build:staging": "bun --bun vite build --mode staging",
|
||||||
"fmt": "bun fmt src/"
|
"fmt": "bun fmt src/",
|
||||||
|
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.12",
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/language": "6.12.3",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.1",
|
"@codemirror/view": "^6.41.1",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@vueuse/core": "^15.0.0",
|
"@vueuse/core": "^15.0.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.2",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"github-markdown-css": "^5.9.0",
|
"github-markdown-css": "^5.9.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
"marked": "^18.1.1",
|
"marked": "^18.1.1",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
"marked-code-preview": "^1.3.7",
|
"marked-code-preview": "^1.3.7",
|
||||||
@@ -31,13 +36,13 @@
|
|||||||
"vue-router": "^5.4.0"
|
"vue-router": "^5.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.4",
|
|
||||||
"@vitejs/plugin-legacy": "^8.2.3",
|
"@vitejs/plugin-legacy": "^8.2.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.10",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "~5.9.3",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.5",
|
||||||
|
"vue-tsc": "^3.3.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
@font-face {
|
|
||||||
font-family: "Monaco";
|
|
||||||
src: url(/Monaco.ttf);
|
|
||||||
}
|
|
||||||
+15
-25
@@ -1,32 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { dateZhCN, zhCN } from "naive-ui"
|
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||||
import type { GlobalThemeOverrides } from "naive-ui"
|
|
||||||
import Login from "./components/Login.vue"
|
import Login from "./components/Login.vue"
|
||||||
import { onMounted } from "vue"
|
import AppShell from "./components/layout/AppShell.vue"
|
||||||
import { Account } from "./api"
|
import { computed, onMounted } from "vue"
|
||||||
import { user } from "./store/user"
|
import { useRoute } from "vue-router"
|
||||||
|
import { loadUser } from "./store/session"
|
||||||
|
import { dark, light, naiveOverrides } from "./theme"
|
||||||
|
import { isDark } from "./store/theme"
|
||||||
import hljs from "highlight.js/lib/core"
|
import hljs from "highlight.js/lib/core"
|
||||||
|
|
||||||
const themeOverrides: GlobalThemeOverrides = {
|
const route = useRoute()
|
||||||
common: {
|
const theme = computed(() => (isDark.value ? darkTheme : null))
|
||||||
borderRadius: "6px",
|
const themeOverrides = computed(() => naiveOverrides(isDark.value ? dark : light))
|
||||||
borderRadiusSmall: "4px",
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
borderRadius: "8px",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(loadUser)
|
||||||
try {
|
|
||||||
const data = await Account.getMyProfile()
|
|
||||||
user.loaded = true
|
|
||||||
user.username = data.username
|
|
||||||
user.role = data.role
|
|
||||||
} catch {
|
|
||||||
user.loaded = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -35,12 +22,15 @@ onMounted(async () => {
|
|||||||
:locale="zhCN"
|
:locale="zhCN"
|
||||||
:date-locale="dateZhCN"
|
:date-locale="dateZhCN"
|
||||||
:hljs="hljs"
|
:hljs="hljs"
|
||||||
|
:theme="theme"
|
||||||
:theme-overrides="themeOverrides"
|
:theme-overrides="themeOverrides"
|
||||||
>
|
>
|
||||||
<n-modal-provider>
|
<n-modal-provider>
|
||||||
<n-message-provider :max="1">
|
<n-message-provider :max="1">
|
||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
<router-view></router-view>
|
<!-- 全屏预览这类页面不要顶栏 -->
|
||||||
|
<router-view v-if="route.meta.bare" />
|
||||||
|
<AppShell v-else />
|
||||||
<Login />
|
<Login />
|
||||||
</n-dialog-provider>
|
</n-dialog-provider>
|
||||||
</n-message-provider>
|
</n-message-provider>
|
||||||
|
|||||||
+60
-19
@@ -17,13 +17,16 @@ import type {
|
|||||||
AwardItemManageOut,
|
AwardItemManageOut,
|
||||||
GradebookOut,
|
GradebookOut,
|
||||||
GradebookQuery,
|
GradebookQuery,
|
||||||
ShowcaseSubmissionLookupOut,
|
|
||||||
ShowcaseDetail,
|
ShowcaseDetail,
|
||||||
PromptRound,
|
PromptRound,
|
||||||
RandomRatingItem,
|
RandomRatingItem,
|
||||||
CreationSlim,
|
CreationSlim,
|
||||||
CreationDetail,
|
CreationDetail,
|
||||||
CreationIn,
|
CreationIn,
|
||||||
|
ReviewOptions,
|
||||||
|
ReviewProgress,
|
||||||
|
ReviewStudent,
|
||||||
|
PeerWorks,
|
||||||
} from "./utils/type"
|
} from "./utils/type"
|
||||||
import { BASE_URL } from "./utils/const"
|
import { BASE_URL } from "./utils/const"
|
||||||
import {
|
import {
|
||||||
@@ -60,6 +63,8 @@ http.interceptors.response.use(
|
|||||||
) {
|
) {
|
||||||
err.config._retried = true
|
err.config._retried = true
|
||||||
if (await refreshAccessToken()) return http(err.config)
|
if (await refreshAccessToken()) return http(err.config)
|
||||||
|
// 刷新失败但 refresh token 还在,说明只是网络问题:保留登录,下次请求再试
|
||||||
|
if (hasLogin()) return Promise.reject(err)
|
||||||
}
|
}
|
||||||
switch (err.response.status) {
|
switch (err.response.status) {
|
||||||
case 401: // 未授权
|
case 401: // 未授权
|
||||||
@@ -259,6 +264,7 @@ export const Submission = {
|
|||||||
prompt?: string
|
prompt?: string
|
||||||
},
|
},
|
||||||
messageId?: number,
|
messageId?: number,
|
||||||
|
reflection?: string,
|
||||||
) {
|
) {
|
||||||
const { prompt, ...rest } = code
|
const { prompt, ...rest } = code
|
||||||
const data = {
|
const data = {
|
||||||
@@ -266,6 +272,7 @@ export const Submission = {
|
|||||||
...rest,
|
...rest,
|
||||||
prompt: prompt || null,
|
prompt: prompt || null,
|
||||||
message_id: messageId ?? null,
|
message_id: messageId ?? null,
|
||||||
|
reflection: reflection || null,
|
||||||
}
|
}
|
||||||
const res = await http.post("/submission/", data)
|
const res = await http.post("/submission/", data)
|
||||||
return res.data
|
return res.data
|
||||||
@@ -277,7 +284,7 @@ export const Submission = {
|
|||||||
username?: string
|
username?: string
|
||||||
user_id?: number
|
user_id?: number
|
||||||
flag?: string | null
|
flag?: string | null
|
||||||
zone?: string
|
zone?: string | null
|
||||||
task_id?: number
|
task_id?: number
|
||||||
task_type?: string
|
task_type?: string
|
||||||
score_min?: number
|
score_min?: number
|
||||||
@@ -318,7 +325,11 @@ export const Submission = {
|
|||||||
await http.post(`/submission/${id}/view`)
|
await http.post(`/submission/${id}/view`)
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateScore(id: string, score: number) {
|
// 返回作者名:互评打分前是匿名的
|
||||||
|
async updateScore(
|
||||||
|
id: string,
|
||||||
|
score: number,
|
||||||
|
): Promise<{ message: string; username?: string }> {
|
||||||
const res = await http.put(`/submission/${id}/score`, { score })
|
const res = await http.put(`/submission/${id}/score`, { score })
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
@@ -335,11 +346,6 @@ export const Submission = {
|
|||||||
return res.data
|
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> {
|
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
|
||||||
const params: Record<string, string | number> = {}
|
const params: Record<string, string | number> = {}
|
||||||
if (classname) params.classname = classname
|
if (classname) params.classname = classname
|
||||||
@@ -393,6 +399,20 @@ export const Gradebook = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Prompt = {
|
export const Prompt = {
|
||||||
|
// 保存一个不是 AI 生成的版本:手改的代码,或者带提示词的外部 AI 代码
|
||||||
|
async saveVersion(
|
||||||
|
taskId: number,
|
||||||
|
code: { html: string; css: string; js: string },
|
||||||
|
prompt?: string,
|
||||||
|
): Promise<{ message_id: number; user_message_id: number; source: string }> {
|
||||||
|
const res = await http.post("/prompt/versions", {
|
||||||
|
task_id: taskId,
|
||||||
|
...code,
|
||||||
|
prompt: prompt || undefined,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
async listConversations(taskId?: number, userId?: number) {
|
async listConversations(taskId?: number, userId?: number) {
|
||||||
const params: Record<string, number> = {}
|
const params: Record<string, number> = {}
|
||||||
if (taskId) params.task_id = taskId
|
if (taskId) params.task_id = taskId
|
||||||
@@ -441,6 +461,28 @@ export const Helper = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 老师按“班级 × 任务”批改;学生看本班同学的作品
|
||||||
|
export const Review = {
|
||||||
|
async options(): Promise<ReviewOptions> {
|
||||||
|
return (await http.get("/submission/review/options")).data
|
||||||
|
},
|
||||||
|
// classname 不传就是全部班级
|
||||||
|
async progress(classname?: string): Promise<ReviewProgress> {
|
||||||
|
return (await http.get("/submission/review/progress", { params: { classname } })).data
|
||||||
|
},
|
||||||
|
async classWall(classname: string | undefined, taskId: number): Promise<ReviewStudent[]> {
|
||||||
|
return (
|
||||||
|
await http.get("/submission/review/class", {
|
||||||
|
params: { classname, task_id: taskId },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
},
|
||||||
|
// scope:class 只看本班,all 看所有班
|
||||||
|
async peers(taskId: number, scope: "class" | "all" = "class"): Promise<PeerWorks> {
|
||||||
|
return (await http.get(`/submission/review/peers/${taskId}`, { params: { scope } })).data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const Showcase = {
|
export const Showcase = {
|
||||||
async list(): Promise<AwardSection[]> {
|
async list(): Promise<AwardSection[]> {
|
||||||
const res = await http.get("/submission/showcase/")
|
const res = await http.get("/submission/showcase/")
|
||||||
@@ -478,15 +520,6 @@ export const Showcase = {
|
|||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async findSubmissionForAward(
|
|
||||||
submissionId: string,
|
|
||||||
): Promise<ShowcaseSubmissionLookupOut> {
|
|
||||||
const res = await http.get(
|
|
||||||
`/submission/showcase/manage/submissions/${submissionId}`,
|
|
||||||
)
|
|
||||||
return res.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async addAwardItem(
|
async addAwardItem(
|
||||||
id: number,
|
id: number,
|
||||||
payload: AwardItemIn,
|
payload: AwardItemIn,
|
||||||
@@ -553,7 +586,11 @@ export const TaskAssets = {
|
|||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
async deleteChallenge(display: number, name: string) {
|
async deleteChallenge(display: number, name: string) {
|
||||||
return (await http.delete(`/assets/challenge/${display}/${name}`)).data
|
return (
|
||||||
|
await http.delete(
|
||||||
|
`/assets/challenge/${display}/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
).data
|
||||||
},
|
},
|
||||||
async listTutorial(display: number): Promise<TaskAsset[]> {
|
async listTutorial(display: number): Promise<TaskAsset[]> {
|
||||||
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
||||||
@@ -573,6 +610,10 @@ export const TaskAssets = {
|
|||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
async deleteTutorial(display: number, name: string) {
|
async deleteTutorial(display: number, name: string) {
|
||||||
return (await http.delete(`/assets/tutorial/${display}/${name}`)).data
|
return (
|
||||||
|
await http.delete(
|
||||||
|
`/assets/tutorial/${display}/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
).data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
<n-modal
|
<n-modal
|
||||||
preset="card"
|
preset="card"
|
||||||
title="登录"
|
title="登录"
|
||||||
style="width: 420px"
|
style="width: min(400px, 92vw)"
|
||||||
|
:bordered="false"
|
||||||
v-model:show="loginModal"
|
v-model:show="loginModal"
|
||||||
>
|
>
|
||||||
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
|
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
|
||||||
<n-tab-pane name="student" tab="学生登录">
|
<n-tab-pane name="student" tab="学生">
|
||||||
<n-form>
|
<n-form :show-feedback="false" class="form">
|
||||||
<n-form-item label="班级">
|
<n-form-item label="班级">
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="selectedClass"
|
v-model:value="selectedClass"
|
||||||
@@ -21,37 +22,25 @@
|
|||||||
<n-select
|
<n-select
|
||||||
v-model:value="selectedUsername"
|
v-model:value="selectedUsername"
|
||||||
:options="nameOptions"
|
:options="nameOptions"
|
||||||
placeholder="选择姓名"
|
filterable
|
||||||
|
:placeholder="selectedClass ? '选择或输入姓名' : '先选班级'"
|
||||||
:loading="namesLoading"
|
:loading="namesLoading"
|
||||||
:disabled="!selectedClass || namesLoading"
|
:disabled="!selectedClass || namesLoading"
|
||||||
|
@update:value="focusPassword"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="密码">
|
<n-form-item label="密码">
|
||||||
<n-input
|
<n-input
|
||||||
|
ref="studentPasswordRef"
|
||||||
type="password"
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
v-model:value="studentPassword"
|
v-model:value="studentPassword"
|
||||||
name="password"
|
name="password"
|
||||||
|
placeholder="输入密码,按 Enter 登录"
|
||||||
@keyup.enter="submitStudent"
|
@keyup.enter="submitStudent"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-alert
|
<div v-if="studentError" class="error">{{ studentError }}</div>
|
||||||
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
|
<n-button
|
||||||
block
|
block
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -61,28 +50,26 @@
|
|||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<div class="note">忘了密码找老师重置</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|
||||||
<n-tab-pane name="admin" tab="管理员登录">
|
<n-tab-pane name="admin" tab="老师">
|
||||||
<n-form>
|
<n-form :show-feedback="false" class="form">
|
||||||
<n-form-item label="用户名">
|
<n-form-item label="用户名">
|
||||||
<n-input v-model:value="adminName" name="username" />
|
<n-input v-model:value="adminName" name="username" placeholder="用户名" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="密码">
|
<n-form-item label="密码">
|
||||||
<n-input
|
<n-input
|
||||||
type="password"
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
v-model:value="adminPassword"
|
v-model:value="adminPassword"
|
||||||
name="password"
|
name="password"
|
||||||
|
placeholder="输入密码,按 Enter 登录"
|
||||||
@keyup.enter="submitAdmin"
|
@keyup.enter="submitAdmin"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-alert
|
<div v-if="adminError" class="error">{{ adminError }}</div>
|
||||||
type="error"
|
|
||||||
v-if="showAdminError"
|
|
||||||
class="message"
|
|
||||||
title="登录失败,请检查用户名和密码"
|
|
||||||
/>
|
|
||||||
<n-button
|
<n-button
|
||||||
block
|
block
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -99,26 +86,27 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from "vue"
|
import { ref, computed, nextTick, onMounted } from "vue"
|
||||||
import { Account } from "../api"
|
import { Account } from "../api"
|
||||||
import { loginModal } from "../store/modal"
|
import { loginModal } from "../store/modal"
|
||||||
import { user } from "../store/user"
|
import { user } from "../store/user"
|
||||||
import { router } from "../router"
|
import { router } from "../router"
|
||||||
|
|
||||||
// Tab state
|
// 机房的电脑一节课一个班轮流用:记住班级,不记姓名
|
||||||
|
const CLASS_KEY = "login-class"
|
||||||
|
|
||||||
const activeTab = ref("student")
|
const activeTab = ref("student")
|
||||||
|
const studentPasswordRef = ref<{ focus: () => void } | null>(null)
|
||||||
|
|
||||||
// Student tab state
|
// Student tab state
|
||||||
const selectedClass = ref<string | null>(null)
|
const selectedClass = ref<string | null>(null)
|
||||||
const selectedUsername = ref<string | null>(null)
|
const selectedUsername = ref<string | null>(null)
|
||||||
const studentPassword = ref("")
|
const studentPassword = ref("")
|
||||||
const studentLoading = ref(false)
|
const studentLoading = ref(false)
|
||||||
const showStudentError = ref(false)
|
const studentError = ref("")
|
||||||
|
|
||||||
// Classes data
|
|
||||||
const classes = ref<string[]>([])
|
const classes = ref<string[]>([])
|
||||||
const classesLoading = ref(false)
|
const classesLoading = ref(false)
|
||||||
const classesError = ref(false)
|
|
||||||
const classOptions = computed(() =>
|
const classOptions = computed(() =>
|
||||||
classes.value.map((c) => ({ label: c, value: c })),
|
classes.value.map((c) => ({ label: c, value: c })),
|
||||||
)
|
)
|
||||||
@@ -126,7 +114,6 @@ const classOptions = computed(() =>
|
|||||||
// Names data
|
// Names data
|
||||||
const names = ref<{ name: string; username: string }[]>([])
|
const names = ref<{ name: string; username: string }[]>([])
|
||||||
const namesLoading = ref(false)
|
const namesLoading = ref(false)
|
||||||
const namesError = ref(false)
|
|
||||||
const nameOptions = computed(() =>
|
const nameOptions = computed(() =>
|
||||||
names.value.map((n) => ({ label: n.name, value: n.username })),
|
names.value.map((n) => ({ label: n.name, value: n.username })),
|
||||||
)
|
)
|
||||||
@@ -135,53 +122,68 @@ const nameOptions = computed(() =>
|
|||||||
const adminName = ref("")
|
const adminName = ref("")
|
||||||
const adminPassword = ref("")
|
const adminPassword = ref("")
|
||||||
const adminLoading = ref(false)
|
const adminLoading = ref(false)
|
||||||
const showAdminError = ref(false)
|
const adminError = ref("")
|
||||||
|
|
||||||
// Load classes on mount (cached — not reloaded on tab switch)
|
// 班级列表只在打开页面时取一次
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
classesLoading.value = true
|
classesLoading.value = true
|
||||||
classesError.value = false
|
|
||||||
try {
|
try {
|
||||||
classes.value = await Account.listClasses()
|
classes.value = await Account.listClasses()
|
||||||
} catch {
|
} catch {
|
||||||
classesError.value = true
|
studentError.value = "班级列表没加载出来,请刷新页面重试"
|
||||||
|
return
|
||||||
} finally {
|
} finally {
|
||||||
classesLoading.value = false
|
classesLoading.value = false
|
||||||
}
|
}
|
||||||
|
let saved: string | null = null
|
||||||
|
try {
|
||||||
|
saved = localStorage.getItem(CLASS_KEY)
|
||||||
|
} catch {}
|
||||||
|
if (saved && classes.value.includes(saved)) {
|
||||||
|
selectedClass.value = saved
|
||||||
|
onClassChange(saved)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 换标签页时清掉输入的密码和错误;选好的班级和姓名留着
|
||||||
function onTabChange() {
|
function onTabChange() {
|
||||||
// Reset student tab state
|
|
||||||
selectedClass.value = null
|
|
||||||
selectedUsername.value = null
|
|
||||||
studentPassword.value = ""
|
studentPassword.value = ""
|
||||||
showStudentError.value = false
|
studentError.value = ""
|
||||||
namesError.value = false
|
|
||||||
names.value = []
|
|
||||||
// Reset admin tab state
|
|
||||||
adminName.value = ""
|
adminName.value = ""
|
||||||
adminPassword.value = ""
|
adminPassword.value = ""
|
||||||
showAdminError.value = false
|
adminError.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClassChange(classname: string) {
|
async function onClassChange(classname: string) {
|
||||||
selectedUsername.value = null
|
selectedUsername.value = null
|
||||||
names.value = []
|
names.value = []
|
||||||
namesError.value = false
|
studentError.value = ""
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CLASS_KEY, classname)
|
||||||
|
} catch {}
|
||||||
namesLoading.value = true
|
namesLoading.value = true
|
||||||
try {
|
try {
|
||||||
names.value = await Account.listNamesByClass(classname)
|
names.value = await Account.listNamesByClass(classname)
|
||||||
} catch {
|
} catch {
|
||||||
namesError.value = true
|
studentError.value = "姓名列表没加载出来,请重新选一次班级"
|
||||||
} finally {
|
} finally {
|
||||||
namesLoading.value = false
|
namesLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusPassword() {
|
||||||
|
nextTick(() => studentPasswordRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败次数太多被暂时禁止登录时,显示服务端给的提示
|
||||||
|
function loginError(err: any, fallback: string) {
|
||||||
|
return err.response?.status === 429 ? err.response.data.detail : fallback
|
||||||
|
}
|
||||||
|
|
||||||
async function submitStudent() {
|
async function submitStudent() {
|
||||||
if (!selectedUsername.value || !studentPassword.value) return
|
if (!selectedUsername.value || !studentPassword.value) return
|
||||||
studentLoading.value = true
|
studentLoading.value = true
|
||||||
showStudentError.value = false
|
studentError.value = ""
|
||||||
try {
|
try {
|
||||||
const data = await Account.login(
|
const data = await Account.login(
|
||||||
selectedUsername.value,
|
selectedUsername.value,
|
||||||
@@ -194,8 +196,8 @@ async function submitStudent() {
|
|||||||
router.replace(
|
router.replace(
|
||||||
window.location.pathname + window.location.search + window.location.hash,
|
window.location.pathname + window.location.search + window.location.hash,
|
||||||
)
|
)
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
showStudentError.value = true
|
studentError.value = loginError(err, "登录失败,请检查密码")
|
||||||
} finally {
|
} finally {
|
||||||
studentLoading.value = false
|
studentLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -204,7 +206,7 @@ async function submitStudent() {
|
|||||||
async function submitAdmin() {
|
async function submitAdmin() {
|
||||||
if (!adminName.value || !adminPassword.value) return
|
if (!adminName.value || !adminPassword.value) return
|
||||||
adminLoading.value = true
|
adminLoading.value = true
|
||||||
showAdminError.value = false
|
adminError.value = ""
|
||||||
try {
|
try {
|
||||||
const data = await Account.login(adminName.value, adminPassword.value)
|
const data = await Account.login(adminName.value, adminPassword.value)
|
||||||
user.username = data.username
|
user.username = data.username
|
||||||
@@ -214,8 +216,8 @@ async function submitAdmin() {
|
|||||||
router.replace(
|
router.replace(
|
||||||
window.location.pathname + window.location.search + window.location.hash,
|
window.location.pathname + window.location.search + window.location.hash,
|
||||||
)
|
)
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
showAdminError.value = true
|
adminError.value = loginError(err, "登录失败,请检查用户名和密码")
|
||||||
} finally {
|
} finally {
|
||||||
adminLoading.value = false
|
adminLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -223,7 +225,24 @@ async function submitAdmin() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message {
|
.form {
|
||||||
margin-bottom: 12px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-bad);
|
||||||
|
background: var(--wp-bad-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--wp-faint);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -2,89 +2,81 @@
|
|||||||
<n-modal
|
<n-modal
|
||||||
:show="isOpen"
|
:show="isOpen"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="调教提示词"
|
title="帮我把要求写清楚"
|
||||||
style="width: min(560px, 92vw)"
|
style="width: min(640px, 92vw)"
|
||||||
:mask-closable="!streaming"
|
:mask-closable="!streaming"
|
||||||
@update:show="onModalClose"
|
@update:show="onModalClose"
|
||||||
>
|
>
|
||||||
<div class="guidance-body">
|
<p class="intro">
|
||||||
<div class="guidance-messages" ref="messagesRef">
|
AI 会看看你的要求哪里还不够清楚,给你提建议。照着建议改好下面的要求,再让它看看;
|
||||||
<div
|
觉得可以了就点“用这段生成网页”。
|
||||||
v-for="(msg, i) in messages"
|
</p>
|
||||||
:key="msg.id ?? i"
|
|
||||||
class="guidance-msg"
|
<div ref="messagesRef" class="messages">
|
||||||
:class="msg.role"
|
<div v-for="(msg, i) in messages" :key="msg.id ?? i" :class="['msg', msg.role]">
|
||||||
>
|
<div class="who">
|
||||||
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
|
{{ msg.role === "user" ? `你的要求 · 第 ${versionOf(i)} 版` : "AI 的建议" }}
|
||||||
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
|
</div>
|
||||||
|
<div class="content markdown-body" v-html="renderMarkdown(msg.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="streaming" class="guidance-msg assistant">
|
<div v-if="streaming" class="msg assistant">
|
||||||
<div class="msg-role">AI</div>
|
<div class="who">AI 的建议</div>
|
||||||
<div v-if="!displayStreamingContent" class="typing-indicator">
|
<div v-if="!displayStreamingContent" class="typing">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="msg-content"
|
class="content markdown-body"
|
||||||
v-html="renderMarkdown(displayStreamingContent)"
|
v-html="renderMarkdown(displayStreamingContent)"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isReady" class="ready-hint">
|
<div v-if="isReady" class="ready">
|
||||||
<Icon icon="lucide:check-circle" :width="16" />
|
<Icon icon="lucide:check-circle" :width="16" />
|
||||||
提示词已优化,可以生成代码了!
|
AI 觉得要求已经写清楚了,可以生成网页了
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="guidance-input">
|
<div class="draft">
|
||||||
|
<div class="draft-label">你的要求</div>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="draftPrompt"
|
v-model:value="draftPrompt"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||||
placeholder="修改你的提示词后发送..."
|
placeholder="按 AI 的建议把要求改得更具体:要什么内容、什么颜色和布局、点了以后会怎样……"
|
||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
@keydown.enter.exact.prevent="send"
|
@keydown.enter.exact.prevent="send"
|
||||||
/>
|
/>
|
||||||
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
<n-flex justify="space-between" align="center" class="draft-actions">
|
||||||
<div>
|
<n-flex align="center" :size="8">
|
||||||
<n-button
|
<n-button v-if="streaming" size="small" @click="stopGuidance">停止</n-button>
|
||||||
v-if="streaming"
|
|
||||||
type="error"
|
|
||||||
size="small"
|
|
||||||
@click="stopGuidance"
|
|
||||||
>
|
|
||||||
停止
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
<n-button
|
||||||
v-else
|
v-else
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!draftPrompt.trim() || !connected"
|
:disabled="!draftPrompt.trim() || !connected || draftPrompt.trim() === lastSent"
|
||||||
@click="send"
|
@click="send"
|
||||||
>
|
>
|
||||||
发送
|
让 AI 再看看
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
<span class="muted">Enter 发送,Shift + Enter 换行</span>
|
||||||
|
</n-flex>
|
||||||
<n-button
|
<n-button
|
||||||
:type="isReady ? 'primary' : 'default'"
|
:type="isReady ? 'primary' : 'default'"
|
||||||
size="small"
|
:disabled="!draftPrompt.trim() || streaming"
|
||||||
:disabled="!draftPrompt.trim()"
|
|
||||||
@click="generate"
|
@click="generate"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon><Icon icon="lucide:play" :width="14" /></template>
|
||||||
<Icon icon="lucide:play" :width="14" />
|
用这段生成网页
|
||||||
</template>
|
|
||||||
生成代码
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from "vue"
|
import { ref, computed, watch, nextTick } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import {
|
import {
|
||||||
messages,
|
messages,
|
||||||
@@ -106,13 +98,23 @@ const emit = defineEmits<{
|
|||||||
const draftPrompt = ref("")
|
const draftPrompt = ref("")
|
||||||
const messagesRef = ref<HTMLElement>()
|
const messagesRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
// 第几版要求:只数学生发的
|
||||||
|
function versionOf(index: number) {
|
||||||
|
return messages.value.slice(0, index + 1).filter((m) => m.role === "user").length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没改过就不用再发一遍
|
||||||
|
const lastSent = computed(
|
||||||
|
() => [...messages.value].reverse().find((m) => m.role === "user")?.content.trim() ?? "",
|
||||||
|
)
|
||||||
|
|
||||||
const displayStreamingContent = computed(() =>
|
const displayStreamingContent = computed(() =>
|
||||||
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||||
)
|
)
|
||||||
|
|
||||||
function send() {
|
function send() {
|
||||||
const text = draftPrompt.value.trim()
|
const text = draftPrompt.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value || text === lastSent.value) return
|
||||||
sendGuidance(text)
|
sendGuidance(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,87 +143,113 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.guidance-body {
|
.intro {
|
||||||
display: flex;
|
margin: 0 0 12px;
|
||||||
flex-direction: column;
|
font-size: 13px;
|
||||||
gap: 12px;
|
line-height: 1.7;
|
||||||
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-messages {
|
.messages {
|
||||||
min-height: 160px;
|
min-height: 120px;
|
||||||
max-height: 360px;
|
max-height: 45vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg .msg-role {
|
.who {
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: #888;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.user .msg-role {
|
.msg.assistant .who {
|
||||||
color: #2080f0;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.assistant .msg-role {
|
.content {
|
||||||
color: #18a058;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guidance-msg.assistant .msg-content {
|
|
||||||
background: #f0f7ff;
|
|
||||||
border-left: 3px solid #2080f0;
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 0 6px 6px 0;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
|
color: var(--wp-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.user .msg-content {
|
.msg.user .content {
|
||||||
font-size: 14px;
|
border: 1px solid var(--wp-line);
|
||||||
line-height: 1.6;
|
background: var(--wp-paper);
|
||||||
color: #333;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-content :deep(p) {
|
.msg.assistant .content {
|
||||||
margin: 0;
|
border-left: 3px solid var(--wp-brand);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ready-hint {
|
.content :deep(p) {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content :deep(:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: #18a058;
|
margin-top: 12px;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: #f0fff4;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #b8e8cc;
|
font-size: 13px;
|
||||||
|
color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-indicator {
|
.draft {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-label {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 8px 0;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-indicator span {
|
.typing span {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #2080f0;
|
background: var(--wp-brand);
|
||||||
animation: bounce 1.4s ease-in-out infinite;
|
animation: bounce 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-indicator span:nth-child(2) {
|
.typing span:nth-child(2) {
|
||||||
animation-delay: 0.2s;
|
animation-delay: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-indicator span:nth-child(3) {
|
.typing span:nth-child(3) {
|
||||||
animation-delay: 0.4s;
|
animation-delay: 0.4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="history-panel">
|
<div class="history-panel">
|
||||||
<n-flex
|
<div class="toolbar">
|
||||||
class="history-toolbar"
|
<span class="muted">共 {{ items.length }} 轮 · 点一条就把那一版放回编辑器</span>
|
||||||
align="center"
|
|
||||||
justify="space-between"
|
|
||||||
:wrap="false"
|
|
||||||
>
|
|
||||||
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
|
|
||||||
<n-tooltip>
|
<n-tooltip>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button
|
<n-button quaternary size="small" :loading="loading" @click="load(true)">
|
||||||
quaternary
|
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||||
circle
|
|
||||||
size="small"
|
|
||||||
:loading="loading"
|
|
||||||
aria-label="刷新历史对话"
|
|
||||||
@click="load(true)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="lucide:refresh-cw" />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
刷新历史对话
|
刷新
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</div>
|
||||||
|
|
||||||
<n-spin v-if="loading" class="state" />
|
<n-spin v-if="loading && !items.length" class="state" />
|
||||||
<n-empty
|
<n-empty v-else-if="!items.length" class="state" description="还没有和 AI 对话过" />
|
||||||
v-else-if="!items.length"
|
<n-scrollbar v-else class="scroll">
|
||||||
class="state"
|
<ol class="list">
|
||||||
description="暂无历史对话"
|
<li
|
||||||
/>
|
v-for="item in newestFirst"
|
||||||
<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"
|
:key="item.assistant_message_id"
|
||||||
class="history-card"
|
:class="['item', { on: selectedAssistantMessageId === item.assistant_message_id }]"
|
||||||
: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)"
|
@click="selectItem(item)"
|
||||||
>
|
>
|
||||||
<n-flex
|
<div class="head">
|
||||||
class="history-main"
|
<span class="num">{{ item.index }}</span>
|
||||||
align="center"
|
<span :class="['src', `src-${item.source || 'conversation'}`]">
|
||||||
justify="space-between"
|
{{ sourceLabel(item.source) }}
|
||||||
:wrap="false"
|
</span>
|
||||||
>
|
<span v-if="item.prompt_level" class="level">{{ levelLabel(item.prompt_level) }}</span>
|
||||||
<n-flex align="center" :wrap="false" :size="6">
|
<span v-if="item.submission_id" class="submitted">交过</span>
|
||||||
<n-tag
|
<span class="time">{{ parseTime(item.created, "MM-DD HH:mm") }}</span>
|
||||||
round
|
<n-popconfirm @positive-click="deleteItem(item)">
|
||||||
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>
|
<template #trigger>
|
||||||
<n-button
|
<n-button
|
||||||
quaternary
|
quaternary
|
||||||
circle
|
size="tiny"
|
||||||
size="small"
|
class="del"
|
||||||
:loading="deletingId === item.assistant_message_id"
|
:loading="deletingId === item.assistant_message_id"
|
||||||
:disabled="deletingId !== null"
|
:disabled="deletingId !== null"
|
||||||
@click="deleteItem(item, $event)"
|
@click.stop
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon><Icon icon="lucide:trash-2" :width="14" /></template>
|
||||||
<Icon icon="lucide:trash-2" />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
删除这条历史对话
|
<template v-if="item.submission_id">
|
||||||
</n-tooltip>
|
这一版交过,删掉这轮对话后交的那一份也会一起删掉(有人评过分的删不了)。
|
||||||
</n-flex>
|
</template>
|
||||||
</n-flex>
|
<template v-else>删掉这一轮对话和它生成的网页?</template>
|
||||||
<div
|
</n-popconfirm>
|
||||||
class="prompt-markdown markdown-body"
|
</div>
|
||||||
v-html="renderMarkdown(item.prompt)"
|
<div class="q markdown-body" v-html="renderMarkdown(item.prompt)"></div>
|
||||||
/>
|
<div class="thumb">
|
||||||
<div class="thumbnail" aria-label="页面缩略图">
|
|
||||||
<iframe
|
<iframe
|
||||||
v-if="item.hasPage"
|
v-if="item.hasPage"
|
||||||
title="页面缩略图"
|
title="页面缩略图"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
sandbox="allow-scripts"
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
:srcdoc="item.previewDoc"
|
:srcdoc="item.previewDoc"
|
||||||
/>
|
></iframe>
|
||||||
<n-empty v-else size="small" description="未生成页面" />
|
<span v-else class="muted">这一轮没有生成网页</span>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</li>
|
||||||
</n-flex>
|
</ol>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
import { levelLabel, sourceLabel } from "../../utils/helper"
|
||||||
import { Prompt } from "../../api"
|
import { Prompt } from "../../api"
|
||||||
import type { PromptHistoryItem } from "../../utils/type"
|
import type { PromptHistoryItem } from "../../utils/type"
|
||||||
import { parseTime } from "../../utils/helper"
|
import { parseTime } from "../../utils/helper"
|
||||||
import { buildPreviewDocument } from "../../utils/previewDocument"
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
@@ -151,6 +91,8 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
type HistoryViewItem = PromptHistoryItem & {
|
type HistoryViewItem = PromptHistoryItem & {
|
||||||
|
// 按时间先后的序号(从 1 开始)
|
||||||
|
index: number
|
||||||
hasPage: boolean
|
hasPage: boolean
|
||||||
previewDoc: string
|
previewDoc: string
|
||||||
}
|
}
|
||||||
@@ -164,12 +106,16 @@ let pendingRefresh = false
|
|||||||
|
|
||||||
const naiveMessage = useMessage()
|
const naiveMessage = useMessage()
|
||||||
|
|
||||||
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
// 最新的放最上面,序号还是按时间先后
|
||||||
|
const newestFirst = computed(() => [...items.value].reverse())
|
||||||
|
|
||||||
|
function toViewItem(item: PromptHistoryItem, i: number): HistoryViewItem {
|
||||||
const html = item.code_html ?? ""
|
const html = item.code_html ?? ""
|
||||||
const css = item.code_css ?? ""
|
const css = item.code_css ?? ""
|
||||||
const js = item.code_js ?? ""
|
const js = item.code_js ?? ""
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
index: i + 1,
|
||||||
hasPage: !!(html.trim() || css.trim() || js.trim()),
|
hasPage: !!(html.trim() || css.trim() || js.trim()),
|
||||||
previewDoc: buildPreviewDocument({
|
previewDoc: buildPreviewDocument({
|
||||||
html,
|
html,
|
||||||
@@ -180,15 +126,14 @@ function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteItem(item: HistoryViewItem, e: Event) {
|
async function deleteItem(item: HistoryViewItem) {
|
||||||
e.stopPropagation()
|
|
||||||
if (deletingId.value !== null) return
|
if (deletingId.value !== null) return
|
||||||
deletingId.value = item.assistant_message_id
|
deletingId.value = item.assistant_message_id
|
||||||
try {
|
try {
|
||||||
await Prompt.deleteMessagePair(item.assistant_message_id)
|
await Prompt.deleteMessagePair(item.assistant_message_id)
|
||||||
items.value = items.value.filter(
|
items.value = items.value
|
||||||
(i) => i.assistant_message_id !== item.assistant_message_id,
|
.filter((i) => i.assistant_message_id !== item.assistant_message_id)
|
||||||
)
|
.map((i, idx) => ({ ...i, index: idx + 1 }))
|
||||||
if (selectedAssistantMessageId.value === item.assistant_message_id) {
|
if (selectedAssistantMessageId.value === item.assistant_message_id) {
|
||||||
selectedAssistantMessageId.value = null
|
selectedAssistantMessageId.value = null
|
||||||
}
|
}
|
||||||
@@ -210,14 +155,21 @@ function selectItem(item: HistoryViewItem) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载中又要求刷新(比如自动提交了新代码、换了任务),等这次完成后再加载一次
|
||||||
|
let reloadAfter = false
|
||||||
|
|
||||||
async function load(force = true) {
|
async function load(force = true) {
|
||||||
if (!props.taskId || loading.value) return
|
if (!props.taskId) return
|
||||||
|
if (loading.value) {
|
||||||
|
reloadAfter = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!force && loadedTaskId === props.taskId) return
|
if (!force && loadedTaskId === props.taskId) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const taskChanged = loadedTaskId !== props.taskId
|
const taskChanged = loadedTaskId !== props.taskId
|
||||||
const data = await Prompt.listHistory(props.taskId)
|
const data = await Prompt.listHistory(props.taskId)
|
||||||
items.value = data.map(toViewItem)
|
items.value = data.map((item, i) => toViewItem(item, i))
|
||||||
if (
|
if (
|
||||||
taskChanged ||
|
taskChanged ||
|
||||||
!items.value.some(
|
!items.value.some(
|
||||||
@@ -230,6 +182,10 @@ async function load(force = true) {
|
|||||||
loadedTaskId = props.taskId
|
loadedTaskId = props.taskId
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
if (reloadAfter) {
|
||||||
|
reloadAfter = false
|
||||||
|
load(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,100 +224,164 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-toolbar {
|
.toolbar {
|
||||||
height: 42px;
|
height: 42px;
|
||||||
padding: 0 12px;
|
flex: none;
|
||||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
display: flex;
|
||||||
flex-shrink: 0;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 8px 0 14px;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-scrollbar {
|
.scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-list {
|
.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;
|
margin: 0;
|
||||||
padding: 0 12px 12px;
|
padding: 12px;
|
||||||
color: #333;
|
list-style: none;
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
line-height: 1.6;
|
flex-direction: column;
|
||||||
word-break: break-word;
|
gap: 10px;
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(p),
|
.item {
|
||||||
.prompt-markdown :deep(ul),
|
border: 1px solid var(--wp-line);
|
||||||
.prompt-markdown :deep(ol),
|
border-radius: 8px;
|
||||||
.prompt-markdown :deep(blockquote),
|
overflow: hidden;
|
||||||
.prompt-markdown :deep(pre) {
|
background: var(--wp-surface);
|
||||||
margin-top: 0;
|
cursor: pointer;
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(:last-child) {
|
.item:hover {
|
||||||
margin-bottom: 0;
|
border-color: var(--wp-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(pre) {
|
.item.on {
|
||||||
padding: 8px;
|
border-color: var(--wp-brand);
|
||||||
overflow-x: auto;
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 6px 4px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(code) {
|
.num {
|
||||||
white-space: pre-wrap;
|
min-width: 18px;
|
||||||
word-break: break-word;
|
font: 600 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail {
|
.src,
|
||||||
height: 160px;
|
.level,
|
||||||
border-top: 1px solid var(--n-border-color, #efeff5);
|
.submitted {
|
||||||
background: #f7f7f9;
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.src {
|
||||||
|
color: var(--wp-css);
|
||||||
|
background: var(--wp-css-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.src-edit {
|
||||||
|
color: var(--wp-js);
|
||||||
|
background: var(--wp-js-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.src-manual {
|
||||||
|
color: var(--wp-html);
|
||||||
|
background: var(--wp-html-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
color: var(--wp-ink);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
margin-left: auto;
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q {
|
||||||
|
max-height: 66px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
padding: 0 10px 8px;
|
||||||
align-items: center;
|
background: transparent;
|
||||||
justify-content: center;
|
color: var(--wp-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
-webkit-mask-image: linear-gradient(#000 60%, transparent);
|
||||||
|
mask-image: linear-gradient(#000 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card.is-selected .thumbnail {
|
.q :deep(p),
|
||||||
border-top-color: #d8f1e2;
|
.q :deep(ul),
|
||||||
background: #f4fbf6;
|
.q :deep(ol),
|
||||||
|
.q :deep(pre),
|
||||||
|
.q :deep(blockquote) {
|
||||||
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail iframe {
|
.q :deep(pre) {
|
||||||
width: 100%;
|
padding: 6px 8px;
|
||||||
height: 100%;
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q :deep(code) {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
height: 140px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-top: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按 2 倍大小渲染再缩小 */
|
||||||
|
.thumb iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: #fff;
|
transform: scale(0.5);
|
||||||
|
transform-origin: 0 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="prompt-panel">
|
<div class="prompt-panel">
|
||||||
<div class="messages" ref="messagesRef">
|
<div class="messages" ref="messagesRef">
|
||||||
<div
|
<div
|
||||||
v-for="(pair, pi) in pairs"
|
v-for="pair in pairs"
|
||||||
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
||||||
class="message-pair"
|
class="message-pair"
|
||||||
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
||||||
@@ -18,8 +18,15 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- User message -->
|
<!-- User message -->
|
||||||
<div class="message user">
|
<div v-if="pair.userMsg.source === 'edit'" class="message note">
|
||||||
<div class="message-role">我</div>
|
你手动修改了代码
|
||||||
|
</div>
|
||||||
|
<div v-else class="message user">
|
||||||
|
<div class="message-role">
|
||||||
|
我<span v-if="pair.userMsg.source === 'manual'" class="role-tag">
|
||||||
|
用外部 AI</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="message-content"
|
class="message-content"
|
||||||
v-html="renderContent(pair.userMsg)"
|
v-html="renderContent(pair.userMsg)"
|
||||||
@@ -28,23 +35,16 @@
|
|||||||
|
|
||||||
<!-- Assistant message -->
|
<!-- Assistant message -->
|
||||||
<div v-if="pair.assistantMsg" class="message assistant">
|
<div v-if="pair.assistantMsg" class="message assistant">
|
||||||
|
<template v-if="pair.assistantMsg.source === 'conversation' || !pair.assistantMsg.source">
|
||||||
<div class="message-role">AI</div>
|
<div class="message-role">AI</div>
|
||||||
<div
|
<div
|
||||||
class="message-content"
|
class="message-content"
|
||||||
v-html="renderContent(pair.assistantMsg)"
|
v-html="renderContent(pair.assistantMsg)"
|
||||||
></div>
|
></div>
|
||||||
<div
|
</template>
|
||||||
v-if="allowSubmit && hasCode(pair.assistantMsg)"
|
<div v-if="versionOf(pair.assistantMsg)" class="version-line">
|
||||||
class="message-actions"
|
<span class="version-chip">v{{ versionOf(pair.assistantMsg) }}</span>
|
||||||
>
|
<span v-if="pair.assistantMsg.submitted">已提交</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,19 +52,59 @@
|
|||||||
<!-- Streaming indicator -->
|
<!-- Streaming indicator -->
|
||||||
<div v-if="streaming" class="message assistant">
|
<div v-if="streaming" class="message assistant">
|
||||||
<div class="message-role">AI</div>
|
<div class="message-role">AI</div>
|
||||||
<div v-if="!streamingContent" class="typing-indicator">
|
<template v-if="!streamingContent">
|
||||||
|
<div class="typing-indicator">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="streaming-hint">
|
||||||
v-else
|
AI 正在构思… 已 {{ elapsedText }}<template v-if="elapsed >= 20">,复杂的网页要想一两分钟</template>
|
||||||
class="message-content"
|
|
||||||
v-html="renderMarkdown(streamingContent)"
|
|
||||||
></div>
|
|
||||||
<div class="streaming-hint">AI 正在生成中…</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="streamParts.prose"
|
||||||
|
class="message-content"
|
||||||
|
v-html="renderMarkdown(streamParts.prose)"
|
||||||
|
></div>
|
||||||
|
<div v-if="streamParts.lines >= 0" class="code-progress">
|
||||||
|
<Icon icon="lucide:sparkles" :width="14" />
|
||||||
|
正在写网页… 已写 {{ streamParts.lines }} 行
|
||||||
|
</div>
|
||||||
|
<div v-else class="streaming-hint">AI 正在回复…</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<RatingCard v-if="rating" :active="streaming" />
|
||||||
</div>
|
</div>
|
||||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
|
<!-- 只说“不行”“没反应”时提醒一下,可以直接发 -->
|
||||||
|
<div v-if="vagueHint" class="vague-hint">
|
||||||
|
<span>说清楚哪里不对,AI 更容易一次修好</span>
|
||||||
|
<n-flex :size="4" :wrap="false">
|
||||||
|
<n-button size="tiny" type="primary" secondary @click="openProblem">说清楚</n-button>
|
||||||
|
<n-button size="tiny" quaternary @click="sendAnyway">直接发</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
<div v-if="problemOpen" class="problem">
|
||||||
|
<div class="problem-title">出问题了?说清楚这三件事</div>
|
||||||
|
<label>我做了什么</label>
|
||||||
|
<n-input v-model:value="problem.did" placeholder="比如:点了「开始」按钮" />
|
||||||
|
<label>想看到什么</label>
|
||||||
|
<n-input v-model:value="problem.want" placeholder="比如:倒计时开始走" />
|
||||||
|
<label>实际看到了什么</label>
|
||||||
|
<n-input v-model:value="problem.saw" placeholder="比如:数字一直停在 60 不动" />
|
||||||
|
<n-flex justify="end" :size="8" style="margin-top: 10px">
|
||||||
|
<n-button @click="problemOpen = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!problem.did.trim() || !problem.saw.trim() || !connected || streaming"
|
||||||
|
@click="sendProblem"
|
||||||
|
>
|
||||||
|
发给 AI
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
<n-flex
|
<n-flex
|
||||||
class="prompt-input-row"
|
class="prompt-input-row"
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
@@ -75,25 +115,41 @@
|
|||||||
v-model:value="input"
|
v-model:value="input"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||||
placeholder="描述你想要的网页效果..."
|
placeholder="用你自己的话说说想做成什么样…"
|
||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
style="flex: 1; min-width: 0"
|
style="flex: 1; min-width: 0"
|
||||||
@keydown.enter.exact.prevent="send"
|
@keydown.enter.exact.prevent="send"
|
||||||
/>
|
/>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button
|
<n-button
|
||||||
secondary
|
secondary
|
||||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||||
style="flex-shrink: 0"
|
style="flex-shrink: 0"
|
||||||
title="调教提示词"
|
|
||||||
aria-label="调教提示词"
|
|
||||||
@click="startGuidance"
|
@click="startGuidance"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="lucide:lightbulb" :width="16" />
|
<Icon icon="lucide:lightbulb" :width="16" />
|
||||||
</template>
|
</template>
|
||||||
|
帮我写清楚
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
|
AI 教练会问你几个问题,帮你把想要的效果描述清楚
|
||||||
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary :disabled="!hasVersion || streaming" @click="openProblem">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:bug" :width="16" />
|
||||||
|
</template>
|
||||||
|
出问题了
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
网页哪里不对?说清楚做了什么、想看到什么、实际看到了什么
|
||||||
|
</n-tooltip>
|
||||||
|
<n-flex align="center" :size="8">
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="selectedModel"
|
v-model:value="selectedModel"
|
||||||
:options="modelOptions"
|
:options="modelOptions"
|
||||||
@@ -112,34 +168,43 @@
|
|||||||
发送
|
发送
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, computed } from "vue"
|
import { ref, reactive, watch, nextTick, computed, onUnmounted } from "vue"
|
||||||
import { useStorage } from "@vueuse/core"
|
import { useStorage } from "@vueuse/core"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import GuidancePanel from "./GuidancePanel.vue"
|
import GuidancePanel from "./GuidancePanel.vue"
|
||||||
|
import RatingCard from "../workbench/RatingCard.vue"
|
||||||
import { openGuidance } from "../../store/guidance"
|
import { openGuidance } from "../../store/guidance"
|
||||||
import {
|
import {
|
||||||
messages,
|
messages,
|
||||||
streaming,
|
streaming,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
|
streamStartedAt,
|
||||||
connected,
|
connected,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
stopPrompt,
|
stopPrompt,
|
||||||
currentTaskId,
|
currentTaskId,
|
||||||
removeMessagePair,
|
removeMessagePair,
|
||||||
markMessageSubmitted,
|
type PromptMessage,
|
||||||
} from "../../store/prompt"
|
} from "../../store/prompt"
|
||||||
import { Prompt, Submission } from "../../api"
|
import { Prompt } from "../../api"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
|
||||||
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>()
|
const props = defineProps<{
|
||||||
|
// 发送前调用:工作台用它把还没存的手改先存成版本,AI 才会在手改后的代码上继续改
|
||||||
|
beforeSend?: () => Promise<void>
|
||||||
|
// AI 生成时在对话里显示同学作品让学生打分(挑战页才有)
|
||||||
|
rating?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ deleted: []; submitted: [] }>()
|
const emit = defineEmits<{ deleted: [] }>()
|
||||||
|
|
||||||
const input = ref("")
|
const input = ref("")
|
||||||
const messagesRef = ref<HTMLElement>()
|
const messagesRef = ref<HTMLElement>()
|
||||||
@@ -156,18 +221,11 @@ if (!modelOptions.some((o) => o.value === selectedModel.value)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group messages into user+assistant pairs
|
// Group messages into user+assistant pairs
|
||||||
const submittingId = ref<number | null>(null)
|
|
||||||
|
|
||||||
const pairs = computed(() => {
|
const pairs = computed(() => {
|
||||||
const result: Array<{
|
const result: Array<{
|
||||||
userMsg: { role: string; content: string; id?: number }
|
userMsg: PromptMessage
|
||||||
assistantMsg: {
|
assistantMsg: PromptMessage | null
|
||||||
role: string
|
|
||||||
content: string
|
|
||||||
id?: number
|
|
||||||
code?: any
|
|
||||||
submitted?: boolean
|
|
||||||
} | null
|
|
||||||
index: number
|
index: number
|
||||||
}> = []
|
}> = []
|
||||||
const msgs = messages.value
|
const msgs = messages.value
|
||||||
@@ -190,30 +248,17 @@ function hasCode(msg: { code?: any } | null): boolean {
|
|||||||
return !!(msg.code.html || msg.code.css || msg.code.js)
|
return !!(msg.code.html || msg.code.css || msg.code.js)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitVersion(msg: {
|
// 带代码的 AI 回复就是一个版本,按出现顺序编号(和工作台底部的版本条一致)
|
||||||
id?: number
|
const versionNumbers = computed(() => {
|
||||||
code?: { html: string | null; css: string | null; js: string | null }
|
const map = new Map<number, number>()
|
||||||
}) {
|
for (const m of messages.value) {
|
||||||
if (!msg.id || !msg.code || !currentTaskId.value) return
|
if (m.role === "assistant" && m.id && hasCode(m)) map.set(m.id, map.size + 1)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
function versionOf(msg: { id?: number }) {
|
||||||
|
return msg.id ? versionNumbers.value.get(msg.id) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePair(assistantMsgId: number) {
|
async function deletePair(assistantMsgId: number) {
|
||||||
@@ -227,11 +272,52 @@ async function deletePair(assistantMsgId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function send() {
|
async function send() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value) return
|
||||||
sendPrompt(text, selectedModel.value)
|
// 只说“不行”“没反应”,AI 只能瞎猜;提醒一次,学生选“直接发”以后这次就不再拦
|
||||||
|
if (!skipVagueCheck && hasVersion.value && [...text].length <= 15 && VAGUE.test(text)) {
|
||||||
|
vagueHint.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
input.value = ""
|
input.value = ""
|
||||||
|
await props.beforeSend?.()
|
||||||
|
sendPrompt(text, selectedModel.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// “出问题了”:做了什么、想看到什么、实际看到了什么,拼成一句发给 AI
|
||||||
|
const VAGUE =
|
||||||
|
/不行|不对|没反应|没用|没效果|用不了|不能用|不能玩|动不了|进不去|打不开|没显示|不显示|显示不出|bug|出错|报错|错了|坏了|白屏|空白|有问题/i
|
||||||
|
const vagueHint = ref(false)
|
||||||
|
let skipVagueCheck = false
|
||||||
|
const problemOpen = ref(false)
|
||||||
|
const problem = reactive({ did: "", want: "", saw: "" })
|
||||||
|
|
||||||
|
watch(input, () => (vagueHint.value = false))
|
||||||
|
|
||||||
|
const hasVersion = computed(() => messages.value.some((m) => m.role === "assistant" && hasCode(m)))
|
||||||
|
|
||||||
|
function openProblem() {
|
||||||
|
vagueHint.value = false
|
||||||
|
problemOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAnyway() {
|
||||||
|
skipVagueCheck = true
|
||||||
|
vagueHint.value = false
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendProblem() {
|
||||||
|
const part = (s: string) => s.trim().replace(/[。,,.!!;;]+$/, "")
|
||||||
|
const did = part(problem.did).replace(/^我/, "")
|
||||||
|
const want = part(problem.want)
|
||||||
|
const text = `我${did},${want ? `想看到${want},` : ""}但实际${part(problem.saw)}。`
|
||||||
|
Object.assign(problem, { did: "", want: "", saw: "" })
|
||||||
|
problemOpen.value = false
|
||||||
|
input.value = ""
|
||||||
|
await props.beforeSend?.()
|
||||||
|
sendPrompt(text, selectedModel.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startGuidance() {
|
function startGuidance() {
|
||||||
@@ -240,15 +326,53 @@ function startGuidance() {
|
|||||||
openGuidance(currentTaskId.value, text)
|
openGuidance(currentTaskId.value, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGuidanceGenerate(finalPrompt: string) {
|
async function onGuidanceGenerate(finalPrompt: string) {
|
||||||
sendPrompt(finalPrompt, selectedModel.value)
|
|
||||||
input.value = ""
|
input.value = ""
|
||||||
|
await props.beforeSend?.()
|
||||||
|
sendPrompt(finalPrompt, selectedModel.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(msg: { role: string; content: string }): string {
|
// 学生只关心成品:AI 回复里的代码块不在对话里展开(右边“代码”里能看),只说写了多少行。
|
||||||
return renderMarkdown(msg.content)
|
// 按 ``` 切开,奇数段是代码;段数是偶数说明最后一个代码块还没写完
|
||||||
|
function splitCode(text: string) {
|
||||||
|
const parts = text.split("```")
|
||||||
|
const prose = parts.filter((_, i) => i % 2 === 0).join("\n\n").trim()
|
||||||
|
const code = parts.filter((_, i) => i % 2 === 1)
|
||||||
|
const lines = code.reduce((n, c) => n + c.split("\n").length - 1, 0)
|
||||||
|
return { prose, lines: code.length ? lines : -1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const streamParts = computed(() => splitCode(streamingContent.value))
|
||||||
|
|
||||||
|
function renderContent(msg: { role: string; content: string }): string {
|
||||||
|
if (msg.role !== "assistant") return renderMarkdown(msg.content)
|
||||||
|
const { prose, lines } = splitCode(msg.content)
|
||||||
|
const note = lines >= 0 ? `<p class="code-note">网页代码 ${lines} 行,在右边“代码”里看</p>` : ""
|
||||||
|
return renderMarkdown(prose) + note
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等 AI 的计时:每秒刷新一次
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let ticker: ReturnType<typeof setInterval> | undefined
|
||||||
|
watch(
|
||||||
|
streaming,
|
||||||
|
(on) => {
|
||||||
|
clearInterval(ticker)
|
||||||
|
now.value = Date.now()
|
||||||
|
if (on) ticker = setInterval(() => (now.value = Date.now()), 1000)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
onUnmounted(() => clearInterval(ticker))
|
||||||
|
|
||||||
|
const elapsed = computed(() =>
|
||||||
|
streamStartedAt.value ? Math.max(0, Math.floor((now.value - streamStartedAt.value) / 1000)) : 0,
|
||||||
|
)
|
||||||
|
const elapsedText = computed(() => {
|
||||||
|
const s = elapsed.value
|
||||||
|
return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${s % 60} 秒`
|
||||||
|
})
|
||||||
|
|
||||||
watch([() => messages.value.length, streamingContent], () => {
|
watch([() => messages.value.length, streamingContent], () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (messagesRef.value) {
|
if (messagesRef.value) {
|
||||||
@@ -281,15 +405,15 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: #666;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.user .message-role {
|
.message.user .message-role {
|
||||||
color: #2080f0;
|
color: var(--wp-css);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.assistant .message-role {
|
.message.assistant .message-role {
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
@@ -297,8 +421,38 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手改代码这类不是对话的一行说明 */
|
||||||
|
.message.note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-chip {
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid var(--wp-brand);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font: 600 12px/18px var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
.message-content :deep(pre) {
|
.message-content :deep(pre) {
|
||||||
background: #f5f5f5;
|
background: var(--wp-paper);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -329,7 +483,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #18a058;
|
background: var(--wp-brand);
|
||||||
animation: bounce 1.4s ease-in-out infinite;
|
animation: bounce 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,17 +508,56 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(.code-note) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.streaming-hint {
|
.streaming-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #aaa;
|
color: var(--wp-faint);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vague-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem label {
|
||||||
|
display: block;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.input-area {
|
.input-area {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid var(--wp-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-pair {
|
.message-pair {
|
||||||
@@ -382,11 +575,11 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--wp-line);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #bbb;
|
color: var(--wp-faint);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition:
|
transition:
|
||||||
color 0.15s,
|
color 0.15s,
|
||||||
@@ -394,8 +587,8 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pair-delete-btn:hover {
|
.pair-delete-btn:hover {
|
||||||
color: #e03e3e;
|
color: var(--wp-bad);
|
||||||
border-color: #e03e3e;
|
border-color: var(--wp-bad);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-pair.has-delete:hover .pair-delete-btn {
|
.message-pair.has-delete:hover .pair-delete-btn {
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
<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"
|
|
||||||
:sandbox="PREVIEW_SANDBOX"
|
|
||||||
: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,
|
|
||||||
PREVIEW_SANDBOX,
|
|
||||||
} 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)
|
|
||||||
// 请求期间 AI 已经回复完了,就不要再弹出来
|
|
||||||
if (!streaming.value) return
|
|
||||||
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>
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<MdEditor v-bind="props" v-model="modelValue" @upload-img="uploadImg" />
|
<MdEditor
|
||||||
|
v-bind="props"
|
||||||
|
v-model="modelValue"
|
||||||
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
|
@upload-img="uploadImg"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { MdEditor } from "md-editor-v3"
|
import { MdEditor } from "md-editor-v3"
|
||||||
@@ -7,6 +12,7 @@ import { MdEditor } from "md-editor-v3"
|
|||||||
import "md-editor-v3/lib/style.css"
|
import "md-editor-v3/lib/style.css"
|
||||||
import { Helper } from "../../api"
|
import { Helper } from "../../api"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
|
import { isDark } from "../../store/theme"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { useMessage } from "naive-ui"
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
import { Account } from "../../api"
|
import { Account } from "../../api"
|
||||||
import { Role, type User } from "../../utils/type"
|
import { Role, type User } from "../../utils/type"
|
||||||
import UserFormModal from "./UserFormModal.vue"
|
import UserFormModal from "./UserFormModal.vue"
|
||||||
@@ -41,15 +41,27 @@ const props = defineProps<Props>()
|
|||||||
const emits = defineEmits(["reload"])
|
const emits = defineEmits(["reload"])
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
|
|
||||||
function edit() {
|
function edit() {
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleUserActive() {
|
async function doToggle() {
|
||||||
const data = await Account.toggleActive(props.user.id)
|
const data = await Account.toggleActive(props.user.id)
|
||||||
message.success(data.message)
|
message.success(data.message)
|
||||||
emits("reload")
|
emits("reload")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleUserActive() {
|
||||||
|
if (!props.user.is_active) return doToggle()
|
||||||
|
dialog.warning({
|
||||||
|
title: `封禁「${props.user.username}」?`,
|
||||||
|
content: "封禁后这个账号不能登录,已经登录的也会被退出。之后可以再解封。",
|
||||||
|
positiveText: "封禁",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: doToggle,
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -5,23 +5,26 @@ import { css } from "@codemirror/lang-css"
|
|||||||
import { javascript } from "@codemirror/lang-javascript"
|
import { javascript } from "@codemirror/lang-javascript"
|
||||||
import { html } from "@codemirror/lang-html"
|
import { html } from "@codemirror/lang-html"
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
|
import { syntaxHighlighting } from "@codemirror/language"
|
||||||
|
import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
|
||||||
|
import { isDark } from "../../store/theme"
|
||||||
|
|
||||||
const styleTheme = EditorView.baseTheme({
|
const styleTheme = EditorView.baseTheme({
|
||||||
"& .cm-scroller": {
|
"& .cm-scroller": {
|
||||||
"font-family": "Monaco",
|
"font-family": "var(--wp-font-mono)",
|
||||||
},
|
},
|
||||||
"&.cm-editor.cm-focused": {
|
"&.cm-editor.cm-focused": {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
},
|
||||||
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||||
"font-family": "Monaco",
|
"font-family": "var(--wp-font-mono)",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const bgColors: Record<string, string> = {
|
const bgColors: Record<string, string> = {
|
||||||
html: "#fff6f3",
|
html: "var(--wp-html-soft)",
|
||||||
css: "#f3f6ff",
|
css: "var(--wp-css-soft)",
|
||||||
js: "#fffdf0",
|
js: "var(--wp-js-soft)",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,7 +34,7 @@ interface Props {
|
|||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
language: "html",
|
language: "html",
|
||||||
fontSize: 20,
|
fontSize: 16,
|
||||||
})
|
})
|
||||||
|
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
@@ -43,14 +46,25 @@ const lang = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const bgTheme = computed(() => {
|
const bgTheme = computed(() => {
|
||||||
const bg = bgColors[props.language] ?? "#ffffff"
|
const bg = bgColors[props.language] ?? "var(--wp-surface)"
|
||||||
return EditorView.theme({
|
return EditorView.theme(
|
||||||
"&": { backgroundColor: bg },
|
{
|
||||||
".cm-gutters": { backgroundColor: bg },
|
"&": { backgroundColor: bg, color: "var(--wp-ink)" },
|
||||||
})
|
".cm-gutters": { backgroundColor: bg, color: "var(--wp-faint)", borderColor: "var(--wp-line)" },
|
||||||
|
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "rgba(127, 127, 127, 0.08)" },
|
||||||
|
".cm-cursor": { borderLeftColor: "var(--wp-ink)" },
|
||||||
|
},
|
||||||
|
{ dark: isDark.value },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const extensions = computed(() => [styleTheme, bgTheme.value, lang.value])
|
// 暗色时换成 One Dark 的语法配色(默认配色是给浅色背景设计的)
|
||||||
|
const extensions = computed(() => [
|
||||||
|
styleTheme,
|
||||||
|
bgTheme.value,
|
||||||
|
...(isDark.value ? [syntaxHighlighting(oneDarkHighlightStyle)] : []),
|
||||||
|
lang.value,
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Codemirror
|
<Codemirror
|
||||||
|
|||||||
@@ -10,37 +10,25 @@
|
|||||||
>
|
>
|
||||||
<n-tab-pane name="html" tab="HTML">
|
<n-tab-pane name="html" tab="HTML">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab lang-html">HTML</span>
|
||||||
<Icon :width="20" icon="skill-icons:html"></Icon>
|
|
||||||
<span>HTML</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<Editor v-model:value="html" :font-size="size" language="html" />
|
<Editor v-model:value="html" :font-size="size" language="html" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="css" tab="CSS">
|
<n-tab-pane name="css" tab="CSS">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab lang-css">CSS</span>
|
||||||
<Icon :width="20" icon="skill-icons:css"></Icon>
|
|
||||||
<span>CSS</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<Editor v-model:value="css" :font-size="size" language="css" />
|
<Editor v-model:value="css" :font-size="size" language="css" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="js" tab="JS">
|
<n-tab-pane name="js" tab="JS">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab lang-js">JS</span>
|
||||||
<Icon :width="20" icon="skill-icons:javascript"></Icon>
|
|
||||||
<span>JS</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<Editor v-model:value="js" :font-size="size" language="js" />
|
<Editor v-model:value="js" :font-size="size" language="js" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="actions" tab="选项">
|
<n-tab-pane name="actions" tab="选项">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<n-flex align="center">
|
<span class="lang-tab">选项</span>
|
||||||
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
|
|
||||||
<span>选项</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<n-flex class="wrapper" vertical>
|
<n-flex class="wrapper" vertical>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
@@ -53,7 +41,7 @@
|
|||||||
<span class="label">字号</span>
|
<span class="label">字号</span>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
||||||
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
|
<n-button :disabled="size <= 12" @click="changeSize(size - 2)">
|
||||||
调小
|
调小
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
||||||
@@ -78,16 +66,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<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 Editor from "./Editor.vue"
|
||||||
import Toolbar from "./Toolbar.vue"
|
import Toolbar from "./Toolbar.vue"
|
||||||
import { html, css, js, tab, size, reset } from "../../store/editors"
|
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||||
import { taskId } from "../../store/task"
|
import { taskId } from "../../store/task"
|
||||||
|
import { formatCode } from "../../utils/code"
|
||||||
import { Submission } from "../../api"
|
import { Submission } from "../../api"
|
||||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||||
import { h, ref } from "vue"
|
import { h, ref } from "vue"
|
||||||
@@ -104,34 +87,16 @@ function changeSize(num: number) {
|
|||||||
size.value = num
|
size.value = num
|
||||||
}
|
}
|
||||||
|
|
||||||
async function formatCode() {
|
async function formatEditors() {
|
||||||
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
|
const out = await formatCode({ html: html.value, css: css.value, js: js.value })
|
||||||
prettier.format(html.value, {
|
html.value = out.html
|
||||||
parser: "html",
|
css.value = out.css
|
||||||
//@ts-ignore
|
js.value = out.js
|
||||||
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() {
|
async function format() {
|
||||||
try {
|
try {
|
||||||
await formatCode()
|
await formatEditors()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
dialog.error({
|
dialog.error({
|
||||||
title: "格式化失败",
|
title: "格式化失败",
|
||||||
@@ -157,7 +122,7 @@ async function doSubmit() {
|
|||||||
async function formatAndSubmit() {
|
async function formatAndSubmit() {
|
||||||
submitLoading.value = true
|
submitLoading.value = true
|
||||||
try {
|
try {
|
||||||
await formatCode()
|
await formatEditors()
|
||||||
await doSubmit()
|
await doSubmit()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
@@ -189,14 +154,15 @@ async function formatAndSubmit() {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
||||||
background-color: #fff6f3 !important;
|
background-color: var(--wp-html-soft) !important;
|
||||||
}
|
}
|
||||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
|
||||||
background-color: #f3f6ff !important;
|
background-color: var(--wp-css-soft) !important;
|
||||||
}
|
}
|
||||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
|
||||||
background-color: #fffdf0 !important;
|
background-color: var(--wp-js-soft) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editors-root {
|
.editors-root {
|
||||||
@@ -204,12 +170,12 @@ async function formatAndSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.tab-active-html .n-tab-pane) {
|
:deep(.tab-active-html .n-tab-pane) {
|
||||||
background-color: #fff6f3;
|
background-color: var(--wp-html-soft);
|
||||||
}
|
}
|
||||||
:deep(.tab-active-css .n-tab-pane) {
|
:deep(.tab-active-css .n-tab-pane) {
|
||||||
background-color: #f3f6ff;
|
background-color: var(--wp-css-soft);
|
||||||
}
|
}
|
||||||
:deep(.tab-active-js .n-tab-pane) {
|
:deep(.tab-active-js .n-tab-pane) {
|
||||||
background-color: #fffdf0;
|
background-color: var(--wp-js-soft);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex align="center" justify="space-between" class="title">
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
<div>
|
<div>
|
||||||
<n-text class="titleText">预览</n-text>
|
<slot name="title"><n-text class="titleText">预览</n-text></slot>
|
||||||
<n-text v-if="!!submission.id" depth="3">
|
|
||||||
({{ submission.view_count || 0 }})
|
|
||||||
</n-text>
|
|
||||||
</div>
|
</div>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
<n-tooltip>
|
<n-tooltip>
|
||||||
@@ -19,26 +16,6 @@
|
|||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||||
<n-button quaternary @click="open">全屏</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>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||||
@@ -46,6 +23,7 @@
|
|||||||
ref="frame"
|
ref="frame"
|
||||||
class="iframe"
|
class="iframe"
|
||||||
:sandbox="PREVIEW_SANDBOX"
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:key="frameKey"
|
||||||
:srcdoc="previewContent"
|
:srcdoc="previewContent"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,12 +31,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { Icon } from "../../icons"
|
||||||
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 {
|
import {
|
||||||
buildPreviewDocument,
|
buildPreviewDocument,
|
||||||
PREVIEW_SANDBOX,
|
PREVIEW_SANDBOX,
|
||||||
@@ -67,7 +40,6 @@ import {
|
|||||||
listenPreviewStorage,
|
listenPreviewStorage,
|
||||||
loadPreviewStorage,
|
loadPreviewStorage,
|
||||||
} from "../../utils/previewStorage"
|
} from "../../utils/previewStorage"
|
||||||
import { taskId } from "../../store/task"
|
|
||||||
import { user } from "../../store/user"
|
import { user } from "../../store/user"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -75,15 +47,11 @@ interface Props {
|
|||||||
css: string
|
css: string
|
||||||
js: string
|
js: string
|
||||||
assetBaseUrl?: string
|
assetBaseUrl?: string
|
||||||
submissionId?: string
|
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
||||||
showCodeButton?: boolean
|
storageTaskId?: number
|
||||||
clearable?: boolean
|
|
||||||
// 自己的编辑器:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
|
||||||
persistStorage?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
|
||||||
|
|
||||||
type Layout = "desktop" | "mobile" | "tablet"
|
type Layout = "desktop" | "mobile" | "tablet"
|
||||||
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
||||||
@@ -121,15 +89,12 @@ function cycleLayout() {
|
|||||||
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const showDL = computed(() => props.html || props.css || props.js)
|
const showDL = computed(() => props.html || props.css || props.js)
|
||||||
const previewContent = ref("")
|
const previewContent = ref("")
|
||||||
|
|
||||||
const frame = useTemplateRef<HTMLIFrameElement>("frame")
|
const frame = useTemplateRef<HTMLIFrameElement>("frame")
|
||||||
const storageScope = () =>
|
const storageScope = () =>
|
||||||
props.persistStorage && taskId.value ? `${user.username}:${taskId.value}` : ""
|
props.storageTaskId ? `${user.username}:${props.storageTaskId}` : ""
|
||||||
|
|
||||||
function getContent(forDownload = false) {
|
function getContent(forDownload = false) {
|
||||||
const scope = storageScope()
|
const scope = storageScope()
|
||||||
@@ -142,8 +107,12 @@ function getContent(forDownload = false) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每次内容变了换一个新的 iframe:在同一个 iframe 里改 srcdoc 会进浏览器历史,
|
||||||
|
// 按“后退”就成了在预览里倒退,而不是离开页面
|
||||||
|
const frameKey = ref(0)
|
||||||
function preview() {
|
function preview() {
|
||||||
previewContent.value = getContent()
|
previewContent.value = getContent()
|
||||||
|
frameKey.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
@@ -158,13 +127,6 @@ function download() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function open() {
|
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")
|
const newTab = window.open("about:blank", "_blank")
|
||||||
if (!newTab) return
|
if (!newTab) return
|
||||||
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
||||||
@@ -178,28 +140,8 @@ function open() {
|
|||||||
// 全屏页里的写入也存到同一个地方
|
// 全屏页里的写入也存到同一个地方
|
||||||
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
||||||
tabFrame.srcdoc = getContent()
|
tabFrame.srcdoc = getContent()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
watchDebounced(
|
||||||
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||||
@@ -215,9 +157,11 @@ onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.title {
|
.title {
|
||||||
height: 43px;
|
height: 43px;
|
||||||
padding: 0 20px;
|
padding: 0 12px 0 16px;
|
||||||
border-bottom: 1px solid rgb(239, 239, 245);
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleText {
|
.titleText {
|
||||||
@@ -226,7 +170,8 @@ onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
|||||||
|
|
||||||
.iframe-wrapper {
|
.iframe-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
/* 减去上面标题栏的高度,否则底部 43px 会被裁掉 */
|
||||||
|
height: calc(100% - 43px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
transition: max-width 0.3s ease;
|
transition: max-width 0.3s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,43 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex align="center" class="corner">
|
<n-flex align="center" class="corner">
|
||||||
<n-button quaternary v-if="!show" @click="showTutorial"> 教程 </n-button>
|
<n-button quaternary v-if="!show" @click="showTutorial">显示教程</n-button>
|
||||||
<template v-if="user.loaded && authed">
|
<template v-if="user.loaded && authed">
|
||||||
<n-button quaternary @click="emit('format')">整理</n-button>
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary @click="emit('format')">整理代码</n-button>
|
||||||
|
</template>
|
||||||
|
自动整理缩进和换行
|
||||||
|
</n-tooltip>
|
||||||
<n-button
|
<n-button
|
||||||
type="primary"
|
type="primary"
|
||||||
secondary
|
|
||||||
:disabled="submitDisabled"
|
:disabled="submitDisabled"
|
||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
@click="emit('submit')"
|
@click="emit('submit')"
|
||||||
>
|
>
|
||||||
提交
|
提交
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-dropdown :options="menu" @select="clickMenu">
|
|
||||||
<n-button>{{ user.username }}</n-button>
|
|
||||||
</n-dropdown>
|
|
||||||
</template>
|
</template>
|
||||||
<n-button
|
|
||||||
v-if="user.loaded && !authed"
|
|
||||||
@click="handleLogin"
|
|
||||||
secondary
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, h } from "vue"
|
import { computed } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { authed, user } from "../../store/user"
|
||||||
import { authed, roleAdmin, roleNormal, user } from "../../store/user"
|
|
||||||
import { loginModal } from "../../store/modal"
|
|
||||||
import { show, panelSize } from "../../store/panel"
|
import { show, panelSize } from "../../store/panel"
|
||||||
import { taskId } from "../../store/task"
|
import { taskId } from "../../store/task"
|
||||||
import { Account } from "../../api"
|
|
||||||
import { Role } from "../../utils/type"
|
|
||||||
import { router } from "../../router"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
submitLoading: boolean
|
submitLoading: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits(["format", "submit"])
|
const emit = defineEmits(["format", "submit"])
|
||||||
@@ -46,74 +34,13 @@ const submitDisabled = computed(() => {
|
|||||||
return taskId.value === 0
|
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() {
|
function showTutorial() {
|
||||||
show.value = true
|
show.value = true
|
||||||
panelSize.value = 2 / 5
|
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>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.corner {
|
.corner {
|
||||||
margin-right: 20px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<router-link class="brand" :to="tutorialRoute">
|
||||||
|
<img src="/noto--honeybee.ico" alt="" width="20" height="20" />
|
||||||
|
<span>前端开发学习平台</span>
|
||||||
|
</router-link>
|
||||||
|
<nav class="nav">
|
||||||
|
<router-link
|
||||||
|
v-for="item in nav"
|
||||||
|
:key="item.label"
|
||||||
|
:to="item.to"
|
||||||
|
:class="['nav-item', { active: item.active }]"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle size="small" @click="cycleTheme">
|
||||||
|
<Icon :icon="themeIcon" :width="16" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
外观:{{ themeLabel }}(点击切换)
|
||||||
|
</n-tooltip>
|
||||||
|
<template v-if="user.loaded">
|
||||||
|
<router-link
|
||||||
|
v-if="roleStaff"
|
||||||
|
:to="{ name: 'dashboard' }"
|
||||||
|
:class="['nav-item', { active: inTeach }]"
|
||||||
|
>
|
||||||
|
教学
|
||||||
|
</router-link>
|
||||||
|
<n-dropdown
|
||||||
|
v-if="authed"
|
||||||
|
:options="userMenu"
|
||||||
|
trigger="click"
|
||||||
|
@select="clickUserMenu"
|
||||||
|
>
|
||||||
|
<n-button quaternary class="user-btn">
|
||||||
|
{{ user.username }}
|
||||||
|
</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
<n-button v-else type="primary" size="small" @click="loginModal = true">
|
||||||
|
登录
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</header>
|
||||||
|
<main class="main">
|
||||||
|
<router-view v-if="$route.matched.length" />
|
||||||
|
<div v-else-if="user.loaded && !authed" class="signed-out">
|
||||||
|
<p>登录后开始学习</p>
|
||||||
|
<n-button type="primary" @click="loginModal = true">登录</n-button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { authed, roleStaff, user } from "../../store/user"
|
||||||
|
import { loginModal } from "../../store/modal"
|
||||||
|
import { step } from "../../store/tutorial"
|
||||||
|
import { logout } from "../../store/session"
|
||||||
|
import { themeMode, type ThemeMode } from "../../store/theme"
|
||||||
|
import { Icon } from "../../icons"
|
||||||
|
|
||||||
|
const themeOrder: ThemeMode[] = ["auto", "light", "dark"]
|
||||||
|
const themeLabel = computed(
|
||||||
|
() => ({ auto: "跟随系统", light: "亮色", dark: "暗色" })[themeMode.value],
|
||||||
|
)
|
||||||
|
const themeIcon = computed(
|
||||||
|
() => ({ auto: "lucide:sun-moon", light: "lucide:sun", dark: "lucide:moon" })[themeMode.value],
|
||||||
|
)
|
||||||
|
function cycleTheme() {
|
||||||
|
themeMode.value = themeOrder[(themeOrder.indexOf(themeMode.value) + 1) % themeOrder.length]!
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const routeName = computed(() => String(route.name ?? ""))
|
||||||
|
const inTeach = computed(() => route.path.startsWith("/dashboard"))
|
||||||
|
|
||||||
|
const tutorialRoute = computed(() =>
|
||||||
|
step.value
|
||||||
|
? { name: "home-tutorial", params: { display: step.value } }
|
||||||
|
: { name: "home-tutorial-list" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const nav = computed(() => [
|
||||||
|
{
|
||||||
|
label: "教程",
|
||||||
|
to: tutorialRoute.value,
|
||||||
|
active:
|
||||||
|
routeName.value === "home" || routeName.value.startsWith("home-tutorial"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "挑战",
|
||||||
|
to: { name: "home-challenge-list" },
|
||||||
|
active: routeName.value.startsWith("home-challenge"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "自由创作",
|
||||||
|
to: { name: "creation-list" },
|
||||||
|
active: routeName.value.startsWith("creation"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "作品展",
|
||||||
|
to: { name: "showcase" },
|
||||||
|
active: routeName.value === "showcase",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const userMenu = [
|
||||||
|
{ label: "我的提交", key: "submissions" },
|
||||||
|
{ type: "divider", key: "d1" },
|
||||||
|
{ label: "退出登录", key: "logout" },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function clickUserMenu(key: string) {
|
||||||
|
if (key === "submissions") {
|
||||||
|
router.push({ name: "submissions" })
|
||||||
|
} else if (key === "logout") {
|
||||||
|
await logout()
|
||||||
|
// 留在需要登录或特定角色的页面上没有意义
|
||||||
|
if (route.meta.auth || route.meta.role) router.push(tutorialRoute.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.shell {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: var(--wp-topbar-h);
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: 20px;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--wp-ink);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:focus-visible,
|
||||||
|
.brand:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
height: var(--wp-main-h);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-out {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<n-drawer v-model:show="show" :width="600" placement="right">
|
||||||
|
<n-drawer-content title="作品" :native-scrollbar="false">
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-result
|
||||||
|
v-if="data?.locked"
|
||||||
|
status="info"
|
||||||
|
title="交过这道题以后就能看到"
|
||||||
|
description="先完成自己的作品并提交,再来看看同学们是怎么做的。"
|
||||||
|
style="margin-top: 40px"
|
||||||
|
/>
|
||||||
|
<template v-else-if="data">
|
||||||
|
<n-flex justify="space-between" align="center" class="bar">
|
||||||
|
<span class="hint">
|
||||||
|
{{
|
||||||
|
roleStaff
|
||||||
|
? "学生们在这道题上最新的作品。"
|
||||||
|
: "同学们在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<n-radio-group v-model:value="scope" size="small">
|
||||||
|
<n-radio-button value="class">本班</n-radio-button>
|
||||||
|
<n-radio-button value="all">全部班级</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
</n-flex>
|
||||||
|
<n-empty v-if="!data.items.length" description="还没有同学交这道题" />
|
||||||
|
<div class="grid">
|
||||||
|
<div v-for="w in data.items" :key="w.id" class="work">
|
||||||
|
<button class="thumb" title="点击放大看" @click="large = w">
|
||||||
|
<iframe
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="docOf(w)"
|
||||||
|
loading="lazy"
|
||||||
|
tabindex="-1"
|
||||||
|
></iframe>
|
||||||
|
</button>
|
||||||
|
<div class="info">
|
||||||
|
<span :class="{ anon: !w.username }">
|
||||||
|
<span v-if="scope === 'all' && w.classname" class="cls">{{ w.classname }} 班</span>
|
||||||
|
{{ w.username ?? "匿名同学" }}
|
||||||
|
</span>
|
||||||
|
<span v-if="w.my_score" class="mine">我给了 {{ w.my_score }} 分</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!w.my_score" class="rates">
|
||||||
|
<n-tooltip v-for="s in scores" :key="s.value">
|
||||||
|
<template #trigger>
|
||||||
|
<button class="rate" :disabled="rating === w.id" @click="rate(w, s.value)">
|
||||||
|
{{ s.value }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ s.value }} 分:{{ s.label }}
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-spin>
|
||||||
|
</n-drawer-content>
|
||||||
|
</n-drawer>
|
||||||
|
<n-modal
|
||||||
|
:show="!!large"
|
||||||
|
preset="card"
|
||||||
|
:title="large?.username ?? '匿名同学的作品'"
|
||||||
|
style="width: 90vw; max-width: 1000px"
|
||||||
|
@update:show="(v: boolean) => !v && (large = null)"
|
||||||
|
>
|
||||||
|
<div v-if="large" class="large">
|
||||||
|
<iframe :key="large.id" :sandbox="PREVIEW_SANDBOX" :srcdoc="docOf(large)"></iframe>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Review, Submission } from "../../api"
|
||||||
|
import { roleStaff } from "../../store/user"
|
||||||
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||||
|
import type { PeerWork, PeerWorks } from "../../utils/type"
|
||||||
|
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
const props = defineProps<{ taskId: number; assetBaseUrl?: string }>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const scores = [
|
||||||
|
{ value: 1, label: "没完成题目要求" },
|
||||||
|
{ value: 2, label: "完成了一小部分" },
|
||||||
|
{ value: 3, label: "基本完成" },
|
||||||
|
{ value: 4, label: "完成得不错" },
|
||||||
|
{ value: 5, label: "完成且有自己的想法" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const data = ref<PeerWorks | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const rating = ref<string | null>(null)
|
||||||
|
const large = ref<PeerWork | null>(null)
|
||||||
|
const scope = ref<"class" | "all">("class")
|
||||||
|
|
||||||
|
const docOf = (w: PeerWork) =>
|
||||||
|
buildPreviewDocument({ html: w.html, css: w.css, js: w.js, assetBaseUrl: props.assetBaseUrl })
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
async function load() {
|
||||||
|
if (!props.taskId) return
|
||||||
|
const s = ++seq
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await Review.peers(props.taskId, scope.value)
|
||||||
|
if (s === seq) data.value = res
|
||||||
|
} finally {
|
||||||
|
if (s === seq) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(show, (open) => open && load())
|
||||||
|
watch(scope, load)
|
||||||
|
|
||||||
|
async function rate(w: PeerWork, score: number) {
|
||||||
|
rating.value = w.id
|
||||||
|
try {
|
||||||
|
const res = await Submission.updateScore(w.id, score)
|
||||||
|
w.my_score = score
|
||||||
|
w.username = res.username ?? null
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||||
|
} finally {
|
||||||
|
rating.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.bar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work {
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 300%;
|
||||||
|
height: 300%;
|
||||||
|
border: 0;
|
||||||
|
transform: scale(0.3333);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anon {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rates {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: 600 13px var(--wp-font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate:hover:not(:disabled) {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
height: 70vh;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['panel', { wide }]">
|
||||||
|
<div class="head">
|
||||||
|
<div class="who">
|
||||||
|
<b>{{ sub?.username ?? "…" }}</b>
|
||||||
|
<span class="muted">{{ sub?.task_title }}</span>
|
||||||
|
</div>
|
||||||
|
<n-flex :size="4" :wrap="false" align="center">
|
||||||
|
<template v-if="nav">
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary :disabled="!nav.prev" @click="emit('prev')">
|
||||||
|
<Icon icon="lucide:arrow-left" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
上一个学生(J)
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary :disabled="!nav.next" @click="emit('next')">
|
||||||
|
<Icon icon="lucide:arrow-right" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
下一个学生(K)
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
|
<n-button
|
||||||
|
v-if="canWiden"
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
@click="emit('toggle-wide')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon :icon="wide ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
||||||
|
</template>
|
||||||
|
{{ wide ? "小面板" : "大图" }}
|
||||||
|
</n-button>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary @click="emit('close')">
|
||||||
|
<Icon icon="lucide:x" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ wide ? "返回作品墙(Esc)" : "关闭" }}
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-spin :show="loading" class="body">
|
||||||
|
<div v-if="sub" class="layout">
|
||||||
|
<div class="stage">
|
||||||
|
<div class="versions" v-if="versions.length > 1">
|
||||||
|
<span class="muted">共交了 {{ versions.length }} 次:</span>
|
||||||
|
<button
|
||||||
|
v-for="(v, i) in versions"
|
||||||
|
:key="v.id"
|
||||||
|
:class="['ver', { on: v.id === currentId }]"
|
||||||
|
@click="currentId = v.id"
|
||||||
|
>
|
||||||
|
第 {{ versions.length - i }} 次
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="frame">
|
||||||
|
<!-- key:换作品时换新的 iframe,不进浏览器历史 -->
|
||||||
|
<iframe :key="currentId" :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
|
||||||
|
</div>
|
||||||
|
<n-flex justify="space-between" align="center" class="meta">
|
||||||
|
<span class="muted">
|
||||||
|
{{ parseTime(sub.created, "MM-DD HH:mm") }} 提交 ·
|
||||||
|
{{ sub.score ? `得分 ${sub.score.toFixed(2)}` : "还没有人评分" }}
|
||||||
|
</span>
|
||||||
|
<n-button text size="small" @click="openFull">全屏打开</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
<div class="side">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>打分</h4>
|
||||||
|
<div v-if="sub.my_score" class="muted">你已经给了 {{ sub.my_score }} 分</div>
|
||||||
|
<div v-else class="rates">
|
||||||
|
<button
|
||||||
|
v-for="n in 5"
|
||||||
|
:key="n"
|
||||||
|
class="rate"
|
||||||
|
:disabled="rating"
|
||||||
|
@click="rate(n)"
|
||||||
|
>
|
||||||
|
{{ n }}
|
||||||
|
</button>
|
||||||
|
<span class="muted hint">数字键 1–5</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>操作</h4>
|
||||||
|
<n-flex :size="8">
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:type="sub.flag === 'present' ? 'primary' : 'default'"
|
||||||
|
@click="togglePresent"
|
||||||
|
>
|
||||||
|
{{ sub.flag === "present" ? "已加入课上展示" : "加入课上展示" }}
|
||||||
|
</n-button>
|
||||||
|
<n-dropdown
|
||||||
|
:options="awardOptions"
|
||||||
|
trigger="click"
|
||||||
|
:disabled="!awardOptions.length"
|
||||||
|
@select="addToAward"
|
||||||
|
>
|
||||||
|
<n-button size="small">加入作品展</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
</n-flex>
|
||||||
|
<div class="muted hint">P 键:加入 / 取消课上展示</div>
|
||||||
|
<n-popconfirm v-if="canDelete" @positive-click="remove">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary type="error" class="delete">删除这一版</n-button>
|
||||||
|
</template>
|
||||||
|
删除后找不回来,评分和作品展里的这一版也会一起删掉;它对应的那轮 AI 对话也会删掉。
|
||||||
|
</n-popconfirm>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="sub.reflection">
|
||||||
|
<h4>学生说</h4>
|
||||||
|
<div class="reflection">{{ sub.reflection }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<n-flex justify="space-between" align="center" class="chain-head">
|
||||||
|
<h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4>
|
||||||
|
<n-button v-if="chain.length" size="tiny" quaternary @click="showChain = true">
|
||||||
|
逐轮看网页
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
|
||||||
|
<ol class="chain">
|
||||||
|
<li v-for="(r, i) in chain" :key="i" :class="{ guide: r.source === 'guidance' }">
|
||||||
|
<n-flex :size="6" align="center">
|
||||||
|
<span class="src">{{ sourceLabel(r.source) }}</span>
|
||||||
|
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
|
||||||
|
</n-flex>
|
||||||
|
<div class="q">{{ r.question }}</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
<ChainModal
|
||||||
|
v-if="sub"
|
||||||
|
v-model:show="showChain"
|
||||||
|
:submission-id="sub.id"
|
||||||
|
:title="`${sub.username} · ${sub.task_title}`"
|
||||||
|
:asset-base-url="`/media/tasks/${sub.task_type}/${sub.task_display}/`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "../../icons"
|
||||||
|
import { Showcase, Submission } from "../../api"
|
||||||
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||||
|
import { levelLabel, parseTime, sourceLabel } from "../../utils/helper"
|
||||||
|
import ChainModal from "../submissions/ChainModal.vue"
|
||||||
|
import type {
|
||||||
|
AwardManageOut,
|
||||||
|
FlagType,
|
||||||
|
PromptRound,
|
||||||
|
SubmissionAll,
|
||||||
|
SubmissionOut,
|
||||||
|
} from "../../utils/type"
|
||||||
|
|
||||||
|
// 老师看一份提交:预览、交过的各个版本、打分、课上展示、加入作品展、提示词链
|
||||||
|
const props = defineProps<{
|
||||||
|
submissionId: string
|
||||||
|
// 大图:预览占满左边,打分和提示词放右边一栏
|
||||||
|
wide?: boolean
|
||||||
|
// 是否显示“大图 / 小面板”切换
|
||||||
|
canWiden?: boolean
|
||||||
|
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||||
|
nav?: { prev: boolean; next: boolean }
|
||||||
|
// 全部提交页(超管):能删除这一版
|
||||||
|
canDelete?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
prev: []
|
||||||
|
next: []
|
||||||
|
close: []
|
||||||
|
"toggle-wide": []
|
||||||
|
// 让作品墙同步更新这张卡片
|
||||||
|
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||||
|
deleted: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const loading = ref(false)
|
||||||
|
const currentId = ref(props.submissionId)
|
||||||
|
const sub = ref<SubmissionAll | null>(null)
|
||||||
|
const versions = ref<SubmissionOut[]>([])
|
||||||
|
const chain = ref<PromptRound[]>([])
|
||||||
|
const rating = ref(false)
|
||||||
|
const showChain = ref(false)
|
||||||
|
// 引导对话不生成网页,不算轮数
|
||||||
|
const chainSteps = computed(() => chain.value.filter((r) => r.source !== "guidance").length)
|
||||||
|
|
||||||
|
let awardsCache: Promise<AwardManageOut[]> | null = null
|
||||||
|
const awards = ref<AwardManageOut[]>([])
|
||||||
|
const awardOptions = computed(() =>
|
||||||
|
awards.value.filter((a) => a.is_active).map((a) => ({ label: a.name, key: a.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const doc = computed(() =>
|
||||||
|
sub.value
|
||||||
|
? buildPreviewDocument({
|
||||||
|
html: sub.value.html,
|
||||||
|
css: sub.value.css,
|
||||||
|
js: sub.value.js,
|
||||||
|
assetBaseUrl: `/media/tasks/${sub.value.task_type}/${sub.value.task_display}/`,
|
||||||
|
})
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
async function load(id: string) {
|
||||||
|
const s = ++seq
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data: SubmissionAll = await Submission.get(id)
|
||||||
|
if (s !== seq) return
|
||||||
|
sub.value = data
|
||||||
|
const [list, rounds] = await Promise.all([
|
||||||
|
Submission.listByUserTask(data.userid, data.task_id),
|
||||||
|
Submission.getPromptChain(id).catch(() => [] as PromptRound[]),
|
||||||
|
])
|
||||||
|
if (s !== seq) return
|
||||||
|
versions.value = list
|
||||||
|
chain.value = rounds
|
||||||
|
} finally {
|
||||||
|
if (s === seq) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.submissionId,
|
||||||
|
(id) => {
|
||||||
|
currentId.value = id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(currentId, load, { immediate: true })
|
||||||
|
|
||||||
|
async function rate(score: number) {
|
||||||
|
if (!sub.value || sub.value.my_score || rating.value) return
|
||||||
|
rating.value = true
|
||||||
|
try {
|
||||||
|
const res = await Submission.updateScore(sub.value.id, score)
|
||||||
|
sub.value.my_score = score
|
||||||
|
message.success(res.message)
|
||||||
|
// 重新取一次得分(加权平均由服务端算)
|
||||||
|
const fresh: SubmissionAll = await Submission.get(sub.value.id)
|
||||||
|
sub.value.score = fresh.score
|
||||||
|
emit("changed", { id: sub.value.id, my_score: score, score: fresh.score })
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "打分失败")
|
||||||
|
} finally {
|
||||||
|
rating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePresent() {
|
||||||
|
if (!sub.value) return
|
||||||
|
const flag: FlagType = sub.value.flag === "present" ? null : "present"
|
||||||
|
await Submission.updateFlag(sub.value.id, flag)
|
||||||
|
sub.value.flag = flag
|
||||||
|
emit("changed", { id: sub.value.id, flag })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToAward(awardId: number) {
|
||||||
|
if (!sub.value) return
|
||||||
|
try {
|
||||||
|
await Showcase.addAwardItem(awardId, { submission_id: sub.value.id, sort_order: 0 })
|
||||||
|
const name = awards.value.find((a) => a.id === awardId)?.name
|
||||||
|
message.success(`已加入作品展「${name}」`)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "加入失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!sub.value) return
|
||||||
|
const id = sub.value.id
|
||||||
|
try {
|
||||||
|
await Submission.delete(id)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success("已删除")
|
||||||
|
emit("deleted", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFull() {
|
||||||
|
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
|
||||||
|
if (/^[1-5]$/.test(e.key)) rate(Number(e.key))
|
||||||
|
else if (e.key === "p" || e.key === "P") togglePresent()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
awardsCache ??= Showcase.listManageAwards()
|
||||||
|
awards.value = await awardsCache.catch(() => [])
|
||||||
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
height: 44px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px 0 14px;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 大图:左边预览占满高度,右边一栏单独滚动 */
|
||||||
|
.wide .body {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .body :deep(.n-spin-content) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .layout {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .stage .frame {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .side {
|
||||||
|
border-left: 1px solid var(--wp-line);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver.on {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
height: 260px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rates {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate {
|
||||||
|
width: 40px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: 600 14px var(--wp-font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate:hover:not(:disabled) {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-left: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .rates {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 交的时候写的反思 */
|
||||||
|
.reflection {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-left: 3px solid var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.src {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 不是 list-item,就不占 ol 的序号 */
|
||||||
|
.chain .guide {
|
||||||
|
display: block;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
font: 600 11px var(--wp-font-mono);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,234 +2,99 @@
|
|||||||
<n-modal
|
<n-modal
|
||||||
:show="show"
|
:show="show"
|
||||||
preset="card"
|
preset="card"
|
||||||
style="width: 90vw; max-width: 1400px"
|
style="width: 92vw; max-width: 1280px"
|
||||||
@update:show="$emit('update:show', $event)"
|
:bordered="false"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-flex justify="start" align="center">
|
<div class="head">
|
||||||
<span>提示词</span>
|
<b>提示词</b>
|
||||||
<n-tooltip placement="bottom-start">
|
<span v-if="steps" class="muted">{{ steps }} 轮</span>
|
||||||
|
<span v-if="title" class="muted title">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-popover trigger="click" placement="bottom-end">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Icon icon="lucide:info" :width="20" style="color: #aaa" />
|
<n-button size="small" quaternary style="margin-right: 8px">
|
||||||
|
<template #icon><Icon icon="lucide:info" /></template>
|
||||||
|
L1–L6 是什么
|
||||||
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<div style="line-height: 2">
|
<div class="levels">
|
||||||
<div>
|
<div class="levels-note">按布鲁姆认知层级给每次提问打的标签,越高越需要思考:</div>
|
||||||
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
|
<div v-for="(l, n) in PROMPT_LEVELS" :key="n">
|
||||||
L1
|
<b>L{{ n }} {{ l.name }}</b>:{{ l.desc }}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</n-tooltip>
|
</n-popover>
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-spin :show="loading">
|
<n-spin :show="loading">
|
||||||
<n-empty
|
<n-empty
|
||||||
v-if="!loading && rounds.length === 0"
|
v-if="!loading && !rounds.length"
|
||||||
description="暂无对话记录"
|
description="这份提交没有关联的提示词"
|
||||||
|
style="padding: 80px 0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div v-else class="body">
|
||||||
v-else
|
<div class="left">
|
||||||
style="
|
<div v-if="showTrend" class="trend">
|
||||||
display: grid;
|
<span class="muted">层级</span>
|
||||||
grid-template-columns: 1fr 1fr;
|
<div class="bars">
|
||||||
gap: 16px;
|
<button
|
||||||
height: 75vh;
|
v-for="b in trend"
|
||||||
"
|
:key="b.i"
|
||||||
|
:class="['bar', { on: b.i === selected, none: !b.level }]"
|
||||||
|
:style="{ height: `${b.level ? 6 + b.level * 5 : 4}px` }"
|
||||||
|
:title="`第 ${nums[b.i]} 轮${b.level ? ' · ' + levelLabel(b.level) : ''}`"
|
||||||
|
@click="select(b.i)"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="rounds">
|
||||||
|
<li
|
||||||
|
v-for="(r, i) in rounds"
|
||||||
|
:key="i"
|
||||||
|
:class="['round', { on: i === selected, guide: isGuide(r) }]"
|
||||||
|
@click="isGuide(r) || (selected = i)"
|
||||||
>
|
>
|
||||||
<div
|
<div class="round-head">
|
||||||
style="
|
<span class="num">{{ nums[i] }}</span>
|
||||||
overflow-y: auto;
|
<span :class="['src', `src-${r.source || 'conversation'}`]">
|
||||||
padding-right: 8px;
|
{{ sourceLabel(r.source) }}
|
||||||
border-right: 1px solid #e0e0e0;
|
</span>
|
||||||
display: flex;
|
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
|
||||||
flex-direction: column;
|
<span v-if="repeats[i]" class="repeat" title="和前面某一轮的提问一字不差">重复提问</span>
|
||||||
gap: 8px;
|
<span v-if="changes[i]" class="muted">{{ changes[i] }}</span>
|
||||||
"
|
<span v-if="i === rounds.length - 1" class="final">交的这一版</span>
|
||||||
>
|
<span v-else-if="isGuide(r)" class="muted no-code">问写提示词的建议,不生成网页</span>
|
||||||
<div
|
<span v-else-if="!r.html" class="muted no-code">没有网页</span>
|
||||||
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>
|
||||||
<div
|
<div
|
||||||
:style="{
|
:class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
|
||||||
flex: 1,
|
v-html="renderMarkdown(r.question)"
|
||||||
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>
|
||||||
<div
|
<button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
|
||||||
style="
|
{{ expanded.has(i) ? "收起" : "展开全部" }}
|
||||||
display: flex;
|
</button>
|
||||||
flex-direction: row;
|
</li>
|
||||||
align-items: center;
|
</ol>
|
||||||
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 class="stage">
|
||||||
|
<div class="stage-head">
|
||||||
|
<b>第 {{ nums[selected] }} 轮生成的网页</b>
|
||||||
|
<span class="muted">↑ ↓ 切换</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="frame">
|
||||||
</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
|
<iframe
|
||||||
v-if="selectedPageHtml"
|
v-if="doc"
|
||||||
:srcdoc="selectedPageHtml"
|
:key="selected"
|
||||||
:key="selectedRound"
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
sandbox="allow-scripts"
|
:srcdoc="doc"
|
||||||
style="
|
></iframe>
|
||||||
flex: 1;
|
<n-empty v-else description="这一轮没有生成网页" style="margin: auto" />
|
||||||
border: 1px solid #e0e0e0;
|
</div>
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
@@ -237,156 +102,441 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, nextTick, onUnmounted, ref, watch } from "vue"
|
||||||
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
import { Icon } from "../../icons"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Showcase, Submission } from "../../api"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
import { storageShim } from "../../utils/previewStorage"
|
import { levelLabel, PROMPT_LEVELS, sourceLabel } from "../../utils/helper"
|
||||||
import { Prompt, Submission } from "../../api"
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||||
import type { PromptRound } from "../../utils/type"
|
import type { PromptRound } from "../../utils/type"
|
||||||
import { user, roleSuper } from "../../store/user"
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
|
// 一份提交是怎么一轮一轮问出来的:左边每轮的提问,右边这一轮生成的网页
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
submissionId: string
|
submissionId: string
|
||||||
username?: string
|
// 例如“张三 · 个人名片”
|
||||||
|
title?: string
|
||||||
|
// 题目素材的地址,网页里的图片要用
|
||||||
|
assetBaseUrl?: string
|
||||||
|
// 作品展里的作品走作品展的接口(不用是老师或作者本人)
|
||||||
|
fromShowcase?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const emit = defineEmits<{ "update:show": [value: boolean] }>()
|
||||||
const canDelete = computed(
|
|
||||||
() =>
|
|
||||||
roleSuper.value || (!!props.username && props.username === user.username),
|
|
||||||
)
|
|
||||||
|
|
||||||
defineEmits<{ "update:show": [value: boolean] }>()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectedRound = ref(0)
|
const rounds = ref<PromptRound[]>([])
|
||||||
const expandedRounds = ref<Set<number>>(new Set())
|
const selected = ref(0)
|
||||||
type ChainRound = Omit<PromptRound, "source"> & {
|
const expanded = ref(new Set<number>())
|
||||||
source: string | null
|
|
||||||
assistantMsgId: number | null
|
|
||||||
}
|
|
||||||
const rounds = ref<ChainRound[]>([])
|
|
||||||
|
|
||||||
async function deleteRound(index: number) {
|
// 引导对话(问怎么写提示词)夹在 AI 对话中间,不算轮数,也不能选中
|
||||||
const round = rounds.value[index]
|
const isGuide = (r: PromptRound) => r.source === "guidance"
|
||||||
if (!round.assistantMsgId) return
|
const steps = computed(() => rounds.value.filter((r) => !isGuide(r)).length)
|
||||||
try {
|
const nums = computed(() => {
|
||||||
await Prompt.deleteMessagePair(round.assistantMsgId)
|
let n = 0
|
||||||
} catch (error: any) {
|
return rounds.value.map((r) => (isGuide(r) ? "" : String(++n)))
|
||||||
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">${storageShim()}${style}</head><body>${round.html}${script}</body></html>`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function isPromptLong(text: string): boolean {
|
// 层级走势:每轮 AI 对话 / 外部 AI 一根柱子,两轮以上、有标过层级才显示
|
||||||
return text.length > 220 || text.split(/\r?\n/).length > 4
|
const trend = computed(() =>
|
||||||
|
rounds.value.flatMap((r, i) => (isGuide(r) ? [] : [{ i, level: r.prompt_level }])),
|
||||||
|
)
|
||||||
|
const showTrend = computed(() => trend.value.length > 1 && trend.value.some((b) => b.level))
|
||||||
|
|
||||||
|
// 和前面某一轮一字不差的提问(手改那一轮的正文都一样,不算)
|
||||||
|
const repeats = computed(() => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return rounds.value.map((r) => {
|
||||||
|
if (isGuide(r) || r.source === "edit") return false
|
||||||
|
const key = r.question.replace(/\s+/g, " ").trim()
|
||||||
|
const dup = seen.has(key)
|
||||||
|
seen.add(key)
|
||||||
|
return dup
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 这一轮的代码比上一版改了多少行:只按行内容数,不管顺序
|
||||||
|
function codeLines(r: PromptRound) {
|
||||||
|
return [r.html, r.css, r.js]
|
||||||
|
.join("\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpanded(index: number): boolean {
|
function lineDiff(before: string[], after: string[]) {
|
||||||
return expandedRounds.value.has(index)
|
const left = new Map<string, number>()
|
||||||
}
|
for (const l of before) left.set(l, (left.get(l) ?? 0) + 1)
|
||||||
|
let added = 0
|
||||||
function toggleExpanded(index: number) {
|
for (const l of after) {
|
||||||
const next = new Set(expandedRounds.value)
|
const n = left.get(l) ?? 0
|
||||||
if (next.has(index)) {
|
if (n) left.set(l, n - 1)
|
||||||
next.delete(index)
|
else added++
|
||||||
} else {
|
|
||||||
next.add(index)
|
|
||||||
}
|
}
|
||||||
expandedRounds.value = next
|
const removed = before.length - (after.length - added)
|
||||||
|
return added || removed ? `+${added} −${removed} 行` : "代码没变"
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages() {
|
const changes = computed(() => {
|
||||||
if (!props.submissionId) return
|
let prev: string[] | null = null
|
||||||
|
return rounds.value.map((r) => {
|
||||||
|
if (isGuide(r) || !r.html) return ""
|
||||||
|
const cur = codeLines(r)
|
||||||
|
const text = prev ? lineDiff(prev, cur) : ""
|
||||||
|
prev = cur
|
||||||
|
return text
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const doc = computed(() => {
|
||||||
|
const r = rounds.value[selected.value]
|
||||||
|
if (!r?.html) return ""
|
||||||
|
return buildPreviewDocument({
|
||||||
|
html: r.html,
|
||||||
|
css: r.css ?? "",
|
||||||
|
js: r.js ?? "",
|
||||||
|
assetBaseUrl: props.assetBaseUrl,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function isLong(text: string) {
|
||||||
|
return text.length > 200 || text.split(/\r?\n/).length > 4
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(i: number) {
|
||||||
|
const next = new Set(expanded.value)
|
||||||
|
if (!next.delete(i)) next.add(i)
|
||||||
|
expanded.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
async function load() {
|
||||||
|
const s = ++seq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
rounds.value = []
|
rounds.value = []
|
||||||
selectedRound.value = 0
|
expanded.value = new Set()
|
||||||
expandedRounds.value = new Set()
|
|
||||||
try {
|
try {
|
||||||
const data = await Submission.getPromptChain(props.submissionId)
|
const data = props.fromShowcase
|
||||||
rounds.value = data.map((round) => ({
|
? await Showcase.getPromptChain(props.submissionId)
|
||||||
...round,
|
: await Submission.getPromptChain(props.submissionId)
|
||||||
source: round.source ?? null,
|
if (s !== seq) return
|
||||||
assistantMsgId: round.assistant_msg_id ?? null,
|
rounds.value = data
|
||||||
}))
|
// 默认看交的那一版
|
||||||
const last = rounds.value.length - 1
|
selected.value = Math.max(0, data.length - 1)
|
||||||
if (last >= 0) selectedRound.value = last
|
await nextTick()
|
||||||
|
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" })
|
||||||
} catch {
|
} catch {
|
||||||
rounds.value = []
|
if (s === seq) rounds.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (s === seq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function select(i: number) {
|
||||||
|
selected.value = i
|
||||||
|
nextTick(() =>
|
||||||
|
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在捕获阶段接管按键:弹框开着时,批改页的 Esc / J / K、批改面板的 1–5 / P 都不能触发
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (!props.show) return
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
emit("update:show", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0
|
||||||
|
if (!step || !rounds.value.length) return
|
||||||
|
e.preventDefault()
|
||||||
|
let i = selected.value + step
|
||||||
|
while (rounds.value[i] && isGuide(rounds.value[i]!)) i += step
|
||||||
|
if (rounds.value[i]) select(i)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.show, props.submissionId] as const,
|
() => [props.show, props.submissionId] as const,
|
||||||
([visible]) => {
|
([visible]) => {
|
||||||
if (visible) loadMessages()
|
if (visible) {
|
||||||
|
load()
|
||||||
|
window.addEventListener("keydown", onKey, true)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener("keydown", onKey, true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
onUnmounted(() => window.removeEventListener("keydown", onKey, true))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.prompt-markdown {
|
.head {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown.is-collapsed {
|
.title {
|
||||||
position: relative;
|
white-space: nowrap;
|
||||||
max-height: 126px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(p),
|
.muted {
|
||||||
.prompt-markdown :deep(ul),
|
font-size: 12px;
|
||||||
.prompt-markdown :deep(ol),
|
font-weight: 400;
|
||||||
.prompt-markdown :deep(blockquote),
|
color: var(--wp-muted);
|
||||||
.prompt-markdown :deep(pre) {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(:last-child) {
|
.levels {
|
||||||
margin-bottom: 0;
|
font-size: 13px;
|
||||||
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(pre) {
|
.levels-note {
|
||||||
padding: 8px;
|
color: var(--wp-muted);
|
||||||
overflow-x: auto;
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 2fr) 3fr;
|
||||||
|
gap: 16px;
|
||||||
|
height: 72vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 6px 6px 2px;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars {
|
||||||
|
flex: 1;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: var(--wp-faint);
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.none {
|
||||||
|
background: var(--wp-line);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:hover,
|
||||||
|
.bar.on {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.on {
|
||||||
|
background: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounds {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 6px 0 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round:hover {
|
||||||
|
border-color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round.on {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-markdown :deep(code) {
|
.num {
|
||||||
white-space: pre-wrap;
|
min-width: 18px;
|
||||||
|
font: 600 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round.on .num {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.src,
|
||||||
|
.level,
|
||||||
|
.repeat,
|
||||||
|
.final {
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.src {
|
||||||
|
color: var(--wp-css);
|
||||||
|
background: var(--wp-css-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.src-edit {
|
||||||
|
color: var(--wp-js);
|
||||||
|
background: var(--wp-js-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.src-manual {
|
||||||
|
color: var(--wp-html);
|
||||||
|
background: var(--wp-html-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.src-guidance {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round.guide {
|
||||||
|
border-style: dashed;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round.guide:hover {
|
||||||
|
border-color: var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round.guide .q {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repeat {
|
||||||
|
color: var(--wp-warn);
|
||||||
|
background: var(--wp-warn-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
color: var(--wp-ink);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.final {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-code {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.q.clamp {
|
||||||
|
max-height: 84px;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-mask-image: linear-gradient(#000 55%, transparent);
|
||||||
|
mask-image: linear-gradient(#000 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q :deep(p),
|
||||||
|
.q :deep(ul),
|
||||||
|
.q :deep(ol),
|
||||||
|
.q :deep(pre),
|
||||||
|
.q :deep(blockquote) {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q :deep(:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q :deep(pre) {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q :deep(code) {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame iframe {
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,78 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex vertical>
|
<n-button @click="show = true">
|
||||||
<n-button size="small" @click="showUpload = true">图片素材</n-button>
|
图片素材{{ assets.length ? `(${assets.length})` : "" }}
|
||||||
<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-button>
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-modal
|
<n-modal v-model:show="show" preset="card" title="图片素材" style="width: min(720px, 92vw)">
|
||||||
v-model:show="showUpload"
|
<p class="hint">
|
||||||
preset="card"
|
题目和学生的网页里直接写文件名就能用,例如
|
||||||
title="上传素材"
|
<code><img src="{{ assets[0]?.name ?? "1.png" }}"></code>。
|
||||||
style="width: 400px"
|
上传同名文件会替换原来的。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label
|
||||||
|
:class="['drop', { over: dragging }]"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
>
|
>
|
||||||
<n-form>
|
<input type="file" accept="image/*" multiple hidden @change="onPick" />
|
||||||
<n-form-item label="文件名(如 1.png)">
|
<Icon icon="lucide:image" :width="22" />
|
||||||
<n-input v-model:value="uploadName" placeholder="1.png" />
|
<span v-if="uploading">正在上传…</span>
|
||||||
</n-form-item>
|
<span v-else>点这里选图片,或者把图片拖进来(可以一次选多张)</span>
|
||||||
<n-form-item label="选择文件">
|
</label>
|
||||||
<n-upload :max="1" @change="onFileChange" :default-upload="false">
|
|
||||||
<n-button>选择文件</n-button>
|
<div v-if="assets.length" class="grid">
|
||||||
</n-upload>
|
<div v-for="a in assets" :key="a.name" class="asset">
|
||||||
</n-form-item>
|
<div class="img">
|
||||||
</n-form>
|
<img :src="a.url" :alt="a.name" loading="lazy" />
|
||||||
<template #footer>
|
</div>
|
||||||
<n-flex justify="end">
|
<div class="name" :title="a.name">{{ a.name }}</div>
|
||||||
<n-button @click="showUpload = false">取消</n-button>
|
<div class="tools">
|
||||||
<n-button
|
<n-button size="tiny" quaternary @click="copyName(a.name)">复制文件名</n-button>
|
||||||
type="primary"
|
<n-popconfirm @positive-click="remove(a.name)">
|
||||||
:disabled="!uploadName || !uploadFile"
|
<template #trigger>
|
||||||
:loading="uploading"
|
<n-button size="tiny" quaternary type="error">删除</n-button>
|
||||||
@click="upload"
|
|
||||||
>
|
|
||||||
上传
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
|
删除 {{ a.name }}?用到它的网页里图片会显示不出来。
|
||||||
|
</n-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-empty v-else description="还没有素材" style="margin: 24px 0 8px" />
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import type { UploadFileInfo } from "naive-ui"
|
import copy from "copy-text-to-clipboard"
|
||||||
|
import { Icon } from "../../icons"
|
||||||
import { TaskAssets } from "../../api"
|
import { TaskAssets } from "../../api"
|
||||||
import type { TaskAsset } from "../../utils/type"
|
import type { TaskAsset } from "../../utils/type"
|
||||||
|
|
||||||
|
// 出题页的图片素材:存在 /media/tasks/<类型>/<序号>/ 下,预览时那里是网页的 base 地址
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskType: "challenge" | "tutorial"
|
taskType: "challenge" | "tutorial"
|
||||||
display: number
|
display: number
|
||||||
@@ -80,10 +59,9 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const assets = ref<TaskAsset[]>([])
|
const assets = ref<TaskAsset[]>([])
|
||||||
const showUpload = ref(false)
|
const show = ref(false)
|
||||||
const uploadName = ref("")
|
|
||||||
const uploadFile = ref<File | null>(null)
|
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!props.display) return
|
if (!props.display) return
|
||||||
@@ -97,40 +75,56 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileChange({ file }: { file: UploadFileInfo }) {
|
// 和服务端的 validFilename 一样:空格换成下划线,只留字母(含中文)、数字和 - _ .
|
||||||
if (file.status === "pending") {
|
function cleanName(name: string) {
|
||||||
uploadFile.value = file.file ?? null
|
return name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "").slice(-100)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload() {
|
async function uploadAll(files: File[]) {
|
||||||
if (!uploadName.value || !uploadFile.value) return
|
const images = files.filter((f) => f.type.startsWith("image/"))
|
||||||
|
if (images.length < files.length) message.warning("只能上传图片,其他文件跳过了")
|
||||||
|
if (!images.length) return
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
|
let done = 0
|
||||||
|
for (const file of images) {
|
||||||
|
const name = cleanName(file.name)
|
||||||
|
if (!name || name.startsWith(".")) {
|
||||||
|
message.error(`文件名“${file.name}”不能用,请改个名字再传`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const asset =
|
const asset =
|
||||||
props.taskType === "challenge"
|
props.taskType === "challenge"
|
||||||
? await TaskAssets.uploadChallenge(
|
? await TaskAssets.uploadChallenge(props.display, name, file)
|
||||||
props.display,
|
: await TaskAssets.uploadTutorial(props.display, name, file)
|
||||||
uploadName.value,
|
const i = assets.value.findIndex((a) => a.name === asset.name)
|
||||||
uploadFile.value,
|
// 同名替换后浏览器可能还用着旧图的缓存
|
||||||
)
|
const fresh = { ...asset, url: `${asset.url}?t=${Date.now()}` }
|
||||||
: await TaskAssets.uploadTutorial(
|
if (i >= 0) assets.value[i] = fresh
|
||||||
props.display,
|
else assets.value.push(fresh)
|
||||||
uploadName.value,
|
done++
|
||||||
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) {
|
} catch (err: any) {
|
||||||
message.error(err?.response?.data?.detail ?? "上传失败")
|
message.error(`${name}:${err?.response?.data?.detail ?? "上传失败"}`)
|
||||||
} finally {
|
|
||||||
uploading.value = false
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
uploading.value = false
|
||||||
|
if (done) message.success(`上传了 ${done} 张`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPick(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
uploadAll([...(input.files ?? [])])
|
||||||
|
input.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
dragging.value = false
|
||||||
|
uploadAll([...(e.dataTransfer?.files ?? [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyName(name: string) {
|
||||||
|
copy(name)
|
||||||
|
message.success(`已复制 ${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(name: string) {
|
async function remove(name: string) {
|
||||||
@@ -139,7 +133,7 @@ async function remove(name: string) {
|
|||||||
? await TaskAssets.deleteChallenge(props.display, name)
|
? await TaskAssets.deleteChallenge(props.display, name)
|
||||||
: await TaskAssets.deleteTutorial(props.display, name)
|
: await TaskAssets.deleteTutorial(props.display, name)
|
||||||
assets.value = assets.value.filter((a) => a.name !== name)
|
assets.value = assets.value.filter((a) => a.name !== name)
|
||||||
message.success("删除成功")
|
message.success("已删除")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.response?.data?.detail ?? "删除失败")
|
message.error(err?.response?.data?.detail ?? "删除失败")
|
||||||
}
|
}
|
||||||
@@ -147,3 +141,85 @@ async function remove(name: string) {
|
|||||||
|
|
||||||
watch(() => props.display, load, { immediate: true })
|
watch(() => props.display, load, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint code {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px dashed var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
background: var(--wp-paper);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop:hover,
|
||||||
|
.drop.over {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset {
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
height: 100px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
/* 透明图片放在浅色底上才看得出边界 */
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
padding: 6px 8px 0;
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 4px 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,99 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<n-flex align="center" justify="space-between" class="title">
|
<n-flex align="center" justify="space-between" class="title" :wrap="false">
|
||||||
<n-flex align="center">
|
<n-flex align="center" :wrap="false">
|
||||||
<Icon
|
<span class="panel-name">教程</span>
|
||||||
: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">
|
<template v-if="!hideNav">
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button text @click="prev()" :disabled="prevDisabled()">
|
<n-button text @click="prev()" :disabled="prevDisabled()">
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
|
<Icon :width="18" icon="lucide:arrow-left" />
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
|
上一节
|
||||||
|
</n-tooltip>
|
||||||
<span v-if="progressText" class="progress-text">
|
<span v-if="progressText" class="progress-text">
|
||||||
{{ progressText }}
|
{{ progressText }}
|
||||||
</span>
|
</span>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button text @click="next()" :disabled="nextDisabled()">
|
<n-button text @click="next()" :disabled="nextDisabled()">
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
|
<Icon :width="18" icon="lucide:arrow-right" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
下一节
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex>
|
<n-flex align="center" :wrap="false">
|
||||||
<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
|
<n-button
|
||||||
text
|
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
|
||||||
@click="
|
size="small"
|
||||||
$router.push({ name: 'submissions', params: { page: 1 } })
|
quaternary
|
||||||
"
|
@click="showAssets = true"
|
||||||
>
|
>
|
||||||
<Icon :width="16" icon="lucide:list"></Icon>
|
<template #icon><Icon icon="lucide:image" /></template>
|
||||||
|
素材
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
<n-button
|
||||||
提交记录
|
v-if="authed && taskTab === TASK_TYPE.Tutorial && taskId"
|
||||||
</n-tooltip>
|
size="small"
|
||||||
<n-tooltip v-if="authed" trigger="hover">
|
quaternary
|
||||||
<template #trigger>
|
@click="showPeers = true"
|
||||||
<n-button text @click="$router.push({ name: 'creation-list' })">
|
>
|
||||||
<Icon :width="16" icon="lucide:sparkles"></Icon>
|
作品
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
<n-button v-if="roleSuper" size="small" quaternary @click="edit">
|
||||||
自由创作
|
<template #icon><Icon icon="lucide:edit" /></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-button>
|
||||||
<n-tooltip trigger="hover">
|
<n-tooltip>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button text @click="$emit('hide')">
|
<n-button text @click="$emit('hide')">
|
||||||
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
|
<Icon :width="18" icon="lucide:x" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
关闭
|
收起教程,编辑器占满屏幕
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
|
<TutorialContent />
|
||||||
<ChallengeList v-else />
|
|
||||||
</div>
|
</div>
|
||||||
|
<PeersDrawer
|
||||||
|
v-model:show="showPeers"
|
||||||
|
:task-id="taskId"
|
||||||
|
:asset-base-url="`/media/tasks/tutorial/${step}/`"
|
||||||
|
/>
|
||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="showAssets"
|
v-model:show="showAssets"
|
||||||
preset="card"
|
preset="card"
|
||||||
@@ -119,7 +89,7 @@
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../../icons"
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import {
|
import {
|
||||||
step,
|
step,
|
||||||
@@ -130,16 +100,17 @@ import {
|
|||||||
nextDisabled,
|
nextDisabled,
|
||||||
} from "../../store/tutorial"
|
} from "../../store/tutorial"
|
||||||
import { authed, roleSuper } from "../../store/user"
|
import { authed, roleSuper } from "../../store/user"
|
||||||
|
import PeersDrawer from "../review/PeersDrawer.vue"
|
||||||
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { TASK_TYPE } from "../../utils/const"
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
import { TaskAssets } from "../../api"
|
import { TaskAssets } from "../../api"
|
||||||
import type { TaskAsset } from "../../utils/type"
|
import type { TaskAsset } from "../../utils/type"
|
||||||
import ChallengeList from "./ChallengeList.vue"
|
|
||||||
import TutorialContent from "./TutorialContent.vue"
|
import TutorialContent from "./TutorialContent.vue"
|
||||||
|
|
||||||
const tutorialAssets = ref<TaskAsset[]>([])
|
const tutorialAssets = ref<TaskAsset[]>([])
|
||||||
const showAssets = ref(false)
|
const showAssets = ref(false)
|
||||||
|
const showPeers = ref(false)
|
||||||
|
|
||||||
async function loadTutorialAssets(display: number) {
|
async function loadTutorialAssets(display: number) {
|
||||||
if (!display) {
|
if (!display) {
|
||||||
@@ -159,14 +130,11 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 路由同步:初始执行 + watch 响应 SPA 内部导航
|
// 路由同步:初始执行 + watch 响应 SPA 内部导航
|
||||||
|
// 这个面板只用在教程页(挑战有自己的页面)
|
||||||
function syncRoute(routeName: string) {
|
function syncRoute(routeName: string) {
|
||||||
if (routeName.startsWith("home-tutorial")) {
|
|
||||||
taskTab.value = TASK_TYPE.Tutorial
|
taskTab.value = TASK_TYPE.Tutorial
|
||||||
if (route.params.display) step.value = Number(route.params.display)
|
if (routeName.startsWith("home-tutorial") && route.params.display) {
|
||||||
} else if (routeName.startsWith("home-challenge")) {
|
step.value = Number(route.params.display)
|
||||||
taskTab.value = TASK_TYPE.Challenge
|
|
||||||
if (route.params.display)
|
|
||||||
challengeDisplay.value = Number(route.params.display)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
syncRoute(route.name as string)
|
syncRoute(route.name as string)
|
||||||
@@ -178,9 +146,7 @@ watch(
|
|||||||
|
|
||||||
defineEmits(["hide"])
|
defineEmits(["hide"])
|
||||||
|
|
||||||
const hideNav = computed(
|
const hideNav = computed(() => tutorialIds.value.length <= 1)
|
||||||
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
const progressText = computed(() => {
|
const progressText = computed(() => {
|
||||||
const ids = tutorialIds.value
|
const ids = tutorialIds.value
|
||||||
@@ -189,20 +155,6 @@ const progressText = computed(() => {
|
|||||||
return i === -1 ? "" : `${i + 1} / ${ids.length}`
|
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() {
|
function edit() {
|
||||||
const name =
|
const name =
|
||||||
taskTab.value === TASK_TYPE.Tutorial
|
taskTab.value === TASK_TYPE.Tutorial
|
||||||
@@ -222,15 +174,22 @@ function edit() {
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
height: 43px;
|
height: 43px;
|
||||||
padding: 0 20px;
|
padding: 0 12px 0 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid rgb(239, 239, 245);
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
}
|
||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: var(--wp-faint);
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ import { trustedExtensions } from "../../utils/markdown"
|
|||||||
import copyFn from "copy-text-to-clipboard"
|
import copyFn from "copy-text-to-clipboard"
|
||||||
import { css, html, js, tab } from "../../store/editors"
|
import { css, html, js, tab } from "../../store/editors"
|
||||||
import { Tutorial } from "../../api"
|
import { Tutorial } from "../../api"
|
||||||
|
import { readTaskCode } from "../../composables/useTaskCode"
|
||||||
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
||||||
import { taskId, assetBaseUrl } from "../../store/task"
|
import { taskId, assetBaseUrl } from "../../store/task"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
@@ -45,11 +46,13 @@ async function render() {
|
|||||||
const seq = ++renderSeq
|
const seq = ++renderSeq
|
||||||
const data = await Tutorial.get(step.value)
|
const data = await Tutorial.get(step.value)
|
||||||
if (seq !== renderSeq) return
|
if (seq !== renderSeq) return
|
||||||
|
// 这一节写过代码就接着写,没写过才放示例代码(以前每次切换都会被示例覆盖)
|
||||||
|
const saved = readTaskCode(data.task_ptr)
|
||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||||
html.value = data.example_html ?? ""
|
html.value = saved?.html ?? data.example_html ?? ""
|
||||||
css.value = data.example_css ?? ""
|
css.value = saved?.css ?? data.example_css ?? ""
|
||||||
js.value = data.example_js ?? ""
|
js.value = saved?.js ?? data.example_js ?? ""
|
||||||
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||||
const rendered = await tutorialMarked.parse(merged, { async: true })
|
const rendered = await tutorialMarked.parse(merged, { async: true })
|
||||||
if (seq !== renderSeq) return
|
if (seq !== renderSeq) return
|
||||||
@@ -119,12 +122,12 @@ watch(step, (v) => {
|
|||||||
.markdown-body pre code {
|
.markdown-body pre code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: Monaco;
|
font-family: var(--wp-font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .codeblock-wrapper {
|
.markdown-body .codeblock-wrapper {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: #f6f8fa;
|
background-color: var(--wp-paper);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -170,15 +173,15 @@ watch(step, (v) => {
|
|||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid var(--wp-line);
|
||||||
background-color: #fff;
|
background-color: var(--wp-surface);
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeblock-action .action-btn:hover {
|
.codeblock-action .action-btn:hover {
|
||||||
border-color: #18a058;
|
border-color: var(--wp-brand);
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<n-tabs
|
||||||
|
v-model:value="tab"
|
||||||
|
type="card"
|
||||||
|
size="small"
|
||||||
|
class="code-tabs"
|
||||||
|
:class="`tab-active-${tab}`"
|
||||||
|
pane-class="code-pane"
|
||||||
|
>
|
||||||
|
<template #prefix><slot name="prefix" /></template>
|
||||||
|
<n-tab-pane name="html">
|
||||||
|
<template #tab><span class="lang-tab lang-html">HTML</span></template>
|
||||||
|
<Editor v-model:value="code.html" :font-size="size" language="html" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="css">
|
||||||
|
<template #tab><span class="lang-tab lang-css">CSS</span></template>
|
||||||
|
<Editor v-model:value="code.css" :font-size="size" language="css" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="js">
|
||||||
|
<template #tab><span class="lang-tab lang-js">JS</span></template>
|
||||||
|
<Editor v-model:value="code.js" :font-size="size" language="js" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<template #suffix>
|
||||||
|
<n-flex align="center" :wrap="false" :size="4" class="suffix">
|
||||||
|
<n-dropdown :options="sizeOptions" @select="(v: number) => (size = v)">
|
||||||
|
<n-button quaternary size="small">字号 {{ size }}</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
<slot name="suffix" />
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-tabs>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from "vue"
|
||||||
|
import Editor from "../editor/Editor.vue"
|
||||||
|
import { size } from "../../store/editors"
|
||||||
|
import type { Code } from "../../utils/code"
|
||||||
|
|
||||||
|
// 直接改传进来的响应式对象,工作台按任务保存它
|
||||||
|
defineProps<{ code: Code }>()
|
||||||
|
|
||||||
|
const tab = ref<"html" | "css" | "js">("html")
|
||||||
|
const sizeOptions = [12, 14, 16, 18, 20, 24, 28, 32].map((n) => ({
|
||||||
|
label: `${n}`,
|
||||||
|
key: n,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.code-tabs {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-tabs :deep(.n-tabs-pane-wrapper) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-tabs :deep(.code-pane) {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-tabs :deep(.n-tabs-nav) {
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-tabs :deep(.n-tabs-tab[data-name="html"]) {
|
||||||
|
background-color: var(--wp-html-soft) !important;
|
||||||
|
}
|
||||||
|
.code-tabs :deep(.n-tabs-tab[data-name="css"]) {
|
||||||
|
background-color: var(--wp-css-soft) !important;
|
||||||
|
}
|
||||||
|
.code-tabs :deep(.n-tabs-tab[data-name="js"]) {
|
||||||
|
background-color: var(--wp-js-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="show"
|
||||||
|
preset="card"
|
||||||
|
title="粘贴外部 AI 生成的代码"
|
||||||
|
style="width: 640px; max-width: 94vw"
|
||||||
|
>
|
||||||
|
<n-form-item
|
||||||
|
label="你在外部 AI 里说了什么"
|
||||||
|
:validation-status="promptTooShort ? 'warning' : undefined"
|
||||||
|
:feedback="promptTooShort ? `再写具体一点:你让 AI 做了什么、改了什么(至少 ${MIN_PROMPT} 个字)` : ''"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
v-model:value="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||||
|
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里;说了好几轮的,每一轮都贴进来,一轮一行"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
|
||||||
|
<n-input
|
||||||
|
v-model:value="raw"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 8, maxRows: 16 }"
|
||||||
|
placeholder="粘贴整页 HTML,里面的 <style> 和 <script> 会自动拆到 CSS 和 JS"
|
||||||
|
class="code-input"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-text depth="3" class="hint">
|
||||||
|
保存后会成为一个新版本,提示词会记进你的提示词记录里。
|
||||||
|
</n-text>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="show = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="[...prompt.trim()].length < MIN_PROMPT || !raw.trim()"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
存为新版本
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { splitHtml, type Code } from "../../utils/code"
|
||||||
|
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
const props = defineProps<{
|
||||||
|
onSave: (prompt: string, code: Code) => Promise<void>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const prompt = ref("")
|
||||||
|
// 以前四分之一的外部提示词只有“1”“倒计时”这样几个字,看不出学生想了什么
|
||||||
|
const MIN_PROMPT = 8
|
||||||
|
const promptTooShort = computed(() => {
|
||||||
|
const n = [...prompt.value.trim()].length
|
||||||
|
return n > 0 && n < MIN_PROMPT
|
||||||
|
})
|
||||||
|
const raw = ref("")
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await props.onSave(prompt.value.trim(), splitHtml(raw.value))
|
||||||
|
prompt.value = ""
|
||||||
|
raw.value = ""
|
||||||
|
show.value = false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.code-input :deep(textarea) {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="!!(current || revealed)"
|
||||||
|
preset="card"
|
||||||
|
:title="done ? 'AI 写好了' : '等 AI 写代码的时候,给同学的作品打个分'"
|
||||||
|
:closable="false"
|
||||||
|
:mask-closable="false"
|
||||||
|
:close-on-esc="false"
|
||||||
|
:auto-focus="false"
|
||||||
|
style="width: 90vw; max-width: 1000px"
|
||||||
|
>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button v-if="done" type="primary" size="small" @click="close"
|
||||||
|
>看结果</n-button
|
||||||
|
>
|
||||||
|
<n-button v-else-if="current" text size="small" @click="next(true)"
|
||||||
|
>跳过这份</n-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<div v-if="revealed" class="revealed">
|
||||||
|
刚才那份是 <b>{{ revealed }}</b> 的作品,谢谢你的评分
|
||||||
|
</div>
|
||||||
|
<template v-else-if="current">
|
||||||
|
<div class="task">{{ current.task_title }}</div>
|
||||||
|
<div class="large">
|
||||||
|
<iframe
|
||||||
|
:key="current.submission_id"
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="doc"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="rates">
|
||||||
|
<button
|
||||||
|
v-for="s in scores"
|
||||||
|
:key="s.value"
|
||||||
|
class="rate"
|
||||||
|
:disabled="rating"
|
||||||
|
@click="rate(s.value)"
|
||||||
|
>
|
||||||
|
<b>{{ s.value }}</b> {{ s.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
import {
|
||||||
|
buildPreviewDocument,
|
||||||
|
PREVIEW_SANDBOX,
|
||||||
|
} from "../../utils/previewDocument"
|
||||||
|
import type { RandomRatingItem } from "../../utils/type"
|
||||||
|
|
||||||
|
// AI 生成期间弹框遮住页面,让学生给一份同学的作品打分(学生只关心结果,不用盯着代码一行行出来)。
|
||||||
|
// 打分前不显示作者,减少给熟人打高分。AI 写完时正在看的这份不收走,换成“看结果”按钮
|
||||||
|
const props = defineProps<{ active: boolean }>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const scores = [
|
||||||
|
{ value: 1, label: "没完成题目要求" },
|
||||||
|
{ value: 2, label: "完成了一小部分" },
|
||||||
|
{ value: 3, label: "基本完成" },
|
||||||
|
{ value: 4, label: "完成得不错" },
|
||||||
|
{ value: 5, label: "完成且有自己的想法" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const current = ref<RandomRatingItem | null>(null)
|
||||||
|
const revealed = ref("")
|
||||||
|
const rating = ref(false)
|
||||||
|
const done = ref(false)
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
const doc = computed(() =>
|
||||||
|
current.value
|
||||||
|
? buildPreviewDocument({
|
||||||
|
html: current.value.html ?? "",
|
||||||
|
css: current.value.css ?? "",
|
||||||
|
js: current.value.js ?? "",
|
||||||
|
})
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
async function next(skip = false) {
|
||||||
|
const s = ++seq
|
||||||
|
const exclude = skip ? current.value?.submission_id : undefined
|
||||||
|
try {
|
||||||
|
const item = await Submission.randomForRating(exclude)
|
||||||
|
// 请求期间 AI 已经回复完了,就不要再显示
|
||||||
|
if (s !== seq || !props.active) return
|
||||||
|
current.value = item
|
||||||
|
} catch {
|
||||||
|
if (s === seq) current.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
seq++
|
||||||
|
current.value = null
|
||||||
|
revealed.value = ""
|
||||||
|
done.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rate(score: number) {
|
||||||
|
if (!current.value) return
|
||||||
|
rating.value = true
|
||||||
|
try {
|
||||||
|
const res = await Submission.updateScore(current.value.submission_id, score)
|
||||||
|
revealed.value = res.username ?? "同学"
|
||||||
|
current.value = null
|
||||||
|
const s = seq
|
||||||
|
setTimeout(() => {
|
||||||
|
if (s !== seq) return
|
||||||
|
revealed.value = ""
|
||||||
|
if (props.active) next()
|
||||||
|
else done.value = false
|
||||||
|
}, 1800)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||||
|
} finally {
|
||||||
|
rating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(active) => {
|
||||||
|
if (active) {
|
||||||
|
done.value = false
|
||||||
|
next()
|
||||||
|
} else if (current.value) {
|
||||||
|
seq++
|
||||||
|
done.value = true
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.task {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
height: 60vh;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rates {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: 13px var(--wp-font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate b {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate:hover:not(:disabled) {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revealed {
|
||||||
|
padding: 48px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="show"
|
||||||
|
preset="card"
|
||||||
|
:title="title"
|
||||||
|
style="width: 520px; max-width: 94vw"
|
||||||
|
:mask-closable="!saving"
|
||||||
|
>
|
||||||
|
<div class="question">
|
||||||
|
{{ hasEarlier ? "比起第一版,这一版你最满意的改动是什么?为什么这样改?" : "这一版你最满意的地方是什么?为什么?" }}
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="reflection"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||||
|
:maxlength="500"
|
||||||
|
placeholder="比如:我把按钮从五个减到三个,因为按钮太多了看不出先点哪个"
|
||||||
|
/>
|
||||||
|
<n-text depth="3" class="hint">可以不写,直接交。写了的话,老师批改时能看到。</n-text>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button :disabled="saving" @click="show = false">取消</n-button>
|
||||||
|
<n-button type="primary" :loading="saving" @click="save">交</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
// 交作品前问一句反思:最满意的改动和理由。不拦着交,只是让学生回头看一眼自己改了什么
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
// 之前还有别的版本,就问“比起第一版”
|
||||||
|
hasEarlier: boolean
|
||||||
|
onSubmit: (reflection: string) => Promise<void>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const reflection = ref("")
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await props.onSubmit(reflection.value.trim())
|
||||||
|
reflection.value = ""
|
||||||
|
show.value = false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.question {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<div class="version-bar">
|
||||||
|
<span class="label">版本</span>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-if="!versions.length" class="empty">
|
||||||
|
还没有版本:让 AI 生成,或者在“代码”里自己写
|
||||||
|
</span>
|
||||||
|
<n-tooltip v-for="v in versions" :key="v.id">
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
:class="['chip', { on: v.id === selectedId && !modified }]"
|
||||||
|
@click="emit('select', v.id)"
|
||||||
|
>
|
||||||
|
v{{ v.n }}
|
||||||
|
<span v-if="v.source === 'edit'" class="src">手改</span>
|
||||||
|
<span v-else-if="v.source === 'manual'" class="src">外部</span>
|
||||||
|
<Icon v-if="v.submitted" icon="lucide:check" :width="12" class="ok" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ describe(v) }}
|
||||||
|
</n-tooltip>
|
||||||
|
<span v-if="modified" class="chip on draft">当前修改</span>
|
||||||
|
</div>
|
||||||
|
<n-button quaternary size="small" @click="emit('history')">全部历史</n-button>
|
||||||
|
<template v-if="kind === 'challenge'">
|
||||||
|
<span v-if="submittedLabel" class="hint">{{ submittedLabel }}</span>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!canSubmit || selectedSubmitted"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('submit')"
|
||||||
|
>
|
||||||
|
{{ modified ? "交当前修改" : selectedSubmitted ? "已交这一版" : "交这一版" }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<n-button
|
||||||
|
v-else
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('submit')"
|
||||||
|
>
|
||||||
|
保存作品
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { Icon } from "../../icons"
|
||||||
|
|
||||||
|
export interface VersionItem {
|
||||||
|
id: number
|
||||||
|
n: number
|
||||||
|
source: string
|
||||||
|
submitted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
kind: "challenge" | "creation"
|
||||||
|
versions: VersionItem[]
|
||||||
|
selectedId: number | null
|
||||||
|
modified: boolean
|
||||||
|
canSubmit: boolean
|
||||||
|
busy: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ select: [id: number]; submit: []; history: [] }>()
|
||||||
|
|
||||||
|
const selectedSubmitted = computed(
|
||||||
|
() =>
|
||||||
|
!props.modified &&
|
||||||
|
!!props.versions.find((v) => v.id === props.selectedId)?.submitted,
|
||||||
|
)
|
||||||
|
|
||||||
|
const submittedLabel = computed(() => {
|
||||||
|
const done = props.versions.filter((v) => v.submitted).map((v) => `v${v.n}`)
|
||||||
|
if (done.length > 3) return `已交 ${done.length} 个版本`
|
||||||
|
return done.length ? `已交 ${done.join("、")}` : ""
|
||||||
|
})
|
||||||
|
|
||||||
|
function describe(v: VersionItem) {
|
||||||
|
const from =
|
||||||
|
v.source === "edit" ? "你手改的" : v.source === "manual" ? "外部 AI 生成的" : "AI 生成的"
|
||||||
|
return `${from}第 ${v.n} 版${v.submitted ? ",已提交" : ""},点击查看`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.version-bar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: 12px/20px var(--wp-font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.draft {
|
||||||
|
cursor: default;
|
||||||
|
border-style: dashed;
|
||||||
|
font-family: var(--wp-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.src {
|
||||||
|
font-family: var(--wp-font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty,
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
<template>
|
||||||
|
<n-split
|
||||||
|
class="workbench"
|
||||||
|
:default-size="0.38"
|
||||||
|
min="360px"
|
||||||
|
:max="0.62"
|
||||||
|
>
|
||||||
|
<template #1>
|
||||||
|
<div class="left">
|
||||||
|
<div class="left-head">
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="$router.push(backTo)">
|
||||||
|
<Icon icon="lucide:arrow-left" :width="18" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ backLabel }}
|
||||||
|
</n-tooltip>
|
||||||
|
<span class="title">{{ title }}</span>
|
||||||
|
<slot name="badges" />
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<slot name="tools" />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.brief" class="brief">
|
||||||
|
<button class="brief-toggle" @click="briefOpen = !briefOpen">
|
||||||
|
<b>{{ briefLabel }}</b>
|
||||||
|
<span>{{ briefOpen ? "收起" : "展开" }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-show="briefOpen" class="brief-body">
|
||||||
|
<slot name="brief" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PromptPanel
|
||||||
|
class="chat"
|
||||||
|
:before-send="beforeSend"
|
||||||
|
:rating="kind === 'challenge'"
|
||||||
|
@deleted="historyKey++"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #2>
|
||||||
|
<div class="right">
|
||||||
|
<div class="right-body">
|
||||||
|
<div v-if="mode === 'preview'" class="preview-only">
|
||||||
|
<Preview
|
||||||
|
:html="code.html"
|
||||||
|
:css="code.css"
|
||||||
|
:js="code.js"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
:storage-task-id="taskId"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<n-radio-group v-model:value="mode" size="small">
|
||||||
|
<n-radio-button value="preview">预览</n-radio-button>
|
||||||
|
<n-radio-button value="code">代码</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
</template>
|
||||||
|
</Preview>
|
||||||
|
</div>
|
||||||
|
<n-split v-else direction="vertical" :default-size="0.55" min="160px" max="0.8">
|
||||||
|
<template #1>
|
||||||
|
<CodeTabs :code="code">
|
||||||
|
<template #prefix>
|
||||||
|
<n-radio-group v-model:value="mode" size="small" class="mode">
|
||||||
|
<n-radio-button value="preview">预览</n-radio-button>
|
||||||
|
<n-radio-button value="code">代码</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<n-button quaternary size="small" @click="format">整理代码</n-button>
|
||||||
|
<n-button quaternary size="small" @click="showPaste = true">
|
||||||
|
粘贴外部代码
|
||||||
|
</n-button>
|
||||||
|
<n-button quaternary size="small" @click="clear">清空</n-button>
|
||||||
|
</template>
|
||||||
|
</CodeTabs>
|
||||||
|
</template>
|
||||||
|
<template #2>
|
||||||
|
<div class="preview-only">
|
||||||
|
<Preview
|
||||||
|
:html="code.html"
|
||||||
|
:css="code.css"
|
||||||
|
:js="code.js"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
:storage-task-id="taskId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-split>
|
||||||
|
</div>
|
||||||
|
<VersionBar
|
||||||
|
:kind="kind"
|
||||||
|
:versions="versions"
|
||||||
|
:selected-id="selectedId"
|
||||||
|
:modified="modified"
|
||||||
|
:can-submit="canSubmit"
|
||||||
|
:busy="busy"
|
||||||
|
@select="select"
|
||||||
|
@submit="submit"
|
||||||
|
@history="openHistory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-split>
|
||||||
|
<PasteCodeModal v-model:show="showPaste" :on-save="savePasted" />
|
||||||
|
<SubmitModal
|
||||||
|
v-model:show="showSubmit"
|
||||||
|
:title="submitTitle"
|
||||||
|
:has-earlier="submitHasEarlier"
|
||||||
|
:on-submit="submitChallenge"
|
||||||
|
/>
|
||||||
|
<n-drawer v-model:show="showHistory" :width="420" placement="right">
|
||||||
|
<n-drawer-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0">
|
||||||
|
<PromptHistoryPanel
|
||||||
|
:task-id="taskId"
|
||||||
|
:active="showHistory"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
:refresh-key="historyKey"
|
||||||
|
@select="adopt"
|
||||||
|
@deleted="removeMessagePair"
|
||||||
|
/>
|
||||||
|
</n-drawer-content>
|
||||||
|
</n-drawer>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, h, onUnmounted, ref, toRef, watch } from "vue"
|
||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
|
import type { RouteLocationRaw } from "vue-router"
|
||||||
|
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "../../icons"
|
||||||
|
import PromptPanel from "../ai/PromptPanel.vue"
|
||||||
|
import PromptHistoryPanel from "../ai/PromptHistoryPanel.vue"
|
||||||
|
import Preview from "../editor/Preview.vue"
|
||||||
|
import CodeTabs from "./CodeTabs.vue"
|
||||||
|
import VersionBar, { type VersionItem } from "./VersionBar.vue"
|
||||||
|
import PasteCodeModal from "./PasteCodeModal.vue"
|
||||||
|
import SubmitModal from "./SubmitModal.vue"
|
||||||
|
import { Prompt, Submission } from "../../api"
|
||||||
|
import { useTaskCode } from "../../composables/useTaskCode"
|
||||||
|
import {
|
||||||
|
addVersionPair,
|
||||||
|
connectPrompt,
|
||||||
|
disconnectPrompt,
|
||||||
|
markMessageSubmitted,
|
||||||
|
messages,
|
||||||
|
removeMessagePair,
|
||||||
|
sendPrompt,
|
||||||
|
setCodeSink,
|
||||||
|
} from "../../store/prompt"
|
||||||
|
import { closeGuidance } from "../../store/guidance"
|
||||||
|
import { authed } from "../../store/user"
|
||||||
|
import {
|
||||||
|
emptyCode,
|
||||||
|
formatCode,
|
||||||
|
isEmptyCode,
|
||||||
|
sameCode,
|
||||||
|
type Code,
|
||||||
|
} from "../../utils/code"
|
||||||
|
|
||||||
|
// 挑战和自由创作共用的工作台:左边题目 + AI 对话,右边预览 / 代码,底部是版本条。
|
||||||
|
// 每轮 AI 回复、每次手改或粘贴外部代码都是一个版本;挑战由学生自己点“交这一版”提交
|
||||||
|
const props = defineProps<{
|
||||||
|
kind: "challenge" | "creation"
|
||||||
|
// 任务还没加载完时是 0
|
||||||
|
taskId: number
|
||||||
|
title: string
|
||||||
|
backLabel: string
|
||||||
|
backTo: RouteLocationRaw
|
||||||
|
briefLabel?: string
|
||||||
|
assetBaseUrl?: string
|
||||||
|
// 自由创作:保存作品(AI 生成完也会自动保存)
|
||||||
|
saveWork?: (code: Code) => Promise<void>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const { code, restored } = useTaskCode(toRef(props, "taskId"))
|
||||||
|
const mode = ref<"preview" | "code">("preview")
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const busy = ref(false)
|
||||||
|
const briefOpen = ref(true)
|
||||||
|
const showPaste = ref(false)
|
||||||
|
const showSubmit = ref(false)
|
||||||
|
const showHistory = ref(false)
|
||||||
|
const historyKey = ref(0)
|
||||||
|
|
||||||
|
type CodeOut = { html: string | null; css: string | null; js: string | null }
|
||||||
|
const normalize = (c: CodeOut): Code => ({
|
||||||
|
html: c.html ?? "",
|
||||||
|
css: c.css ?? "",
|
||||||
|
js: c.js ?? "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const versionMessages = computed(() =>
|
||||||
|
messages.value.filter(
|
||||||
|
(m) => m.role === "assistant" && m.id && m.code && (m.code.html || m.code.css || m.code.js),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const versions = computed<VersionItem[]>(() =>
|
||||||
|
versionMessages.value.map((m, i) => ({
|
||||||
|
id: m.id!,
|
||||||
|
n: i + 1,
|
||||||
|
source: m.source ?? "conversation",
|
||||||
|
submitted: !!m.submitted,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function versionCode(id: number | null): Code | null {
|
||||||
|
const m = versionMessages.value.find((v) => v.id === id)
|
||||||
|
return m?.code ? normalize(m.code) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const modified = computed(() => {
|
||||||
|
const base = versionCode(selectedId.value)
|
||||||
|
return base ? !sameCode(code, base) : !isEmptyCode(code)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => !!props.taskId && !isEmptyCode(code))
|
||||||
|
|
||||||
|
function matchVersion(): number | null {
|
||||||
|
const hit = [...versionMessages.value]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => sameCode(code, normalize(m.code!)))
|
||||||
|
return hit?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连上时:本地存着这个任务的代码就继续用它,否则打开最新一版。AI 生成完:换成新代码
|
||||||
|
function onCode(c: CodeOut, id: number, reason: "init" | "complete") {
|
||||||
|
if (reason === "init" && restored.value) {
|
||||||
|
selectedId.value = matchVersion() ?? id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Object.assign(code, normalize(c))
|
||||||
|
selectedId.value = id
|
||||||
|
if (reason === "complete" && props.kind === "creation") {
|
||||||
|
props.saveWork?.({ ...code }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.taskId,
|
||||||
|
(id) => {
|
||||||
|
if (!id || !authed.value) return
|
||||||
|
setCodeSink(onCode)
|
||||||
|
connectPrompt(id)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnectPrompt()
|
||||||
|
closeGuidance()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开时已经有对话的,题目默认收起,给对话留地方
|
||||||
|
let briefDecided = false
|
||||||
|
watch(
|
||||||
|
() => messages.value.length,
|
||||||
|
(n) => {
|
||||||
|
if (briefDecided || !n) return
|
||||||
|
briefDecided = true
|
||||||
|
briefOpen.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function saveAsVersion(prompt?: string): Promise<number> {
|
||||||
|
const res = await Prompt.saveVersion(props.taskId, { ...code }, prompt)
|
||||||
|
addVersionPair(prompt || "(手动修改了代码)", res.source, res.message_id, { ...code })
|
||||||
|
selectedId.value = res.message_id
|
||||||
|
historyKey.value++
|
||||||
|
return res.message_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发给 AI 之前,先把还没存的手改存成版本,AI 才会在手改后的代码上继续改
|
||||||
|
async function beforeSend() {
|
||||||
|
briefOpen.value = false
|
||||||
|
briefDecided = true
|
||||||
|
if (!modified.value || isEmptyCode(code)) return
|
||||||
|
try {
|
||||||
|
await saveAsVersion()
|
||||||
|
message.info("已把你的修改存为新版本,AI 会在它的基础上改")
|
||||||
|
} catch {
|
||||||
|
message.error("保存你的修改失败,AI 会在上一版的基础上改")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(id: number) {
|
||||||
|
const apply = () => {
|
||||||
|
const c = versionCode(id)
|
||||||
|
if (!c) return
|
||||||
|
Object.assign(code, c)
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
if (!modified.value) return apply()
|
||||||
|
dialog.warning({
|
||||||
|
title: "放弃当前修改?",
|
||||||
|
content: "当前的修改还没有存成版本,切换后会丢失。想留着的话,可以先点“交当前修改”或发给 AI。",
|
||||||
|
positiveText: "放弃并切换",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: apply,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从“全部历史”里选了一份代码
|
||||||
|
function adopt(c: Code) {
|
||||||
|
Object.assign(code, c)
|
||||||
|
selectedId.value = matchVersion()
|
||||||
|
showHistory.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHistory() {
|
||||||
|
historyKey.value++
|
||||||
|
showHistory.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (props.kind === "challenge") {
|
||||||
|
showSubmit.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await props.saveWork?.({ ...code })
|
||||||
|
message.success("作品已保存")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "保存失败,请重试")
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitTitle = computed(() => {
|
||||||
|
if (modified.value || !selectedId.value) return "交当前修改"
|
||||||
|
const n = versions.value.find((v) => v.id === selectedId.value)?.n
|
||||||
|
return n ? `交 v${n}` : "交这一版"
|
||||||
|
})
|
||||||
|
|
||||||
|
// 要交的这一版之前还有版本(当前修改会存成新的一版)
|
||||||
|
const submitHasEarlier = computed(() => {
|
||||||
|
if (modified.value || !selectedId.value) return versions.value.length > 0
|
||||||
|
return (versions.value.find((v) => v.id === selectedId.value)?.n ?? 1) > 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 挑战:先在弹框里写一句反思(可以不写)再交;出错时抛出去,弹框不关
|
||||||
|
async function submitChallenge(reflection: string) {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const id =
|
||||||
|
modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value
|
||||||
|
await Submission.create(props.taskId, { ...code }, id, reflection)
|
||||||
|
markMessageSubmitted(id)
|
||||||
|
const n = versions.value.find((v) => v.id === id)?.n
|
||||||
|
message.success(n ? `已提交 v${n}` : "已提交")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "提交失败,请重试")
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePasted(prompt: string, c: Code) {
|
||||||
|
Object.assign(code, c)
|
||||||
|
try {
|
||||||
|
await saveAsVersion(prompt)
|
||||||
|
message.success("已存为新版本")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "保存失败,请重试")
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function format() {
|
||||||
|
try {
|
||||||
|
Object.assign(code, await formatCode({ ...code }))
|
||||||
|
} catch (err: any) {
|
||||||
|
dialog.error({
|
||||||
|
title: "整理失败,代码里可能有语法错误",
|
||||||
|
content: () => h(NCode, { code: err.message }),
|
||||||
|
style: { width: "auto" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
dialog.warning({
|
||||||
|
title: "清空代码?",
|
||||||
|
content: "只清空编辑器里的代码,已经存下的版本不受影响。",
|
||||||
|
positiveText: "清空",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: () => {
|
||||||
|
Object.assign(code, emptyCode())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 和 PromptPanel 里的模型选择是同一个存储
|
||||||
|
const model = useStorage("prompt-model", "deepseek-flash")
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
// 页面自己发提示词(自由创作的“用这个主题生成”)
|
||||||
|
async send(text: string) {
|
||||||
|
await beforeSend()
|
||||||
|
sendPrompt(text, model.value)
|
||||||
|
},
|
||||||
|
// 页面用来放入示例代码,或自由创作从服务器读到的作品
|
||||||
|
setCode(c: Code) {
|
||||||
|
Object.assign(code, c)
|
||||||
|
selectedId.value = matchVersion()
|
||||||
|
},
|
||||||
|
restored,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.workbench {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
border-right: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-head {
|
||||||
|
height: 43px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief {
|
||||||
|
flex: none;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--wp-paper);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-toggle span {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-body {
|
||||||
|
max-height: 42vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-only {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode {
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { reactive, ref, watch, type Ref } from "vue"
|
||||||
|
import { user } from "../store/user"
|
||||||
|
import { emptyCode, type Code } from "../utils/code"
|
||||||
|
|
||||||
|
const key = (taskId: number) => `web-code:${user.username}:${taskId}`
|
||||||
|
|
||||||
|
export function readTaskCode(taskId: number): Code | null {
|
||||||
|
return user.username ? read(taskId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(taskId: number): Code | null {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(key(taskId)) ?? "null")
|
||||||
|
return saved && typeof saved.html === "string" ? { ...emptyCode(), ...saved } : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接写入某个任务的代码(比如把别人的提交复制过去),下次打开那个任务的工作台时读到
|
||||||
|
export function saveTaskCode(taskId: number, code: Code) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key(taskId), JSON.stringify(code))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工作台里正在编辑的代码,每个任务各存一份(按用户 + 任务存在 localStorage)。
|
||||||
|
// 以前挑战、教程、自由创作共用一份,在挑战页点“清空”会把教程里的代码也清掉
|
||||||
|
export function useTaskCode(taskId: Ref<number>) {
|
||||||
|
const code = reactive(emptyCode())
|
||||||
|
// 打开时本地有没有存着这个任务的代码;没有的话由调用方填入最新版本
|
||||||
|
const restored = ref(false)
|
||||||
|
|
||||||
|
// 用户名也在 key 里,刚打开页面时可能还没取到
|
||||||
|
watch(
|
||||||
|
() => [taskId.value, user.username] as const,
|
||||||
|
([id]) => {
|
||||||
|
const saved = id && user.username ? read(id) : null
|
||||||
|
Object.assign(code, saved ?? emptyCode())
|
||||||
|
restored.value = !!saved
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
watch(
|
||||||
|
() => ({ ...code }),
|
||||||
|
(value) => {
|
||||||
|
if (!taskId.value || !user.username) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key(taskId.value), JSON.stringify(value))
|
||||||
|
} catch {}
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return { code, restored }
|
||||||
|
}
|
||||||
Vendored
-1
@@ -3,7 +3,6 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_WEB_URL: string
|
readonly PUBLIC_WEB_URL: string
|
||||||
readonly PUBLIC_BASE_URL: string
|
readonly PUBLIC_BASE_URL: string
|
||||||
readonly PUBLIC_ICONIFY_URL: string
|
|
||||||
readonly PUBLIC_WS_URL: string
|
readonly PUBLIC_WS_URL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-2
@@ -4,10 +4,26 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--wp-paper);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font-family: var(--wp-font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 顶栏下面的内容区高度;页面用它代替 100vh */
|
||||||
|
:root {
|
||||||
|
--wp-topbar-h: 48px;
|
||||||
|
--wp-main-h: calc(100vh - var(--wp-topbar-h));
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
pre,
|
||||||
|
samp {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -18,10 +34,57 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #d0d0d0;
|
background: var(--wp-line);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #b0b0b0;
|
background: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iframe 里都是学生写的网页,网页默认是白底;不设的话会透出页面的底色 */
|
||||||
|
iframe {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTML / CSS / JS 各有固定颜色,见 theme.ts */
|
||||||
|
.lang-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.lang-tab.lang-html::before,
|
||||||
|
.lang-tab.lang-css::before,
|
||||||
|
.lang-tab.lang-js::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.lang-html {
|
||||||
|
color: var(--wp-html);
|
||||||
|
}
|
||||||
|
.lang-html::before {
|
||||||
|
background: var(--wp-html);
|
||||||
|
}
|
||||||
|
.lang-css {
|
||||||
|
color: var(--wp-css);
|
||||||
|
}
|
||||||
|
.lang-css::before {
|
||||||
|
background: var(--wp-css);
|
||||||
|
}
|
||||||
|
.lang-js {
|
||||||
|
color: var(--wp-js);
|
||||||
|
}
|
||||||
|
.lang-js::before {
|
||||||
|
background: var(--wp-js);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* markdown 区域用页面的底色,暗色时不出现一块 GitHub 的深蓝底 */
|
||||||
|
:root[data-theme="dark"] body .markdown-body {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--wp-ink);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { defineComponent, h, type Component } from "vue"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Award,
|
||||||
|
BookOpen,
|
||||||
|
Bug,
|
||||||
|
ChartNoAxesColumn,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
CircleCheck,
|
||||||
|
Download,
|
||||||
|
GraduationCap,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Image,
|
||||||
|
Info,
|
||||||
|
Lightbulb,
|
||||||
|
List,
|
||||||
|
Lock,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
LogOut,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Search,
|
||||||
|
Smartphone,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
SunMoon,
|
||||||
|
SquarePen,
|
||||||
|
Star,
|
||||||
|
Swords,
|
||||||
|
Tablet,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-vue-next"
|
||||||
|
|
||||||
|
// 全站只用 lucide 一套图标,并且打包进前端(以前走在线的 iconify 接口,机房断网时图标会全部消失)。
|
||||||
|
// 沿用 <Icon icon="集合:名字" /> 的写法;原来其他图标集里用到的几个映射到 lucide 的对应图标
|
||||||
|
const registry: Record<string, Component> = {
|
||||||
|
"arrow-left": ArrowLeft,
|
||||||
|
"arrow-right": ArrowRight,
|
||||||
|
award: Award,
|
||||||
|
"book-open": BookOpen,
|
||||||
|
bug: Bug,
|
||||||
|
"bar-chart-2": ChartNoAxesColumn,
|
||||||
|
check: Check,
|
||||||
|
"chevron-down": ChevronDown,
|
||||||
|
"chevron-right": ChevronRight,
|
||||||
|
"chevron-up": ChevronUp,
|
||||||
|
globe: Globe,
|
||||||
|
lock: Lock,
|
||||||
|
"maximize-2": Maximize2,
|
||||||
|
"minimize-2": Minimize2,
|
||||||
|
"check-circle": CircleCheck,
|
||||||
|
download: Download,
|
||||||
|
eye: Eye,
|
||||||
|
image: Image,
|
||||||
|
info: Info,
|
||||||
|
lightbulb: Lightbulb,
|
||||||
|
list: List,
|
||||||
|
"log-out": LogOut,
|
||||||
|
play: Play,
|
||||||
|
plus: Plus,
|
||||||
|
"refresh-cw": RefreshCw,
|
||||||
|
save: Save,
|
||||||
|
search: Search,
|
||||||
|
sparkles: Sparkles,
|
||||||
|
edit: SquarePen,
|
||||||
|
star: Star,
|
||||||
|
swords: Swords,
|
||||||
|
"trash-2": Trash2,
|
||||||
|
users: Users,
|
||||||
|
"graduation-cap": GraduationCap,
|
||||||
|
x: X,
|
||||||
|
sun: Sun,
|
||||||
|
moon: Moon,
|
||||||
|
"sun-moon": SunMoon,
|
||||||
|
// 原 material-symbols / pepicons
|
||||||
|
"close-rounded": X,
|
||||||
|
"desktop-windows-outline": Monitor,
|
||||||
|
"smartphone-outline": Smartphone,
|
||||||
|
"tablet-outline": Tablet,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Icon = defineComponent({
|
||||||
|
name: "Icon",
|
||||||
|
props: {
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
width: { type: [Number, String], default: 16 },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
const name = props.icon.slice(props.icon.indexOf(":") + 1)
|
||||||
|
const comp = registry[name]
|
||||||
|
if (!comp) {
|
||||||
|
if (import.meta.env.DEV) console.warn(`图标 ${props.icon} 没有登记在 icons.ts 里`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return h(comp, { size: props.width, strokeWidth: 2, "aria-hidden": "true" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
+5
-7
@@ -1,8 +1,10 @@
|
|||||||
import { createApp } from "vue"
|
import { createApp } from "vue"
|
||||||
import { create } from "naive-ui"
|
import { create } from "naive-ui"
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
|
import "@fontsource/jetbrains-mono/400.css"
|
||||||
|
import "@fontsource/jetbrains-mono/600.css"
|
||||||
import "./global.css"
|
import "./global.css"
|
||||||
import { addAPIProvider } from "@iconify/vue"
|
import "./store/theme"
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import "github-markdown-css/github-markdown-light.css"
|
import "github-markdown-css/github-markdown-light.css"
|
||||||
@@ -11,6 +13,8 @@ import { marked } from "marked"
|
|||||||
import { trustedExtensions } from "./utils/markdown"
|
import { trustedExtensions } from "./utils/markdown"
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import "highlight.js/styles/github.min.css"
|
import "highlight.js/styles/github.min.css"
|
||||||
|
// 暗色时覆盖上面两份亮色样式
|
||||||
|
import "./styles/dark-content.css"
|
||||||
import { router } from "./router"
|
import { router } from "./router"
|
||||||
|
|
||||||
marked.use(...trustedExtensions)
|
marked.use(...trustedExtensions)
|
||||||
@@ -21,9 +25,3 @@ const naive = create()
|
|||||||
app.use(naive)
|
app.use(naive)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount("#app")
|
app.mount("#app")
|
||||||
|
|
||||||
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
|
||||||
addAPIProvider("", {
|
|
||||||
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,105 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="challenge-layout">
|
<Workbench
|
||||||
<div class="challenge-sider">
|
ref="wb"
|
||||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
kind="challenge"
|
||||||
<template #prefix>
|
:task-id="taskId"
|
||||||
<n-button text @click="back" style="margin: 0 8px">
|
:title="title"
|
||||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
back-label="返回挑战列表"
|
||||||
</n-button>
|
:back-to="{ name: 'home-challenge-list' }"
|
||||||
</template>
|
brief-label="题目"
|
||||||
<template #suffix>
|
:asset-base-url="assetBaseUrl"
|
||||||
<n-flex style="margin: 0 8px">
|
|
||||||
<n-button
|
|
||||||
v-if="roleAdmin || roleSuper"
|
|
||||||
text
|
|
||||||
@click="showStats = true"
|
|
||||||
>
|
>
|
||||||
|
<template #badges>
|
||||||
|
<n-tag v-if="score" size="small" :bordered="false" type="warning">
|
||||||
|
{{ score }} 分
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
<template #tools>
|
||||||
|
<n-button quaternary size="small" @click="showPeers = true">作品</n-button>
|
||||||
|
<n-button v-if="assets.length" quaternary size="small" @click="showAssets = true">
|
||||||
|
素材
|
||||||
|
</n-button>
|
||||||
|
<n-tooltip v-if="roleStaff">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="showStats = true">
|
||||||
<Icon :width="16" icon="lucide:bar-chart-2" />
|
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
</template>
|
||||||
v-if="authed"
|
课堂总结
|
||||||
text
|
</n-tooltip>
|
||||||
@click="
|
<n-tooltip v-if="roleSuper">
|
||||||
$router.push({ name: 'submissions', params: { page: 1 } })
|
<template #trigger>
|
||||||
"
|
<n-button text @click="edit">
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:list" />
|
|
||||||
</n-button>
|
|
||||||
<n-button v-if="roleSuper" text @click="edit">
|
|
||||||
<Icon :width="16" icon="lucide:edit" />
|
<Icon :width="16" icon="lucide:edit" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
编辑这道题
|
||||||
<div class="desc-pane">
|
</n-tooltip>
|
||||||
<div class="challenge-meta">
|
</template>
|
||||||
<n-flex align="center" justify="space-between">
|
<template #brief>
|
||||||
<n-text depth="3">
|
<div class="brief">
|
||||||
出题人:{{ challengeAuthor || "未设置" }}
|
<n-flex align="center" justify="space-between" class="meta">
|
||||||
</n-text>
|
<n-text depth="3">出题人:{{ author || "未设置" }}</n-text>
|
||||||
<n-flex align="center" size="small">
|
<n-button v-if="exampleCode" size="tiny" @click="useExample">
|
||||||
<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-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="markdown-body content no-select"
|
class="markdown-body challenge-desc no-select"
|
||||||
v-html="challengeContent"
|
v-html="content"
|
||||||
ref="$desc"
|
|
||||||
@copy.prevent
|
@copy.prevent
|
||||||
|
@click="onCopyClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</template>
|
||||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
</Workbench>
|
||||||
<PromptPanel
|
<TaskStatsModal v-model:show="showStats" :task-id="taskId" :title="title" />
|
||||||
@deleted="historyRefreshKey++"
|
<PeersDrawer v-model:show="showPeers" :task-id="taskId" :asset-base-url="assetBaseUrl" />
|
||||||
@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"
|
|
||||||
persist-storage
|
|
||||||
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
|
<n-modal
|
||||||
v-model:show="showAssets"
|
v-model:show="showAssets"
|
||||||
preset="card"
|
preset="card"
|
||||||
@@ -117,61 +72,32 @@
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-modal>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { useMessage } from "naive-ui"
|
import { Marked } from "marked"
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { marked, type MarkedOptions } from "marked"
|
|
||||||
import copyFn from "copy-text-to-clipboard"
|
import copyFn from "copy-text-to-clipboard"
|
||||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
import { Icon } from "../icons"
|
||||||
import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
|
import Workbench from "../components/workbench/Workbench.vue"
|
||||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
|
||||||
import Preview from "../components/editor/Preview.vue"
|
|
||||||
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||||
import RandomRatingModal from "../components/ai/RandomRatingModal.vue"
|
import PeersDrawer from "../components/review/PeersDrawer.vue"
|
||||||
import { Challenge, Submission, TaskAssets } from "../api"
|
import { Challenge, TaskAssets } from "../api"
|
||||||
import type { TaskAsset } from "../utils/type"
|
import type { TaskAsset } from "../utils/type"
|
||||||
import { html, css, js } from "../store/editors"
|
|
||||||
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import { authed, roleAdmin, roleSuper } from "../store/user"
|
import { roleStaff, roleSuper } from "../store/user"
|
||||||
import {
|
import { trustedExtensions } from "../utils/markdown"
|
||||||
connectPrompt,
|
import type { Code } from "../utils/code"
|
||||||
disconnectPrompt,
|
|
||||||
streaming,
|
|
||||||
setOnCodeComplete,
|
|
||||||
removeMessagePair,
|
|
||||||
markMessageSubmitted,
|
|
||||||
} from "../store/prompt"
|
|
||||||
import { closeGuidance } from "../store/guidance"
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const challengeRenderer = new marked.Renderer()
|
// 题目里的代码块带“复制”按钮;题目正文本身不让复制
|
||||||
challengeRenderer.code = ({ text, lang }) => {
|
const challengeMarked = new Marked(...trustedExtensions, {
|
||||||
|
renderer: {
|
||||||
|
code({ text, lang }) {
|
||||||
const language = lang?.toLowerCase() ?? "text"
|
const language = lang?.toLowerCase() ?? "text"
|
||||||
return `<div class="codeblock-wrapper" data-lang="${language}">
|
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||||
<div class="codeblock-action">
|
<div class="codeblock-action">
|
||||||
@@ -180,42 +106,32 @@ challengeRenderer.code = ({ text, lang }) => {
|
|||||||
</div>
|
</div>
|
||||||
<pre><code class="language-${language}">${text}</code></pre>
|
<pre><code class="language-${language}">${text}</code></pre>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
},
|
||||||
challengeRenderer.link = ({ href, text }) =>
|
link({ href, text }) {
|
||||||
`<a href="${href}" target="_blank">${text}</a>`
|
return `<a href="${href}" target="_blank">${text}</a>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function setupCodeCopy() {
|
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||||
$desc.value?.addEventListener("click", (e: MouseEvent) => {
|
const title = ref("")
|
||||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
const score = ref(0)
|
||||||
if (!btn) return
|
const author = ref("")
|
||||||
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
const content = ref("")
|
||||||
const code = wrapper.querySelector("code")?.textContent ?? ""
|
const assets = ref<TaskAsset[]>([])
|
||||||
copyFn(code)
|
const exampleCode = ref<Code | null>(null)
|
||||||
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 showStats = ref(false)
|
||||||
const showAssets = ref(false)
|
const showAssets = ref(false)
|
||||||
const assets = ref<TaskAsset[]>([])
|
const showPeers = ref(false)
|
||||||
const historyRefreshKey = ref(0)
|
|
||||||
const exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
|
|
||||||
|
|
||||||
const assetBaseUrl = computed(
|
const assetBaseUrl = computed(
|
||||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(streaming, (val) => {
|
// 加载还没完成就离开页面时,不要再改全局状态
|
||||||
if (val) activeTab.value = "chat"
|
let disposed = false
|
||||||
})
|
|
||||||
|
|
||||||
async function loadChallenge() {
|
async function load() {
|
||||||
const display = Number(route.params.display)
|
const display = Number(route.params.display)
|
||||||
taskTab.value = TASK_TYPE.Challenge
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
challengeDisplay.value = display
|
challengeDisplay.value = display
|
||||||
@@ -224,151 +140,57 @@ async function loadChallenge() {
|
|||||||
TaskAssets.listChallenge(display),
|
TaskAssets.listChallenge(display),
|
||||||
])
|
])
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
taskId.value = data.task_ptr
|
title.value = `${data.display}. ${data.title}`
|
||||||
challengeAuthor.value = data.author_name ?? ""
|
score.value = data.score
|
||||||
if (data.example_html || data.example_css || data.example_js) {
|
author.value = data.author_name ?? ""
|
||||||
exampleCode.value = {
|
assets.value = fetchedAssets
|
||||||
|
exampleCode.value =
|
||||||
|
data.example_html || data.example_css || data.example_js
|
||||||
|
? {
|
||||||
html: data.example_html ?? "",
|
html: data.example_html ?? "",
|
||||||
css: data.example_css ?? "",
|
css: data.example_css ?? "",
|
||||||
js: data.example_js ?? "",
|
js: data.example_js ?? "",
|
||||||
}
|
}
|
||||||
} else {
|
: null
|
||||||
exampleCode.value = null
|
content.value = await challengeMarked.parse(data.content, { async: true })
|
||||||
}
|
|
||||||
challengeContent.value = await marked.parse(data.content, {
|
|
||||||
async: true,
|
|
||||||
renderer: challengeRenderer,
|
|
||||||
} as MarkedOptions)
|
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
assets.value = fetchedAssets
|
taskId.value = data.task_ptr
|
||||||
if (!authed.value) return
|
}
|
||||||
connectPrompt(data.task_ptr)
|
|
||||||
setOnCodeComplete(async (code, messageId) => {
|
function useExample() {
|
||||||
try {
|
if (exampleCode.value) wb.value?.setCode({ ...exampleCode.value })
|
||||||
await Submission.create(
|
}
|
||||||
taskId.value,
|
|
||||||
{
|
function onCopyClick(e: MouseEvent) {
|
||||||
html: code.html ?? "",
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||||
css: code.css ?? "",
|
if (!btn) return
|
||||||
js: code.js ?? "",
|
const code = btn.closest<HTMLElement>("[data-lang]")?.querySelector("code")?.textContent ?? ""
|
||||||
},
|
copyFn(code)
|
||||||
messageId,
|
btn.textContent = "已复制"
|
||||||
)
|
setTimeout(() => {
|
||||||
markMessageSubmitted(messageId)
|
btn.textContent = "复制"
|
||||||
historyRefreshKey.value++
|
}, 1000)
|
||||||
message.success("已自动提交本次对话生成的代码")
|
|
||||||
} catch {
|
|
||||||
message.error("自动提交失败,请稍后重试")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit() {
|
function edit() {
|
||||||
router.push({
|
router.push({ name: "challenge-editor", params: { display: route.params.display } })
|
||||||
name: "challenge-editor",
|
|
||||||
params: { display: route.params.display },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewExample() {
|
onMounted(load)
|
||||||
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" })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载还没完成就离开页面时,不要再建立连接
|
|
||||||
let disposed = false
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setupCodeCopy()
|
|
||||||
loadChallenge()
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
disconnectPrompt()
|
taskId.value = 0
|
||||||
closeGuidance()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.challenge-layout {
|
.brief {
|
||||||
display: flex;
|
padding: 10px 12px 12px;
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider {
|
.meta {
|
||||||
width: 40%;
|
margin-bottom: 8px;
|
||||||
height: 100%;
|
font-size: 12px;
|
||||||
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 {
|
.no-select {
|
||||||
@@ -377,15 +199,20 @@ onUnmounted(() => {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.challenge-sider .codeblock-wrapper {
|
.challenge-desc {
|
||||||
padding: 1rem;
|
font-size: 14px;
|
||||||
background-color: #f6f8fa;
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-desc .codeblock-wrapper {
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--wp-paper);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 12px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-wrapper pre {
|
.challenge-desc .codeblock-wrapper pre {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -393,40 +220,39 @@ onUnmounted(() => {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-wrapper pre code {
|
.challenge-desc .codeblock-wrapper pre code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1rem;
|
font-size: 13px;
|
||||||
font-family: Monaco, monospace;
|
font-family: var(--wp-font-mono);
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-action {
|
.challenge-desc .codeblock-action {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-action .lang {
|
.challenge-desc .codeblock-action .lang {
|
||||||
font-size: 0.9rem;
|
font: 600 12px var(--wp-font-mono);
|
||||||
font-weight: bold;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .action-btn {
|
.challenge-desc .action-btn {
|
||||||
height: 28px;
|
height: 24px;
|
||||||
padding: 0 14px;
|
padding: 0 10px;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid var(--wp-line);
|
||||||
background-color: #fff;
|
background-color: var(--wp-surface);
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .action-btn:hover {
|
.challenge-desc .action-btn:hover {
|
||||||
border-color: #18a058;
|
border-color: var(--wp-brand);
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
<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,178 @@
|
|||||||
|
<template>
|
||||||
|
<main class="challenge-home">
|
||||||
|
<header class="head">
|
||||||
|
<div>
|
||||||
|
<h2>挑战</h2>
|
||||||
|
<p class="sub">
|
||||||
|
用提示词让 AI 做出题目要求的网页。已完成 {{ doneCount }} / {{ challenges.length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<n-radio-group v-model:value="filter" size="small">
|
||||||
|
<n-radio-button value="all">全部</n-radio-button>
|
||||||
|
<n-radio-button value="todo">未做</n-radio-button>
|
||||||
|
<n-radio-button value="done">已交</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!loading && !visible.length"
|
||||||
|
:description="challenges.length ? '没有符合条件的挑战' : '暂无挑战,敬请期待'"
|
||||||
|
style="margin-top: 72px"
|
||||||
|
/>
|
||||||
|
<ol v-else class="list">
|
||||||
|
<li v-for="item in visible" :key="item.display">
|
||||||
|
<router-link
|
||||||
|
class="row"
|
||||||
|
:to="{ name: 'home-challenge', params: { display: item.display } }"
|
||||||
|
>
|
||||||
|
<span class="num">{{ item.display }}</span>
|
||||||
|
<span class="main">
|
||||||
|
<span class="title">{{ item.title }}</span>
|
||||||
|
<span class="author">出题人:{{ item.author_name || "未设置" }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="score">{{ item.score }} 分</span>
|
||||||
|
<span :class="['state', { done: item.submitted }]">
|
||||||
|
<template v-if="item.submitted">
|
||||||
|
<Icon icon="lucide:check" :width="14" /> 已交
|
||||||
|
</template>
|
||||||
|
<template v-else>未做</template>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</n-spin>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import { Challenge } from "../api"
|
||||||
|
import { taskId, taskTab } from "../store/task"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
|
|
||||||
|
const challenges = ref<ChallengeSlim[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const filter = ref<"all" | "todo" | "done">("all")
|
||||||
|
|
||||||
|
const doneCount = computed(() => challenges.value.filter((c) => c.submitted).length)
|
||||||
|
const visible = computed(() =>
|
||||||
|
challenges.value.filter((c) =>
|
||||||
|
filter.value === "todo" ? !c.submitted : filter.value === "done" ? c.submitted : true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
|
taskId.value = 0
|
||||||
|
try {
|
||||||
|
challenges.value = await Challenge.listDisplay()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.challenge-home {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 20px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li + li {
|
||||||
|
border-top: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 1fr auto 72px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font: 600 15px var(--wp-font-mono);
|
||||||
|
color: var(--wp-faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font: 13px var(--wp-font-mono);
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.done {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="creation-layout">
|
<Workbench
|
||||||
<div class="creation-sider">
|
ref="wb"
|
||||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
kind="creation"
|
||||||
<template #prefix>
|
:task-id="taskId"
|
||||||
<n-button text @click="back" style="margin: 0 8px">
|
:title="title || '自由创作'"
|
||||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
back-label="返回我的创作"
|
||||||
</n-button>
|
:back-to="{ name: 'creation-list' }"
|
||||||
</template>
|
brief-label="我的主题"
|
||||||
<template #suffix>
|
:save-work="saveWork"
|
||||||
<n-flex style="margin: 0 8px">
|
>
|
||||||
<n-tooltip trigger="hover">
|
<template #brief>
|
||||||
<template #trigger>
|
<div class="topic">
|
||||||
<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-form-item label="主题名称" :show-feedback="false">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="title"
|
v-model:value="title"
|
||||||
@@ -28,20 +18,17 @@
|
|||||||
maxlength="100"
|
maxlength="100"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item
|
<n-form-item label="主题描述" :show-feedback="false" style="margin-top: 10px">
|
||||||
label="主题描述"
|
|
||||||
:show-feedback="false"
|
|
||||||
style="margin-top: 12px"
|
|
||||||
>
|
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="content"
|
v-model:value="content"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 8 }"
|
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-flex style="margin-top: 16px">
|
<n-flex style="margin-top: 12px">
|
||||||
<n-button
|
<n-button
|
||||||
|
size="small"
|
||||||
:loading="savingTopic"
|
:loading="savingTopic"
|
||||||
:disabled="!title.trim()"
|
:disabled="!title.trim()"
|
||||||
@click="saveTopic"
|
@click="saveTopic"
|
||||||
@@ -49,6 +36,7 @@
|
|||||||
保存主题
|
保存主题
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!content.trim() || streaming"
|
:disabled="!content.trim() || streaming"
|
||||||
@click="generate"
|
@click="generate"
|
||||||
@@ -56,107 +44,50 @@
|
|||||||
用这个主题生成
|
用这个主题生成
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-text depth="3" class="topic-hint">
|
<n-text depth="3" class="hint">
|
||||||
自由创作只属于你自己,不会进入提交记录和评分。
|
自由创作只属于你自己,不会进入提交记录和评分。
|
||||||
</n-text>
|
</n-text>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</template>
|
||||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
</Workbench>
|
||||||
<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"
|
|
||||||
persist-storage
|
|
||||||
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, watch } from "vue"
|
import { onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import Workbench from "../components/workbench/Workbench.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 { Creation } from "../api"
|
||||||
import { html, css, js } from "../store/editors"
|
import { streaming } from "../store/prompt"
|
||||||
import {
|
import type { Code } from "../utils/code"
|
||||||
connectPrompt,
|
|
||||||
disconnectPrompt,
|
|
||||||
removeMessagePair,
|
|
||||||
sendPrompt,
|
|
||||||
setOnCodeComplete,
|
|
||||||
streaming,
|
|
||||||
} from "../store/prompt"
|
|
||||||
import { closeGuidance } from "../store/guidance"
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||||
|
|
||||||
const display = Number(route.params.display)
|
const display = Number(route.params.display)
|
||||||
const activeTab = ref("topic")
|
|
||||||
const taskId = ref(0)
|
const taskId = ref(0)
|
||||||
const title = ref("")
|
const title = ref("")
|
||||||
const content = ref("")
|
const content = ref("")
|
||||||
const showCode = ref(false)
|
|
||||||
const savingTopic = ref(false)
|
const savingTopic = ref(false)
|
||||||
const savingCode = ref(false)
|
|
||||||
const historyRefreshKey = ref(0)
|
|
||||||
|
|
||||||
watch(streaming, (val) => {
|
// 加载还没完成就离开页面时,不要再改状态
|
||||||
if (val) activeTab.value = "chat"
|
let disposed = false
|
||||||
})
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await Creation.get(display)
|
const data = await Creation.get(display)
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
taskId.value = data.task_id
|
|
||||||
title.value = data.title
|
title.value = data.title
|
||||||
content.value = data.content
|
content.value = data.content
|
||||||
html.value = data.html
|
taskId.value = data.task_id
|
||||||
css.value = data.css
|
// 本地没有存着修改到一半的代码,就用服务器上保存的作品
|
||||||
js.value = data.js
|
if (!wb.value?.restored) {
|
||||||
connectPrompt(data.task_id)
|
wb.value?.setCode({ html: data.html, css: data.css, js: data.js })
|
||||||
setOnCodeComplete(() => saveCode())
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWork(code: Code) {
|
||||||
|
await Creation.saveCode(display, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTopic() {
|
async function saveTopic() {
|
||||||
@@ -175,110 +106,26 @@ async function saveTopic() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function generate() {
|
||||||
const text = content.value.trim()
|
const text = content.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value) return
|
||||||
activeTab.value = "chat"
|
wb.value?.send(text)
|
||||||
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" })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载还没完成就离开页面时,不要再建立连接
|
|
||||||
let disposed = false
|
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
disconnectPrompt()
|
|
||||||
closeGuidance()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.creation-layout {
|
.topic {
|
||||||
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;
|
padding: 12px;
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-hint {
|
.hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 12px;
|
margin-top: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,64 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="creation-home">
|
<main class="creation-home">
|
||||||
<n-flex
|
<header class="head">
|
||||||
justify="space-between"
|
|
||||||
align="flex-end"
|
|
||||||
style="margin-bottom: 32px"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<n-flex align="center" :size="8">
|
<h2>自由创作</h2>
|
||||||
<n-button text @click="$router.push({ name: 'home' })">
|
<p class="sub">自己定一个主题,和 AI 一起把它做出来。不用交,也不打分</p>
|
||||||
<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>
|
</div>
|
||||||
<n-button type="primary" @click="openCreate">
|
<n-button type="primary" @click="openCreate()">
|
||||||
<template #icon>
|
<template #icon><Icon icon="lucide:plus" /></template>
|
||||||
<Icon icon="lucide:plus" :width="16" />
|
|
||||||
</template>
|
|
||||||
新建主题
|
新建主题
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</header>
|
||||||
|
|
||||||
<n-spin :show="loading">
|
<n-spin :show="loading">
|
||||||
<n-empty
|
<div v-if="!loading && !creations.length" class="empty">
|
||||||
v-if="!loading && !creations.length"
|
<p>还没有创作。不知道做什么?从这些开始:</p>
|
||||||
description="还没有创作,点右上角新建一个主题吧"
|
<div class="ideas">
|
||||||
style="margin-top: 72px"
|
<button v-for="idea in IDEAS" :key="idea.title" class="idea" @click="openCreate(idea)">
|
||||||
/>
|
<b>{{ idea.title }}</b>
|
||||||
<div v-else class="card-grid">
|
<span>{{ idea.content }}</span>
|
||||||
<n-card
|
</button>
|
||||||
v-for="item in creations"
|
</div>
|
||||||
:key="item.display"
|
</div>
|
||||||
class="creation-card"
|
<ol v-else class="list">
|
||||||
hoverable
|
<li v-for="item in creations" :key="item.display">
|
||||||
@click="open(item)"
|
<router-link class="row" :to="{ name: 'creation', params: { display: item.display } }">
|
||||||
>
|
<span class="main">
|
||||||
<template #header>
|
<span class="title">{{ item.title }}</span>
|
||||||
<n-ellipsis style="max-width: 100%">{{ item.title }}</n-ellipsis>
|
<span class="desc">{{ item.content || "还没写主题描述" }}</span>
|
||||||
</template>
|
</span>
|
||||||
<template #header-extra>
|
<span :class="['state', { done: item.has_code }]">
|
||||||
<n-flex :size="4" align="center">
|
{{ item.has_code ? "已有作品" : "还没开始" }}
|
||||||
<n-tag v-if="item.has_code" type="success" size="small">
|
</span>
|
||||||
已有作品
|
<span class="time">{{ formatTime(item.modified) }}</span>
|
||||||
</n-tag>
|
<n-popconfirm @positive-click="remove(item)">
|
||||||
<n-button text @click.stop="remove(item)">
|
<template #trigger>
|
||||||
|
<n-button text class="del" title="删除" @click.prevent.stop>
|
||||||
<Icon icon="lucide:trash-2" :width="15" />
|
<Icon icon="lucide:trash-2" :width="15" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<n-ellipsis :line-clamp="2" class="creation-desc">
|
删除「{{ item.title }}」?和它相关的对话也会一起删掉。
|
||||||
{{ item.content || "还没写主题描述" }}
|
</n-popconfirm>
|
||||||
</n-ellipsis>
|
</router-link>
|
||||||
<template #footer>
|
</li>
|
||||||
<n-text depth="3" style="font-size: 12px">
|
</ol>
|
||||||
更新于 {{ formatTime(item.modified) }}
|
|
||||||
</n-text>
|
|
||||||
</template>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</n-spin>
|
</n-spin>
|
||||||
|
|
||||||
<n-modal
|
<n-modal
|
||||||
@@ -70,6 +54,7 @@
|
|||||||
<n-form>
|
<n-form>
|
||||||
<n-form-item label="主题名称">
|
<n-form-item label="主题名称">
|
||||||
<n-input
|
<n-input
|
||||||
|
ref="titleRef"
|
||||||
v-model:value="form.title"
|
v-model:value="form.title"
|
||||||
placeholder="例如:我的番茄钟"
|
placeholder="例如:我的番茄钟"
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
@@ -103,22 +88,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from "vue"
|
import { nextTick, onMounted, reactive, ref } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { useDialog, useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Creation } from "../api"
|
import { Creation } from "../api"
|
||||||
import type { CreationSlim } from "../utils/type"
|
import type { CreationSlim } from "../utils/type"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const creations = ref<CreationSlim[]>([])
|
const creations = ref<CreationSlim[]>([])
|
||||||
const form = reactive({ title: "", content: "" })
|
const form = reactive({ title: "", content: "" })
|
||||||
|
const titleRef = ref<{ focus: () => void } | null>(null)
|
||||||
|
|
||||||
|
// 一个创作都没有时给几个主题点子,点了带进新建框
|
||||||
|
const IDEAS = [
|
||||||
|
{ title: "番茄钟", content: "25 分钟倒计时,能开始、暂停、重置,时间到了提醒我休息" },
|
||||||
|
{ title: "我的个人主页", content: "头像、自我介绍、爱好,再放几张喜欢的图片" },
|
||||||
|
{ title: "点击小游戏", content: "方块随机出现,点中加分,30 秒后显示得分" },
|
||||||
|
{ title: "今日待办", content: "能添加、勾选完成、删除待办,刷新后还在" },
|
||||||
|
]
|
||||||
|
|
||||||
function formatTime(value: string) {
|
function formatTime(value: string) {
|
||||||
return new Date(value).toLocaleString("zh-CN", {
|
return new Date(value).toLocaleString("zh-CN", {
|
||||||
@@ -129,14 +122,11 @@ function formatTime(value: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(item: CreationSlim) {
|
function openCreate(idea?: { title: string; content: string }) {
|
||||||
router.push({ name: "creation", params: { display: item.display } })
|
form.title = idea?.title ?? ""
|
||||||
}
|
form.content = idea?.content ?? ""
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
form.title = ""
|
|
||||||
form.content = ""
|
|
||||||
showCreate.value = true
|
showCreate.value = true
|
||||||
|
nextTick(() => titleRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -154,24 +144,14 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(item: CreationSlim) {
|
async function remove(item: CreationSlim) {
|
||||||
dialog.warning({
|
|
||||||
title: "删除主题",
|
|
||||||
content: `确定删除「${item.title}」吗?和它相关的对话也会一起删除。`,
|
|
||||||
positiveText: "删除",
|
|
||||||
negativeText: "取消",
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
try {
|
||||||
await Creation.remove(item.display)
|
await Creation.remove(item.display)
|
||||||
creations.value = creations.value.filter(
|
creations.value = creations.value.filter((c) => c.display !== item.display)
|
||||||
(c) => c.display !== item.display,
|
|
||||||
)
|
|
||||||
message.success("已删除")
|
message.success("已删除")
|
||||||
} catch {
|
} catch {
|
||||||
message.error("删除失败,请重试")
|
message.error("删除失败,请重试")
|
||||||
}
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -185,23 +165,153 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.creation-home {
|
.creation-home {
|
||||||
max-width: 1180px;
|
max-width: 880px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 48px;
|
padding: 28px 20px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid {
|
.head {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.creation-card {
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li + li {
|
||||||
|
border-top: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 96px 24px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.done {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.del:hover {
|
||||||
|
color: var(--wp-bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 28px 24px;
|
||||||
|
border: 1px dashed var(--wp-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ideas {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--wp-paper);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.creation-desc {
|
.idea:hover {
|
||||||
font-size: 13px;
|
border-color: var(--wp-brand);
|
||||||
color: #666;
|
}
|
||||||
|
|
||||||
|
.idea b {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idea span {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+103
-70
@@ -1,126 +1,159 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex class="container" :wrap="false">
|
<div class="teach">
|
||||||
<div class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="back-btn" @click="() => goHome($router, taskTab, step)">
|
<template v-for="group in groups" :key="group.label">
|
||||||
← 返回
|
<div class="group-label">{{ group.label }}</div>
|
||||||
</div>
|
<router-link
|
||||||
<n-divider style="margin: 8px 0" />
|
v-for="item in group.items"
|
||||||
<div
|
|
||||||
v-for="item in menu"
|
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
:class="['nav-item', { active: $route.name === item.route.name }]"
|
:to="item.route"
|
||||||
@click="$router.push(item.route)"
|
:class="['nav-item', { active: item.names.includes(String($route.name)) }]"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
<Icon :icon="item.icon" :width="16" />
|
||||||
</div>
|
<span>{{ item.label }}</span>
|
||||||
</div>
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { taskTab } from "../store/task"
|
import { Icon } from "../icons"
|
||||||
import { step } from "../store/tutorial"
|
import { step } from "../store/tutorial"
|
||||||
import { roleAdmin, roleSuper } from "../store/user"
|
import { roleSuper } from "../store/user"
|
||||||
import { goHome } from "../utils/helper"
|
|
||||||
|
|
||||||
const menu = computed(() =>
|
// admin 只能出挑战、看提交、管作品展;其余是超管的(和后端权限一致)
|
||||||
|
const groups = computed(() =>
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
label: "出题",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: "教程",
|
label: "教程",
|
||||||
|
icon: "lucide:book-open",
|
||||||
route: { name: "tutorial-editor", params: { display: step.value } },
|
route: { name: "tutorial-editor", params: { display: step.value } },
|
||||||
|
names: ["tutorial-editor"],
|
||||||
show: roleSuper.value,
|
show: roleSuper.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "挑战",
|
label: "挑战",
|
||||||
|
icon: "lucide:swords",
|
||||||
route: { name: "challenge-editor", params: { display: 0 } },
|
route: { name: "challenge-editor", params: { display: 0 } },
|
||||||
show: roleAdmin.value || roleSuper.value,
|
names: ["challenge-editor"],
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "班级",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "批改",
|
||||||
|
icon: "lucide:check-circle",
|
||||||
|
route: { name: "review" },
|
||||||
|
names: ["review"],
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "成绩册",
|
||||||
|
icon: "lucide:graduation-cap",
|
||||||
|
route: { name: "gradebook" },
|
||||||
|
names: ["gradebook"],
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 全站提交列表留给超管排查问题;平时批改用上面的“批改”
|
||||||
|
label: "全部提交",
|
||||||
|
icon: "lucide:list",
|
||||||
|
route: { name: "teach-submissions", params: { page: 1 } },
|
||||||
|
names: ["teach-submissions"],
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "管理",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "作品展",
|
||||||
|
icon: "lucide:award",
|
||||||
|
route: { name: "showcase-manage" },
|
||||||
|
names: ["showcase-manage"],
|
||||||
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "用户",
|
label: "用户",
|
||||||
|
icon: "lucide:users",
|
||||||
route: { name: "user-manage", params: { page: 1 } },
|
route: { name: "user-manage", params: { page: 1 } },
|
||||||
|
names: ["user-manage"],
|
||||||
show: roleSuper.value,
|
show: roleSuper.value,
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
label: "工坊",
|
|
||||||
route: { name: "showcase-manage" },
|
|
||||||
show: roleSuper.value,
|
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
label: "成绩",
|
.map((g) => ({ ...g, items: g.items.filter((i) => i.show) }))
|
||||||
route: { name: "gradebook" },
|
.filter((g) => g.items.length),
|
||||||
show: roleSuper.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "提交",
|
|
||||||
route: { name: "submissions", params: { page: 1 } },
|
|
||||||
show: roleAdmin.value || roleSuper.value,
|
|
||||||
},
|
|
||||||
].filter((item) => item.show),
|
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.teach {
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
width: 100vw;
|
display: flex;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 110px;
|
width: 148px;
|
||||||
min-width: 110px;
|
flex: none;
|
||||||
padding: 12px 8px;
|
padding: 8px;
|
||||||
border-right: 1px solid #efeff5;
|
border-right: 1px solid var(--wp-line);
|
||||||
background: #fafafa;
|
background: var(--wp-surface);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.group-label {
|
||||||
padding: 7px 10px;
|
padding: 12px 10px 4px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: var(--wp-faint);
|
||||||
cursor: pointer;
|
letter-spacing: 0.04em;
|
||||||
border-radius: 6px;
|
|
||||||
transition:
|
|
||||||
background-color 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #444;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding: 8px 10px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
color: var(--wp-muted);
|
||||||
color: #444;
|
text-decoration: none;
|
||||||
transition:
|
|
||||||
background-color 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background-color: #f0f0f0;
|
background: var(--wp-paper);
|
||||||
|
color: var(--wp-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
background-color: #e8f8f0;
|
background: var(--wp-brand-soft);
|
||||||
color: #18a058;
|
color: var(--wp-brand);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
box-sizing: border-box;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--wp-surface);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+377
-230
@@ -1,102 +1,125 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex
|
<div class="gradebook">
|
||||||
vertical
|
<div class="toolbar">
|
||||||
: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
|
<n-select
|
||||||
v-model:value="query.classname"
|
v-model:value="query.classname"
|
||||||
class="class-select"
|
class="class-select"
|
||||||
:options="classOptions"
|
:options="classOptions"
|
||||||
placeholder="班级"
|
placeholder="班级"
|
||||||
|
size="small"
|
||||||
:loading="classesLoading"
|
:loading="classesLoading"
|
||||||
/>
|
/>
|
||||||
<n-select
|
<n-radio-group v-model:value="query.task_type" size="small">
|
||||||
v-model:value="query.task_type"
|
<n-radio-button value="">全部</n-radio-button>
|
||||||
class="type-select"
|
<n-radio-button value="tutorial">教程</n-radio-button>
|
||||||
:options="taskTypeOptions"
|
<n-radio-button value="challenge">挑战</n-radio-button>
|
||||||
/>
|
</n-radio-group>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="query.username"
|
v-model:value="query.username"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
placeholder="学生搜索"
|
placeholder="搜索学生"
|
||||||
/>
|
>
|
||||||
<n-switch v-model:value="query.include_all_tasks">
|
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
|
||||||
<template #checked>全部有提交任务</template>
|
</n-input>
|
||||||
<template #unchecked>只看计入任务</template>
|
<n-checkbox v-model:checked="query.include_all_tasks" size="small">
|
||||||
</n-switch>
|
显示不计入的任务
|
||||||
</n-flex>
|
</n-checkbox>
|
||||||
<n-flex align="center" :size="8">
|
|
||||||
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<span v-if="gradebook" class="summary">
|
||||||
|
{{ gradebook.student_count }} 人 · 计入 {{ gradebook.included_task_count }} 个任务
|
||||||
|
</span>
|
||||||
|
<n-popover trigger="click" placement="bottom-end" style="max-width: 340px">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary>
|
||||||
|
<template #icon><Icon icon="lucide:info" /></template>
|
||||||
|
怎么算的
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<div class="rules">
|
||||||
|
<p>
|
||||||
|
<b>计入的任务</b>:班里至少一半人交过的任务才计入(现在是
|
||||||
|
{{ gradebook?.coverage_threshold_count ?? "—" }} 人),灰色的列不计入。
|
||||||
|
</p>
|
||||||
|
<p><b>每格分数</b>:这个学生在这个任务里分数最高的一次提交,点开可以批改。</p>
|
||||||
|
<p>
|
||||||
|
<b>总分 / 平时等级</b>:计入任务的分数相加后排名,A 30%、B 40%、C 20%、D 5%、E 5%。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>考核</b>:计入任务里最高三次的平均分另外排名,A 10%、B 20%、C 40%、D 20%、E 10%;
|
||||||
|
分数在等级的分数段内按名次铺开(A 92–99、B 84–91、C 76–83、D 68–75、E 60–67)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</n-popover>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button
|
<n-button
|
||||||
secondary
|
size="small"
|
||||||
title="刷新"
|
quaternary
|
||||||
:disabled="!query.classname"
|
:disabled="!query.classname"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click="loadGradebook"
|
@click="loadGradebook"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||||
<Icon icon="lucide:refresh-cw" :width="15" />
|
|
||||||
</template>
|
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
|
刷新
|
||||||
|
</n-tooltip>
|
||||||
<n-button
|
<n-button
|
||||||
type="primary"
|
size="small"
|
||||||
secondary
|
secondary
|
||||||
:disabled="!query.classname || !gradebook"
|
type="primary"
|
||||||
|
:disabled="!gradebook"
|
||||||
:loading="exporting"
|
:loading="exporting"
|
||||||
@click="exportCsv"
|
@click="exportCsv"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon><Icon icon="lucide:download" /></template>
|
||||||
<Icon icon="lucide:download" :width="15" />
|
|
||||||
</template>
|
|
||||||
导出 CSV
|
导出 CSV
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
||||||
{{ loadError }}
|
{{ loadError }}
|
||||||
</n-alert>
|
</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
|
<n-data-table
|
||||||
|
class="table gradebook-table"
|
||||||
size="small"
|
size="small"
|
||||||
striped
|
|
||||||
flex-height
|
flex-height
|
||||||
|
:single-line="false"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="rows"
|
:data="rows"
|
||||||
:row-key="(row: GradebookRow) => row.user_id"
|
:row-key="(row: GradebookRow) => row.user_id"
|
||||||
:scroll-x="scrollX"
|
:scroll-x="scrollX"
|
||||||
style="flex: 1; min-height: 0;"
|
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</div>
|
||||||
|
|
||||||
|
<n-drawer
|
||||||
|
:show="!!reviewId"
|
||||||
|
width="min(1180px, 92vw)"
|
||||||
|
placement="right"
|
||||||
|
@update:show="(v: boolean) => !v && (reviewId = null)"
|
||||||
|
>
|
||||||
|
<ReviewPanel
|
||||||
|
v-if="reviewId"
|
||||||
|
:submission-id="reviewId"
|
||||||
|
wide
|
||||||
|
@close="reviewId = null"
|
||||||
|
@changed="loadGradebook"
|
||||||
|
/>
|
||||||
|
</n-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||||
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import {
|
import { useMessage, type DataTableColumn } from "naive-ui"
|
||||||
NButton,
|
|
||||||
NTag,
|
|
||||||
NText,
|
|
||||||
useMessage,
|
|
||||||
type DataTableColumn,
|
|
||||||
} from "naive-ui"
|
|
||||||
import { useRouter } from "vue-router"
|
|
||||||
import { Account, Gradebook } from "../api"
|
import { Account, Gradebook } from "../api"
|
||||||
import { displayGradebookStudentName } from "../utils/gradebook"
|
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||||
import type {
|
import type {
|
||||||
@@ -105,10 +128,11 @@ import type {
|
|||||||
GradebookQuery,
|
GradebookQuery,
|
||||||
GradebookRow,
|
GradebookRow,
|
||||||
GradebookTask,
|
GradebookTask,
|
||||||
GradebookTaskType,
|
|
||||||
} from "../utils/type"
|
} from "../utils/type"
|
||||||
|
|
||||||
const router = useRouter()
|
// 和批改页共用“当前班级”(那边的“全部班级”在这里不适用)
|
||||||
|
const CLASS_KEY = "review-class"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const classesLoading = ref(false)
|
const classesLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -124,183 +148,155 @@ const query = reactive<GradebookQuery>({
|
|||||||
include_all_tasks: false,
|
include_all_tasks: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "教程", value: "tutorial" },
|
|
||||||
{ label: "挑战", value: "challenge" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const classOptions = computed(() =>
|
const classOptions = computed(() =>
|
||||||
classes.value.map((classname) => ({ label: classname, value: classname })),
|
classes.value.map((classname) => ({ label: `${classname} 班`, value: classname })),
|
||||||
)
|
)
|
||||||
const rows = computed(() => gradebook.value?.rows ?? [])
|
const rows = computed(() => gradebook.value?.rows ?? [])
|
||||||
const scrollX = computed(() => 1040 + (gradebook.value?.tasks.length ?? 0) * 96)
|
|
||||||
|
const TASK_WIDTH = 66
|
||||||
|
const scrollX = computed(() => 380 + (gradebook.value?.tasks.length ?? 0) * TASK_WIDTH)
|
||||||
|
|
||||||
function formatScore(value: number | null) {
|
function formatScore(value: number | null) {
|
||||||
if (value === null) return "-"
|
if (value === null) return "—"
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskTitle(task: GradebookTask) {
|
// 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
|
||||||
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战"
|
const reviewId = ref<string | null>(null)
|
||||||
return `${typeLabel}${task.display}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSubmission(cell: GradebookCell) {
|
function openSubmission(cell: GradebookCell) {
|
||||||
if (!cell.submitted || !cell.submission_id) return
|
if (!cell.submitted || !cell.submission_id) return
|
||||||
const { href } = router.resolve({
|
reviewId.value = cell.submission_id
|
||||||
name: "submission",
|
|
||||||
params: { id: cell.submission_id },
|
|
||||||
})
|
|
||||||
window.open(href, "_blank")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gradeTagType(grade: string) {
|
function renderGrade(grade: string, extra?: string | number) {
|
||||||
if (grade === "A") return "success"
|
return h("span", { class: ["grade", `g-${grade}`] }, [
|
||||||
if (grade === "B") return "info"
|
grade,
|
||||||
if (grade === "C") return "default"
|
extra === undefined ? null : h("small", String(extra)),
|
||||||
return "warning"
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTaskHeader(task: GradebookTask) {
|
function renderTaskHeader(task: GradebookTask) {
|
||||||
return h("div", { class: ["task-header", { muted: !task.included }] }, [
|
const pct = Math.round(task.coverage * 100)
|
||||||
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
|
const studentCount = gradebook.value?.student_count ?? 0
|
||||||
h("div", { class: "task-meta" }, [
|
return h(
|
||||||
h("span", `${Math.round(task.coverage * 100)}%`),
|
"div",
|
||||||
task.included
|
{
|
||||||
? null
|
class: ["task-head", { excluded: !task.included }],
|
||||||
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }),
|
title: `${task.display}. ${task.title}\n交了 ${task.submitted_count} / ${studentCount} 人(${pct}%)${task.included ? "" : ",不计入"}`,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h("div", { class: "task-name" }, task.title),
|
||||||
|
h("div", { class: "task-cov" }, [
|
||||||
|
h("span", { class: "cov-bar" }, [h("span", { style: { width: pct + "%" } })]),
|
||||||
]),
|
]),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScore(row: GradebookRow, task: GradebookTask) {
|
function renderScore(row: GradebookRow, task: GradebookTask) {
|
||||||
const cell = row.scores[task.id]
|
const cell = row.scores[task.id]
|
||||||
if (!cell || !cell.submitted) {
|
if (!cell?.submitted)
|
||||||
|
return h("span", { class: ["cell", "missing", { excluded: !task.included }] }, "缺")
|
||||||
return h(
|
return h(
|
||||||
NText,
|
"button",
|
||||||
{ class: "missing-cell", type: "error" },
|
|
||||||
{ default: () => "缺交" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return h(
|
|
||||||
NButton,
|
|
||||||
{
|
{
|
||||||
text: true,
|
class: ["cell", "score", { unrated: cell.score === 0, excluded: !task.included }],
|
||||||
type: task.included ? "primary" : "default",
|
title: cell.score === 0 ? "交了,还没人评分" : `${formatScore(cell.score)},点开批改`,
|
||||||
class: ["score-link", { muted: !task.included }],
|
onClick: () => openSubmission(cell),
|
||||||
onClick: (event: MouseEvent) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
openSubmission(cell)
|
|
||||||
},
|
},
|
||||||
},
|
cell.score === 0 ? "未评" : cell.score.toFixed(1),
|
||||||
{ default: () => formatScore(cell.score) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMissingCount(value: number) {
|
function taskColumn(task: GradebookTask): DataTableColumn<GradebookRow> {
|
||||||
if (value <= 0) return "0"
|
return {
|
||||||
return h(NText, { type: "error" }, { default: () => String(value) })
|
title: () => renderTaskHeader(task),
|
||||||
|
key: `task-${task.id}`,
|
||||||
|
width: TASK_WIDTH,
|
||||||
|
align: "center",
|
||||||
|
className: task.included ? "" : "excluded-col",
|
||||||
|
render: (row) => renderScore(row, task),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
|
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
|
||||||
const tasks = gradebook.value?.tasks ?? []
|
const tasks = gradebook.value?.tasks ?? []
|
||||||
|
const groups = (["tutorial", "challenge"] as const)
|
||||||
|
.map((type) => ({ type, items: tasks.filter((t) => t.task_type === type) }))
|
||||||
|
.filter((g) => g.items.length)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: "排名",
|
title: "#",
|
||||||
key: "rank",
|
key: "rank",
|
||||||
width: 60,
|
width: 40,
|
||||||
fixed: "left",
|
fixed: "left",
|
||||||
},
|
align: "right",
|
||||||
{
|
sorter: (a, b) => a.rank - b.rank,
|
||||||
title: "平时等级",
|
render: (row) => h("span", { class: "rank" }, row.rank),
|
||||||
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: "学生",
|
title: "学生",
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 80,
|
width: 92,
|
||||||
fixed: "left",
|
fixed: "left",
|
||||||
render: (row) => {
|
ellipsis: { tooltip: true },
|
||||||
const studentName = displayGradebookStudentName(row)
|
render: (row) => displayGradebookStudentName(row),
|
||||||
return h(NText, { title: studentName }, { default: () => studentName })
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "平时",
|
||||||
|
key: "grade",
|
||||||
|
width: 52,
|
||||||
|
fixed: "left",
|
||||||
|
align: "center",
|
||||||
|
render: (row) => renderGrade(row.grade),
|
||||||
},
|
},
|
||||||
...tasks.map((task) => ({
|
{
|
||||||
title: () => renderTaskHeader(task),
|
title: "考核",
|
||||||
key: `task-${task.id}`,
|
key: "assessment_score",
|
||||||
width: 96,
|
width: 72,
|
||||||
|
fixed: "left",
|
||||||
|
sorter: (a, b) => a.assessment_score - b.assessment_score,
|
||||||
|
render: (row) => renderGrade(row.assessment_grade, row.assessment_score),
|
||||||
|
},
|
||||||
|
...groups.map((g) => ({
|
||||||
|
title: g.type === "tutorial" ? "教程" : "挑战",
|
||||||
|
key: `group-${g.type}`,
|
||||||
align: "center" as const,
|
align: "center" as const,
|
||||||
className: task.included ? "" : "excluded-task-column",
|
children: g.items.map(taskColumn),
|
||||||
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: "总分",
|
title: "总分",
|
||||||
key: "total_score",
|
key: "total_score",
|
||||||
width: 82,
|
width: 72,
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
render: (row) => formatScore(row.total_score),
|
align: "right",
|
||||||
},
|
sorter: (a, b) => a.total_score - b.total_score,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
{
|
{
|
||||||
title: "平均",
|
class: "total",
|
||||||
key: "average_score",
|
title: `教程合计 ${formatScore(row.tutorial_total)}\n挑战合计 ${formatScore(row.challenge_total)}\n平均 ${formatScore(row.average_score)}`,
|
||||||
width: 82,
|
},
|
||||||
fixed: "right",
|
formatScore(row.total_score),
|
||||||
render: (row) => formatScore(row.average_score),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "已交",
|
title: "已交",
|
||||||
key: "submitted_task_count",
|
key: "submitted_task_count",
|
||||||
width: 70,
|
width: 64,
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
},
|
align: "right",
|
||||||
|
sorter: (a, b) => a.missing_task_count - b.missing_task_count,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
{
|
{
|
||||||
title: "缺交",
|
class: ["done", { short: row.missing_task_count > 0 }],
|
||||||
key: "missing_task_count",
|
title: row.missing_task_count ? `缺交 ${row.missing_task_count} 个` : "都交了",
|
||||||
width: 70,
|
},
|
||||||
fixed: "right",
|
`${row.submitted_task_count}/${row.submitted_task_count + row.missing_task_count}`,
|
||||||
render: (row) => renderMissingCount(row.missing_task_count),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -310,27 +306,39 @@ async function loadClasses() {
|
|||||||
try {
|
try {
|
||||||
classes.value = await Account.listClasses()
|
classes.value = await Account.listClasses()
|
||||||
if (!query.classname && classes.value.length > 0) {
|
if (!query.classname && classes.value.length > 0) {
|
||||||
query.classname = classes.value[0]
|
let saved: string | null = null
|
||||||
|
try {
|
||||||
|
saved = localStorage.getItem(CLASS_KEY)
|
||||||
|
} catch {}
|
||||||
|
query.classname = saved && classes.value.includes(saved) ? saved : classes.value[0]!
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
classesLoading.value = false
|
classesLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速切换班级时只用最后一次请求的结果
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function loadGradebook() {
|
async function loadGradebook() {
|
||||||
|
const seq = ++loadSeq
|
||||||
if (!query.classname) {
|
if (!query.classname) {
|
||||||
gradebook.value = null
|
gradebook.value = null
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ""
|
loadError.value = ""
|
||||||
try {
|
try {
|
||||||
gradebook.value = await Gradebook.get(query)
|
const data = await Gradebook.get(query)
|
||||||
classes.value = gradebook.value.classes
|
if (seq !== loadSeq) return
|
||||||
|
gradebook.value = data
|
||||||
|
classes.value = data.classes
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (seq !== loadSeq) return
|
||||||
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +354,16 @@ async function exportCsv() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => query.classname,
|
||||||
|
(c) => {
|
||||||
|
if (!c) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CLASS_KEY, c)
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [query.classname, query.task_type, query.include_all_tasks],
|
() => [query.classname, query.task_type, query.include_all_tasks],
|
||||||
() => loadGradebook(),
|
() => loadGradebook(),
|
||||||
@@ -358,73 +376,202 @@ watchDebounced(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const before = query.classname
|
||||||
await loadClasses()
|
await loadClasses()
|
||||||
await loadGradebook()
|
// loadClasses 选了默认班级时,上面的 watch 已经会加载
|
||||||
|
if (query.classname === before) loadGradebook()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.class-select {
|
.gradebook {
|
||||||
width: 150px;
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select {
|
.toolbar {
|
||||||
width: 112px;
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-select {
|
||||||
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 160px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-header {
|
.spacer {
|
||||||
min-width: 0;
|
flex: 1;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-header.muted {
|
.summary {
|
||||||
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;
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-link.muted {
|
.rules {
|
||||||
color: #777;
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.excluded-task-column) {
|
.rules p {
|
||||||
background: #fafafa;
|
margin: 0 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
.rules p:last-child {
|
||||||
.toolbar {
|
margin: 0;
|
||||||
align-items: flex-start;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.class-select,
|
.table {
|
||||||
.type-select,
|
flex: 1;
|
||||||
.search-input {
|
min-height: 0;
|
||||||
width: 100%;
|
}
|
||||||
}
|
|
||||||
|
.table :deep(.n-data-table-td) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.n-data-table-th) {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 单元格是 render 函数画的,拿不到 scoped 属性,用表格的类名限定 */
|
||||||
|
.gradebook-table .excluded-col {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .rank {
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .grade {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .grade small {
|
||||||
|
margin-left: 6px;
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .g-A {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .g-B {
|
||||||
|
color: var(--wp-css);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .g-C {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .g-D {
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .g-E {
|
||||||
|
color: var(--wp-bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .task-head {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .task-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .task-head.excluded .task-name {
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .task-cov {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .cov-bar {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--wp-line);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .cov-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .task-head.excluded .cov-bar span {
|
||||||
|
background: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .cell {
|
||||||
|
font: 13px var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .cell.missing {
|
||||||
|
font: 12px var(--wp-font-sans);
|
||||||
|
color: var(--wp-bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .score {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .score:hover {
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .score.unrated {
|
||||||
|
font: 12px var(--wp-font-sans);
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .missing.excluded,
|
||||||
|
.gradebook-table .score.excluded {
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .total {
|
||||||
|
font: 600 13px var(--wp-font-mono);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .done {
|
||||||
|
font: 13px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradebook-table .done.short {
|
||||||
|
color: var(--wp-bad);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mine">
|
||||||
|
<aside class="tasks">
|
||||||
|
<div class="tasks-head">
|
||||||
|
<b>我的提交</b>
|
||||||
|
<span class="muted">{{ tasks.length }} 个任务</span>
|
||||||
|
</div>
|
||||||
|
<n-spin :show="loadingList" class="task-list">
|
||||||
|
<template v-for="g in groups" :key="g.label">
|
||||||
|
<div class="group-label">{{ g.label }}</div>
|
||||||
|
<button
|
||||||
|
v-for="t in g.items"
|
||||||
|
:key="t.task_id"
|
||||||
|
:class="['task', { on: t.task_id === taskId }]"
|
||||||
|
@click="taskId = t.task_id"
|
||||||
|
>
|
||||||
|
<span class="task-title">{{ t.task_display }}. {{ t.task_title }}</span>
|
||||||
|
<span class="task-meta">
|
||||||
|
{{ t.submit_count }} 次 · {{ parseTime(t.created, "MM-DD") }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<n-empty
|
||||||
|
v-if="!loadingList && !tasks.length"
|
||||||
|
description="还没有交过作品"
|
||||||
|
style="margin-top: 60px"
|
||||||
|
/>
|
||||||
|
</n-spin>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="detail">
|
||||||
|
<template v-if="task">
|
||||||
|
<div class="detail-head">
|
||||||
|
<div class="title">
|
||||||
|
<span class="kind">{{ task.task_type === TASK_TYPE.Tutorial ? "教程" : "挑战" }}</span>
|
||||||
|
<b>{{ task.task_display }}. {{ task.task_title }}</b>
|
||||||
|
</div>
|
||||||
|
<n-button size="small" quaternary icon-placement="right" @click="openTask">
|
||||||
|
打开这道题
|
||||||
|
<template #icon><Icon icon="lucide:arrow-right" /></template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-spin :show="loadingVersions" class="stage">
|
||||||
|
<div v-if="versions.length" class="versions">
|
||||||
|
<button
|
||||||
|
v-for="(v, i) in versions"
|
||||||
|
:key="v.id"
|
||||||
|
:class="['ver', { on: v.id === currentId }]"
|
||||||
|
@click="currentId = v.id"
|
||||||
|
>
|
||||||
|
第 {{ versions.length - i }} 次
|
||||||
|
<span class="ver-score">{{ v.score ? v.score.toFixed(1) : "未评" }}</span>
|
||||||
|
<span v-if="v.id === bestId && versions.length > 1" class="best">计入成绩</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="frame">
|
||||||
|
<iframe
|
||||||
|
v-if="current"
|
||||||
|
:key="current.id"
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="doc"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-if="current" class="meta">
|
||||||
|
<span class="muted">
|
||||||
|
{{ parseTime(current.created, "YYYY-MM-DD HH:mm") }} 提交 ·
|
||||||
|
{{ current.score ? `得分 ${current.score.toFixed(2)}` : "还没有人评分" }}
|
||||||
|
<template v-if="versions.length > 1"> · 成绩按分数最高的一次算</template>
|
||||||
|
</span>
|
||||||
|
<n-flex :size="4" :wrap="false">
|
||||||
|
<n-button
|
||||||
|
v-if="task.task_type === TASK_TYPE.Challenge"
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
@click="showChain = true"
|
||||||
|
>
|
||||||
|
提示词
|
||||||
|
</n-button>
|
||||||
|
<n-popconfirm @positive-click="copyToEditor">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary>放回编辑器</n-button>
|
||||||
|
</template>
|
||||||
|
把这一版的代码放进这道题的编辑器,会替换编辑器里现在的代码。
|
||||||
|
</n-popconfirm>
|
||||||
|
<n-popconfirm @positive-click="remove">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary type="error">删除这一版</n-button>
|
||||||
|
</template>
|
||||||
|
删除后找不回来;它对应的那轮 AI 对话也会一起删掉。<br />
|
||||||
|
已经有人评过分的不能删。
|
||||||
|
</n-popconfirm>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</template>
|
||||||
|
<n-empty v-else-if="tasks.length" description="选一个任务" style="margin-top: 120px" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChainModal
|
||||||
|
v-if="current"
|
||||||
|
v-model:show="showChain"
|
||||||
|
:submission-id="current.id"
|
||||||
|
:title="`${current.task_display}. ${current.task_title}`"
|
||||||
|
:asset-base-url="`/media/tasks/${current.task_type}/${current.task_display}/`"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import { Submission } from "../api"
|
||||||
|
import ChainModal from "../components/submissions/ChainModal.vue"
|
||||||
|
import { saveTaskCode } from "../composables/useTaskCode"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import { goHome, parseTime } from "../utils/helper"
|
||||||
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||||
|
import type { SubmissionOut } from "../utils/type"
|
||||||
|
|
||||||
|
// 学生看自己交过的作品:左边按任务列出,右边看每一次交的版本
|
||||||
|
type Version = SubmissionOut & { html: string | null; css: string | null; js: string | null }
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const tasks = ref<SubmissionOut[]>([])
|
||||||
|
const loadingList = ref(false)
|
||||||
|
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
|
||||||
|
const versions = ref<Version[]>([])
|
||||||
|
const loadingVersions = ref(false)
|
||||||
|
const currentId = ref<string | null>(null)
|
||||||
|
const showChain = ref(false)
|
||||||
|
|
||||||
|
const task = computed(() => tasks.value.find((t) => t.task_id === taskId.value) ?? null)
|
||||||
|
const current = computed(() => versions.value.find((v) => v.id === currentId.value) ?? null)
|
||||||
|
|
||||||
|
const groups = computed(() =>
|
||||||
|
[
|
||||||
|
{ label: "教程", type: TASK_TYPE.Tutorial },
|
||||||
|
{ label: "挑战", type: TASK_TYPE.Challenge },
|
||||||
|
]
|
||||||
|
.map((g) => ({
|
||||||
|
label: g.label,
|
||||||
|
items: tasks.value
|
||||||
|
.filter((t) => t.task_type === g.type)
|
||||||
|
.sort((a, b) => a.task_display - b.task_display),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.items.length),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 和成绩册一样:分数最高的一次计入成绩,同分取最新
|
||||||
|
const bestId = computed(() => {
|
||||||
|
let best: Version | null = null
|
||||||
|
for (const v of versions.value) if (v.score > 0 && (!best || v.score > best.score)) best = v
|
||||||
|
return best?.id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const doc = computed(() =>
|
||||||
|
current.value
|
||||||
|
? buildPreviewDocument({
|
||||||
|
html: current.value.html ?? "",
|
||||||
|
css: current.value.css ?? "",
|
||||||
|
js: current.value.js ?? "",
|
||||||
|
assetBaseUrl: `/media/tasks/${current.value.task_type}/${current.value.task_display}/`,
|
||||||
|
})
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
loadingList.value = true
|
||||||
|
try {
|
||||||
|
const all: SubmissionOut[] = []
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const res = await Submission.list({ page, page_size: 100, grouped: true })
|
||||||
|
all.push(...res.items)
|
||||||
|
if (all.length >= res.count || !res.items.length) break
|
||||||
|
}
|
||||||
|
tasks.value = all
|
||||||
|
if (!task.value) taskId.value = groups.value[0]?.items[0]?.task_id ?? null
|
||||||
|
} finally {
|
||||||
|
loadingList.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
async function loadVersions() {
|
||||||
|
const s = ++seq
|
||||||
|
const t = task.value
|
||||||
|
if (!t) {
|
||||||
|
versions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingVersions.value = true
|
||||||
|
try {
|
||||||
|
const list = (await Submission.listByUserTask(t.userid, t.task_id)) as Version[]
|
||||||
|
if (s !== seq) return
|
||||||
|
versions.value = list
|
||||||
|
if (!list.some((v) => v.id === currentId.value)) currentId.value = list[0]?.id ?? null
|
||||||
|
} finally {
|
||||||
|
if (s === seq) loadingVersions.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(taskId, (id) => {
|
||||||
|
router.replace({ query: id ? { task: String(id) } : {} })
|
||||||
|
loadVersions()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openTask() {
|
||||||
|
if (task.value) goHome(router, task.value.task_type, task.value.task_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToEditor() {
|
||||||
|
const v = current.value
|
||||||
|
if (!v) return
|
||||||
|
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
|
||||||
|
saveTaskCode(v.task_id, { html: v.html ?? "", css: v.css ?? "", js: v.js ?? "" })
|
||||||
|
goHome(router, v.task_type, v.task_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
const v = current.value
|
||||||
|
if (!v) return
|
||||||
|
try {
|
||||||
|
await Submission.delete(v.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "删除失败,请重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success("已删除")
|
||||||
|
if (versions.value.length > 1) {
|
||||||
|
await loadVersions()
|
||||||
|
const t = tasks.value.find((x) => x.task_id === v.task_id)
|
||||||
|
if (t) t.submit_count = versions.value.length
|
||||||
|
} else {
|
||||||
|
// 这道题一次都不剩了
|
||||||
|
tasks.value = tasks.value.filter((x) => x.task_id !== v.task_id)
|
||||||
|
taskId.value = groups.value[0]?.items[0]?.task_id ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadTasks()
|
||||||
|
loadVersions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mine {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks {
|
||||||
|
width: 260px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
padding: 10px 6px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task.on {
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage :deep(.n-spin-content) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver:hover {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver.on {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver-score {
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.best {
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ver.on .best {
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<template>
|
||||||
|
<div class="review">
|
||||||
|
<aside v-show="!fullDetail" class="tasks">
|
||||||
|
<n-select
|
||||||
|
v-model:value="classname"
|
||||||
|
:options="classOptions"
|
||||||
|
placeholder="选择班级"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<div v-if="classname" class="task-list">
|
||||||
|
<template v-for="group in taskGroups" :key="group.label">
|
||||||
|
<div class="group-label">{{ group.label }}</div>
|
||||||
|
<button
|
||||||
|
v-for="t in group.items"
|
||||||
|
:key="t.id"
|
||||||
|
:class="['task', { on: t.id === taskId }]"
|
||||||
|
@click="taskId = t.id"
|
||||||
|
>
|
||||||
|
<span class="task-title">{{ t.display }}. {{ t.title }}</span>
|
||||||
|
<span class="task-count">{{ progressOf(t.id) }}</span>
|
||||||
|
<span class="bar"><span :style="{ width: percentOf(t.id) + '%' }"></span></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<n-empty v-else description="先选一个班级" size="small" style="margin-top: 40px" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main v-show="!fullDetail" class="wall">
|
||||||
|
<template v-if="task && classname">
|
||||||
|
<div class="wall-head">
|
||||||
|
<div class="wall-title">
|
||||||
|
<b>{{ task.display }}. {{ task.title }}</b>
|
||||||
|
<span class="chip">已交 {{ submittedCount }} / {{ students.length }}</span>
|
||||||
|
<span v-if="unratedCount" class="chip warn">我未评 {{ unratedCount }}</span>
|
||||||
|
</div>
|
||||||
|
<n-flex :size="8" align="center" :wrap="false">
|
||||||
|
<n-radio-group v-model:value="filter" size="small">
|
||||||
|
<n-radio-button value="all">全部</n-radio-button>
|
||||||
|
<n-radio-button value="unrated">我未评</n-radio-button>
|
||||||
|
<n-radio-button value="missing">未交</n-radio-button>
|
||||||
|
<n-radio-button value="present">课上展示</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!presentList.length"
|
||||||
|
@click="startPresent"
|
||||||
|
>
|
||||||
|
开始讲评({{ presentList.length }})
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" @click="showStats = true">
|
||||||
|
<template #icon><Icon icon="lucide:bar-chart-2" /></template>
|
||||||
|
课堂总结
|
||||||
|
</n-button>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary @click="loadWall">
|
||||||
|
<Icon icon="lucide:refresh-cw" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
刷新
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
<n-spin :show="loadingWall" class="grid-wrap">
|
||||||
|
<template v-for="g in groups" :key="g.name">
|
||||||
|
<div v-if="g.name" class="group-head">
|
||||||
|
<b>{{ g.name }} 班</b>
|
||||||
|
<span>已交 {{ g.submitted }} / {{ g.total }}</span>
|
||||||
|
<span v-if="g.collapsed">· 这个班还没有人交,已收起</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!g.collapsed" class="grid">
|
||||||
|
<button
|
||||||
|
v-for="s in g.items"
|
||||||
|
:key="s.user_id"
|
||||||
|
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
|
||||||
|
:disabled="!s.submission"
|
||||||
|
@click="s.submission && openDetail(s.submission.id)"
|
||||||
|
>
|
||||||
|
<div class="thumb">
|
||||||
|
<iframe
|
||||||
|
v-if="s.submission"
|
||||||
|
:key="s.submission.id"
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="docOf(s)"
|
||||||
|
loading="lazy"
|
||||||
|
tabindex="-1"
|
||||||
|
></iframe>
|
||||||
|
<span v-else>未交</span>
|
||||||
|
</div>
|
||||||
|
<div class="cap">
|
||||||
|
<b>{{ s.username }}</b>
|
||||||
|
<span v-if="!s.submission" class="state grey">未交</span>
|
||||||
|
<span v-else-if="!s.submission.my_score" class="state warn">我未评</span>
|
||||||
|
<span v-else class="state mono">{{ s.submission.my_score }} 分</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="s.submission?.flag === 'present'" class="present">讲评</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<n-empty
|
||||||
|
v-if="!loadingWall && !visible.length"
|
||||||
|
description="没有符合条件的学生"
|
||||||
|
style="margin-top: 60px"
|
||||||
|
/>
|
||||||
|
</n-spin>
|
||||||
|
</template>
|
||||||
|
<n-empty v-else description="选一个班级和任务开始批改" style="margin-top: 120px" />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside v-if="selectedId" :class="['detail', { full: fullDetail }]">
|
||||||
|
<ReviewPanel
|
||||||
|
:submission-id="selectedId"
|
||||||
|
:wide="fullDetail"
|
||||||
|
can-widen
|
||||||
|
@toggle-wide="bigPreview = !bigPreview"
|
||||||
|
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
|
||||||
|
@prev="move(-1)"
|
||||||
|
@next="move(1)"
|
||||||
|
@close="closeDetail"
|
||||||
|
@changed="applyPatch"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskStatsModal
|
||||||
|
v-if="task"
|
||||||
|
v-model:show="showStats"
|
||||||
|
:task-id="task.id"
|
||||||
|
:title="`${task.display}. ${task.title}`"
|
||||||
|
:initial-class="classname"
|
||||||
|
in-review
|
||||||
|
@open="openFromStats"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="presenting" class="present-mode">
|
||||||
|
<div class="present-head">
|
||||||
|
<b>{{ presentList[presentIndex]?.username }}</b>
|
||||||
|
<span>{{ presentIndex + 1 }} / {{ presentList.length }} · ← → 切换,Esc 退出</span>
|
||||||
|
<button class="exit" @click="presenting = false">退出讲评</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-if="presentList[presentIndex]"
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:key="presentIndex"
|
||||||
|
:srcdoc="docOf(presentList[presentIndex]!)"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||||
|
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||||
|
import { Review } from "../api"
|
||||||
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||||
|
import type { FlagType, ReviewOptions, ReviewProgress, ReviewStudent } from "../utils/type"
|
||||||
|
|
||||||
|
// 老师批改:选班级、选任务,看全班的作品墙,点开右侧详情打分
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const options = ref<ReviewOptions>({ classes: [], tasks: [] })
|
||||||
|
const progress = ref<ReviewProgress | null>(null)
|
||||||
|
const students = ref<ReviewStudent[]>([])
|
||||||
|
const loadingWall = ref(false)
|
||||||
|
const classname = ref<string | null>((route.query.class as string) || null)
|
||||||
|
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
|
||||||
|
const filter = ref<"all" | "unrated" | "missing" | "present">("all")
|
||||||
|
// 打开的作品放在地址栏(?sub=),浏览器“后退”就能回到作品墙
|
||||||
|
const selectedId = ref<string | null>((route.query.sub as string) || null)
|
||||||
|
watch(
|
||||||
|
() => route.query.sub,
|
||||||
|
(v) => {
|
||||||
|
selectedId.value = (v as string) || null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// 是不是从作品墙点开的(点开时记了一步历史,关闭时退回那一步)
|
||||||
|
let pushedDetail = false
|
||||||
|
|
||||||
|
function openDetail(id: string) {
|
||||||
|
pushedDetail = true
|
||||||
|
router.push({ query: { ...route.query, sub: id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 课堂总结里点了高分作品:在这里打开;那个学生不在当前班就先切过去
|
||||||
|
const showStats = ref(false)
|
||||||
|
async function openFromStats(id: string, cls: string) {
|
||||||
|
showStats.value = false
|
||||||
|
if (classname.value !== ALL && classname.value !== cls) {
|
||||||
|
classname.value = cls
|
||||||
|
// 先让地址栏变成新班级(切班的 watch 也会这么改),关掉作品时“后退”才回到这个班
|
||||||
|
await nextTick()
|
||||||
|
await router.replace({ query: { class: cls, task: taskId.value ?? undefined } })
|
||||||
|
}
|
||||||
|
pushedDetail = true
|
||||||
|
router.push({ query: { class: classname.value ?? undefined, task: taskId.value ?? undefined, sub: id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
if (pushedDetail) {
|
||||||
|
pushedDetail = false
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
const { sub: _, ...rest } = route.query
|
||||||
|
router.replace({ query: rest })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const presenting = ref(false)
|
||||||
|
// 点开作品默认用大图(预览占满中间),老师也可以切回旁边的小面板;记住选择
|
||||||
|
const bigPreview = useStorage("review-big-preview", true)
|
||||||
|
const fullDetail = computed(() => !!selectedId.value && bigPreview.value)
|
||||||
|
const presentIndex = ref(0)
|
||||||
|
|
||||||
|
// 全部班级:一道题全校的作品放在一面墙上,按班级分组
|
||||||
|
const ALL = "__all__"
|
||||||
|
const classOptions = computed(() => [
|
||||||
|
{ label: "全部班级", value: ALL },
|
||||||
|
...options.value.classes.map((c) => ({ label: `${c} 班`, value: c })),
|
||||||
|
])
|
||||||
|
const classParam = () => (classname.value === ALL ? undefined : classname.value ?? undefined)
|
||||||
|
const task = computed(() => options.value.tasks.find((t) => t.id === taskId.value))
|
||||||
|
const taskGroups = computed(() => [
|
||||||
|
{ label: "教程", items: options.value.tasks.filter((t) => t.task_type === "tutorial") },
|
||||||
|
{ label: "挑战", items: options.value.tasks.filter((t) => t.task_type === "challenge") },
|
||||||
|
])
|
||||||
|
|
||||||
|
const progressOf = (id: number) =>
|
||||||
|
progress.value ? `${progress.value.submitted[id] ?? 0}/${progress.value.student_count}` : ""
|
||||||
|
const percentOf = (id: number) =>
|
||||||
|
progress.value?.student_count
|
||||||
|
? Math.round(((progress.value.submitted[id] ?? 0) / progress.value.student_count) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const submittedCount = computed(() => students.value.filter((s) => s.submission).length)
|
||||||
|
const unratedCount = computed(
|
||||||
|
() => students.value.filter((s) => s.submission && !s.submission.my_score).length,
|
||||||
|
)
|
||||||
|
const visible = computed(() =>
|
||||||
|
students.value.filter((s) => {
|
||||||
|
if (filter.value === "unrated") return s.submission && !s.submission.my_score
|
||||||
|
if (filter.value === "missing") return !s.submission
|
||||||
|
if (filter.value === "present") return s.submission?.flag === "present"
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const groups = computed(() => {
|
||||||
|
if (classname.value !== ALL) {
|
||||||
|
return [{ name: "", items: visible.value, submitted: 0, total: 0, collapsed: false }]
|
||||||
|
}
|
||||||
|
const map = new Map<string, ReviewStudent[]>()
|
||||||
|
for (const s of visible.value) {
|
||||||
|
if (!map.has(s.classname)) map.set(s.classname, [])
|
||||||
|
map.get(s.classname)!.push(s)
|
||||||
|
}
|
||||||
|
return [...map].map(([name, items]) => {
|
||||||
|
const all = students.value.filter((s) => s.classname === name)
|
||||||
|
const submitted = all.filter((s) => s.submission).length
|
||||||
|
// 全校一起看时,没人交的班(这道题没布置给它)只留一行标题,不然一屏都是“未交”
|
||||||
|
return { name, items, submitted, total: all.length, collapsed: !submitted && filter.value !== "missing" }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const presentList = computed(() => students.value.filter((s) => s.submission?.flag === "present"))
|
||||||
|
|
||||||
|
const docs = new Map<string, string>()
|
||||||
|
function docOf(s: ReviewStudent) {
|
||||||
|
const sub = s.submission!
|
||||||
|
let d = docs.get(sub.id)
|
||||||
|
if (!d) {
|
||||||
|
const t = task.value
|
||||||
|
d = buildPreviewDocument({
|
||||||
|
html: sub.html,
|
||||||
|
css: sub.css,
|
||||||
|
js: sub.js,
|
||||||
|
// 学生代码里用相对路径引用题目素材
|
||||||
|
assetBaseUrl: t ? `/media/tasks/${t.task_type}/${t.display}/` : undefined,
|
||||||
|
})
|
||||||
|
docs.set(sub.id, d)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
let wallSeq = 0
|
||||||
|
async function loadWall() {
|
||||||
|
if (!classname.value || !taskId.value) return
|
||||||
|
const s = ++wallSeq
|
||||||
|
loadingWall.value = true
|
||||||
|
try {
|
||||||
|
const data = await Review.classWall(classParam(), taskId.value)
|
||||||
|
if (s === wallSeq) students.value = data
|
||||||
|
} finally {
|
||||||
|
if (s === wallSeq) loadingWall.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProgress() {
|
||||||
|
progress.value = null
|
||||||
|
if (classname.value) progress.value = await Review.progress(classParam())
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(classname, (c) => {
|
||||||
|
if (c) localStorage.setItem("review-class", c)
|
||||||
|
loadProgress()
|
||||||
|
})
|
||||||
|
watch([classname, taskId], () => {
|
||||||
|
router.replace({ query: { class: classname.value ?? undefined, task: taskId.value ?? undefined } })
|
||||||
|
pushedDetail = false
|
||||||
|
filter.value = "all"
|
||||||
|
students.value = []
|
||||||
|
loadWall()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 作品墙里有提交的学生,按当前筛选的顺序切换
|
||||||
|
function neighbor(step: number): string | null {
|
||||||
|
const list = visible.value.filter((s) => s.submission)
|
||||||
|
const i = list.findIndex((s) => s.submission!.id === selectedId.value)
|
||||||
|
return list[i + step]?.submission?.id ?? null
|
||||||
|
}
|
||||||
|
// 切换学生不新增历史,免得要连按很多次后退
|
||||||
|
function move(step: number) {
|
||||||
|
const id = neighbor(step)
|
||||||
|
if (id) router.replace({ query: { ...route.query, sub: id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPatch(p: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
|
||||||
|
const s = students.value.find((x) => x.submission?.id === p.id)?.submission
|
||||||
|
if (!s) return
|
||||||
|
if (p.my_score !== undefined) s.my_score = p.my_score
|
||||||
|
if (p.score !== undefined) s.score = p.score
|
||||||
|
if (p.flag !== undefined) s.flag = p.flag
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPresent() {
|
||||||
|
presentIndex.value = 0
|
||||||
|
presenting.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (presenting.value) {
|
||||||
|
if (e.key === "Escape") presenting.value = false
|
||||||
|
else if (e.key === "ArrowRight") presentIndex.value = Math.min(presentIndex.value + 1, presentList.value.length - 1)
|
||||||
|
else if (e.key === "ArrowLeft") presentIndex.value = Math.max(presentIndex.value - 1, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
|
||||||
|
if (e.key === "Escape" && selectedId.value) closeDetail()
|
||||||
|
// 按键盘上的位置:J 在左边是上一个,K 在右边是下一个(和面板上的左右箭头一致)
|
||||||
|
else if (e.key === "j" || e.key === "J") move(-1)
|
||||||
|
else if (e.key === "k" || e.key === "K") move(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
options.value = await Review.options()
|
||||||
|
if (!classname.value) {
|
||||||
|
const saved = localStorage.getItem("review-class")
|
||||||
|
classname.value =
|
||||||
|
saved && (saved === ALL || options.value.classes.includes(saved)) ? saved : null
|
||||||
|
} else {
|
||||||
|
loadProgress()
|
||||||
|
loadWall()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.review {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks {
|
||||||
|
width: 232px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-right: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
padding: 8px 6px 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 2px 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task.on {
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-count {
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--wp-line);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.warn {
|
||||||
|
background: var(--wp-warn-soft);
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-head span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover:not(:disabled) {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.sel {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
height: 100px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing .thumb {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按 4 倍大小渲染再缩小 */
|
||||||
|
.thumb iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 400%;
|
||||||
|
height: 400%;
|
||||||
|
border: 0;
|
||||||
|
transform: scale(0.25);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap b {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.warn {
|
||||||
|
background: var(--wp-warn-soft);
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.grey {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.mono {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.present {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wp-brand);
|
||||||
|
color: var(--wp-surface);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
width: 380px;
|
||||||
|
flex: none;
|
||||||
|
border-left: 1px solid var(--wp-line);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail.full {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.present-mode {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--wp-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.present-head {
|
||||||
|
height: 48px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 16px;
|
||||||
|
color: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.present-head span {
|
||||||
|
flex: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--wp-faint);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-surface);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit:hover {
|
||||||
|
border-color: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.present-mode iframe {
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+381
-92
@@ -1,166 +1,455 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="showcase">
|
<main class="showcase">
|
||||||
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;">
|
<header class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2>
|
<h2>作品展</h2>
|
||||||
<n-text depth="3">优秀作品展示</n-text>
|
<p class="muted">老师从大家交的作品里挑出来的,点开能看大图和它是怎么问出来的</p>
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
<nav v-if="awards.length > 1" class="jump">
|
||||||
|
<a v-for="a in awards" :key="a.id" :href="`#award-${a.id}`" @click.prevent="jump(a.id)">
|
||||||
|
{{ a.name }}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
<n-spin :show="loading">
|
<n-spin :show="loading">
|
||||||
<n-empty
|
<n-empty
|
||||||
v-if="!loading && awards.length === 0"
|
v-if="!loading && !awards.length"
|
||||||
description="暂无展示作品"
|
description="还没有展出的作品"
|
||||||
style="margin-top: 72px;"
|
style="margin-top: 72px"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section v-for="(section, si) in awards" :id="`award-${section.id}`" :key="section.id">
|
||||||
v-for="section in awards"
|
<div class="section-head">
|
||||||
:key="section.id"
|
<h3>{{ section.name }}</h3>
|
||||||
style="margin-bottom: 48px;"
|
<span class="muted">{{ section.items.length }} 件</span>
|
||||||
>
|
</div>
|
||||||
<n-flex vertical :size="4" style="margin-bottom: 16px;">
|
<p v-if="section.description" class="desc">{{ section.description }}</p>
|
||||||
<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">
|
<div class="grid">
|
||||||
<n-card
|
<button
|
||||||
v-for="item in section.items"
|
v-for="(item, ii) in section.items"
|
||||||
:key="item.submission_id"
|
:key="item.submission_id"
|
||||||
class="work-card"
|
class="card"
|
||||||
content-style="padding: 0;"
|
@click="open(si, ii)"
|
||||||
hoverable
|
|
||||||
@click="openDetail(item)"
|
|
||||||
>
|
>
|
||||||
<div class="card-preview">
|
<div class="thumb">
|
||||||
<iframe
|
<iframe
|
||||||
:srcdoc="buildSrcdoc(item)"
|
:srcdoc="docOf(item)"
|
||||||
sandbox="allow-scripts"
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
loading="lazy"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
class="preview-iframe"
|
tabindex="-1"
|
||||||
/>
|
></iframe>
|
||||||
<div class="preview-overlay" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-info">
|
<div class="cap">
|
||||||
<n-flex justify="space-between" align="center" :wrap="false">
|
<div class="who">
|
||||||
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;">
|
<b>{{ item.username }}</b>
|
||||||
{{ item.username }}
|
<span v-if="item.score" class="score">
|
||||||
</n-ellipsis>
|
<Icon icon="lucide:star" :width="12" />{{ item.score.toFixed(1) }}
|
||||||
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</n-card>
|
<div class="task">{{ item.task_display }}. {{ item.task_title }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
:show="!!current"
|
||||||
|
preset="card"
|
||||||
|
style="width: 94vw; max-width: 1280px"
|
||||||
|
:bordered="false"
|
||||||
|
@update:show="(v: boolean) => !v && (viewing = null)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="viewer-head">
|
||||||
|
<b>{{ current?.username }}</b>
|
||||||
|
<span class="muted">{{ current?.task_display }}. {{ current?.task_title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-flex v-if="viewing" align="center" :size="4" style="margin-right: 8px">
|
||||||
|
<span class="muted">
|
||||||
|
{{ viewing.item + 1 }} / {{ awards[viewing.section]?.items.length }}
|
||||||
|
</span>
|
||||||
|
<n-button size="small" quaternary :disabled="viewing.item === 0" @click="step(-1)">
|
||||||
|
<template #icon><Icon icon="lucide:arrow-left" /></template>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
:disabled="viewing.item >= (awards[viewing.section]?.items.length ?? 0) - 1"
|
||||||
|
@click="step(1)"
|
||||||
|
>
|
||||||
|
<template #icon><Icon icon="lucide:arrow-right" /></template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="current" class="viewer">
|
||||||
|
<div class="big">
|
||||||
|
<iframe :key="current.submission_id" :srcdoc="docOf(current)" :sandbox="PREVIEW_SANDBOX"></iframe>
|
||||||
|
</div>
|
||||||
|
<aside class="info">
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">获奖</div>
|
||||||
|
<div class="awards">
|
||||||
|
<span v-for="name in awardsOf(current.submission_id)" :key="name" class="award">
|
||||||
|
<Icon icon="lucide:award" :width="13" />{{ name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">得分</div>
|
||||||
|
<div v-if="current.score" class="mono">{{ current.score.toFixed(2) }}</div>
|
||||||
|
<div v-else class="muted">还没有评分</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">看过的次数</div>
|
||||||
|
<div class="mono">{{ current.view_count }}</div>
|
||||||
|
</div>
|
||||||
|
<n-flex vertical :size="8" class="actions">
|
||||||
|
<n-button v-if="current.has_prompt_chain" secondary @click="showChain = true">
|
||||||
|
<template #icon><Icon icon="lucide:sparkles" /></template>
|
||||||
|
看看是怎么问出来的
|
||||||
|
</n-button>
|
||||||
|
<n-button quaternary @click="openFull">
|
||||||
|
<template #icon><Icon icon="lucide:maximize-2" /></template>
|
||||||
|
全屏打开
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<div class="muted hint">← → 切换,Esc 关闭</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<ChainModal
|
||||||
|
v-if="current"
|
||||||
|
v-model:show="showChain"
|
||||||
|
:submission-id="current.submission_id"
|
||||||
|
:title="`${current.username} · ${current.task_title}`"
|
||||||
|
:asset-base-url="assetBase(current)"
|
||||||
|
from-showcase
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storageShim } from "../utils/previewStorage"
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
import { onMounted, ref } from "vue"
|
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "../icons"
|
||||||
import { Showcase } from "../api"
|
import { Showcase, Submission } from "../api"
|
||||||
|
import ChainModal from "../components/submissions/ChainModal.vue"
|
||||||
|
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||||
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const awards = ref<AwardSection[]>([])
|
const awards = ref<AwardSection[]>([])
|
||||||
|
// 正在看第几个奖项的第几件
|
||||||
|
const viewing = ref<{ section: number; item: number } | null>(null)
|
||||||
|
const showChain = ref(false)
|
||||||
|
|
||||||
function buildSrcdoc(item: ShowcaseItem): string {
|
const current = computed(() =>
|
||||||
const css = item.css ? `<style>${item.css}</style>` : ""
|
viewing.value ? (awards.value[viewing.value.section]?.items[viewing.value.item] ?? null) : null,
|
||||||
const js = item.js ? `<script>${item.js}<\/script>` : ""
|
)
|
||||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}<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>`
|
|
||||||
|
const assetBase = (item: ShowcaseItem) => `/media/tasks/${item.task_type}/${item.task_display}/`
|
||||||
|
|
||||||
|
function docOf(item: ShowcaseItem) {
|
||||||
|
return buildPreviewDocument({
|
||||||
|
html: item.html ?? "",
|
||||||
|
css: item.css ?? "",
|
||||||
|
js: item.js ?? "",
|
||||||
|
assetBaseUrl: assetBase(item),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDetail(item: ShowcaseItem) {
|
// 一件作品可能同时在几个奖项里
|
||||||
const { href } = router.resolve({
|
function awardsOf(submissionId: string) {
|
||||||
name: "submission",
|
return awards.value
|
||||||
params: { id: item.submission_id },
|
.filter((a) => a.items.some((i) => i.submission_id === submissionId))
|
||||||
})
|
.map((a) => a.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jump(id: number) {
|
||||||
|
document.getElementById(`award-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(section: number, item: number) {
|
||||||
|
viewing.value = { section, item }
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(d: number) {
|
||||||
|
const v = viewing.value
|
||||||
|
if (!v) return
|
||||||
|
const n = awards.value[v.section]?.items.length ?? 0
|
||||||
|
const item = v.item + d
|
||||||
|
if (item >= 0 && item < n) viewing.value = { section: v.section, item }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每打开一件记一次浏览
|
||||||
|
const counted = new Set<string>()
|
||||||
|
watch(current, (item) => {
|
||||||
|
if (!item || counted.has(item.submission_id)) return
|
||||||
|
counted.add(item.submission_id)
|
||||||
|
Submission.incrementView(item.submission_id)
|
||||||
|
.then(() => item.view_count++)
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
function openFull() {
|
||||||
|
if (!current.value) return
|
||||||
|
const { href } = router.resolve({ name: "submission", params: { id: current.value.submission_id } })
|
||||||
window.open(href, "_blank")
|
window.open(href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (!viewing.value || showChain.value) return
|
||||||
|
if (e.key === "ArrowLeft") step(-1)
|
||||||
|
else if (e.key === "ArrowRight") step(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
try {
|
try {
|
||||||
awards.value = await Showcase.list()
|
awards.value = await Showcase.list()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||||
onMounted(init)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.showcase {
|
.showcase {
|
||||||
max-width: 1180px;
|
max-width: 1180px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 48px;
|
padding: 28px 20px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid {
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head .muted {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump a {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump a:hover {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
scroll-margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
margin-top: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.work-card {
|
.card {
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
color: var(--wp-ink);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease;
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.work-card:hover {
|
.card:hover {
|
||||||
|
border-color: var(--wp-brand);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-preview {
|
.thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 160px;
|
aspect-ratio: 16 / 10;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f7f8fa;
|
border-bottom: 1px solid var(--wp-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-iframe {
|
/* 按 2 倍大小渲染再缩小 */
|
||||||
|
.thumb iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
width: 200%;
|
width: 200%;
|
||||||
height: 200%;
|
height: 200%;
|
||||||
border: none;
|
border: 0;
|
||||||
transform: scale(0.5);
|
transform: scale(0.5);
|
||||||
transform-origin: top left;
|
transform-origin: 0 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-overlay {
|
.cap {
|
||||||
position: absolute;
|
padding: 10px 12px 12px;
|
||||||
inset: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-info {
|
.who {
|
||||||
min-height: 72px;
|
display: flex;
|
||||||
padding: 10px 12px;
|
align-items: center;
|
||||||
border-top: 1px solid #f0f0f0;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who b {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 230px;
|
||||||
|
gap: 16px;
|
||||||
|
height: 72vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big iframe {
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-warn);
|
||||||
|
background: var(--wp-warn-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font: 15px var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions :deep(.n-button) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+529
-543
File diff suppressed because it is too large.
Load diff
@@ -24,7 +24,7 @@ async function init() {
|
|||||||
onMounted(init)
|
onMounted(init)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<iframe class="iframe" :sandbox="PREVIEW_SANDBOX" :srcdoc="content"></iframe>
|
<iframe v-if="content" class="iframe" :sandbox="PREVIEW_SANDBOX" :srcdoc="content"></iframe>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.iframe {
|
.iframe {
|
||||||
|
|||||||
+207
-358
@@ -1,386 +1,217 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-split
|
<div class="subs">
|
||||||
class="container"
|
<main class="list">
|
||||||
direction="horizontal"
|
<div class="toolbar">
|
||||||
:default-size="0.5"
|
<n-input
|
||||||
:min="0.2"
|
v-model:value="query.username"
|
||||||
:max="0.8"
|
size="small"
|
||||||
>
|
|
||||||
<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
|
clearable
|
||||||
placeholder="标记"
|
placeholder="搜索学生"
|
||||||
:options="flagFilterOptions"
|
style="width: 150px"
|
||||||
@update:value="handleFlagSelect"
|
>
|
||||||
/>
|
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
|
||||||
|
</n-input>
|
||||||
|
<n-radio-group v-model:value="query.task_type" size="small">
|
||||||
|
<n-radio-button value="">全部</n-radio-button>
|
||||||
|
<n-radio-button value="tutorial">教程</n-radio-button>
|
||||||
|
<n-radio-button value="challenge">挑战</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="query.zone"
|
v-model:value="query.zone"
|
||||||
style="width: 100px"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
placeholder="从夯到拉"
|
placeholder="得分分区"
|
||||||
:options="[
|
:options="zoneOptions"
|
||||||
{ label: '夯爆了', value: 'featured' },
|
style="width: 110px"
|
||||||
{ label: 'NPC', value: 'pending' },
|
|
||||||
{ label: '拉完了', value: 'low' },
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
<n-input
|
<n-select
|
||||||
style="width: 120px"
|
v-model:value="query.ordering"
|
||||||
v-model:value="query.username"
|
size="small"
|
||||||
clearable
|
:options="orderOptions"
|
||||||
|
style="width: 130px"
|
||||||
/>
|
/>
|
||||||
<n-pagination
|
<n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
|
||||||
v-model:page="query.page"
|
<span class="spacer"></span>
|
||||||
:page-size="10"
|
<span class="muted">共 {{ count }} 条</span>
|
||||||
:item-count="count"
|
<n-tooltip>
|
||||||
simple
|
<template #trigger>
|
||||||
/>
|
<n-button size="small" quaternary :loading="loading" @click="load">
|
||||||
<n-button
|
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||||
secondary
|
|
||||||
style="padding: 0 10px"
|
|
||||||
title="刷新"
|
|
||||||
@click="init"
|
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:refresh-cw" />
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</template>
|
||||||
|
刷新
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-data-table
|
<n-data-table
|
||||||
|
class="table"
|
||||||
|
size="small"
|
||||||
flex-height
|
flex-height
|
||||||
striped
|
:loading="loading"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="data"
|
:data="data"
|
||||||
:row-key="(row: SubmissionOut) => row.id"
|
:row-key="(row: SubmissionOut) => row.id"
|
||||||
:expanded-row-keys="expandedKeys"
|
|
||||||
@update:expanded-row-keys="handleExpand"
|
|
||||||
:row-props="rowProps"
|
:row-props="rowProps"
|
||||||
:row-class-name="rowClassName"
|
:row-class-name="(row: SubmissionOut) => (row.id === selectedId ? 'row-active' : '')"
|
||||||
style="flex: 1; min-height: 0"
|
/>
|
||||||
|
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-pagination
|
||||||
|
v-model:page="query.page"
|
||||||
|
:page-size="PAGE_SIZE"
|
||||||
|
:item-count="count"
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</main>
|
||||||
<template #2>
|
|
||||||
<div style="height: 100%; padding-left: 10px">
|
<aside v-if="selectedId" class="detail">
|
||||||
<Preview
|
<ReviewPanel
|
||||||
v-if="submission.id"
|
:submission-id="selectedId"
|
||||||
:html="html"
|
can-delete
|
||||||
:css="css"
|
@close="selectedId = null"
|
||||||
:js="js"
|
@changed="applyPatch"
|
||||||
:submission-id="submission.id"
|
@deleted="onDeleted"
|
||||||
@after-score="afterScore"
|
|
||||||
@show-code="codeModal = true"
|
|
||||||
/>
|
/>
|
||||||
|
</aside>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
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 { watchDebounced } from "@vueuse/core"
|
||||||
import { useRouter, useRoute } from "vue-router"
|
import type { DataTableColumn } from "naive-ui"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import { Submission } from "../api"
|
||||||
|
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import { parseTime } from "../utils/helper"
|
||||||
|
import type { FlagType, SubmissionOut } from "../utils/type"
|
||||||
|
|
||||||
import Preview from "../components/editor/Preview.vue"
|
// 全站提交列表,留给超管查某个学生、处理有问题的提交;平时批改用“批改”页
|
||||||
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
const PAGE_SIZE = 20
|
||||||
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
// 列表数据
|
|
||||||
const data = ref<SubmissionOut[]>([])
|
const data = ref<SubmissionOut[]>([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: Number(route.params.page),
|
page: Number(route.params.page) || 1,
|
||||||
username: (Array.isArray(route.query.username)
|
username: typeof route.query.username === "string" ? route.query.username : "",
|
||||||
? ""
|
task_type: "" as "" | "tutorial" | "challenge",
|
||||||
: (route.query.username ?? "")) as string,
|
|
||||||
flag: null as string | null,
|
|
||||||
zone: null as string | null,
|
zone: null as string | null,
|
||||||
|
ordering: "-created",
|
||||||
|
grouped: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当前选中提交的代码
|
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
|
||||||
const html = computed(() => submission.value.html)
|
const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
|
||||||
const css = computed(() => submission.value.css)
|
const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
|
||||||
const js = computed(() => submission.value.js)
|
const orderOptions = [
|
||||||
|
{ label: "最新的在前", value: "-created" },
|
||||||
|
{ label: "得分从高到低", value: "-score" },
|
||||||
|
{ label: "得分从低到高", value: "score" },
|
||||||
|
]
|
||||||
|
|
||||||
// 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>[] = [
|
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: "时间",
|
title: "时间",
|
||||||
key: "created",
|
key: "created",
|
||||||
width: 110,
|
width: 100,
|
||||||
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "提交者",
|
|
||||||
key: "user",
|
|
||||||
width: 80,
|
|
||||||
render: (row) => row.username,
|
|
||||||
},
|
},
|
||||||
|
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
|
||||||
{
|
{
|
||||||
title: "任务",
|
title: "任务",
|
||||||
key: "task_title",
|
key: "task_title",
|
||||||
render: (row) => h(TaskTitle, { submission: row }),
|
ellipsis: { tooltip: true },
|
||||||
|
render: (row) => [
|
||||||
|
h("span", { class: "muted" }, row.task_type === TASK_TYPE.Tutorial ? "教程 " : "挑战 "),
|
||||||
|
`${row.task_display}. ${row.task_title}`,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "得分",
|
title: "得分",
|
||||||
key: "score",
|
key: "score",
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (row) => {
|
align: "right",
|
||||||
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
|
render: (row) =>
|
||||||
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
|
row.score > 0
|
||||||
return h(
|
? h("span", { class: "mono" }, row.score.toFixed(2))
|
||||||
"div",
|
: h("span", { class: "muted" }, "未评"),
|
||||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
|
||||||
[
|
|
||||||
h("span", avgScore),
|
|
||||||
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "次数",
|
title: "分区",
|
||||||
|
key: "zone",
|
||||||
|
width: 70,
|
||||||
|
render: (row) => (row.zone ? h("span", { class: `zone z-${row.zone}` }, ZONES[row.zone]) : null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "共交",
|
||||||
key: "submit_count",
|
key: "submit_count",
|
||||||
width: 60,
|
width: 60,
|
||||||
render: (row) => row.submit_count || "-",
|
align: "right",
|
||||||
},
|
render: (row) => h("span", { class: "mono muted" }, `${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) {
|
function rowProps(row: SubmissionOut) {
|
||||||
return {
|
return {
|
||||||
style: { cursor: "pointer" },
|
style: { cursor: "pointer" },
|
||||||
onClick: () => {
|
onClick: () => (selectedId.value = row.id),
|
||||||
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" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// 快速翻页时只用最后一次请求的结果
|
// 快速翻页时只用最后一次请求的结果
|
||||||
let initSeq = 0
|
let seq = 0
|
||||||
|
|
||||||
async function init() {
|
async function load() {
|
||||||
const seq = ++initSeq
|
const s = ++seq
|
||||||
expandedKeys.value = []
|
loading.value = true
|
||||||
expandedData.clear()
|
try {
|
||||||
const res = await Submission.list(query)
|
const res = await Submission.list({
|
||||||
if (seq !== initSeq) return
|
page: query.page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
username: query.username || undefined,
|
||||||
|
task_type: query.task_type || undefined,
|
||||||
|
zone: query.zone,
|
||||||
|
ordering: query.ordering,
|
||||||
|
grouped: query.grouped,
|
||||||
|
})
|
||||||
|
if (s !== seq) return
|
||||||
data.value = res.items
|
data.value = res.items
|
||||||
count.value = res.count
|
count.value = res.count
|
||||||
|
} finally {
|
||||||
|
if (s === seq) loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let detailSeq = 0
|
function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
|
||||||
|
const row = data.value.find((d) => d.id === patch.id)
|
||||||
async function getSubmissionByID(id: string) {
|
if (row) Object.assign(row, patch)
|
||||||
const seq = ++detailSeq
|
|
||||||
const res = await Submission.get(id)
|
|
||||||
if (seq === detailSeq) submission.value = res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function afterScore() {
|
function onDeleted() {
|
||||||
data.value = data.value.map((d) => {
|
selectedId.value = null
|
||||||
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
load()
|
||||||
return d
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToEditor() {
|
// 换了筛选条件就回到第一页
|
||||||
eHtml.value = html.value
|
function reset() {
|
||||||
eCss.value = css.value
|
if (query.page !== 1) query.page = 1
|
||||||
eJs.value = js.value
|
else load()
|
||||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => query.page,
|
() => query.page,
|
||||||
(v) => {
|
(page) => {
|
||||||
init()
|
load()
|
||||||
router.push({ params: { page: v } })
|
router.push({ params: { page } })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||||
@@ -391,60 +222,78 @@ watch(
|
|||||||
if (page !== query.page) query.page = page
|
if (page !== query.page) query.page = page
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watchDebounced(
|
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
|
||||||
() => query.username,
|
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
|
||||||
() => {
|
|
||||||
if (query.page !== 1) query.page = 1
|
|
||||||
else init()
|
|
||||||
},
|
|
||||||
{ debounce: 500, maxWait: 1000 },
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => query.flag,
|
|
||||||
() => {
|
|
||||||
if (query.page !== 1) query.page = 1
|
|
||||||
else init()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => query.zone,
|
|
||||||
() => {
|
|
||||||
if (query.page !== 1) query.page = 1
|
|
||||||
else init()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(load)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.subs {
|
||||||
padding: 10px;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
display: flex;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.row-active td) {
|
.list {
|
||||||
background-color: rgba(24, 160, 80, 0.1) !important;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.row-active td) {
|
||||||
|
background: var(--wp-brand-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.mono) {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted,
|
||||||
|
.table :deep(.muted) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.zone) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.z-featured) {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.z-low) {
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table :deep(.z-pending) {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
width: 440px;
|
||||||
|
flex: none;
|
||||||
|
border-left: 1px solid var(--wp-line);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<template>
|
||||||
|
<div class="editor-page">
|
||||||
|
<aside class="list">
|
||||||
|
<div class="list-head">
|
||||||
|
<b>{{ kindLabel }}</b>
|
||||||
|
<n-button size="small" @click="createNew">
|
||||||
|
<template #icon><Icon icon="lucide:plus" /></template>
|
||||||
|
新建
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="items">
|
||||||
|
<div
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.display"
|
||||||
|
:class="['item', { on: item.display === form.display, draft: !item.is_public }]"
|
||||||
|
@click="show(item.display)"
|
||||||
|
>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click.stop="togglePublic(item.display)">
|
||||||
|
<Icon :icon="item.is_public ? 'lucide:globe' : 'lucide:lock'" :width="16" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ item.is_public ? "已公开,点击设为不公开" : "未公开,点击公开" }}
|
||||||
|
</n-tooltip>
|
||||||
|
<span class="item-title">{{ item.display }}. {{ item.title }}</span>
|
||||||
|
<span v-if="kind === 'challenge'" class="item-score">{{ item.score }}分</span>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text class="item-del" @click.stop="remove(item.display)">
|
||||||
|
<Icon icon="lucide:trash-2" :width="14" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
删除
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="form">
|
||||||
|
<n-form inline :show-feedback="false" class="fields">
|
||||||
|
<n-form-item label="序号" label-placement="left">
|
||||||
|
<n-input-number style="width: 96px" v-model:value="form.display" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="标题" label-placement="left">
|
||||||
|
<n-input v-model:value="form.title" style="width: 220px" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="kind === 'challenge'" label="分数" label-placement="left">
|
||||||
|
<n-input-number style="width: 96px" v-model:value="form.score" :min="0" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="公开" label-placement="left">
|
||||||
|
<n-switch v-model:value="form.is_public" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="kind === 'challenge'" label="出题人" label-placement="left">
|
||||||
|
<n-text depth="3">{{ form.author_name || "未设置" }}</n-text>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-flex align="center" class="actions">
|
||||||
|
<n-button type="primary" :disabled="!canSave" :loading="saving" @click="save">
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
<TaskAssetManager v-if="form.display" :task-type="kind" :display="form.display" />
|
||||||
|
<n-button v-if="form.display" @click="showExampleModal = true">示例代码</n-button>
|
||||||
|
<n-text depth="3" class="hint">{{ exampleHint }}</n-text>
|
||||||
|
</n-flex>
|
||||||
|
<MarkdownEditor class="md" v-model="form.content" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showExampleModal"
|
||||||
|
preset="card"
|
||||||
|
:title="`示例代码(${exampleHint})`"
|
||||||
|
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>
|
||||||
|
<template #footer>
|
||||||
|
<n-text depth="3">关掉窗口后点“保存”才会存下来。</n-text>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import { Challenge, Tutorial } from "../api"
|
||||||
|
import { splitHtml, type Code } from "../utils/code"
|
||||||
|
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||||
|
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||||
|
|
||||||
|
// 教程和挑战的出题页(以前是两个几乎一样的文件)
|
||||||
|
const props = defineProps<{ kind: "tutorial" | "challenge" }>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const api = props.kind === "tutorial" ? Tutorial : Challenge
|
||||||
|
const kindLabel = props.kind === "tutorial" ? "教程" : "挑战"
|
||||||
|
const routeName = props.kind === "tutorial" ? "tutorial-editor" : "challenge-editor"
|
||||||
|
const exampleHint =
|
||||||
|
props.kind === "tutorial" ? "打开这一节教程时自动放进编辑器" : "学生点“放入示例代码”时使用"
|
||||||
|
|
||||||
|
interface ListItem {
|
||||||
|
display: number
|
||||||
|
title: string
|
||||||
|
is_public: boolean
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = ref<ListItem[]>([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const showExampleModal = ref(false)
|
||||||
|
const rawCode = ref("")
|
||||||
|
const splitResult = ref<Code | null>(null)
|
||||||
|
|
||||||
|
const blank = () => ({
|
||||||
|
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 form = reactive(blank())
|
||||||
|
|
||||||
|
const canSave = computed(() => !!(form.display && form.title && form.content))
|
||||||
|
|
||||||
|
watch(rawCode, (val) => {
|
||||||
|
if (!val.trim()) {
|
||||||
|
splitResult.value = null
|
||||||
|
Object.assign(form, { example_html: null, example_css: null, example_js: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const split = splitHtml(val)
|
||||||
|
splitResult.value = split
|
||||||
|
form.example_html = split.html || null
|
||||||
|
form.example_css = split.css || null
|
||||||
|
form.example_js = split.js || null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showExampleModal, (visible) => {
|
||||||
|
if (!visible) return
|
||||||
|
const parts: string[] = []
|
||||||
|
if (form.example_css) parts.push(`<style>\n${form.example_css}\n</style>`)
|
||||||
|
if (form.example_html) parts.push(form.example_html)
|
||||||
|
if (form.example_js) parts.push(`<script>\n${form.example_js}\n<\/script>`)
|
||||||
|
rawCode.value = parts.join("\n\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
list.value = await api.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(display: number) {
|
||||||
|
router.replace({ name: routeName, params: { display } })
|
||||||
|
const item: any = await api.get(display)
|
||||||
|
Object.assign(form, blank(), {
|
||||||
|
display: item.display,
|
||||||
|
title: item.title,
|
||||||
|
content: item.content,
|
||||||
|
score: item.score ?? 0,
|
||||||
|
is_public: item.is_public,
|
||||||
|
author_name: item.author_name ?? "",
|
||||||
|
example_html: item.example_html ?? null,
|
||||||
|
example_css: item.example_css ?? null,
|
||||||
|
example_js: item.example_js ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
// 列表按序号升序,新的接在最后
|
||||||
|
Object.assign(form, blank(), {
|
||||||
|
display: (list.value[list.value.length - 1]?.display ?? 0) + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.createOrUpdate(form as any)
|
||||||
|
message.success(res?.message ?? "已保存")
|
||||||
|
await loadList()
|
||||||
|
// 保存后留在这一项上,方便接着改
|
||||||
|
await show(form.display)
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.detail ?? "保存失败")
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(display: number) {
|
||||||
|
const item = list.value.find((i) => i.display === display)
|
||||||
|
dialog.warning({
|
||||||
|
title: `删除${kindLabel}「${item?.title ?? display}」?`,
|
||||||
|
content: "学生在这个任务上的提交和对话也会一起删除,不能恢复。",
|
||||||
|
positiveText: "删除",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await api.remove(display)
|
||||||
|
message.success("已删除")
|
||||||
|
await loadList()
|
||||||
|
if (form.display === display) {
|
||||||
|
if (list.value.length) show(list.value[0]!.display)
|
||||||
|
else createNew()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublic(display: number) {
|
||||||
|
const data = await api.togglePublic(display)
|
||||||
|
message.success(data.message)
|
||||||
|
const item = list.value.find((i) => i.display === display)
|
||||||
|
if (item) item.is_public = !item.is_public
|
||||||
|
if (form.display === display) form.is_public = !form.is_public
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadList()
|
||||||
|
const display = Number(route.params.display)
|
||||||
|
if (list.value.some((item) => item.display === display)) show(display)
|
||||||
|
else if (list.value.length) show(list.value[0]!.display)
|
||||||
|
else createNew()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.editor-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
width: 280px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--wp-line);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.on {
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.draft .item-title {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-score {
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-del {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover .item-del,
|
||||||
|
.item-del:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<n-input
|
<n-input
|
||||||
style="width: 200px"
|
style="width: 200px"
|
||||||
v-model:value="query.username"
|
v-model:value="query.username"
|
||||||
|
placeholder="搜索用户名"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,21 +28,26 @@
|
|||||||
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
||||||
<UserFormModal v-model:show="showCreate" @saved="init" />
|
<UserFormModal v-model:show="showCreate" @saved="init" />
|
||||||
<n-modal
|
<n-modal
|
||||||
style="width: 300px"
|
style="width: 440px"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="批量新建 (前缀 web)"
|
title="批量新建学生"
|
||||||
v-model:show="showBatch"
|
v-model:show="showBatch"
|
||||||
>
|
>
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
|
<n-text depth="3" style="font-size: 13px">
|
||||||
|
每行一个名字。用户名是 web + 班级 + 名字(比如
|
||||||
|
web{{ classname || "241" }}{{ names[0] || "张三" }}),密码随机生成,建好后在列表里点“显示”查看。
|
||||||
|
</n-text>
|
||||||
<n-input
|
<n-input
|
||||||
placeholder="班级"
|
placeholder="班级,比如 241"
|
||||||
v-model:value="classname"
|
v-model:value="classname"
|
||||||
:disabled="batchLoading"
|
:disabled="batchLoading"
|
||||||
/>
|
/>
|
||||||
<n-input
|
<n-input
|
||||||
rows="20"
|
rows="16"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
|
placeholder="张三 李四 王五"
|
||||||
v-model:value="namesStr"
|
v-model:value="namesStr"
|
||||||
:disabled="batchLoading"
|
:disabled="batchLoading"
|
||||||
/>
|
/>
|
||||||
@@ -51,7 +57,7 @@
|
|||||||
:loading="batchLoading"
|
:loading="batchLoading"
|
||||||
@click="batchCreateUsers"
|
@click="batchCreateUsers"
|
||||||
>
|
>
|
||||||
提交
|
创建 {{ names.length }} 个账号
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
@@ -61,7 +67,7 @@
|
|||||||
import { onMounted, reactive, ref, h, watch, computed } from "vue"
|
import { onMounted, reactive, ref, h, watch, computed } from "vue"
|
||||||
import { Account } from "../api"
|
import { Account } from "../api"
|
||||||
import { parseTime } from "../utils/helper"
|
import { parseTime } from "../utils/helper"
|
||||||
import { useMessage, type DataTableColumn } from "naive-ui"
|
import { NButton, useMessage, type DataTableColumn } from "naive-ui"
|
||||||
import { getRole, Role, type User } from "../utils/type"
|
import { getRole, Role, type User } from "../utils/type"
|
||||||
import UserActions from "../components/dashboard/UserActions.vue"
|
import UserActions from "../components/dashboard/UserActions.vue"
|
||||||
import UserFormModal from "../components/dashboard/UserFormModal.vue"
|
import UserFormModal from "../components/dashboard/UserFormModal.vue"
|
||||||
@@ -82,7 +88,13 @@ const showBatch = ref(false)
|
|||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const classname = ref("")
|
const classname = ref("")
|
||||||
const namesStr = ref("")
|
const namesStr = ref("")
|
||||||
const names = computed(() => namesStr.value.split("\n").filter((it) => !!it))
|
const names = computed(() =>
|
||||||
|
namesStr.value
|
||||||
|
.split("\n")
|
||||||
|
.map((it) => it.trim())
|
||||||
|
.filter((it) => !!it),
|
||||||
|
)
|
||||||
|
const revealed = reactive(new Set<number>())
|
||||||
|
|
||||||
const batchLoading = ref(false)
|
const batchLoading = ref(false)
|
||||||
|
|
||||||
@@ -99,8 +111,19 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
key: "username",
|
key: "username",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "密码",
|
// 密码默认不显示,投屏时不会被学生看到
|
||||||
|
title: "初始密码",
|
||||||
key: "raw_password",
|
key: "raw_password",
|
||||||
|
render: (row) =>
|
||||||
|
!row.raw_password
|
||||||
|
? "—"
|
||||||
|
: revealed.has(row.id)
|
||||||
|
? h("span", { style: "font-family: var(--wp-font-mono)" }, row.raw_password)
|
||||||
|
: h(
|
||||||
|
NButton,
|
||||||
|
{ text: true, size: "small", onClick: () => revealed.add(row.id) },
|
||||||
|
() => "显示",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
@@ -198,7 +221,7 @@ onMounted(init)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
padding: 10px 10px 10px 0;
|
padding: 10px 10px 10px 0;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
:css="css"
|
:css="css"
|
||||||
:js="js"
|
:js="js"
|
||||||
:asset-base-url="assetBaseUrl"
|
:asset-base-url="assetBaseUrl"
|
||||||
persist-storage
|
:storage-task-id="taskId"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-split>
|
</n-split>
|
||||||
@@ -22,13 +22,20 @@
|
|||||||
</n-split>
|
</n-split>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { watch } from "vue"
|
||||||
import { useMagicKeys, whenever } from "@vueuse/core"
|
import { useMagicKeys, whenever } from "@vueuse/core"
|
||||||
import Editors from "../components/editor/Editors.vue"
|
import Editors from "../components/editor/Editors.vue"
|
||||||
import Preview from "../components/editor/Preview.vue"
|
import Preview from "../components/editor/Preview.vue"
|
||||||
import TaskPanel from "../components/task/TaskPanel.vue"
|
import TaskPanel from "../components/task/TaskPanel.vue"
|
||||||
import { show, panelSize } from "../store/panel"
|
import { show, panelSize } from "../store/panel"
|
||||||
import { html, css, js } from "../store/editors"
|
import { html, css, js } from "../store/editors"
|
||||||
import { assetBaseUrl } from "../store/task"
|
import { assetBaseUrl, taskId } from "../store/task"
|
||||||
|
import { saveTaskCode } from "../composables/useTaskCode"
|
||||||
|
|
||||||
|
// 每一节教程的代码各存一份;任务 id 和代码一起看,切换小节时不会把新一节的代码存到上一节下面
|
||||||
|
watch([taskId, html, css, js], ([id]) => {
|
||||||
|
if (id) saveTaskCode(id, { html: html.value, css: css.value, js: js.value })
|
||||||
|
})
|
||||||
|
|
||||||
const { ctrl_s } = useMagicKeys({
|
const { ctrl_s } = useMagicKeys({
|
||||||
passive: false,
|
passive: false,
|
||||||
|
|||||||
+49
-12
@@ -1,11 +1,23 @@
|
|||||||
import { createWebHistory, createRouter } from "vue-router"
|
import { createWebHistory, createRouter, type RouteRecordRaw } from "vue-router"
|
||||||
import { loginModal } from "./store/modal"
|
import { loginModal } from "./store/modal"
|
||||||
|
|
||||||
import Workspace from "./pages/Workspace.vue"
|
// 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
|
||||||
|
const Workspace = () => import("./pages/Workspace.vue")
|
||||||
import { hasLogin } from "./token"
|
import { hasLogin } from "./token"
|
||||||
import { authed } from "./store/user"
|
import { authed, roleStaff, roleSuper } from "./store/user"
|
||||||
|
import { loadUser } from "./store/session"
|
||||||
|
|
||||||
const routes = [
|
declare module "vue-router" {
|
||||||
|
interface RouteMeta {
|
||||||
|
auth?: boolean
|
||||||
|
// 教学页面:staff 是 admin 和 super,super 只有超管(和后端的 requireAdmin / requireSuper 对应)
|
||||||
|
role?: "staff" | "super"
|
||||||
|
// 不带顶栏的全屏页面
|
||||||
|
bare?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
|
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
|
||||||
{
|
{
|
||||||
path: "/tutorial",
|
path: "/tutorial",
|
||||||
@@ -22,7 +34,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: "/challenge",
|
path: "/challenge",
|
||||||
name: "home-challenge-list",
|
name: "home-challenge-list",
|
||||||
component: Workspace,
|
component: () => import("./pages/ChallengeHome.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,9 +44,10 @@ const routes = [
|
|||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/submissions/:page",
|
// 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
|
||||||
|
path: "/submissions/:page?",
|
||||||
name: "submissions",
|
name: "submissions",
|
||||||
component: () => import("./pages/Submissions.vue"),
|
component: () => import("./pages/MySubmissions.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,7 +55,7 @@ const routes = [
|
|||||||
name: "submission",
|
name: "submission",
|
||||||
component: () => import("./pages/Submission.vue"),
|
component: () => import("./pages/Submission.vue"),
|
||||||
props: true,
|
props: true,
|
||||||
meta: { auth: true },
|
meta: { auth: true, bare: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/creation",
|
path: "/creation",
|
||||||
@@ -66,22 +79,26 @@ const routes = [
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
component: () => import("./pages/Dashboard.vue"),
|
component: () => import("./pages/Dashboard.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true, role: "staff" },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "tutorial/:display",
|
path: "tutorial/:display",
|
||||||
name: "tutorial-editor",
|
name: "tutorial-editor",
|
||||||
component: () => import("./pages/TutorialEditor.vue"),
|
component: () => import("./pages/TaskEditor.vue"),
|
||||||
|
props: { kind: "tutorial" },
|
||||||
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "challenge/:display",
|
path: "challenge/:display",
|
||||||
name: "challenge-editor",
|
name: "challenge-editor",
|
||||||
component: () => import("./pages/ChallengeEditor.vue"),
|
component: () => import("./pages/TaskEditor.vue"),
|
||||||
|
props: { kind: "challenge" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "user-manage/:page",
|
path: "user-manage/:page",
|
||||||
name: "user-manage",
|
name: "user-manage",
|
||||||
component: () => import("./pages/UserManage.vue"),
|
component: () => import("./pages/UserManage.vue"),
|
||||||
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "showcase",
|
path: "showcase",
|
||||||
@@ -92,6 +109,19 @@ const routes = [
|
|||||||
path: "gradebook",
|
path: "gradebook",
|
||||||
name: "gradebook",
|
name: "gradebook",
|
||||||
component: () => import("./pages/Gradebook.vue"),
|
component: () => import("./pages/Gradebook.vue"),
|
||||||
|
meta: { role: "super" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "review",
|
||||||
|
name: "review",
|
||||||
|
component: () => import("./pages/Review.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 全站提交列表(超管),和学生的“我的提交”是两个页面
|
||||||
|
path: "submissions/:page",
|
||||||
|
name: "teach-submissions",
|
||||||
|
component: () => import("./pages/Submissions.vue"),
|
||||||
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -102,10 +132,17 @@ export const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach(async (to) => {
|
||||||
const isLoggedIn = authed.value || hasLogin()
|
const isLoggedIn = authed.value || hasLogin()
|
||||||
if (to.meta.auth && !isLoggedIn) {
|
if (to.meta.auth && !isLoggedIn) {
|
||||||
loginModal.value = true
|
loginModal.value = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (to.meta.role || to.name === "dashboard") {
|
||||||
|
await loadUser()
|
||||||
|
const allowed = to.meta.role === "super" ? roleSuper.value : roleStaff.value
|
||||||
|
if (!allowed) return { name: "home" }
|
||||||
|
}
|
||||||
|
// 教学的默认页是批改
|
||||||
|
if (to.name === "dashboard") return { name: "review" }
|
||||||
})
|
})
|
||||||
@@ -9,7 +9,7 @@ export const css = useStorage(STORAGE_KEY.CSS, defaultCSS)
|
|||||||
export const js = useStorage(STORAGE_KEY.JS, "")
|
export const js = useStorage(STORAGE_KEY.JS, "")
|
||||||
|
|
||||||
export const tab = useStorage(STORAGE_KEY.TAB, "html")
|
export const tab = useStorage(STORAGE_KEY.TAB, "html")
|
||||||
export const size = useStorage(STORAGE_KEY.FONTSIZE, 24)
|
export const size = useStorage(STORAGE_KEY.FONTSIZE, 16)
|
||||||
|
|
||||||
export function reset(lang: "html" | "css" | "js") {
|
export function reset(lang: "html" | "css" | "js") {
|
||||||
if (lang === "html") {
|
if (lang === "html") {
|
||||||
|
|||||||
+30
-27
@@ -1,6 +1,5 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { ReconnectingSocket } from "../utils/socket"
|
import { ReconnectingSocket } from "../utils/socket"
|
||||||
import { html, css, js } from "./editors"
|
|
||||||
|
|
||||||
export interface PromptMessage {
|
export interface PromptMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
@@ -9,23 +8,27 @@ export interface PromptMessage {
|
|||||||
code?: { html: string | null; css: string | null; js: string | null }
|
code?: { html: string | null; css: string | null; js: string | null }
|
||||||
submitted?: boolean // whether this assistant message's code has been submitted
|
submitted?: boolean // whether this assistant message's code has been submitted
|
||||||
created?: string
|
created?: string
|
||||||
|
// conversation:AI 对话;edit:学生手改;manual:粘贴外部 AI 的代码
|
||||||
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CodeOut = { html: string | null; css: string | null; js: string | null }
|
||||||
|
|
||||||
export const messages = ref<PromptMessage[]>([])
|
export const messages = ref<PromptMessage[]>([])
|
||||||
export const conversationId = ref<string>("")
|
export const conversationId = ref<string>("")
|
||||||
export const connected = ref(false)
|
export const connected = ref(false)
|
||||||
export const streaming = ref(false)
|
export const streaming = ref(false)
|
||||||
export const streamingContent = ref("")
|
export const streamingContent = ref("")
|
||||||
|
// 这次提问发出的时间:AI 先思考,第一个字可能一两分钟后才出来,界面据此显示等了多久
|
||||||
|
export const streamStartedAt = ref(0)
|
||||||
export const currentTaskId = ref(0)
|
export const currentTaskId = ref(0)
|
||||||
let _onCodeComplete:
|
// 新代码交给工作台处理:init 是连上时对话里最新的那一版,complete 是 AI 刚生成的
|
||||||
| ((
|
let _codeSink:
|
||||||
code: { html: string | null; css: string | null; js: string | null },
|
| ((code: CodeOut, messageId: number, reason: "init" | "complete") => void)
|
||||||
messageId: number
|
|
||||||
) => void)
|
|
||||||
| null = null
|
| null = null
|
||||||
|
|
||||||
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
export function setCodeSink(fn: typeof _codeSink) {
|
||||||
_onCodeComplete = fn
|
_codeSink = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMessage(data: any) {
|
function onMessage(data: any) {
|
||||||
@@ -39,8 +42,8 @@ function onMessage(data: any) {
|
|||||||
const lastAssistant = [...messages.value]
|
const lastAssistant = [...messages.value]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((m) => m.role === "assistant" && m.code)
|
.find((m) => m.role === "assistant" && m.code)
|
||||||
if (lastAssistant?.code) {
|
if (lastAssistant?.code && lastAssistant.id) {
|
||||||
applyCode(lastAssistant.code)
|
_codeSink?.(lastAssistant.code, lastAssistant.id, "init")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (data.type === "stream") {
|
} else if (data.type === "stream") {
|
||||||
@@ -54,14 +57,10 @@ function onMessage(data: any) {
|
|||||||
id: data.message_id,
|
id: data.message_id,
|
||||||
code: data.code,
|
code: data.code,
|
||||||
submitted: false,
|
submitted: false,
|
||||||
|
source: "conversation",
|
||||||
})
|
})
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
if (data.code) {
|
if (data.code) _codeSink?.(data.code, data.message_id, "complete")
|
||||||
applyCode(data.code)
|
|
||||||
if (_onCodeComplete) {
|
|
||||||
_onCodeComplete(data.code, data.message_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (data.type === "error") {
|
} else if (data.type === "error") {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
@@ -103,12 +102,13 @@ export function disconnectPrompt() {
|
|||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
currentTaskId.value = 0
|
currentTaskId.value = 0
|
||||||
_onCodeComplete = null
|
_codeSink = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendPrompt(content: string, model: string = "") {
|
export function sendPrompt(content: string, model: string = "") {
|
||||||
if (!socket.send({ type: "message", content, model })) return
|
if (!socket.send({ type: "message", content, model })) return
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
|
streamStartedAt.value = Date.now()
|
||||||
messages.value.push({ role: "user", content })
|
messages.value.push({ role: "user", content })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +130,19 @@ export function stopPrompt() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手改或粘贴外部代码存成版本后,把这一问一答加到当前对话里显示
|
||||||
|
export function addVersionPair(
|
||||||
|
prompt: string,
|
||||||
|
source: string,
|
||||||
|
messageId: number,
|
||||||
|
code: CodeOut,
|
||||||
|
) {
|
||||||
|
messages.value.push(
|
||||||
|
{ role: "user", content: prompt, source },
|
||||||
|
{ role: "assistant", content: "", id: messageId, code, submitted: false, source },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function markMessageSubmitted(assistantMsgId: number) {
|
export function markMessageSubmitted(assistantMsgId: number) {
|
||||||
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
||||||
if (msg) msg.submitted = true
|
if (msg) msg.submitted = true
|
||||||
@@ -141,13 +154,3 @@ export function removeMessagePair(assistantMsgId: number) {
|
|||||||
messages.value.splice(idx - 1, 2)
|
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,31 @@
|
|||||||
|
import { Account } from "../api"
|
||||||
|
import { Role } from "../utils/type"
|
||||||
|
import { user } from "./user"
|
||||||
|
|
||||||
|
let loading: Promise<void> | null = null
|
||||||
|
|
||||||
|
// 取一次当前登录用户(路由守卫要按角色判断,可能比 App 挂载还早)
|
||||||
|
export function loadUser(): Promise<void> {
|
||||||
|
if (user.loaded) return Promise.resolve()
|
||||||
|
loading ??= Account.getMyProfile()
|
||||||
|
.then((data) => {
|
||||||
|
user.username = data.username
|
||||||
|
user.role = data.role
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
user.loaded = true
|
||||||
|
loading = null
|
||||||
|
})
|
||||||
|
return loading
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
|
||||||
|
try {
|
||||||
|
await Account.logout()
|
||||||
|
} finally {
|
||||||
|
user.username = ""
|
||||||
|
user.role = Role.Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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,25 @@
|
|||||||
|
import { computed, watch } from "vue"
|
||||||
|
import { usePreferredDark, useStorage } from "@vueuse/core"
|
||||||
|
import { applyCssVars, dark, light } from "../theme"
|
||||||
|
|
||||||
|
// auto:跟随系统;投屏时老师可以手动切回亮色
|
||||||
|
export type ThemeMode = "auto" | "light" | "dark"
|
||||||
|
|
||||||
|
export const themeMode = useStorage<ThemeMode>("theme-mode", "auto")
|
||||||
|
const prefersDark = usePreferredDark()
|
||||||
|
|
||||||
|
export const isDark = computed(
|
||||||
|
() => themeMode.value === "dark" || (themeMode.value === "auto" && prefersDark.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
// CSS 变量和 data-theme(markdown、代码高亮的暗色样式按它切换)跟着走
|
||||||
|
watch(
|
||||||
|
isDark,
|
||||||
|
(d) => {
|
||||||
|
applyCssVars(d ? dark : light)
|
||||||
|
const root = document.documentElement
|
||||||
|
root.dataset.theme = d ? "dark" : "light"
|
||||||
|
root.style.colorScheme = d ? "dark" : "light"
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
@@ -16,3 +16,5 @@ export const roleAdmin = computed(
|
|||||||
export const roleSuper = computed(
|
export const roleSuper = computed(
|
||||||
() => authed.value && user.role === Role.Super,
|
() => authed.value && user.role === Role.Super,
|
||||||
)
|
)
|
||||||
|
// 老师端(教学):admin 和 super
|
||||||
|
export const roleStaff = computed(() => roleAdmin.value || roleSuper.value)
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
/* 由 github-markdown-css 的 github-markdown-dark.css 和 highlight.js 的 github-dark.css 生成:
|
||||||
|
每个选择器前加 :root[data-theme="dark"],只保留颜色相关的声明。暗色模式下覆盖亮色样式 */
|
||||||
|
:root[data-theme="dark"] .markdown-body {
|
||||||
|
color-scheme: dark;
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #0d1117;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .octicon {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h1:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h2:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h3:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h4:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h5:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h6:hover .anchor .octicon-link:before {
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
color: #4493f8;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body abbr[title] {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h1 {
|
||||||
|
border-bottom: 1px solid #3d444db3;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body mark {
|
||||||
|
background-color: #bb800926;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body hr {
|
||||||
|
background: rgba(0,0,0,0);
|
||||||
|
border-bottom: 1px solid #3d444db3;
|
||||||
|
background-color: #3d444d;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body ::-webkit-input-placeholder {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body ::placeholder {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus,
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=radio]:focus,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus {
|
||||||
|
outline: 2px solid var(--borderColor-accent-emphasis);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=radio]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus:not(:focus-visible) {
|
||||||
|
outline: solid 1px rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus-visible,
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus-visible,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=radio]:focus-visible,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus-visible {
|
||||||
|
outline: 2px solid var(--borderColor-accent-emphasis);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body kbd {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #151b23;
|
||||||
|
border: solid 1px var(--borderColor-muted);
|
||||||
|
border-bottom-color: var(--borderColor-muted);
|
||||||
|
box-shadow: inset 0 -1px 0 var(--borderColor-muted);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h2 {
|
||||||
|
border-bottom: 1px solid #3d444db3;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h6 {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body blockquote {
|
||||||
|
color: #9198a1;
|
||||||
|
border-left: .25em solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .octicon {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:not([href]) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .absent {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .anchor:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h1 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h2 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h3 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h4 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h5 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h6 .octicon-link {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body summary h1,
|
||||||
|
:root[data-theme="dark"] .markdown-body summary h2 {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table th,
|
||||||
|
:root[data-theme="dark"] .markdown-body table td {
|
||||||
|
border: 1px solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table tr {
|
||||||
|
background-color: #0d1117;
|
||||||
|
border-top: 1px solid #3d444db3;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table tr:nth-child(2n) {
|
||||||
|
background-color: #151b23;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table img {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .emoji {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body span.frame>span {
|
||||||
|
border: 1px solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body span.frame span span {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body code,
|
||||||
|
:root[data-theme="dark"] .markdown-body tt {
|
||||||
|
background-color: #656c7633;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body pre>code {
|
||||||
|
background: rgba(0,0,0,0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .highlight pre,
|
||||||
|
:root[data-theme="dark"] .markdown-body pre {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #151b23;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body pre code,
|
||||||
|
:root[data-theme="dark"] .markdown-body pre tt {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .csv-data .blob-num {
|
||||||
|
background: #0d1117;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .csv-data tr {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .csv-data th {
|
||||||
|
background: #151b23;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .footnotes {
|
||||||
|
color: #9198a1;
|
||||||
|
border-top: 1px solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .footnotes li:target::before {
|
||||||
|
border: 2px solid #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .footnotes li:target {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-c {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-c1,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s .pl-v {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-e,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-en {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-smi,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s .pl-s1 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ent {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-k {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-pds,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s .pl-pse .pl-s1,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sre,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sra {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-v,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-smw {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-bu {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ii {
|
||||||
|
color: var(--fgColor-danger);
|
||||||
|
background-color: var(--bgColor-danger-muted);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-c2 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #b62324;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ml {
|
||||||
|
color: #f2cc60;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mh,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mh .pl-en,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ms {
|
||||||
|
color: #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mi {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mb {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-md {
|
||||||
|
color: #ffdcd7;
|
||||||
|
background-color: #67060c;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mi1 {
|
||||||
|
color: #aff5b4;
|
||||||
|
background-color: #033a16;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mc {
|
||||||
|
color: #ffdfb6;
|
||||||
|
background-color: #5a1e02;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mi2 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #1158c7;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mdr {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ba {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sg {
|
||||||
|
color: #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-corl {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body button:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body summary:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body [tabindex="0"]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body details-dialog:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:has(>p,
|
||||||
|
:root[data-theme="dark"] >div,
|
||||||
|
:root[data-theme="dark"] >pre,
|
||||||
|
:root[data-theme="dark"] >blockquote):has(.snippet-clipboard-content,
|
||||||
|
:root[data-theme="dark"] >pre):focus-visible {
|
||||||
|
outline: 2px solid var(--borderColor-accent-emphasis);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert {
|
||||||
|
color: inherit;
|
||||||
|
border-left: .25em solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note {
|
||||||
|
border-left-color: #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {
|
||||||
|
color: #4493f8;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important {
|
||||||
|
border-left-color: #8957e5;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {
|
||||||
|
color: #ab7df8;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning {
|
||||||
|
border-left-color: #9e6a03;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {
|
||||||
|
color: #d29922;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip {
|
||||||
|
border-left-color: #238636;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution {
|
||||||
|
border-left-color: #da3633;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs {
|
||||||
|
color: #c9d1d9;
|
||||||
|
background: #0d1117;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-doctag,
|
||||||
|
:root[data-theme="dark"] .hljs-keyword,
|
||||||
|
:root[data-theme="dark"] .hljs-meta .hljs-keyword,
|
||||||
|
:root[data-theme="dark"] .hljs-template-tag,
|
||||||
|
:root[data-theme="dark"] .hljs-template-variable,
|
||||||
|
:root[data-theme="dark"] .hljs-type,
|
||||||
|
:root[data-theme="dark"] .hljs-variable.language_ {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-title,
|
||||||
|
:root[data-theme="dark"] .hljs-title.class_,
|
||||||
|
:root[data-theme="dark"] .hljs-title.class_.inherited__,
|
||||||
|
:root[data-theme="dark"] .hljs-title.function_ {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-attr,
|
||||||
|
:root[data-theme="dark"] .hljs-attribute,
|
||||||
|
:root[data-theme="dark"] .hljs-literal,
|
||||||
|
:root[data-theme="dark"] .hljs-meta,
|
||||||
|
:root[data-theme="dark"] .hljs-number,
|
||||||
|
:root[data-theme="dark"] .hljs-operator,
|
||||||
|
:root[data-theme="dark"] .hljs-variable,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-attr,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-class,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-id {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-regexp,
|
||||||
|
:root[data-theme="dark"] .hljs-string,
|
||||||
|
:root[data-theme="dark"] .hljs-meta .hljs-string {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-built_in,
|
||||||
|
:root[data-theme="dark"] .hljs-symbol {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-comment,
|
||||||
|
:root[data-theme="dark"] .hljs-code,
|
||||||
|
:root[data-theme="dark"] .hljs-formula {
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-name,
|
||||||
|
:root[data-theme="dark"] .hljs-quote,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-tag,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-pseudo {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-subst {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-section {
|
||||||
|
color: #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-bullet {
|
||||||
|
color: #f2cc60;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-emphasis {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-strong {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-addition {
|
||||||
|
color: #aff5b4;
|
||||||
|
background-color: #033a16;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-deletion {
|
||||||
|
color: #ffdcd7;
|
||||||
|
background-color: #67060c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { GlobalThemeOverrides } from "naive-ui"
|
||||||
|
|
||||||
|
// 全站配色只在这里定义:同一份值既生成 CSS 变量(组件样式里用 var(--wp-*)),
|
||||||
|
// 也生成 Naive UI 的主题设置。机房是 Chrome 105,不支持 color-mix(),浅色底色单独列出来
|
||||||
|
export interface Palette {
|
||||||
|
brand: string
|
||||||
|
brandHover: string
|
||||||
|
brandPressed: string
|
||||||
|
brandSoft: string
|
||||||
|
ink: string
|
||||||
|
muted: string
|
||||||
|
faint: string
|
||||||
|
paper: string
|
||||||
|
surface: string
|
||||||
|
line: string
|
||||||
|
html: string
|
||||||
|
htmlSoft: string
|
||||||
|
css: string
|
||||||
|
cssSoft: string
|
||||||
|
js: string
|
||||||
|
jsSoft: string
|
||||||
|
warn: string
|
||||||
|
warnSoft: string
|
||||||
|
bad: string
|
||||||
|
badSoft: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const light: Palette = {
|
||||||
|
brand: "#0d7a52",
|
||||||
|
brandHover: "#118f61",
|
||||||
|
brandPressed: "#0a6343",
|
||||||
|
brandSoft: "#e3f2eb",
|
||||||
|
ink: "#17201c",
|
||||||
|
muted: "#5d6b65",
|
||||||
|
faint: "#8a9791",
|
||||||
|
paper: "#f5f7f6",
|
||||||
|
surface: "#ffffff",
|
||||||
|
line: "#dfe5e2",
|
||||||
|
html: "#d9532c",
|
||||||
|
htmlSoft: "#fdf3ef",
|
||||||
|
css: "#2c6bd6",
|
||||||
|
cssSoft: "#f1f5fd",
|
||||||
|
js: "#b98900",
|
||||||
|
jsSoft: "#fdf9ec",
|
||||||
|
warn: "#b7791f",
|
||||||
|
warnSoft: "#fbf3e6",
|
||||||
|
bad: "#c2413b",
|
||||||
|
badSoft: "#fbeceb",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暗色(阶段 4 才接上切换开关,值先定好)
|
||||||
|
export const dark: Palette = {
|
||||||
|
brand: "#3cc189",
|
||||||
|
brandHover: "#5ad19d",
|
||||||
|
brandPressed: "#2ea374",
|
||||||
|
brandSoft: "#173326",
|
||||||
|
ink: "#e4ebe7",
|
||||||
|
muted: "#9aaba3",
|
||||||
|
faint: "#6f7f78",
|
||||||
|
paper: "#0f1412",
|
||||||
|
surface: "#161d1a",
|
||||||
|
line: "#2a3531",
|
||||||
|
html: "#f07a52",
|
||||||
|
htmlSoft: "#2a1c17",
|
||||||
|
css: "#6c9cf0",
|
||||||
|
cssSoft: "#18202e",
|
||||||
|
js: "#e2b93b",
|
||||||
|
jsSoft: "#262113",
|
||||||
|
warn: "#e0a34a",
|
||||||
|
warnSoft: "#2b2316",
|
||||||
|
bad: "#ec6b65",
|
||||||
|
badSoft: "#2e1a19",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fontSans =
|
||||||
|
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", system-ui, sans-serif'
|
||||||
|
export const fontMono =
|
||||||
|
'"JetBrains Mono", Consolas, "Microsoft YaHei", monospace'
|
||||||
|
|
||||||
|
const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())
|
||||||
|
|
||||||
|
export function applyCssVars(p: Palette) {
|
||||||
|
const root = document.documentElement.style
|
||||||
|
for (const [k, v] of Object.entries(p)) root.setProperty(`--wp-${kebab(k)}`, v)
|
||||||
|
root.setProperty("--wp-font-sans", fontSans)
|
||||||
|
root.setProperty("--wp-font-mono", fontMono)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function naiveOverrides(p: Palette): GlobalThemeOverrides {
|
||||||
|
return {
|
||||||
|
common: {
|
||||||
|
primaryColor: p.brand,
|
||||||
|
primaryColorHover: p.brandHover,
|
||||||
|
primaryColorPressed: p.brandPressed,
|
||||||
|
primaryColorSuppl: p.brandHover,
|
||||||
|
warningColor: p.warn,
|
||||||
|
errorColor: p.bad,
|
||||||
|
textColorBase: p.ink,
|
||||||
|
textColor1: p.ink,
|
||||||
|
textColor2: p.ink,
|
||||||
|
textColor3: p.muted,
|
||||||
|
borderColor: p.line,
|
||||||
|
dividerColor: p.line,
|
||||||
|
fontFamily: fontSans,
|
||||||
|
fontFamilyMono: fontMono,
|
||||||
|
borderRadius: "6px",
|
||||||
|
borderRadiusSmall: "4px",
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface Code {
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyCode = (): Code => ({ html: "", css: "", js: "" })
|
||||||
|
|
||||||
|
export const sameCode = (a: Code, b: Code) =>
|
||||||
|
a.html === b.html && a.css === b.css && a.js === b.js
|
||||||
|
|
||||||
|
export const isEmptyCode = (c: Code) => !c.html.trim() && !c.css.trim() && !c.js.trim()
|
||||||
|
|
||||||
|
// 把一整页 HTML(外部 AI 常给的格式)拆成 html / css / js 三段
|
||||||
|
export function splitHtml(raw: string): Code {
|
||||||
|
const cssBlocks: string[] = []
|
||||||
|
const jsBlocks: string[] = []
|
||||||
|
let result = raw.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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prettier 加上各语言插件近 1MB,只在点“整理代码”时才加载
|
||||||
|
export async function formatCode(code: Code): Promise<Code> {
|
||||||
|
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
|
||||||
|
await Promise.all([
|
||||||
|
import("prettier/standalone"),
|
||||||
|
import("prettier/parser-html"),
|
||||||
|
import("prettier/parser-postcss"),
|
||||||
|
import("prettier/parser-babel"),
|
||||||
|
import("prettier/plugins/estree"),
|
||||||
|
])
|
||||||
|
const [html, css, js] = await Promise.all([
|
||||||
|
prettier.format(code.html, {
|
||||||
|
parser: "html",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(code.css, {
|
||||||
|
parser: "css",
|
||||||
|
plugins: [cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(code.js, {
|
||||||
|
parser: "babel",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [babelParser, estreeParser],
|
||||||
|
tabWidth: 2,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
return { html, css, js }
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ export const STORAGE_KEY = {
|
|||||||
CSS: "web-css",
|
CSS: "web-css",
|
||||||
JS: "web-js",
|
JS: "web-js",
|
||||||
TAB: "web-tab",
|
TAB: "web-tab",
|
||||||
FONTSIZE: "web-fontsize",
|
// 默认字号从 24 改成 16 时换了 key,让机房电脑上存着的旧值失效
|
||||||
|
FONTSIZE: "web-fontsize-v2",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
|
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
|
||||||
|
|||||||
@@ -16,3 +16,25 @@ export function goHome(router: any, type: TASK_TYPE, display: number) {
|
|||||||
router.push({ name: "home" })
|
router.push({ name: "home" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 一轮对话 / 一个版本是怎么来的
|
||||||
|
export function sourceLabel(source: string | null | undefined) {
|
||||||
|
if (source === "edit") return "手改"
|
||||||
|
if (source === "manual") return "外部 AI"
|
||||||
|
if (source === "guidance") return "引导"
|
||||||
|
return "AI 对话"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提问的 Bloom 等级(services/classifier.ts 后台打的标签)
|
||||||
|
export const PROMPT_LEVELS: Record<number, { name: string; desc: string }> = {
|
||||||
|
1: { name: "记忆", desc: "只说要做什么或只报结果,没有自己的要求和解释" },
|
||||||
|
2: { name: "理解", desc: "想弄懂原理:问为什么、是什么意思、怎么做到的" },
|
||||||
|
3: { name: "应用", desc: "提出具体要求(颜色、布局、功能),照搬题目要求也算" },
|
||||||
|
4: { name: "分析", desc: "说清在什么操作、哪个位置出了什么问题,或推测原因" },
|
||||||
|
5: { name: "评价", desc: "带理由评判现有结果,或比较不同做法再选择" },
|
||||||
|
6: { name: "创造", desc: "题目之外自己构思的整体设计:主题、玩法、交互" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelLabel(level: number | null | undefined) {
|
||||||
|
return level ? `L${level} ${PROMPT_LEVELS[level]?.name ?? ""}`.trim() : ""
|
||||||
|
}
|
||||||
@@ -30,8 +30,9 @@ export function buildPreviewDocument({
|
|||||||
<title>预览</title>
|
<title>预览</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
|
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
|
||||||
<style>${css}</style>
|
<!-- normalize 要在学生的样式前面,否则同样写法的规则(body、h1 的边距等)会被它盖掉 -->
|
||||||
<link rel="stylesheet" href="/normalize.min.css" />
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
|
<style>${css}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
${html}
|
${html}
|
||||||
|
|||||||
+86
-32
@@ -39,7 +39,8 @@ export function getRole(role: Role) {
|
|||||||
}[role]
|
}[role]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlagType = "red" | "blue" | "green" | "yellow" | null
|
// 只用 present(课上展示);以前的四色标记数据里还可能有
|
||||||
|
export type FlagType = "present" | "red" | "blue" | "green" | "yellow" | null
|
||||||
|
|
||||||
export interface TaskAsset {
|
export interface TaskAsset {
|
||||||
name: string
|
name: string
|
||||||
@@ -126,6 +127,8 @@ export interface User {
|
|||||||
role: Role
|
role: Role
|
||||||
classname: string
|
classname: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
// 批量创建时生成的初始密码;超管和改过密码的为空
|
||||||
|
raw_password?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmissionOut {
|
export interface SubmissionOut {
|
||||||
@@ -164,6 +167,8 @@ export interface SubmissionAll {
|
|||||||
view_count: number
|
view_count: number
|
||||||
created: Date
|
created: Date
|
||||||
modified: Date
|
modified: Date
|
||||||
|
// 交的时候写的反思,只有老师和作者本人拿得到
|
||||||
|
reflection?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserTag {
|
export interface UserTag {
|
||||||
@@ -178,25 +183,10 @@ export interface SubmissionCountBucket {
|
|||||||
count_4_plus: number
|
count_4_plus: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScoreBucket {
|
export interface TopScoredItem {
|
||||||
range_1_2: number
|
|
||||||
range_2_3: number
|
|
||||||
range_3_4: number
|
|
||||||
range_4_5: number
|
|
||||||
range_5: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FlagStats {
|
|
||||||
red: number
|
|
||||||
blue: number
|
|
||||||
green: number
|
|
||||||
yellow: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TopViewedItem {
|
|
||||||
username: string
|
username: string
|
||||||
classname: string
|
classname: string
|
||||||
view_count: number
|
score: number
|
||||||
submission_id: string
|
submission_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,10 +198,21 @@ export interface TaskStatsOut {
|
|||||||
unsubmitted_users: UserTag[]
|
unsubmitted_users: UserTag[]
|
||||||
unrated_users: UserTag[]
|
unrated_users: UserTag[]
|
||||||
submission_count_distribution: SubmissionCountBucket
|
submission_count_distribution: SubmissionCountBucket
|
||||||
score_distribution: ScoreBucket
|
present_count: number
|
||||||
flag_stats: FlagStats
|
|
||||||
classes: string[]
|
classes: string[]
|
||||||
top_viewed: TopViewedItem[]
|
top_scored: TopScoredItem[]
|
||||||
|
prompts: PromptChainStats
|
||||||
|
}
|
||||||
|
|
||||||
|
// 课堂总结里每人最新一版的提示词链概况
|
||||||
|
export interface PromptChainStats {
|
||||||
|
with_chain: number
|
||||||
|
one_round: number
|
||||||
|
external: number
|
||||||
|
used_guidance: number
|
||||||
|
avg_rounds: number
|
||||||
|
// L1–L6 各有几次提问
|
||||||
|
levels: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GradebookTaskType = "tutorial" | "challenge"
|
export type GradebookTaskType = "tutorial" | "challenge"
|
||||||
@@ -274,6 +275,7 @@ export interface ShowcaseItem {
|
|||||||
username: string
|
username: string
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
|
task_type: TASK_TYPE
|
||||||
score: number
|
score: number
|
||||||
view_count: number
|
view_count: number
|
||||||
html: string | null
|
html: string | null
|
||||||
@@ -314,28 +316,22 @@ export interface AwardItemUpdateIn {
|
|||||||
sort_order: number
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShowcaseSubmissionLookupOut {
|
|
||||||
submission_id: string
|
|
||||||
username: string
|
|
||||||
task_title: string
|
|
||||||
task_display: number
|
|
||||||
score: number
|
|
||||||
view_count: number
|
|
||||||
has_prompt_chain: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AwardItemManageOut {
|
export interface AwardItemManageOut {
|
||||||
id: number
|
id: number
|
||||||
submission_id: string
|
submission_id: string
|
||||||
username: string
|
username: string
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
|
task_type: TASK_TYPE
|
||||||
score: number
|
score: number
|
||||||
view_count: number
|
view_count: number
|
||||||
sort_order: number
|
sort_order: number
|
||||||
awarded_at: string
|
awarded_at: string
|
||||||
has_prompt_chain: boolean
|
has_prompt_chain: boolean
|
||||||
is_stale: boolean
|
is_stale: boolean
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShowcaseDetail {
|
export interface ShowcaseDetail {
|
||||||
@@ -343,6 +339,7 @@ export interface ShowcaseDetail {
|
|||||||
username: string
|
username: string
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
|
task_type: TASK_TYPE
|
||||||
score: number
|
score: number
|
||||||
view_count: number
|
view_count: number
|
||||||
html: string | null
|
html: string | null
|
||||||
@@ -362,9 +359,9 @@ export interface PromptRound {
|
|||||||
js: string | null
|
js: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打分前不带作者(打完分 updateScore 返回)
|
||||||
export interface RandomRatingItem {
|
export interface RandomRatingItem {
|
||||||
submission_id: string
|
submission_id: string
|
||||||
username: string
|
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
task_type: TASK_TYPE
|
task_type: TASK_TYPE
|
||||||
@@ -372,3 +369,60 @@ export interface RandomRatingItem {
|
|||||||
css: string | null
|
css: string | null
|
||||||
js: string | null
|
js: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewOptions {
|
||||||
|
classes: string[]
|
||||||
|
tasks: {
|
||||||
|
id: number
|
||||||
|
task_type: "tutorial" | "challenge"
|
||||||
|
display: number
|
||||||
|
title: string
|
||||||
|
is_public: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewProgress {
|
||||||
|
student_count: number
|
||||||
|
// task_id → 交了的人数
|
||||||
|
submitted: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSubmission {
|
||||||
|
id: string
|
||||||
|
score: number
|
||||||
|
my_score: number
|
||||||
|
rating_count: number
|
||||||
|
flag: FlagType
|
||||||
|
view_count: number
|
||||||
|
submit_count: number
|
||||||
|
created: string
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewStudent {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
classname: string
|
||||||
|
submission: ReviewSubmission | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerWork {
|
||||||
|
id: string
|
||||||
|
// 自己打分之前是 null
|
||||||
|
username: string | null
|
||||||
|
classname: string
|
||||||
|
score: number | null
|
||||||
|
my_score: number
|
||||||
|
created: string
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerWorks {
|
||||||
|
// 自己还没交这道题
|
||||||
|
locked: boolean
|
||||||
|
items: PeerWork[]
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"build": "bun run --filter webpreview-client build",
|
"build": "bun run --filter webpreview-client build",
|
||||||
"build:staging": "bun run --filter webpreview-client build:staging",
|
"build:staging": "bun run --filter webpreview-client build:staging",
|
||||||
"test": "bun run --filter webpreview-server test",
|
"test": "bun run --filter webpreview-server test",
|
||||||
"typecheck": "bun run --filter webpreview-server typecheck",
|
"typecheck": "bun run --filter '*' typecheck",
|
||||||
"fmt": "bun run --filter webpreview-client fmt"
|
"fmt": "bun run --filter webpreview-client fmt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ const { values } = parseArgs({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const where = ["m.role = 'user'"]
|
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||||
const params: Record<string, unknown> = {}
|
const params: Record<string, unknown> = {}
|
||||||
if (values["task-id"]) {
|
if (values["task-id"]) {
|
||||||
where.push("c.task_id = $task_id")
|
where.push("c.task_id = $task_id")
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 对比某个日期前后,学生和 AI 对话的情况(比如改了 AI 的回复方式以后有没有变化)。
|
||||||
|
* 日期按北京时间,分界日期那天算“之后”。只读,不改数据。
|
||||||
|
*
|
||||||
|
* bun scripts/prompt-stats.ts <分界日期> [--from 日期] [--to 日期]
|
||||||
|
* bun scripts/prompt-stats.ts 2026-10-28 --from 2026-09-01
|
||||||
|
*
|
||||||
|
* 层级是打标签时的标准;分类标准改过以后,先跑 classify-prompts.ts --force 重打,前后才好比。
|
||||||
|
*/
|
||||||
|
import { all, toDbTime } from "../src/db"
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const opt = (name: string) => {
|
||||||
|
const i = args.indexOf(name)
|
||||||
|
return i >= 0 ? args[i + 1] : undefined
|
||||||
|
}
|
||||||
|
const split = args[0]
|
||||||
|
if (!split || !/^\d{4}-\d{2}-\d{2}$/.test(split)) {
|
||||||
|
console.log("用法:bun scripts/prompt-stats.ts <分界日期> [--from 日期] [--to 日期]")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const day = (d: string) => toDbTime(new Date(`${d}T00:00:00+08:00`))
|
||||||
|
const nextDay = (d: string) => toDbTime(new Date(new Date(`${d}T00:00:00+08:00`).getTime() + 86_400_000))
|
||||||
|
const periods = [
|
||||||
|
{ name: `${opt("--from") ?? "最早"} ~ ${split} 之前`, from: opt("--from") ? day(opt("--from")!) : "", to: day(split) },
|
||||||
|
{ name: `${split} ~ ${opt("--to") ?? "现在"}`, from: day(split), to: opt("--to") ? nextDay(opt("--to")!) : "9999" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 和 PromptPanel 里一样:只说“不行”“没反应”的短抱怨;“出问题了”拼出来的句子
|
||||||
|
const VAGUE =
|
||||||
|
/不行|不对|没反应|没用|没效果|用不了|不能用|不能玩|动不了|进不去|打不开|没显示|不显示|显示不出|bug|出错|报错|错了|坏了|白屏|空白|有问题/i
|
||||||
|
const PROBLEM = /^我.+,(想看到.+,)?但实际.+。$/s
|
||||||
|
|
||||||
|
type Msg = {
|
||||||
|
conversation_id: string
|
||||||
|
source: string
|
||||||
|
content: string
|
||||||
|
prompt_level: number | null
|
||||||
|
created: string
|
||||||
|
// 在这个对话的 AI 对话里是第几轮(只数 conversation)
|
||||||
|
round: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批处理脚本,整表扫一遍没关系
|
||||||
|
const msgs = all<Msg>(
|
||||||
|
`SELECT conversation_id, source, content, prompt_level, created,
|
||||||
|
CASE WHEN source = 'conversation'
|
||||||
|
THEN ROW_NUMBER() OVER (PARTITION BY conversation_id, source = 'conversation' ORDER BY created, id) END AS round
|
||||||
|
FROM prompt_message WHERE role = 'user'`,
|
||||||
|
)
|
||||||
|
const subs = all<{ created: string; reflected: number }>(
|
||||||
|
`SELECT s.created, r.submission_id IS NOT NULL AS reflected
|
||||||
|
FROM submission_submission s JOIN task_task t ON t.id = s.task_id
|
||||||
|
LEFT JOIN submission_reflection r ON r.submission_id = s.id
|
||||||
|
WHERE t.task_type = 'challenge'`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const pct = (n: number, d: number) => (d ? `${((n / d) * 100).toFixed(1)}%` : "—")
|
||||||
|
const chars = (s: string) => [...s.trim()].length
|
||||||
|
|
||||||
|
function stats(from: string, to: string) {
|
||||||
|
const inRange = (t: string) => t >= from && t < to
|
||||||
|
const ms = msgs.filter((m) => inRange(m.created))
|
||||||
|
const byConv = new Map<string, Msg[]>()
|
||||||
|
for (const m of ms) byConv.set(m.conversation_id, [...(byConv.get(m.conversation_id) ?? []), m])
|
||||||
|
|
||||||
|
const convs = [...byConv.values()]
|
||||||
|
const ai = convs.filter((c) => c.some((m) => m.source === "conversation"))
|
||||||
|
const manualOnly = convs.filter((c) => c.some((m) => m.source === "manual") && !c.some((m) => m.source === "conversation"))
|
||||||
|
const rounds = ai.map((c) => c.filter((m) => m.source === "conversation").length)
|
||||||
|
const bucket = (lo: number, hi: number) => rounds.filter((n) => n >= lo && n <= hi).length
|
||||||
|
|
||||||
|
const asked = ms.filter((m) => m.source === "conversation")
|
||||||
|
const levels = [1, 2, 3, 4, 5, 6].map((l) => asked.filter((m) => m.prompt_level === l).length)
|
||||||
|
const leveled = levels.reduce((a, b) => a + b, 0)
|
||||||
|
const later = asked.filter((m) => (m.round ?? 1) >= 2 && m.prompt_level)
|
||||||
|
const vague = asked.filter((m) => chars(m.content) <= 15 && VAGUE.test(m.content))
|
||||||
|
const problem = asked.filter((m) => PROBLEM.test(m.content.trim()))
|
||||||
|
const manual = ms.filter((m) => m.source === "manual")
|
||||||
|
const s = subs.filter((x) => inRange(x.created))
|
||||||
|
|
||||||
|
return [
|
||||||
|
["有对话或版本的作品", `${convs.length}`],
|
||||||
|
["只粘贴外部代码", `${manualOnly.length}(${pct(manualOnly.length, convs.length)})`],
|
||||||
|
["和平台 AI 对话的作品", `${ai.length}`],
|
||||||
|
[" 平均轮数", ai.length ? (rounds.reduce((a, b) => a + b, 0) / ai.length).toFixed(2) : "—"],
|
||||||
|
[" 1 轮", pct(bucket(1, 1), ai.length)],
|
||||||
|
[" 2–3 轮", pct(bucket(2, 3), ai.length)],
|
||||||
|
[" 4–6 轮", pct(bucket(4, 6), ai.length)],
|
||||||
|
[" 7 轮以上", pct(bucket(7, Infinity), ai.length)],
|
||||||
|
["提问(AI 对话)", `${asked.length},已分级 ${leveled}`],
|
||||||
|
...levels.map((n, i) => [` L${i + 1}`, pct(n, leveled)]),
|
||||||
|
[" L4–L6", pct(levels[3]! + levels[4]! + levels[5]!, leveled)],
|
||||||
|
[" 第 2 轮起的 L1", pct(later.filter((m) => m.prompt_level === 1).length, later.length)],
|
||||||
|
[" 只说“不行”的短抱怨", `${vague.length}(${pct(vague.length, asked.length)})`],
|
||||||
|
[" 用“出问题了”", `${problem.length}(${pct(problem.length, asked.length)})`],
|
||||||
|
["用了“帮我写清楚”的作品", `${convs.filter((c) => c.some((m) => m.source === "guidance")).length}(占和 AI 对话的 ${pct(ai.filter((c) => c.some((m) => m.source === "guidance")).length, ai.length)})`],
|
||||||
|
["外部 AI 提示词", `${manual.length},12 字以内 ${pct(manual.filter((m) => chars(m.content) <= 12).length, manual.length)}`],
|
||||||
|
["挑战提交", `${s.length},写了反思 ${pct(s.filter((x) => x.reflected).length, s.length)}`],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [a, b] = periods.map((p) => stats(p.from, p.to))
|
||||||
|
// 终端里中文占两格
|
||||||
|
const cols = (k: string) => [...k].reduce((n, ch) => n + (ch.codePointAt(0)! >= 0x2e80 ? 2 : 1), 0)
|
||||||
|
const width = Math.max(...a!.map(([k]) => cols(k!))) + 2
|
||||||
|
const pad = (k: string) => k + " ".repeat(width - cols(k))
|
||||||
|
console.log(`${pad("")}${periods[0]!.name} → ${periods[1]!.name}`)
|
||||||
|
for (const [i, [k, v]] of a!.entries()) console.log(`${pad(k!)}${v} → ${b![i]![1]}`)
|
||||||
@@ -47,6 +47,12 @@ CREATE INDEX IF NOT EXISTS "prompt_conversation_user_id_ecc418b3" ON "prompt_con
|
|||||||
CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "role" varchar(10) NOT NULL, "content" text NOT NULL, "code_html" text NULL, "code_css" text NULL, "code_js" text NULL, "created" datetime NOT NULL, "conversation_id" char(32) NOT NULL REFERENCES "prompt_conversation" ("id") DEFERRABLE INITIALLY DEFERRED, "prompt_level" integer NULL, "source" varchar(12) NOT NULL, "submission_id" char(32) NULL UNIQUE REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED);
|
CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "role" varchar(10) NOT NULL, "content" text NOT NULL, "code_html" text NULL, "code_css" text NULL, "code_js" text NULL, "created" datetime NOT NULL, "conversation_id" char(32) NOT NULL REFERENCES "prompt_conversation" ("id") DEFERRABLE INITIALLY DEFERRED, "prompt_level" integer NULL, "source" varchar(12) NOT NULL, "submission_id" char(32) NULL UNIQUE REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED);
|
||||||
CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
|
CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
|
||||||
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level");
|
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level");
|
||||||
|
-- 课堂总结统计提示词链用的覆盖索引:只读小列,不碰 content / code_*
|
||||||
|
CREATE INDEX IF NOT EXISTS "prompt_message_chain" ON "prompt_message" ("conversation_id", "created", "id", "role", "source", "prompt_level");
|
||||||
|
CREATE INDEX IF NOT EXISTS "prompt_message_submission" ON "prompt_message" ("submission_id", "conversation_id", "created", "source") WHERE "submission_id" IS NOT NULL;
|
||||||
|
|
||||||
|
-- 学生交作品时写的一句反思(最满意的改动和理由);可以不写。删提交时跟着删
|
||||||
|
CREATE TABLE IF NOT EXISTS "submission_reflection" ("submission_id" char(32) NOT NULL PRIMARY KEY REFERENCES "submission_submission" ("id") ON DELETE CASCADE, "content" text NOT NULL, "modified" datetime NOT NULL);
|
||||||
|
|
||||||
-- 登录用 JWT:access token 不落库,refresh token 只存 sha256
|
-- 登录用 JWT:access token 不落库,refresh token 只存 sha256
|
||||||
DROP TABLE IF EXISTS "auth_session";
|
DROP TABLE IF EXISTS "auth_session";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
requireUser,
|
requireUser,
|
||||||
setPassword,
|
setPassword,
|
||||||
} from "../auth"
|
} from "../auth"
|
||||||
import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Env } from "../http"
|
import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Ctx, type Env } from "../http"
|
||||||
|
|
||||||
const router = new Hono<Env>()
|
const router = new Hono<Env>()
|
||||||
|
|
||||||
@@ -54,10 +54,45 @@ async function insertUser(u: {
|
|||||||
const userExists = (username: string) =>
|
const userExists = (username: string) =>
|
||||||
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
|
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
|
||||||
|
|
||||||
|
// 登录失败次数限制(进程内存里计数):同一用户名 10 分钟内失败 10 次、同一 IP 失败 100 次后暂停登录。
|
||||||
|
// 机房的电脑共用一个出口 IP,IP 的阈值要放宽,主要防的是对单个账号猜密码
|
||||||
|
const LOGIN_WINDOW_MS = 10 * 60_000
|
||||||
|
const MAX_FAILS_PER_USER = 10
|
||||||
|
const MAX_FAILS_PER_IP = 100
|
||||||
|
const loginFailures = new Map<string, { n: number; reset: number }>()
|
||||||
|
|
||||||
|
const failCount = (key: string) => {
|
||||||
|
const f = loginFailures.get(key)
|
||||||
|
return f && f.reset > Date.now() ? f.n : 0
|
||||||
|
}
|
||||||
|
function recordFailure(key: string) {
|
||||||
|
const now = Date.now()
|
||||||
|
const f = loginFailures.get(key)
|
||||||
|
if (f && f.reset > now) f.n++
|
||||||
|
else loginFailures.set(key, { n: 1, reset: now + LOGIN_WINDOW_MS })
|
||||||
|
}
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [k, f] of loginFailures) if (f.reset <= now) loginFailures.delete(k)
|
||||||
|
}, LOGIN_WINDOW_MS).unref()
|
||||||
|
|
||||||
|
// 生产环境经过 Caddy,它会把 X-Forwarded-For 设成真实的客户端地址
|
||||||
|
const clientIp = (c: Ctx) => c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "local"
|
||||||
|
|
||||||
router.post("/login", async (c) => {
|
router.post("/login", async (c) => {
|
||||||
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
|
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
|
||||||
|
const userKey = `user:${payload.username}`
|
||||||
|
const ipKey = `ip:${clientIp(c)}`
|
||||||
|
if (failCount(userKey) >= MAX_FAILS_PER_USER || failCount(ipKey) >= MAX_FAILS_PER_IP) {
|
||||||
|
throw new HttpError(429, "登录失败次数太多,请 10 分钟后再试")
|
||||||
|
}
|
||||||
const user = await authenticate(payload.username, payload.password)
|
const user = await authenticate(payload.username, payload.password)
|
||||||
if (!user) throw new HttpError(401, "账号密码错误")
|
if (!user) {
|
||||||
|
recordFailure(userKey)
|
||||||
|
recordFailure(ipKey)
|
||||||
|
throw new HttpError(401, "账号密码错误")
|
||||||
|
}
|
||||||
|
loginFailures.delete(userKey)
|
||||||
return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) })
|
return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+22
-23
@@ -1,6 +1,5 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { mkdir, rm } from "node:fs/promises"
|
import { mkdir, rename, rm } from "node:fs/promises"
|
||||||
import { existsSync } from "node:fs"
|
|
||||||
import { dirname, extname, join, resolve, sep } from "node:path"
|
import { dirname, extname, join, resolve, sep } from "node:path"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { all, get, run } from "../db"
|
import { all, get, run } from "../db"
|
||||||
@@ -22,20 +21,6 @@ export function mediaPath(relative: string) {
|
|||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFile(relative: string, file: File) {
|
|
||||||
let target = relative
|
|
||||||
// 同名文件已存在时加随机后缀(Django 存储的行为)
|
|
||||||
while (existsSync(mediaPath(target))) {
|
|
||||||
const ext = extname(relative)
|
|
||||||
const suffix = Math.random().toString(36).slice(2, 9)
|
|
||||||
target = relative.slice(0, relative.length - ext.length) + "_" + suffix + ext
|
|
||||||
}
|
|
||||||
const full = mediaPath(target)
|
|
||||||
await mkdir(dirname(full), { recursive: true })
|
|
||||||
await Bun.write(full, file)
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formFile(c: Ctx, field: string) {
|
async function formFile(c: Ctx, field: string) {
|
||||||
const form = await c.req.formData().catch(() => null)
|
const form = await c.req.formData().catch(() => null)
|
||||||
const value = form?.get(field)
|
const value = form?.get(field)
|
||||||
@@ -88,21 +73,35 @@ for (const kind of ["challenge", "tutorial"] as const) {
|
|||||||
if (typeof name !== "string" || !name) {
|
if (typeof name !== "string" || !name) {
|
||||||
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
|
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
|
||||||
}
|
}
|
||||||
const existing = get<{ id: number; file: string }>(
|
// 学生代码按这个名字引用素材(<base href> 指向素材目录),存储的文件名必须和它一致
|
||||||
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
if (name !== validFilename(name) || name.length > 100) {
|
||||||
|
throw new ValidationError([
|
||||||
|
{ loc: ["body", "name"], msg: "文件名只能包含字母、数字和 - _ .,最多 100 个字符" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const existing = get<{ file: string }>(
|
||||||
|
`SELECT file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
||||||
{ task_id: task.id, name },
|
{ task_id: task.id, name },
|
||||||
)
|
)
|
||||||
if (existing?.file) await rm(mediaPath(existing.file), { force: true })
|
// 先写临时文件再 rename 覆盖:写失败时旧文件还在,并发上传同名文件也不会留下坏文件
|
||||||
const stored = await saveFile(
|
const stored = `tasks/${task.task_type}/${task.display}/${name}`
|
||||||
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`,
|
const full = mediaPath(stored)
|
||||||
file,
|
const tmp = `${full}.${crypto.randomUUID()}.uploading`
|
||||||
)
|
await mkdir(dirname(full), { recursive: true })
|
||||||
|
try {
|
||||||
|
await Bun.write(tmp, file)
|
||||||
|
await rename(tmp, full)
|
||||||
|
} finally {
|
||||||
|
await rm(tmp, { force: true })
|
||||||
|
}
|
||||||
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
||||||
run(
|
run(
|
||||||
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
||||||
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
||||||
{ name, file: stored, task_id: task.id },
|
{ name, file: stored, task_id: task.id },
|
||||||
)
|
)
|
||||||
|
// 旧数据里同名素材可能存在带随机后缀的文件
|
||||||
|
if (existing?.file && existing.file !== stored) await rm(mediaPath(existing.file), { force: true })
|
||||||
return c.json(assetOut({ name, file: stored }))
|
return c.json(assetOut({ name, file: stored }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+59
-18
@@ -1,15 +1,23 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { all, get, run, tx, iso, uuidOut } from "../db"
|
import { all, get, tx, iso, uuidOut } from "../db"
|
||||||
import { Role, isStaff, requireUser } from "../auth"
|
import { Role, isStaff, requireUser } from "../auth"
|
||||||
import { HttpError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
import { HttpError, ValidationError, body, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
||||||
import { deleteSubmissions } from "../services/cascade"
|
import { deleteRound, hasRatingsOrAwards } from "../services/cascade"
|
||||||
import {
|
import {
|
||||||
classifyConversationMessages,
|
classifyConversationMessages,
|
||||||
|
classifyMessage,
|
||||||
classifyMessagesBatch,
|
classifyMessagesBatch,
|
||||||
inBackground,
|
inBackground,
|
||||||
} from "../services/classifier"
|
} from "../services/classifier"
|
||||||
import { getPrecedingUserMessage, type MessageRow } from "../services/prompt"
|
import {
|
||||||
|
EDIT_PROMPT,
|
||||||
|
canAccessTask,
|
||||||
|
codeAsMarkdown,
|
||||||
|
getOrCreateActiveConversation,
|
||||||
|
insertMessage,
|
||||||
|
type MessageRow,
|
||||||
|
} from "../services/prompt"
|
||||||
|
|
||||||
const router = new Hono<Env>()
|
const router = new Hono<Env>()
|
||||||
|
|
||||||
@@ -30,6 +38,10 @@ router.get("/conversations", (c) => {
|
|||||||
where.push("c.task_id = $task_id")
|
where.push("c.task_id = $task_id")
|
||||||
params.task_id = q.task_id
|
params.task_id = q.task_id
|
||||||
}
|
}
|
||||||
|
// 不带条件就是全站所有对话(每个还要数一遍消息),会卡住整个服务
|
||||||
|
if (!where.length) {
|
||||||
|
throw new ValidationError([{ loc: ["query", "task_id"], msg: "需要 task_id 或 user_id" }])
|
||||||
|
}
|
||||||
const rows = all(
|
const rows = all(
|
||||||
`SELECT c.id, c.user_id, u.username, c.task_id, t.title AS task_title, c.is_active, c.created,
|
`SELECT c.id, c.user_id, u.username, c.task_id, t.title AS task_title, c.is_active, c.created,
|
||||||
(SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) AS message_count
|
(SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) AS message_count
|
||||||
@@ -129,6 +141,45 @@ router.get("/history/:task_id", (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
|
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
|
||||||
|
// 保存一个不是 AI 生成的版本:学生手改的代码(source=edit),或者粘贴外部 AI 的代码(source=manual,带提示词)。
|
||||||
|
// 存成一问一答放进当前对话,版本条和提示词链里都能看到;之后接着和 AI 对话,AI 也是在这份代码上改
|
||||||
|
router.post("/versions", async (c) => {
|
||||||
|
const user = requireUser(c)
|
||||||
|
const p = await body(
|
||||||
|
c,
|
||||||
|
z.object({
|
||||||
|
task_id: z.number().int(),
|
||||||
|
html: z.string(),
|
||||||
|
css: z.string(),
|
||||||
|
js: z.string(),
|
||||||
|
prompt: z.string().trim().max(5000).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: p.task_id }), "任务不存在")
|
||||||
|
if (!canAccessTask(user, p.task_id)) throw new HttpError(403, "没有权限")
|
||||||
|
const source = p.prompt ? "manual" : "edit"
|
||||||
|
const code = { code_html: p.html, code_css: p.css, code_js: p.js }
|
||||||
|
const ids = tx(() => {
|
||||||
|
const convId = getOrCreateActiveConversation(user.id, p.task_id)
|
||||||
|
const userMessageId = insertMessage({
|
||||||
|
conversation_id: convId,
|
||||||
|
role: "user",
|
||||||
|
content: p.prompt || EDIT_PROMPT,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
const messageId = insertMessage({
|
||||||
|
conversation_id: convId,
|
||||||
|
role: "assistant",
|
||||||
|
content: codeAsMarkdown(code),
|
||||||
|
source,
|
||||||
|
...code,
|
||||||
|
})
|
||||||
|
return { userMessageId, messageId }
|
||||||
|
})
|
||||||
|
if (p.prompt) inBackground(classifyMessage(ids.userMessageId))
|
||||||
|
return c.json({ message_id: ids.messageId, user_message_id: ids.userMessageId, source })
|
||||||
|
})
|
||||||
|
|
||||||
router.post("/conversations/:conversation_id/classify", (c) => {
|
router.post("/conversations/:conversation_id/classify", (c) => {
|
||||||
const user = requireUser(c)
|
const user = requireUser(c)
|
||||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||||
@@ -147,7 +198,7 @@ router.post("/classify-batch", (c) => {
|
|||||||
c,
|
c,
|
||||||
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
|
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
|
||||||
)
|
)
|
||||||
const where = ["m.role = 'user'"]
|
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||||
const params: Record<string, unknown> = {}
|
const params: Record<string, unknown> = {}
|
||||||
if (q.task_id) {
|
if (q.task_id) {
|
||||||
where.push("c.task_id = $task_id")
|
where.push("c.task_id = $task_id")
|
||||||
@@ -177,20 +228,10 @@ router.delete("/messages/:message_id/pair", (c) => {
|
|||||||
if (asst.owner_id !== user.id && user.role !== Role.SUPER) {
|
if (asst.owner_id !== user.id && user.role !== Role.SUPER) {
|
||||||
throw new HttpError(403, "只能删除自己的消息")
|
throw new HttpError(403, "只能删除自己的消息")
|
||||||
}
|
}
|
||||||
if (asst.submission_id && user.role !== Role.SUPER) {
|
if (asst.submission_id && user.role !== Role.SUPER && hasRatingsOrAwards(asst.submission_id)) {
|
||||||
const used = get(
|
throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除")
|
||||||
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
|
|
||||||
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
|
|
||||||
{ id: asst.submission_id },
|
|
||||||
)
|
|
||||||
if (used) throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除")
|
|
||||||
}
|
}
|
||||||
const userMsg = getPrecedingUserMessage(asst)
|
tx(() => deleteRound(asst))
|
||||||
tx(() => {
|
|
||||||
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
|
|
||||||
run(`DELETE FROM prompt_message WHERE id = $id`, { id: asst.id })
|
|
||||||
if (asst.submission_id) deleteSubmissions("id = $id", { id: asst.submission_id })
|
|
||||||
})
|
|
||||||
return c.json({ deleted: true, submission_deleted: !!asst.submission_id })
|
return c.json({ deleted: true, submission_deleted: !!asst.submission_id })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Hono } from "hono"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { all, get, iso, uuidOut } from "../db"
|
||||||
|
import { isStaff, requireUser, type User } from "../auth"
|
||||||
|
import { HttpError, intParam, orNotFound, query, type Env } from "../http"
|
||||||
|
|
||||||
|
// 老师按“班级 × 任务”批改,学生看本班同学的作品。挂在 /submission/review 下
|
||||||
|
const router = new Hono<Env>()
|
||||||
|
|
||||||
|
function requireStaff(user: User) {
|
||||||
|
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 和成绩册一致:班里的学生包括当助教的 admin
|
||||||
|
const CLASS_MEMBERS = `SELECT id FROM account_user
|
||||||
|
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1`
|
||||||
|
// 不传班级时:所有有班级的学生
|
||||||
|
const ALL_MEMBERS = `SELECT id FROM account_user
|
||||||
|
WHERE classname != '' AND role IN ('normal', 'admin') AND is_active = 1`
|
||||||
|
|
||||||
|
function members(classname: string | undefined) {
|
||||||
|
return classname
|
||||||
|
? { sql: CLASS_MEMBERS, params: { classname } as Record<string, unknown> }
|
||||||
|
: { sql: ALL_MEMBERS, params: {} as Record<string, unknown> }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每个人在这个任务上最新的一次提交,以及提交了几次。
|
||||||
|
// 先只在 slim 覆盖索引上找,再按 rowid 读这几行的完整内容(代码是宽列,见 CLAUDE.md)
|
||||||
|
function latestByUser(taskId: number, members: string, params: Record<string, unknown>) {
|
||||||
|
const rows = all<{ rid: number; user_id: number; created: string }>(
|
||||||
|
`SELECT s.rowid AS rid, s.user_id, s.created FROM submission_submission s
|
||||||
|
WHERE s.task_id = $task_id AND s.user_id IN (${members})`,
|
||||||
|
{ task_id: taskId, ...params },
|
||||||
|
)
|
||||||
|
const latest = new Map<number, { rid: number; created: string; count: number }>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const cur = latest.get(r.user_id)
|
||||||
|
if (!cur) latest.set(r.user_id, { rid: r.rid, created: r.created, count: 1 })
|
||||||
|
else {
|
||||||
|
cur.count++
|
||||||
|
if (r.created > cur.created) Object.assign(cur, { rid: r.rid, created: r.created })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRows(rids: number[], me: number) {
|
||||||
|
if (!rids.length) return new Map<number, any>()
|
||||||
|
const rows = all(
|
||||||
|
`SELECT s.rowid AS rid, s.id, s.user_id, s.score, s.html, s.css, s.js, s.flag,
|
||||||
|
s.view_count, s.created,
|
||||||
|
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id) AS my_score,
|
||||||
|
(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) AS rating_count
|
||||||
|
FROM submission_submission s WHERE s.rowid IN (SELECT value FROM json_each($rids))`,
|
||||||
|
{ rids: JSON.stringify(rids), me },
|
||||||
|
)
|
||||||
|
return new Map(rows.map((r) => [r.user_id as number, r]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级列表和可批改的任务(教程、挑战)
|
||||||
|
router.get("/options", (c) => {
|
||||||
|
requireStaff(requireUser(c))
|
||||||
|
const classes = all<{ classname: string }>(
|
||||||
|
`SELECT DISTINCT classname FROM account_user
|
||||||
|
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
|
||||||
|
).map((r) => r.classname)
|
||||||
|
const tasks = all(
|
||||||
|
`SELECT id, task_type, display, title, is_public FROM task_task
|
||||||
|
WHERE task_type IN ('tutorial', 'challenge')
|
||||||
|
ORDER BY task_type = 'challenge', display`,
|
||||||
|
).map((t) => ({ ...t, is_public: !!t.is_public }))
|
||||||
|
return c.json({ classes, tasks })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 一个班每个任务交了几个人
|
||||||
|
// 不传 classname 就是全部班级
|
||||||
|
router.get("/progress", (c) => {
|
||||||
|
requireStaff(requireUser(c))
|
||||||
|
const { classname } = query(c, z.object({ classname: z.string().min(1).optional() }))
|
||||||
|
const m = members(classname)
|
||||||
|
const studentCount = get<{ n: number }>(`SELECT COUNT(*) AS n FROM (${m.sql})`, m.params)!.n
|
||||||
|
const rows = all<{ task_id: number; n: number }>(
|
||||||
|
`SELECT s.task_id, COUNT(DISTINCT s.user_id) AS n FROM submission_submission s
|
||||||
|
WHERE s.user_id IN (${m.sql}) GROUP BY s.task_id`,
|
||||||
|
m.params,
|
||||||
|
)
|
||||||
|
return c.json({
|
||||||
|
student_count: studentCount,
|
||||||
|
submitted: Object.fromEntries(rows.map((r) => [r.task_id, r.n])),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 作品墙:班里每个学生在这个任务上最新的一次提交(没交的 submission 为 null)
|
||||||
|
router.get("/class", (c) => {
|
||||||
|
const user = requireUser(c)
|
||||||
|
requireStaff(user)
|
||||||
|
const q = query(
|
||||||
|
c,
|
||||||
|
z.object({ classname: z.string().min(1).optional(), task_id: z.coerce.number().int() }),
|
||||||
|
)
|
||||||
|
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: q.task_id }), "任务不存在")
|
||||||
|
const m = members(q.classname)
|
||||||
|
const students = all<{ id: number; username: string; classname: string }>(
|
||||||
|
`SELECT id, username, classname FROM account_user WHERE id IN (${m.sql})
|
||||||
|
ORDER BY classname, username, id`,
|
||||||
|
m.params,
|
||||||
|
)
|
||||||
|
const latest = latestByUser(q.task_id, m.sql, m.params)
|
||||||
|
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||||
|
return c.json(
|
||||||
|
students.map((s) => {
|
||||||
|
const r = rows.get(s.id)
|
||||||
|
return {
|
||||||
|
user_id: s.id,
|
||||||
|
username: s.username,
|
||||||
|
classname: s.classname,
|
||||||
|
submission: r
|
||||||
|
? {
|
||||||
|
id: uuidOut(r.id),
|
||||||
|
score: r.score,
|
||||||
|
my_score: r.my_score || 0,
|
||||||
|
rating_count: r.rating_count,
|
||||||
|
flag: r.flag,
|
||||||
|
view_count: r.view_count,
|
||||||
|
submit_count: latest.get(s.id)!.count,
|
||||||
|
created: iso(r.created),
|
||||||
|
html: r.html,
|
||||||
|
css: r.css,
|
||||||
|
js: r.js,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 学生看同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分。
|
||||||
|
// 老师不受这两条限制。scope=class 只看本班(默认,一个班几十份),scope=all 看全校所有班
|
||||||
|
router.get("/peers/:task_id", (c) => {
|
||||||
|
const user = requireUser(c)
|
||||||
|
const taskId = intParam(c, "task_id")
|
||||||
|
const { scope } = query(c, z.object({ scope: z.enum(["class", "all"]).default("class") }))
|
||||||
|
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }), "任务不存在")
|
||||||
|
const staff = isStaff(user)
|
||||||
|
const submitted =
|
||||||
|
staff ||
|
||||||
|
get(`SELECT 1 FROM submission_submission WHERE user_id = $me AND task_id = $task_id LIMIT 1`, {
|
||||||
|
me: user.id,
|
||||||
|
task_id: taskId,
|
||||||
|
})
|
||||||
|
if (!submitted) return c.json({ locked: true, items: [] })
|
||||||
|
const byClass = scope === "class" && !!user.classname
|
||||||
|
const members = `SELECT id FROM account_user
|
||||||
|
WHERE role = 'normal' AND is_active = 1 AND id != $me
|
||||||
|
${byClass ? "AND classname = $classname" : ""}`
|
||||||
|
const params = byClass ? { classname: user.classname, me: user.id } : { me: user.id }
|
||||||
|
const latest = latestByUser(taskId, members, params)
|
||||||
|
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||||
|
const people = new Map(
|
||||||
|
all<{ id: number; username: string; classname: string }>(
|
||||||
|
`SELECT id, username, classname FROM account_user WHERE id IN (${members})`,
|
||||||
|
params,
|
||||||
|
).map((u) => [u.id, u]),
|
||||||
|
)
|
||||||
|
const items = [...rows.values()]
|
||||||
|
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
||||||
|
.map((r) => {
|
||||||
|
const rated = staff || (r.my_score || 0) > 0
|
||||||
|
const p = people.get(r.user_id)
|
||||||
|
return {
|
||||||
|
id: uuidOut(r.id),
|
||||||
|
username: rated ? p?.username : null,
|
||||||
|
// 班级不算身份信息,打分前也显示
|
||||||
|
classname: p?.classname ?? "",
|
||||||
|
score: rated ? r.score : null,
|
||||||
|
my_score: r.my_score || 0,
|
||||||
|
created: iso(r.created),
|
||||||
|
html: r.html,
|
||||||
|
css: r.css,
|
||||||
|
js: r.js,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return c.json({ locked: false, items })
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -20,7 +20,7 @@ const ORDER_BY: Record<string, string> = {
|
|||||||
const HAS_CHAIN = `EXISTS (SELECT 1 FROM prompt_message m WHERE m.submission_id = s.id) AS has_prompt_chain`
|
const HAS_CHAIN = `EXISTS (SELECT 1 FROM prompt_message m WHERE m.submission_id = s.id) AS has_prompt_chain`
|
||||||
|
|
||||||
const ITEM_SELECT = `SELECT sa.id, sa.submission_id, sa.sort_order, sa.awarded_at, sa.is_stale,
|
const ITEM_SELECT = `SELECT sa.id, sa.submission_id, sa.sort_order, sa.awarded_at, sa.is_stale,
|
||||||
u.username, t.title AS task_title, t.display AS task_display,
|
u.username, t.title AS task_title, t.display AS task_display, t.task_type,
|
||||||
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
|
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
|
||||||
FROM submission_submissionaward sa
|
FROM submission_submissionaward sa
|
||||||
JOIN submission_submission s ON s.id = sa.submission_id
|
JOIN submission_submission s ON s.id = sa.submission_id
|
||||||
@@ -34,12 +34,17 @@ function itemManageOut(r: any) {
|
|||||||
username: r.username,
|
username: r.username,
|
||||||
task_title: r.task_title,
|
task_title: r.task_title,
|
||||||
task_display: r.task_display,
|
task_display: r.task_display,
|
||||||
|
task_type: r.task_type,
|
||||||
score: r.score,
|
score: r.score,
|
||||||
view_count: r.view_count,
|
view_count: r.view_count,
|
||||||
sort_order: r.sort_order,
|
sort_order: r.sort_order,
|
||||||
awarded_at: iso(r.awarded_at),
|
awarded_at: iso(r.awarded_at),
|
||||||
has_prompt_chain: bool(r.has_prompt_chain),
|
has_prompt_chain: bool(r.has_prompt_chain),
|
||||||
is_stale: bool(r.is_stale),
|
is_stale: bool(r.is_stale),
|
||||||
|
// 管理页的缩略图要用
|
||||||
|
html: r.html,
|
||||||
|
css: r.css,
|
||||||
|
js: r.js,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +109,7 @@ router.get("/", (c) => {
|
|||||||
username: r.username,
|
username: r.username,
|
||||||
task_title: r.task_title,
|
task_title: r.task_title,
|
||||||
task_display: r.task_display,
|
task_display: r.task_display,
|
||||||
|
task_type: r.task_type,
|
||||||
score: r.score,
|
score: r.score,
|
||||||
view_count: r.view_count,
|
view_count: r.view_count,
|
||||||
html: r.html,
|
html: r.html,
|
||||||
@@ -162,31 +168,6 @@ router.delete("/manage/awards/:award_id", (c) => {
|
|||||||
return c.json({ message: "删除成功" })
|
return c.json({ message: "删除成功" })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get("/manage/submissions/:submission_id", (c) => {
|
|
||||||
requireAdmin(c)
|
|
||||||
const id = uuidParam(c, "submission_id")
|
|
||||||
const s = orNotFound(
|
|
||||||
get(
|
|
||||||
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
|
|
||||||
s.score, s.view_count, ${HAS_CHAIN}
|
|
||||||
FROM submission_submission s
|
|
||||||
JOIN account_user u ON u.id = s.user_id
|
|
||||||
JOIN task_task t ON t.id = s.task_id
|
|
||||||
WHERE s.id = $id`,
|
|
||||||
{ id },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return c.json({
|
|
||||||
submission_id: uuidOut(s.id),
|
|
||||||
username: s.username,
|
|
||||||
task_title: s.task_title,
|
|
||||||
task_display: s.task_display,
|
|
||||||
score: s.score,
|
|
||||||
view_count: s.view_count,
|
|
||||||
has_prompt_chain: bool(s.has_prompt_chain),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get("/manage/awards/:award_id/items", (c) => {
|
router.get("/manage/awards/:award_id/items", (c) => {
|
||||||
requireAdmin(c)
|
requireAdmin(c)
|
||||||
const award = orNotFound(
|
const award = orNotFound(
|
||||||
@@ -301,7 +282,7 @@ router.get("/:submission_id", (c) => {
|
|||||||
assertAwarded(id)
|
assertAwarded(id)
|
||||||
const s = orNotFound(
|
const s = orNotFound(
|
||||||
get(
|
get(
|
||||||
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
|
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display, t.task_type,
|
||||||
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
|
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
|
||||||
FROM submission_submission s
|
FROM submission_submission s
|
||||||
JOIN account_user u ON u.id = s.user_id
|
JOIN account_user u ON u.id = s.user_id
|
||||||
@@ -320,6 +301,7 @@ router.get("/:submission_id", (c) => {
|
|||||||
username: s.username,
|
username: s.username,
|
||||||
task_title: s.task_title,
|
task_title: s.task_title,
|
||||||
task_display: s.task_display,
|
task_display: s.task_display,
|
||||||
|
task_type: s.task_type,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
view_count: s.view_count,
|
view_count: s.view_count,
|
||||||
html: s.html,
|
html: s.html,
|
||||||
|
|||||||
+121
-63
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
|
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
|
||||||
import { Role, isStaff, requireSuper, requireUser } from "../auth"
|
import { Role, isStaff, requireSuper, requireUser, type User } from "../auth"
|
||||||
import {
|
import {
|
||||||
HttpError,
|
HttpError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type Env,
|
type Env,
|
||||||
uuidParam,
|
uuidParam,
|
||||||
} from "../http"
|
} from "../http"
|
||||||
import { deleteSubmissions } from "../services/cascade"
|
import { deleteRound, deleteSubmissions, hasRatingsOrAwards } from "../services/cascade"
|
||||||
import { updateSubmissionScore } from "../services/score"
|
import { updateSubmissionScore } from "../services/score"
|
||||||
import { classifyMessage, inBackground } from "../services/classifier"
|
import { classifyMessage, inBackground } from "../services/classifier"
|
||||||
import { buildGradebook, gradebookCsvRows, toCsv } from "../services/gradebook"
|
import { buildGradebook, gradebookCsvRows, toCsv } from "../services/gradebook"
|
||||||
@@ -26,14 +26,39 @@ import {
|
|||||||
getActiveConversation,
|
getActiveConversation,
|
||||||
getPrecedingUserMessage,
|
getPrecedingUserMessage,
|
||||||
insertMessage,
|
insertMessage,
|
||||||
|
promptChainStats,
|
||||||
type MessageRow,
|
type MessageRow,
|
||||||
} from "../services/prompt"
|
} from "../services/prompt"
|
||||||
import { pyRound } from "../services/pyround"
|
import { pyRound } from "../services/pyround"
|
||||||
import showcase from "./showcase"
|
import showcase from "./showcase"
|
||||||
|
import review from "./review"
|
||||||
|
|
||||||
const router = new Hono<Env>()
|
const router = new Hono<Env>()
|
||||||
|
|
||||||
router.route("/showcase", showcase)
|
router.route("/showcase", showcase)
|
||||||
|
router.route("/review", review)
|
||||||
|
|
||||||
|
const inShowcase = (id: string) => !!get(`SELECT 1 FROM submission_submissionaward WHERE submission_id = $id`, { id })
|
||||||
|
|
||||||
|
// 学生能看的提交:自己的、进了作品展的、自己也交过的那道题上同学(任何班)的。老师都能看
|
||||||
|
function canView(user: User, sub: { id: string; user_id: number; task_id: number }) {
|
||||||
|
if (isStaff(user) || sub.user_id === user.id) return true
|
||||||
|
if (inShowcase(sub.id)) return true
|
||||||
|
return !!get(
|
||||||
|
`SELECT 1 FROM submission_submission s WHERE s.user_id = $me AND s.task_id = $task_id LIMIT 1`,
|
||||||
|
{ me: user.id, task_id: sub.task_id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireView(user: User, id: string) {
|
||||||
|
const sub = orNotFound(
|
||||||
|
get<{ id: string; user_id: number; task_id: number }>(
|
||||||
|
`SELECT id, user_id, task_id FROM submission_submission WHERE id = $id`,
|
||||||
|
{ id },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!canView(user, sub)) throw new HttpError(403, "交过这道题以后才能看同学的作品")
|
||||||
|
}
|
||||||
|
|
||||||
// 当天(UTC 零点起)的评分次数,与原实现保持一致
|
// 当天(UTC 零点起)的评分次数,与原实现保持一致
|
||||||
function todayRatingCount(userId: number) {
|
function todayRatingCount(userId: number) {
|
||||||
@@ -53,9 +78,11 @@ const SUBMISSION_SELECT = `SELECT s.id, s.user_id, u.username, s.task_id, t.disp
|
|||||||
s.view_count, s.created, s.modified,
|
s.view_count, s.created, s.modified,
|
||||||
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
|
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
|
||||||
|
|
||||||
|
// +s.task_id:不让 SQLite 从 task_task 出发、用 task_id 单列索引去找提交(那样要回表读
|
||||||
|
// html/css/js 大行,6 万条提交时列表要几百毫秒),改走 slim 覆盖索引;按 task_id 筛选不受影响
|
||||||
const SUBMISSION_FROM = `FROM submission_submission s
|
const SUBMISSION_FROM = `FROM submission_submission s
|
||||||
JOIN account_user u ON u.id = s.user_id
|
JOIN account_user u ON u.id = s.user_id
|
||||||
JOIN task_task t ON t.id = s.task_id`
|
JOIN task_task t ON t.id = +s.task_id`
|
||||||
|
|
||||||
function submissionOut(s: any) {
|
function submissionOut(s: any) {
|
||||||
return {
|
return {
|
||||||
@@ -80,13 +107,6 @@ function submissionOut(s: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRatingsOrAwards = (submissionId: string) =>
|
|
||||||
!!get(
|
|
||||||
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
|
|
||||||
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
|
|
||||||
{ id: submissionId },
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 创建提交 ──────────────────────────────────────────────────────────────────
|
// ── 创建提交 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post("/", async (c) => {
|
router.post("/", async (c) => {
|
||||||
@@ -100,6 +120,7 @@ router.post("/", async (c) => {
|
|||||||
js: z.string().nullable().optional(),
|
js: z.string().nullable().optional(),
|
||||||
prompt: z.string().nullable().optional(),
|
prompt: z.string().nullable().optional(),
|
||||||
message_id: z.number().int().nullable().optional(), // 关联的 assistant message
|
message_id: z.number().int().nullable().optional(), // 关联的 assistant message
|
||||||
|
reflection: z.string().max(1000).nullable().optional(), // 交的时候写的一句反思,可以不写
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const task = orNotFound(
|
const task = orNotFound(
|
||||||
@@ -111,6 +132,8 @@ router.post("/", async (c) => {
|
|||||||
|
|
||||||
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
|
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
|
||||||
|
|
||||||
|
const reflection = p.reflection?.trim() || null
|
||||||
|
|
||||||
const { submissionId, newUserMsgId } = tx(() => {
|
const { submissionId, newUserMsgId } = tx(() => {
|
||||||
let manualAsstMsgId: number | null = null
|
let manualAsstMsgId: number | null = null
|
||||||
let linkedMsg: MessageRow | null = null
|
let linkedMsg: MessageRow | null = null
|
||||||
@@ -183,6 +206,15 @@ router.post("/", async (c) => {
|
|||||||
return { submissionId: id, newUserMsgId }
|
return { submissionId: id, newUserMsgId }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 同一版再交时没写就留着上次写的
|
||||||
|
if (reflection) {
|
||||||
|
run(
|
||||||
|
`INSERT INTO submission_reflection (submission_id, content, modified) VALUES ($id, $content, $ts)
|
||||||
|
ON CONFLICT (submission_id) DO UPDATE SET content = excluded.content, modified = excluded.modified`,
|
||||||
|
{ id: submissionId, content: reflection, ts: now() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 只对新增的提示词做层级分类
|
// 只对新增的提示词做层级分类
|
||||||
if (
|
if (
|
||||||
newUserMsgId !== null &&
|
newUserMsgId !== null &&
|
||||||
@@ -226,6 +258,11 @@ router.get("/", (c) => {
|
|||||||
where.push("t.task_type = $task_type")
|
where.push("t.task_type = $task_type")
|
||||||
params.task_type = f.task_type
|
params.task_type = f.task_type
|
||||||
}
|
}
|
||||||
|
// 学生只能看自己的提交(同学的作品走 /review/peers)
|
||||||
|
if (user.role === Role.NORMAL) {
|
||||||
|
where.push("s.user_id = $user_id")
|
||||||
|
params.user_id = user.id
|
||||||
|
} else {
|
||||||
if (f.username) {
|
if (f.username) {
|
||||||
where.push(`u.username LIKE $username ESCAPE '\\'`)
|
where.push(`u.username LIKE $username ESCAPE '\\'`)
|
||||||
params.username = likeEscape(f.username)
|
params.username = likeEscape(f.username)
|
||||||
@@ -234,6 +271,7 @@ router.get("/", (c) => {
|
|||||||
where.push("s.user_id = $user_id")
|
where.push("s.user_id = $user_id")
|
||||||
params.user_id = f.user_id
|
params.user_id = f.user_id
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
|
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
|
||||||
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
|
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
|
||||||
else if (f.flag) {
|
else if (f.flag) {
|
||||||
@@ -269,7 +307,8 @@ router.get("/", (c) => {
|
|||||||
score: "s.score ASC",
|
score: "s.score ASC",
|
||||||
"-created": "s.created DESC",
|
"-created": "s.created DESC",
|
||||||
}
|
}
|
||||||
const orderBy = orderings[f.ordering ?? ""] ?? "s.created DESC"
|
// 同分、同一时间的提交很多,加上 rowid 让排序唯一,翻页才不会重复或漏掉
|
||||||
|
const orderBy = `${orderings[f.ordering ?? ""] ?? "s.created DESC"}, s.rowid DESC`
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
|
||||||
const { limit, offset } = pageWindow(f)
|
const { limit, offset } = pageWindow(f)
|
||||||
|
|
||||||
@@ -292,6 +331,7 @@ router.get("/", (c) => {
|
|||||||
router.get("/by-user-task", (c) => {
|
router.get("/by-user-task", (c) => {
|
||||||
const me = requireUser(c)
|
const me = requireUser(c)
|
||||||
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
|
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
|
||||||
|
if (me.role === Role.NORMAL && q.user_id !== me.id) throw new HttpError(403, "没有权限")
|
||||||
const rows = all(
|
const rows = all(
|
||||||
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
|
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
|
||||||
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
|
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
|
||||||
@@ -311,28 +351,42 @@ router.get("/random-for-rating", (c) => {
|
|||||||
}
|
}
|
||||||
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null)
|
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null)
|
||||||
|
|
||||||
// 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js
|
// 学生只抽自己也交过的题(和同学作品一样,没交过的题不能看别人的答案)
|
||||||
const base = `SELECT s.rowid AS rid FROM submission_submission s
|
const cond = `s.user_id != $me
|
||||||
WHERE s.user_id != $me
|
${user.role === Role.NORMAL ? "AND +s.task_id IN (SELECT task_id FROM submission_submission WHERE user_id = $me)" : ""}
|
||||||
AND NOT EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id AND r.user_id = $me)
|
AND NOT EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id AND r.user_id = $me)
|
||||||
${excludeId ? "AND s.id != $exclude" : ""}`
|
${excludeId ? "AND s.id != $exclude" : ""}`
|
||||||
const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) }
|
const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) }
|
||||||
const picked =
|
const fewRatings = `(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5`
|
||||||
get<{ rid: number }>(
|
// 每个学生生成代码时都会调这个接口,ORDER BY RANDOM() 要把所有提交过一遍(6 万条约 50ms,期间整个服务都卡住)。
|
||||||
`${base} AND (SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5
|
// 先随机抽几十个 rowid 试试,抽到评分少于 5 个的就用;抽不中再退回全表随机
|
||||||
|
const maxRowid = get<{ m: number | null }>(`SELECT MAX(rowid) AS m FROM submission_submission`)!.m ?? 0
|
||||||
|
let picked: { rid: number } | null = null
|
||||||
|
for (let round = 0; round < 3 && maxRowid && !picked; round++) {
|
||||||
|
const sample = Array.from({ length: 64 }, () => 1 + Math.floor(Math.random() * maxRowid))
|
||||||
|
// 抽样作为参数传入(SQL 文本固定,db.query 按文本缓存语句,拼进 SQL 会让缓存越来越大)
|
||||||
|
picked = get<{ rid: number }>(
|
||||||
|
`SELECT s.rowid AS rid FROM submission_submission s
|
||||||
|
WHERE s.rowid IN (SELECT value FROM json_each($sample)) AND ${cond} AND ${fewRatings}
|
||||||
ORDER BY RANDOM() LIMIT 1`,
|
ORDER BY RANDOM() LIMIT 1`,
|
||||||
params,
|
{ ...params, sample: JSON.stringify(sample) },
|
||||||
) ?? get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
|
)
|
||||||
|
}
|
||||||
|
// 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js
|
||||||
|
const base = `SELECT s.rowid AS rid FROM submission_submission s WHERE ${cond}`
|
||||||
|
picked ??=
|
||||||
|
get<{ rid: number }>(`${base} AND ${fewRatings} ORDER BY RANDOM() LIMIT 1`, params) ??
|
||||||
|
get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
|
||||||
if (!picked) return c.json(null)
|
if (!picked) return c.json(null)
|
||||||
const pick = get(
|
const pick = get(
|
||||||
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
|
`SELECT s.id, t.title AS task_title, t.display AS task_display,
|
||||||
t.task_type, s.html, s.css, s.js
|
t.task_type, s.html, s.css, s.js
|
||||||
${SUBMISSION_FROM} WHERE s.rowid = $rid`,
|
${SUBMISSION_FROM} WHERE s.rowid = $rid`,
|
||||||
{ rid: picked.rid },
|
{ rid: picked.rid },
|
||||||
)!
|
)!
|
||||||
|
// 打分前不告诉作者是谁(打完分 PUT /:id/score 会返回)
|
||||||
return c.json({
|
return c.json({
|
||||||
submission_id: uuidOut(pick.id),
|
submission_id: uuidOut(pick.id),
|
||||||
username: pick.username,
|
|
||||||
task_title: pick.task_title,
|
task_title: pick.task_title,
|
||||||
task_display: pick.task_display,
|
task_display: pick.task_display,
|
||||||
task_type: pick.task_type,
|
task_type: pick.task_type,
|
||||||
@@ -342,13 +396,6 @@ router.get("/random-for-rating", (c) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete("/flags", (c) => {
|
|
||||||
const user = requireUser(c)
|
|
||||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
|
||||||
const res = run(`UPDATE submission_submission SET flag = NULL WHERE flag IS NOT NULL`)
|
|
||||||
return c.json({ cleared: res.changes })
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 统计 ──────────────────────────────────────────────────────────────────────
|
// ── 统计 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/stats/:task_id", (c) => {
|
router.get("/stats/:task_id", (c) => {
|
||||||
@@ -414,25 +461,18 @@ router.get("/stats/:task_id", (c) => {
|
|||||||
else dist.count_4_plus++
|
else dist.count_4_plus++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记统计:该任务所有提交(不按学生过滤)
|
// 高分作品:每人分数最高的一份(同分取较早的),取前五
|
||||||
const flagStats = { red: 0, blue: 0, green: 0, yellow: 0 }
|
const best = new Map<number, (typeof subs)[number]>()
|
||||||
for (const s of subs) {
|
for (const s of studentSubs) {
|
||||||
if (s.flag && s.flag in flagStats) flagStats[s.flag as keyof typeof flagStats]++
|
const cur = best.get(s.user_id)
|
||||||
|
if (s.score > 0 && (!cur || s.score > cur.score || (s.score === cur.score && s.created < cur.created)))
|
||||||
|
best.set(s.user_id, s)
|
||||||
}
|
}
|
||||||
|
const studentById = new Map(students.map((s) => [s.id, s]))
|
||||||
const topViewed = classname
|
const topScored = [...best.values()]
|
||||||
? all(
|
.sort((a, b) => b.score - a.score || (a.created < b.created ? -1 : 1))
|
||||||
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
|
.slice(0, 5)
|
||||||
WHERE s.task_id = $id AND u.role = 'normal' AND u.classname = $classname
|
.map((s) => ({ ...tag(studentById.get(s.user_id)!), score: s.score, submission_id: uuidOut(s.id) }))
|
||||||
ORDER BY s.view_count DESC LIMIT 5`,
|
|
||||||
{ id: taskId, classname },
|
|
||||||
)
|
|
||||||
: all(
|
|
||||||
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
|
|
||||||
WHERE s.task_id = $id AND u.role = 'normal'
|
|
||||||
ORDER BY s.view_count DESC LIMIT 5`,
|
|
||||||
{ id: taskId },
|
|
||||||
)
|
|
||||||
|
|
||||||
const unrated = students.filter((s) => submittedIds.has(s.id) && !ratedIds.has(s.id))
|
const unrated = students.filter((s) => submittedIds.has(s.id) && !ratedIds.has(s.id))
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -443,14 +483,11 @@ router.get("/stats/:task_id", (c) => {
|
|||||||
unsubmitted_users: students.filter((s) => !submittedIds.has(s.id)).map(tag),
|
unsubmitted_users: students.filter((s) => !submittedIds.has(s.id)).map(tag),
|
||||||
unrated_users: unrated.map(tag),
|
unrated_users: unrated.map(tag),
|
||||||
submission_count_distribution: dist,
|
submission_count_distribution: dist,
|
||||||
flag_stats: flagStats,
|
present_count: studentSubs.filter((s) => s.flag === "present").length,
|
||||||
classes,
|
classes,
|
||||||
top_viewed: topViewed.map((s) => ({
|
top_scored: topScored,
|
||||||
username: s.username,
|
// 每人最新一版是怎么问出来的
|
||||||
classname: s.classname,
|
prompts: promptChainStats([...latest.values()].map((s) => s.id)),
|
||||||
view_count: s.view_count,
|
|
||||||
submission_id: uuidOut(s.id),
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -493,19 +530,18 @@ router.delete("/:id", (c) => {
|
|||||||
throw new HttpError(400, "该提交已被评分或获奖,无法删除")
|
throw new HttpError(400, "该提交已被评分或获奖,无法删除")
|
||||||
}
|
}
|
||||||
tx(() => {
|
tx(() => {
|
||||||
// 关联的助手消息会随提交一起删除,它前面那条用户消息也一起删掉
|
// 有关联的 AI 回复时,回复和它前面的提问一起删掉
|
||||||
const asst = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
|
const asst = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
|
||||||
const userMsg = asst ? getPrecedingUserMessage(asst) : null
|
if (asst) deleteRound(asst)
|
||||||
deleteSubmissions("id = $id", { id })
|
else deleteSubmissions("id = $id", { id })
|
||||||
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
|
|
||||||
})
|
})
|
||||||
return c.json({ message: "删除成功" })
|
return c.json({ message: "删除成功" })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get("/:id/prompt-chain", (c) => {
|
router.get("/:id/prompt-chain", (c) => {
|
||||||
requireUser(c)
|
const user = requireUser(c)
|
||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
requireView(user, id)
|
||||||
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
|
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
|
||||||
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
|
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
|
||||||
return c.json(buildPromptRounds(msg))
|
return c.json(buildPromptRounds(msg))
|
||||||
@@ -514,8 +550,20 @@ router.get("/:id/prompt-chain", (c) => {
|
|||||||
router.get("/:id", (c) => {
|
router.get("/:id", (c) => {
|
||||||
const user = requireUser(c)
|
const user = requireUser(c)
|
||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
|
requireView(user, id)
|
||||||
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
|
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
|
||||||
return c.json(submissionOut(s))
|
// 反思只给老师和作者本人看
|
||||||
|
const reflection =
|
||||||
|
isStaff(user) || s.user_id === user.id
|
||||||
|
? (get<{ content: string }>(`SELECT content FROM submission_reflection WHERE submission_id = $id`, { id })
|
||||||
|
?.content ?? null)
|
||||||
|
: null
|
||||||
|
const out = { ...submissionOut(s), reflection }
|
||||||
|
// 和同学作品一样:学生给同学打分之前不告诉作者和得分(作品展里的本来就署名)
|
||||||
|
if (!isStaff(user) && s.user_id !== user.id && !out.my_score && !inShowcase(id)) {
|
||||||
|
return c.json({ ...out, userid: null, username: null, score: null })
|
||||||
|
}
|
||||||
|
return c.json(out)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 增加浏览次数(仅在全屏预览时调用)
|
// 增加浏览次数(仅在全屏预览时调用)
|
||||||
@@ -532,7 +580,14 @@ router.put("/:id/score", async (c) => {
|
|||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
|
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
|
||||||
if (score <= 0) throw new HttpError(400, "分数不能为零")
|
if (score <= 0) throw new HttpError(400, "分数不能为零")
|
||||||
const sub = orNotFound(get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }))
|
requireView(user, id)
|
||||||
|
const sub = orNotFound(
|
||||||
|
get<{ user_id: number; username: string }>(
|
||||||
|
`SELECT s.user_id, u.username FROM submission_submission s
|
||||||
|
JOIN account_user u ON u.id = s.user_id WHERE s.id = $id`,
|
||||||
|
{ id },
|
||||||
|
),
|
||||||
|
)
|
||||||
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
|
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
|
||||||
|
|
||||||
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
|
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
|
||||||
@@ -551,7 +606,8 @@ router.put("/:id/score", async (c) => {
|
|||||||
)
|
)
|
||||||
updateSubmissionScore(id)
|
updateSubmissionScore(id)
|
||||||
})
|
})
|
||||||
return c.json({ message: "打分成功" })
|
// 互评时打分前是匿名的,打完分再告诉作者是谁
|
||||||
|
return c.json({ message: "打分成功", username: sub.username })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.put("/:id/flag", async (c) => {
|
router.put("/:id/flag", async (c) => {
|
||||||
@@ -560,7 +616,9 @@ router.put("/:id/flag", async (c) => {
|
|||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
const { flag } = await body(
|
const { flag } = await body(
|
||||||
c,
|
c,
|
||||||
z.object({ flag: z.enum(["red", "blue", "green", "yellow"]).nullable().default(null) }),
|
z.object({
|
||||||
|
flag: z.enum(["present", "red", "blue", "green", "yellow"]).nullable().default(null),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
||||||
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
|
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
|
||||||
|
|||||||
@@ -274,8 +274,9 @@ challenge.post("/", async (c) => {
|
|||||||
return c.json({ message: "创建成功" })
|
return c.json({ message: "创建成功" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 管理员保存挑战时本来就能设“公开”,这里和它保持一致
|
||||||
challenge.put("/public/:display", (c) => {
|
challenge.put("/public/:display", (c) => {
|
||||||
requireSuper(c)
|
requireAdmin(c)
|
||||||
const found = orNotFound(findChallenge(intParam(c, "display")))
|
const found = orNotFound(findChallenge(intParam(c, "display")))
|
||||||
return c.json(togglePublic(found.id))
|
return c.json(togglePublic(found.id))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import { run } from "../db"
|
import { all, get, run } from "../db"
|
||||||
|
import { getPrecedingUserMessage, type MessageRow } from "./prompt"
|
||||||
|
|
||||||
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
|
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
|
||||||
// 调用方负责包在事务里。
|
// 调用方负责包在事务里。
|
||||||
|
|
||||||
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
|
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
|
||||||
const ids = `SELECT id FROM submission_submission WHERE ${where}`
|
// 先查出 id 再逐个按等值删:prompt_message.submission_id 绝大多数是 NULL,
|
||||||
run(`DELETE FROM submission_rating WHERE submission_id IN (${ids})`, params)
|
// 统计信息会让 SQLite 觉得这个索引没用,写成 IN (子查询) 时它会扫整张大表(几百毫秒)
|
||||||
run(`DELETE FROM submission_submissionaward WHERE submission_id IN (${ids})`, params)
|
const ids = all<{ id: string }>(`SELECT id FROM submission_submission WHERE ${where}`, params)
|
||||||
run(`DELETE FROM prompt_message WHERE submission_id IN (${ids})`, params)
|
for (const { id } of ids) {
|
||||||
|
run(`DELETE FROM submission_rating WHERE submission_id = $id`, { id })
|
||||||
|
run(`DELETE FROM submission_submissionaward WHERE submission_id = $id`, { id })
|
||||||
|
run(`DELETE FROM prompt_message WHERE submission_id = $id`, { id })
|
||||||
|
}
|
||||||
run(`DELETE FROM submission_submission WHERE ${where}`, params)
|
run(`DELETE FROM submission_submission WHERE ${where}`, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 被评过分或获过奖的提交只有超管能删
|
||||||
|
export const hasRatingsOrAwards = (submissionId: string) =>
|
||||||
|
!!get(
|
||||||
|
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
|
||||||
|
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
|
||||||
|
{ id: submissionId },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 删除一轮对话:AI 回复、它前面那条用户提问,以及回复关联的提交
|
||||||
|
export function deleteRound(asst: MessageRow) {
|
||||||
|
const userMsg = getPrecedingUserMessage(asst)
|
||||||
|
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
|
||||||
|
run(`DELETE FROM prompt_message WHERE id = $id`, { id: asst.id })
|
||||||
|
if (asst.submission_id) deleteSubmissions("id = $id", { id: asst.submission_id })
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteTask(taskId: number) {
|
export function deleteTask(taskId: number) {
|
||||||
const p = { task_id: taskId }
|
const p = { task_id: taskId }
|
||||||
deleteSubmissions("task_id = $task_id", p)
|
deleteSubmissions("task_id = $task_id", p)
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import { all, get, run } from "../db"
|
import { all, get, run } from "../db"
|
||||||
import { complete, llmConfigured } from "./llm"
|
import { complete, llmConfigured } from "./llm"
|
||||||
|
import { briefOf } from "./prompt"
|
||||||
|
|
||||||
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
|
// 学生在这里是“指挥 AI 做网页”,看的是这句提问里学生自己想了多少,不是要 AI 做的东西有多大:
|
||||||
|
// 把题目要求原样贴过去,功能列得再多也只是照着做(L3)
|
||||||
|
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。学生在网页制作课上让 AI 生成或修改网页,下面给出题目要求、学生上一轮的提问和这一轮的提问。请按布鲁姆认知分类学,判断“这一轮提问”体现的学生自己的思考层级。
|
||||||
|
|
||||||
层级定义:
|
按下面的顺序判断,命中哪一条就是哪一级:
|
||||||
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
|
1. L1 记忆:没有任何具体要求、解释或问题。只给作品名称、只报结果或只让 AI 修(例:"帮我做一个象棋游戏"、"制作一个吃豆人"、"继续"、"预览代码"、"还是没有显示"、"点击开始游戏进不去"、"帮我修复代码bug")
|
||||||
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
|
2. L2 理解:想弄懂原理、代码或概念(问为什么、是什么意思、怎么做到的),或者用自己的话解释现有代码在做什么(例:"为什么点了按钮没反应?"、"grid 和 flex 有什么区别"、"这段 js 里的 setInterval 是干什么的")
|
||||||
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
|
3. L4 分析:说清在哪个操作、哪个位置出了什么具体问题,或者自己推测原因、指出问题出在哪部分(例:"拖动绘制时松开鼠标还在画,是不是没监听 mouseup"、"缩小窗口后网格就不显示了"、"围棋我刚下一颗黑棋就判我赢了")
|
||||||
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
|
4. L5 评价:明确给出判断理由来评价现有结果,或者比较两种做法再选择;必须有“因为……所以……”“A 比 B 好”这样的理由或比较(例:"现在按钮太多,小学生看不懂,改成只留三个主要按钮"、"用 canvas 画比用一堆 div 流畅,改成 canvas")
|
||||||
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
|
5. L6 创造:在题目要求之外提出自己构思的设计,而且几部分互相配合成一个整体,如原创的主题、玩法、规则或交互(例:"改成星空主题:转盘是星座,抽中稀有奖励时流星划过并解锁对应星座的图鉴")
|
||||||
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
|
6. L3 应用:其余给出具体、可执行要求的,如颜色、风格、布局、要加或要改的功能(例:"方块大一点,旁边加一个橡皮擦和清空按钮"、"页面风格可爱,浅粉加浅蓝的渐变"、"加上撤销和保存为图片")
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 只看学生自己想出来的部分。照搬或改写题目要求里的功能清单是 L3,要求写得长、功能多不等于层级高。
|
||||||
|
- 只说想要什么风格、加什么功能,没有理由,是 L3 不是 L5;只列很多功能,没有自己的整体构思,是 L3 不是 L6。
|
||||||
|
- 只说“没反应”“进不去”“不能玩”“有 bug”,哪怕提到了是哪个按钮,也是 L1;要说出更具体的现象(如“打字速度始终是 0”)或推测原因才是 L4。
|
||||||
|
- 说明要改成什么样(如“弧线应该对着中圈”“球门要比禁区小”)是 L3,不是 L4。
|
||||||
|
- 整段贴了题目要求或一长串功能清单,不是 L1,是 L3。
|
||||||
|
- 结合上一轮看:重复上一轮的提问,层级不高于上一轮。
|
||||||
|
|
||||||
只返回一个数字(1-6),不要解释。`
|
只返回一个数字(1-6),不要解释。`
|
||||||
|
|
||||||
|
const MIN_PROMPT_CHARS = 4
|
||||||
|
const PROMPT_CHARS = 1500
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
async function callLlm(content: string): Promise<number | null> {
|
async function callLlm(content: string): Promise<number | null> {
|
||||||
@@ -23,7 +37,8 @@ async function callLlm(content: string): Promise<number | null> {
|
|||||||
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
|
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
|
||||||
{ role: "user", content },
|
{ role: "user", content },
|
||||||
],
|
],
|
||||||
{ maxTokens: 10, timeoutMs: 30_000 },
|
// 只回一个数字,不用思考;关了思考 temperature 才生效,同一句提示词得到同一个层级
|
||||||
|
{ thinking: false, maxTokens: 10, temperature: 0, timeoutMs: 30_000 },
|
||||||
)
|
)
|
||||||
const match = /[1-6]/.exec(text)
|
const match = /[1-6]/.exec(text)
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -37,13 +52,45 @@ async function callLlm(content: string): Promise<number | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clip(text: string) {
|
||||||
|
return text.length > PROMPT_CHARS ? text.slice(0, PROMPT_CHARS) + "……" : text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发给模型的:题目要求、同一对话里上一轮的提问(引导对话只和引导对话比)、这一轮的提问
|
||||||
|
function classifyInput(msg: { id: number; content: string; source: string; conversation_id: string; created: string }) {
|
||||||
|
const task = get<{ title: string; content: string }>(
|
||||||
|
`SELECT t.title, t.content FROM prompt_conversation c JOIN task_task t ON t.id = c.task_id WHERE c.id = $conv`,
|
||||||
|
{ conv: msg.conversation_id },
|
||||||
|
)
|
||||||
|
const prev = get<{ content: string }>(
|
||||||
|
`SELECT content FROM prompt_message
|
||||||
|
WHERE conversation_id = $conv AND role = 'user' AND source != 'edit'
|
||||||
|
AND (source = 'guidance') = $guidance
|
||||||
|
AND (created < $created OR (created = $created AND id < $id))
|
||||||
|
ORDER BY created DESC, id DESC LIMIT 1`,
|
||||||
|
{ conv: msg.conversation_id, guidance: msg.source === "guidance", created: msg.created, id: msg.id },
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
`题目:${task?.title ?? "(未知)"}`,
|
||||||
|
`题目要求:${task ? briefOf(task.content) : "(无)"}`,
|
||||||
|
`上一轮提问:${prev ? clip(prev.content) : "(这是第一轮)"}`,
|
||||||
|
`这一轮提问:${clip(msg.content)}`,
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
export async function classifyMessage(messageId: number): Promise<number | null> {
|
export async function classifyMessage(messageId: number): Promise<number | null> {
|
||||||
const msg = get<{ content: string }>(
|
const msg = get<{ id: number; content: string; source: string; conversation_id: string; created: string }>(
|
||||||
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user'`,
|
`SELECT id, content, source, conversation_id, created FROM prompt_message
|
||||||
|
WHERE id = $id AND role = 'user' AND source != 'edit'`,
|
||||||
{ id: messageId },
|
{ id: messageId },
|
||||||
)
|
)
|
||||||
if (!msg) return null
|
if (!msg) return null
|
||||||
const level = await callLlm(msg.content)
|
// “1”“,”“可以”这类太短,判断不出层级;清掉以前打的标签(--force 重标时)
|
||||||
|
if ([...msg.content.trim()].length < MIN_PROMPT_CHARS) {
|
||||||
|
run(`UPDATE prompt_message SET prompt_level = NULL WHERE id = $id`, { id: messageId })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const level = await callLlm(classifyInput(msg))
|
||||||
if (level !== null) {
|
if (level !== null) {
|
||||||
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
|
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
|
||||||
level,
|
level,
|
||||||
@@ -56,7 +103,7 @@ export async function classifyMessage(messageId: number): Promise<number | null>
|
|||||||
export async function classifyConversationMessages(conversationId: string, force = false) {
|
export async function classifyConversationMessages(conversationId: string, force = false) {
|
||||||
const rows = all<{ id: number }>(
|
const rows = all<{ id: number }>(
|
||||||
`SELECT id FROM prompt_message
|
`SELECT id FROM prompt_message
|
||||||
WHERE conversation_id = $id AND role = 'user' ${force ? "" : "AND prompt_level IS NULL"}
|
WHERE conversation_id = $id AND role = 'user' AND source != 'edit' ${force ? "" : "AND prompt_level IS NULL"}
|
||||||
ORDER BY created, id`,
|
ORDER BY created, id`,
|
||||||
{ id: conversationId },
|
{ id: conversationId },
|
||||||
)
|
)
|
||||||
|
|||||||
+94
-31
@@ -1,18 +1,29 @@
|
|||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
|
|
||||||
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
|
// AI 回复里的说明是学生唯一会读的文字,用它引导学生想下一步:
|
||||||
|
// “我替你定的”指出学生没说、AI 自己拿的主意,学生看了才会说“不要这样,因为……”;“想一想”只问不答
|
||||||
|
export const SYSTEM_PROMPT = `你是一个网页生成助手,和学生一起做网页。根据学生的描述生成网页代码。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 使用一个 \`\`\`html 代码块返回所有代码
|
1. 生成或修改网页时,使用一个 \`\`\`html 代码块返回所有代码
|
||||||
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
|
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
|
||||||
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
|
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
|
||||||
4. 用中文回复,先简要说明你做了什么,然后给出代码
|
4. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
|
||||||
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
|
5. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
|
||||||
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
|
6. 用中文回复,对学生称“你”。先写说明,再给代码。说明分下面三项,标题照抄,每项一两句话,不要写长:
|
||||||
|
- **做了什么**:这次按学生的要求做了哪些改动
|
||||||
|
- **我替你定的**:学生没说、你自己拿主意的地方,比如配色、布局、规则、数值、文字内容;列 1-3 条,只写在网页上看得到、学生可以要求改的。学生这次已经说清楚、没有需要你拿主意的地方,就省略这一项
|
||||||
|
- **想一想**:问学生一个问题,帮他想清楚下一步想要什么。问他自己的想法或判断(如“……的时候你希望它怎样?”“这两种你觉得哪个更适合……,为什么?”),不要列一串功能让他挑,也不要替他回答
|
||||||
|
7. 学生只是在问问题(为什么、是什么意思、怎么做到的),没有要求改网页时,用浅显的话直接回答,不给代码,也不用分三项
|
||||||
|
8. 学生只说“不行”“有 bug”“没反应”,没说清哪里不对时,照样尽力修好,并在“想一想”里请他下次说清楚:做了什么操作、想看到什么、实际看到了什么
|
||||||
|
|
||||||
输出格式示例(必须严格遵守,只用一个代码块):
|
输出格式示例(必须严格遵守,只用一个代码块):
|
||||||
|
|
||||||
好的,我为你创建了一个点击按钮变色的示例。
|
**做了什么**:做了一个按钮,点一下会变成红色。
|
||||||
|
|
||||||
|
**我替你定的**:按钮放在页面左上角;变色后不会再变回来。
|
||||||
|
|
||||||
|
**想一想**:再点一次的时候,你希望它变回原来的颜色,还是换成别的颜色?
|
||||||
|
|
||||||
\`\`\`html
|
\`\`\`html
|
||||||
<style>
|
<style>
|
||||||
@@ -28,23 +39,36 @@ document.getElementById('btn').onclick = function() {
|
|||||||
</script>
|
</script>
|
||||||
\`\`\``
|
\`\`\``
|
||||||
|
|
||||||
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
|
// 挑战题目要求只当对照清单:学生没说的不替他做,挑一项放进“想一想”问他打算怎么做,
|
||||||
|
// 要做成什么样还是学生自己说;整段贴了题目的照做(不拦,拦了只会去用外部 AI)
|
||||||
|
export const BRIEF_RULES = `下面是这道题的题目要求。它是对照清单,不是让你照着做——这是课堂练习,题目要求要学生自己一步步提出来,这正是这节课要练的:
|
||||||
|
- 只做学生在对话里说了的内容,做出来要能打开、能用。学生说的话里包含了题目要求(比如整段贴了题目),就照着做
|
||||||
|
- 题目要求里学生还没提到的功能、规则和细节(如计时方式、对错的颜色、计分、重来按钮),哪怕很常见、顺手就能做,也先不做;也不要照搬题目里的类名、变量名和数据
|
||||||
|
- 从学生没提到的要求里挑一项最重要的放进“想一想”,问他打算怎么做(如“题目还要求标出打错的字,你希望打错的时候是什么样子?”)
|
||||||
|
- 学生要的和题目要求冲突时,按学生说的做,在“想一想”里提醒他题目是怎么要求的
|
||||||
|
- 题目要求都做到了,“想一想”就问他自己的想法,不再提题目`
|
||||||
|
|
||||||
判断标准——满足以下条件视为"够好":
|
// 判断标准和分类器一致:看学生自己想了什么,照搬题目写得再长也不算清楚
|
||||||
- 有明确主题(例如:登录页、计时器、商品卡片列表)
|
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练。学生在网页制作课上让 AI 生成或修改网页,你帮他把这一次要对 AI 说的话想清楚、说具体。后面会给你题目要求、他之前对 AI 说过的话和 AI 上一次的说明。
|
||||||
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
|
|
||||||
|
判断标准——看学生这一次的话,满足下面一条就算"够好":
|
||||||
|
- 第一次描述作品:除了题目要求,有他自己的想法,至少有一处具体的视觉、交互或内容是他自己定的(如配色风格、玩法规则、页面上放什么)
|
||||||
|
- 修改已有网页:说清楚要改哪里、改成什么样;或者说清楚出了什么问题——做了什么操作、想看到什么、实际看到了什么
|
||||||
|
|
||||||
判断为"不够好"的典型情况:
|
判断为"不够好"的典型情况:
|
||||||
- 目标太泛,如"做一个好看的页面"
|
- 只有作品名称,如"做一个象棋游戏"
|
||||||
- 只有主题,完全没有任何视觉、交互或内容描述
|
- 照搬或改写题目要求,没有自己的想法——哪怕写得很长、功能很多
|
||||||
|
- 只说"不行""有 bug""再好看一点",没说是哪里、要怎样
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
|
1. 如果不够好,用 1-2 个启发性问题引导学生补充,问他自己的想法,不要直接给出答案,也不要列一串功能让他挑
|
||||||
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
|
2. 照搬题目要求时,先肯定他读懂了题目,再问题目之外他想让作品有什么不一样
|
||||||
3. 用中文回复,语气鼓励,简洁明了
|
3. AI 上一次的说明里如果有“我替你定的”,可以问他同不同意这些决定
|
||||||
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
|
4. 如果已经够好,以 [READY] 开头回复,简短说明好在哪里,告诉他可以生成了;他还给了理由(为什么这样改)的,特别夸一下
|
||||||
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
5. 用中文回复,语气鼓励,简洁明了
|
||||||
6. 不要生成任何代码`
|
6. 使用 Markdown 语法高亮关键词,优先突出 **视觉**、**交互**、**内容**、**你的想法**、**可以生成** 等重点
|
||||||
|
7. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
||||||
|
8. 不要生成任何代码`
|
||||||
|
|
||||||
// 走火山方舟(ARK)接口的模型
|
// 走火山方舟(ARK)接口的模型
|
||||||
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
||||||
@@ -59,10 +83,14 @@ export interface ChatMessage {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepSeek(deepseek-flash)默认先思考再回答,思考的内容在 reasoning_content 里,也算输出 token;
|
||||||
|
// 思考模式下 temperature 不起作用。thinking 只发给 DeepSeek,豆包不传
|
||||||
interface CompletionOptions {
|
interface CompletionOptions {
|
||||||
model?: string
|
model?: string
|
||||||
stream: boolean
|
stream: boolean
|
||||||
|
thinking?: boolean
|
||||||
maxTokens?: number
|
maxTokens?: number
|
||||||
|
temperature?: number
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
@@ -81,7 +109,9 @@ function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
|
|||||||
messages,
|
messages,
|
||||||
stream: opts.stream,
|
stream: opts.stream,
|
||||||
}
|
}
|
||||||
|
if (!ark && opts.thinking !== undefined) body.thinking = { type: opts.thinking ? "enabled" : "disabled" }
|
||||||
if (opts.maxTokens) body.max_tokens = opts.maxTokens
|
if (opts.maxTokens) body.max_tokens = opts.maxTokens
|
||||||
|
if (opts.temperature !== undefined) body.temperature = opts.temperature
|
||||||
const apiKey = ark ? config.arkApiKey : config.llmApiKey
|
const apiKey = ark ? config.arkApiKey : config.llmApiKey
|
||||||
if (!apiKey) throw new Error("未配置模型接口密钥")
|
if (!apiKey) throw new Error("未配置模型接口密钥")
|
||||||
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
|
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
|
||||||
@@ -107,8 +137,7 @@ async function ensureOk(res: Response) {
|
|||||||
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
|
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
|
||||||
async function* streamCompletion(
|
async function* streamCompletion(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
model?: string,
|
{ model, thinking, signal }: { model?: string; thinking?: boolean; signal?: AbortSignal },
|
||||||
signal?: AbortSignal,
|
|
||||||
): AsyncGenerator<string> {
|
): AsyncGenerator<string> {
|
||||||
// 带思考的模型生成整页代码可能超过两分钟,只要一直有数据就不该中断
|
// 带思考的模型生成整页代码可能超过两分钟,只要一直有数据就不该中断
|
||||||
const idle = new AbortController()
|
const idle = new AbortController()
|
||||||
@@ -123,6 +152,7 @@ async function* streamCompletion(
|
|||||||
const res = await buildRequest(messages, {
|
const res = await buildRequest(messages, {
|
||||||
model,
|
model,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
thinking,
|
||||||
signal: AbortSignal.any(signals),
|
signal: AbortSignal.any(signals),
|
||||||
timeoutMs: STREAM_MAX_MS,
|
timeoutMs: STREAM_MAX_MS,
|
||||||
})
|
})
|
||||||
@@ -139,6 +169,7 @@ async function* streamCompletion(
|
|||||||
if (data === "[DONE]") return true
|
if (data === "[DONE]") return true
|
||||||
const json = JSON.parse(data)
|
const json = JSON.parse(data)
|
||||||
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
|
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
|
||||||
|
// 思考阶段只有 delta.reasoning_content,不发给学生(它也会重置空闲计时)
|
||||||
const content = json.choices?.[0]?.delta?.content
|
const content = json.choices?.[0]?.delta?.content
|
||||||
if (content) yield content
|
if (content) yield content
|
||||||
}
|
}
|
||||||
@@ -158,30 +189,62 @@ async function* streamCompletion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
|
// 发给模型的对话上下文:只留最近 20 条;除最后一条带代码的 AI 回复外,旧回复里的代码块换成占位文字。
|
||||||
return streamCompletion(
|
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度。
|
||||||
[{ role: "system", content: SYSTEM_PROMPT }, ...history],
|
// 只回答问题的回复没有代码,不能拿它当“最后一条”,不然最新的代码也被省略了
|
||||||
chatModel(model),
|
const MAX_HISTORY = 20
|
||||||
signal,
|
const CODE_BLOCK = /```[\s\S]*?```/g
|
||||||
|
|
||||||
|
export function trimHistory(history: ChatMessage[]): ChatMessage[] {
|
||||||
|
const recent = history.slice(-MAX_HISTORY)
|
||||||
|
const last = recent.findLastIndex((m) => m.role === "assistant" && m.content.includes("```"))
|
||||||
|
return recent.map((m, i) =>
|
||||||
|
m.role === "assistant" && i !== last
|
||||||
|
? { ...m, content: m.content.replace(CODE_BLOCK, "```\n(旧版本代码已省略)\n```") }
|
||||||
|
: m,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
// 给学生看的错误:不把模型接口的原始返回(可能带接口细节)原样发出去,完整信息写日志
|
||||||
return streamCompletion(
|
export function userFacingError(e: unknown) {
|
||||||
[{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history],
|
console.error("LLM request failed:", e)
|
||||||
undefined,
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
if (message === "模型响应超时" || (e instanceof Error && e.name === "TimeoutError")) {
|
||||||
|
return "AI 响应超时,请重试"
|
||||||
|
}
|
||||||
|
const status = /^Error code: (\d+)/.exec(message)?.[1]
|
||||||
|
if (status === "429") return "AI 服务繁忙,请稍后重试"
|
||||||
|
return status ? `AI 服务出错(${status}),请稍后重试` : "AI 服务出错,请稍后重试"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成整页代码:开着思考,质量优先。brief 是挑战的题目要求(见 challengeBrief),自由创作没有
|
||||||
|
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal, brief?: string | null) {
|
||||||
|
const system = brief ? `${SYSTEM_PROMPT}\n\n${BRIEF_RULES}\n\n${brief}` : SYSTEM_PROMPT
|
||||||
|
return streamCompletion([{ role: "system", content: system }, ...trimHistory(history)], {
|
||||||
|
model: chatModel(model),
|
||||||
|
thinking: true,
|
||||||
signal,
|
signal,
|
||||||
)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引导只是简短追问,不用思考,回得快。context 是题目和学生之前的对话(见 guidanceContext)
|
||||||
|
export function streamGuidance(history: ChatMessage[], context: string, signal?: AbortSignal) {
|
||||||
|
return streamCompletion([{ role: "system", content: `${GUIDANCE_SYSTEM_PROMPT}\n\n${context}` }, ...history], {
|
||||||
|
thinking: false,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function complete(
|
export async function complete(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
opts: { model?: string; maxTokens?: number; timeoutMs?: number } = {},
|
opts: { model?: string; thinking?: boolean; maxTokens?: number; temperature?: number; timeoutMs?: number } = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const res = await buildRequest(messages, {
|
const res = await buildRequest(messages, {
|
||||||
model: opts.model ?? config.llmModel,
|
model: opts.model ?? config.llmModel,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
thinking: opts.thinking,
|
||||||
maxTokens: opts.maxTokens,
|
maxTokens: opts.maxTokens,
|
||||||
|
temperature: opts.temperature,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
})
|
})
|
||||||
await ensureOk(res)
|
await ensureOk(res)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { all, get, run, now, newUuidHex } from "../db"
|
import { all, get, run, now, newUuidHex } from "../db"
|
||||||
import { Role, type User } from "../auth"
|
import { Role, type User } from "../auth"
|
||||||
|
import { pyRound } from "./pyround"
|
||||||
|
|
||||||
export interface MessageRow {
|
export interface MessageRow {
|
||||||
id: number
|
id: number
|
||||||
@@ -81,6 +82,79 @@ export function insertMessage(m: {
|
|||||||
return Number(res.lastInsertRowid)
|
return Number(res.lastInsertRowid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不是 AI 生成的版本(手改 / 粘贴外部 AI 的代码),AI 回复那一条的正文写成和 AI 输出一样的代码块,
|
||||||
|
// 后面接着和 AI 对话时,它在上下文里看到的就是这份代码
|
||||||
|
export const EDIT_PROMPT = "(手动修改了代码)"
|
||||||
|
|
||||||
|
export function codeAsMarkdown(code: {
|
||||||
|
code_html: string | null
|
||||||
|
code_css: string | null
|
||||||
|
code_js: string | null
|
||||||
|
}) {
|
||||||
|
const parts = [
|
||||||
|
code.code_css ? `<style>\n${code.code_css}\n</style>` : "",
|
||||||
|
code.code_html ?? "",
|
||||||
|
code.code_js ? `<script>\n${code.code_js}\n</script>` : "",
|
||||||
|
].filter(Boolean)
|
||||||
|
return "```html\n" + parts.join("\n\n") + "\n```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题目要求去掉图片、压成一段,太长的截掉
|
||||||
|
const BRIEF_CHARS = 800
|
||||||
|
|
||||||
|
export function briefOf(content: string, max = BRIEF_CHARS) {
|
||||||
|
const text = content
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
return clipTo(text, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipTo(text: string, n: number) {
|
||||||
|
return text.length > n ? text.slice(0, n) + "……" : text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挑战的题目要求,给生成网页的 AI 当对照清单(见 llm.ts 的 BRIEF_RULES);要逐条对照,不能截得太短。
|
||||||
|
// 自由创作的题目是学生自己写的介绍,不用对照,返回 null
|
||||||
|
export function challengeBrief(taskId: number) {
|
||||||
|
const task = get<{ title: string; content: string }>(
|
||||||
|
`SELECT title, content FROM task_task WHERE id = $id AND task_type = 'challenge'`,
|
||||||
|
{ id: taskId },
|
||||||
|
)
|
||||||
|
const brief = task && briefOf(task.content, 4000)
|
||||||
|
return brief ? `题目:${task.title}\n\n题目要求:${brief}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// “帮我写清楚”的教练要知道题目和学生已经做到哪了,才分得清照搬题目和自己的想法:
|
||||||
|
// 题目要求、学生最近几次对 AI 说的话、AI 上一次的说明(代码去掉)
|
||||||
|
export function guidanceContext(conversationId: string, taskId: number) {
|
||||||
|
const task = get<{ title: string; content: string }>(
|
||||||
|
`SELECT title, content FROM task_task WHERE id = $id`,
|
||||||
|
{ id: taskId },
|
||||||
|
)
|
||||||
|
const asked = all<{ content: string }>(
|
||||||
|
`SELECT content FROM prompt_message
|
||||||
|
WHERE conversation_id = $id AND role = 'user' AND source IN ('conversation', 'manual')
|
||||||
|
ORDER BY created DESC, id DESC LIMIT 5`,
|
||||||
|
{ id: conversationId },
|
||||||
|
).reverse()
|
||||||
|
const reply = get<{ content: string }>(
|
||||||
|
`SELECT content FROM prompt_message
|
||||||
|
WHERE conversation_id = $id AND role = 'assistant' AND source = 'conversation'
|
||||||
|
ORDER BY created DESC, id DESC LIMIT 1`,
|
||||||
|
{ id: conversationId },
|
||||||
|
)
|
||||||
|
const replyText = reply?.content.replace(/```[\s\S]*?(```|$)/g, "").trim()
|
||||||
|
return [
|
||||||
|
`题目:${task?.title ?? "(未知)"}`,
|
||||||
|
`题目要求:${task ? briefOf(task.content) || "(无)" : "(无)"}`,
|
||||||
|
`学生之前对 AI 说过的话:${
|
||||||
|
asked.length ? asked.map((m, i) => `\n${i + 1}. ${clipTo(m.content.trim(), 300)}`).join("") : "(还没有,这是第一次描述作品)"
|
||||||
|
}`,
|
||||||
|
`AI 上一次的说明:${replyText ? clipTo(replyText, 600) : "(无)"}`,
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
||||||
return get<MessageRow>(
|
return get<MessageRow>(
|
||||||
`SELECT * FROM prompt_message
|
`SELECT * FROM prompt_message
|
||||||
@@ -124,3 +198,38 @@ export function buildPromptRounds(source: MessageRow) {
|
|||||||
}
|
}
|
||||||
return rounds
|
return rounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 课堂总结:这些提交各自是怎么问出来的(只走覆盖索引,不读提问和代码)
|
||||||
|
export function promptChainStats(submissionIds: string[]) {
|
||||||
|
type Slim = { conversation_id: string; created: string; id: number; role: string; source: string; prompt_level: number | null }
|
||||||
|
const finals = all<Omit<Slim, "role" | "prompt_level">>(
|
||||||
|
`SELECT conversation_id, created, id, source FROM prompt_message
|
||||||
|
WHERE submission_id IN (SELECT value FROM json_each($ids))`,
|
||||||
|
{ ids: JSON.stringify(submissionIds) },
|
||||||
|
)
|
||||||
|
const byConv = new Map<string, Slim[]>()
|
||||||
|
const msgs = all<Slim>(
|
||||||
|
`SELECT conversation_id, created, id, role, source, prompt_level FROM prompt_message
|
||||||
|
WHERE conversation_id IN (SELECT value FROM json_each($convs))`,
|
||||||
|
{ convs: JSON.stringify([...new Set(finals.map((f) => f.conversation_id))]) },
|
||||||
|
)
|
||||||
|
for (const m of msgs) byConv.set(m.conversation_id, [...(byConv.get(m.conversation_id) ?? []), m])
|
||||||
|
|
||||||
|
const out = { with_chain: finals.length, one_round: 0, external: 0, used_guidance: 0, avg_rounds: 0 }
|
||||||
|
const levels = [0, 0, 0, 0, 0, 0]
|
||||||
|
let rounds = 0
|
||||||
|
for (const f of finals) {
|
||||||
|
// 和 buildPromptRounds 一样:只算交的那一版之前的提问
|
||||||
|
const asked = (byConv.get(f.conversation_id) ?? []).filter(
|
||||||
|
(m) => m.role === "user" && (m.created < f.created || (m.created === f.created && m.id < f.id)),
|
||||||
|
)
|
||||||
|
const code = asked.filter((m) => m.source !== "guidance")
|
||||||
|
rounds += code.length
|
||||||
|
if (code.length === 1) out.one_round++
|
||||||
|
if (f.source === "manual") out.external++
|
||||||
|
if (code.length < asked.length) out.used_guidance++
|
||||||
|
for (const m of code) if (m.prompt_level) levels[m.prompt_level - 1]!++
|
||||||
|
}
|
||||||
|
out.avg_rounds = finals.length ? pyRound(rounds / finals.length, 1) : 0
|
||||||
|
return { ...out, levels }
|
||||||
|
}
|
||||||
+52
-16
@@ -1,14 +1,23 @@
|
|||||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||||
import { all, get, run, iso, uuidOut } from "./db"
|
import { all, get, run, iso, uuidOut } from "./db"
|
||||||
import { userFromAccessToken, type User } from "./auth"
|
import { userFromAccessToken, type User } from "./auth"
|
||||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
import {
|
||||||
|
canAccessTask,
|
||||||
|
challengeBrief,
|
||||||
|
codeAsMarkdown,
|
||||||
|
getOrCreateActiveConversation,
|
||||||
|
guidanceContext,
|
||||||
|
insertMessage,
|
||||||
|
} from "./services/prompt"
|
||||||
import {
|
import {
|
||||||
extractCode,
|
extractCode,
|
||||||
parseGuidanceResponse,
|
parseGuidanceResponse,
|
||||||
streamChat,
|
streamChat,
|
||||||
streamGuidance,
|
streamGuidance,
|
||||||
|
userFacingError,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "./services/llm"
|
} from "./services/llm"
|
||||||
|
import { classifyMessage, inBackground } from "./services/classifier"
|
||||||
|
|
||||||
type Kind = "prompt" | "guidance"
|
type Kind = "prompt" | "guidance"
|
||||||
|
|
||||||
@@ -21,8 +30,8 @@ export interface WsData {
|
|||||||
pendingUserMessageId: number | null
|
pendingUserMessageId: number | null
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
closed: boolean
|
closed: boolean
|
||||||
// 串行处理消息(与 Django Channels 的行为一致)
|
// 正在处理一条消息(等 AI 回复);期间再发来的直接拒绝,不排队,免得一次塞几百条刷模型费用
|
||||||
queue: Promise<void>
|
busy: boolean
|
||||||
// 引导对话只在本次连接内保留上下文
|
// 引导对话只在本次连接内保留上下文
|
||||||
guidanceHistory: ChatMessage[]
|
guidanceHistory: ChatMessage[]
|
||||||
}
|
}
|
||||||
@@ -57,7 +66,7 @@ export async function upgrade(
|
|||||||
pendingUserMessageId: null,
|
pendingUserMessageId: null,
|
||||||
abort: new AbortController(),
|
abort: new AbortController(),
|
||||||
closed: false,
|
closed: false,
|
||||||
queue: Promise.resolve(),
|
busy: false,
|
||||||
guidanceHistory: [],
|
guidanceHistory: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -79,6 +88,7 @@ function conversationHistory(conversationId: string) {
|
|||||||
return all<{
|
return all<{
|
||||||
id: number
|
id: number
|
||||||
role: string
|
role: string
|
||||||
|
source: string
|
||||||
content: string
|
content: string
|
||||||
code_html: string | null
|
code_html: string | null
|
||||||
code_css: string | null
|
code_css: string | null
|
||||||
@@ -86,8 +96,9 @@ function conversationHistory(conversationId: string) {
|
|||||||
submission_id: string | null
|
submission_id: string | null
|
||||||
created: string
|
created: string
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, role, content, code_html, code_css, code_js, submission_id, created
|
// 除了 AI 对话,还有学生手改(edit)和粘贴外部 AI 代码(manual)的版本;引导对话(guidance)不算
|
||||||
FROM prompt_message WHERE conversation_id = $id AND source = 'conversation'
|
`SELECT id, role, source, content, code_html, code_css, code_js, submission_id, created
|
||||||
|
FROM prompt_message WHERE conversation_id = $id AND source IN ('conversation', 'edit', 'manual')
|
||||||
ORDER BY created, id`,
|
ORDER BY created, id`,
|
||||||
{ id: conversationId },
|
{ id: conversationId },
|
||||||
)
|
)
|
||||||
@@ -108,21 +119,25 @@ async function handlePrompt(ws: WS, data: any) {
|
|||||||
const model = String(data.model ?? "")
|
const model = String(data.model ?? "")
|
||||||
if (!prompt) return
|
if (!prompt) return
|
||||||
|
|
||||||
ws.data.pendingUserMessageId = insertMessage({
|
const userMessageId = insertMessage({
|
||||||
conversation_id: ws.data.conversationId,
|
conversation_id: ws.data.conversationId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: prompt,
|
content: prompt,
|
||||||
})
|
})
|
||||||
|
ws.data.pendingUserMessageId = userMessageId
|
||||||
try {
|
try {
|
||||||
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
||||||
role: m.role as ChatMessage["role"],
|
role: m.role as ChatMessage["role"],
|
||||||
content: m.content,
|
// 以前“手动提交”存的 AI 回复正文是空的,代码只在 code_* 里
|
||||||
|
content: m.role === "assistant" && !m.content ? codeAsMarkdown(m) : m.content,
|
||||||
}))
|
}))
|
||||||
let full: string
|
let full: string
|
||||||
try {
|
try {
|
||||||
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal))
|
const brief = challengeBrief(ws.data.taskId)
|
||||||
|
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal, brief))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
|
// 客户端自己断开导致的中止不算出错
|
||||||
|
if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (ws.data.closed) return
|
if (ws.data.closed) return
|
||||||
@@ -136,8 +151,12 @@ async function handlePrompt(ws: WS, data: any) {
|
|||||||
code_css: code.css,
|
code_css: code.css,
|
||||||
code_js: code.js,
|
code_js: code.js,
|
||||||
})
|
})
|
||||||
|
// 有了回复,这条提问才留下来;在后台打 L1–L6 层级
|
||||||
|
inBackground(classifyMessage(userMessageId))
|
||||||
ws.data.pendingUserMessageId = null
|
ws.data.pendingUserMessageId = null
|
||||||
send(ws, { type: "complete", code, message_id: messageId })
|
// 只回答问题、没给代码的回复不是新版本;发 null,免得工作台把网页清空
|
||||||
|
const hasCode = !!(code.html || code.css || code.js)
|
||||||
|
send(ws, { type: "complete", code: hasCode ? code : null, message_id: messageId })
|
||||||
} finally {
|
} finally {
|
||||||
dropPendingUserMessage(ws)
|
dropPendingUserMessage(ws)
|
||||||
}
|
}
|
||||||
@@ -147,21 +166,27 @@ async function handleGuidance(ws: WS, data: any) {
|
|||||||
const prompt = String(data.content ?? "").trim()
|
const prompt = String(data.content ?? "").trim()
|
||||||
if (!prompt) return
|
if (!prompt) return
|
||||||
|
|
||||||
ws.data.pendingUserMessageId = insertMessage({
|
const userMessageId = insertMessage({
|
||||||
conversation_id: ws.data.conversationId,
|
conversation_id: ws.data.conversationId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: prompt,
|
content: prompt,
|
||||||
source: "guidance",
|
source: "guidance",
|
||||||
})
|
})
|
||||||
|
ws.data.pendingUserMessageId = userMessageId
|
||||||
ws.data.guidanceHistory.push({ role: "user", content: prompt })
|
ws.data.guidanceHistory.push({ role: "user", content: prompt })
|
||||||
try {
|
try {
|
||||||
let full: string
|
let full: string
|
||||||
try {
|
try {
|
||||||
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
|
const context = guidanceContext(ws.data.conversationId, ws.data.taskId)
|
||||||
|
full = await streamToClient(
|
||||||
|
ws,
|
||||||
|
streamGuidance(ws.data.guidanceHistory, context, ws.data.abort.signal),
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
|
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
|
||||||
ws.data.guidanceHistory.pop()
|
ws.data.guidanceHistory.pop()
|
||||||
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
|
// 客户端自己断开导致的中止不算出错
|
||||||
|
if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (ws.data.closed) return
|
if (ws.data.closed) return
|
||||||
@@ -174,6 +199,7 @@ async function handleGuidance(ws: WS, data: any) {
|
|||||||
content: clean,
|
content: clean,
|
||||||
source: "guidance",
|
source: "guidance",
|
||||||
})
|
})
|
||||||
|
inBackground(classifyMessage(userMessageId))
|
||||||
ws.data.pendingUserMessageId = null
|
ws.data.pendingUserMessageId = null
|
||||||
send(ws, { type: "complete", message_id: messageId, is_ready: isReady })
|
send(ws, { type: "complete", message_id: messageId, is_ready: isReady })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -185,6 +211,8 @@ export const websocket: WebSocketHandler<WsData> = {
|
|||||||
// AI 生成可能要一两分钟,期间没有客户端消息
|
// AI 生成可能要一两分钟,期间没有客户端消息
|
||||||
idleTimeout: 960,
|
idleTimeout: 960,
|
||||||
sendPings: true,
|
sendPings: true,
|
||||||
|
// 提示词不会这么长;Bun 默认 16MB
|
||||||
|
maxPayloadLength: 64 * 1024,
|
||||||
|
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const d = ws.data
|
const d = ws.data
|
||||||
@@ -206,6 +234,7 @@ export const websocket: WebSocketHandler<WsData> = {
|
|||||||
messages: conversationHistory(d.conversationId).map((m) => ({
|
messages: conversationHistory(d.conversationId).map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
|
source: m.source,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
code:
|
code:
|
||||||
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
|
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
|
||||||
@@ -222,13 +251,20 @@ export const websocket: WebSocketHandler<WsData> = {
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (ws.data.busy) {
|
||||||
|
send(ws, { type: "error", content: "上一条消息还在处理,请等 AI 回复完再发" })
|
||||||
|
return
|
||||||
|
}
|
||||||
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
|
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
|
||||||
ws.data.queue = ws.data.queue
|
ws.data.busy = true
|
||||||
.then(() => (ws.data.closed ? undefined : handler(ws, data)))
|
handler(ws, data)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error("websocket handler failed:", e)
|
console.error("websocket handler failed:", e)
|
||||||
send(ws, { type: "error", content: "服务器出错,请刷新页面重试" })
|
send(ws, { type: "error", content: "服务器出错,请刷新页面重试" })
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ws.data.busy = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
close(ws) {
|
close(ws) {
|
||||||
|
|||||||
+352
-5
@@ -1,6 +1,11 @@
|
|||||||
import { beforeEach, describe, expect, test } from "bun:test"
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { get, all } from "../src/db"
|
import { get, all, run } from "../src/db"
|
||||||
import { client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
|
import { app } from "../src/app"
|
||||||
|
import { config } from "../src/config"
|
||||||
|
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { PASSWORD, client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
|
||||||
|
|
||||||
beforeEach(freshDb)
|
beforeEach(freshDb)
|
||||||
|
|
||||||
@@ -45,6 +50,17 @@ describe("权限", () => {
|
|||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("同一用户名连续登录失败 10 次后暂停登录", async () => {
|
||||||
|
makeUser("brute")
|
||||||
|
const anon = await client()
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
expect((await anon("POST", "/api/account/login", { username: "brute", password: "wrong" })).status).toBe(401)
|
||||||
|
}
|
||||||
|
// 密码对了也要等窗口过去
|
||||||
|
const res = await anon("POST", "/api/account/login", { username: "brute", password: PASSWORD })
|
||||||
|
expect(res.status).toBe(429)
|
||||||
|
})
|
||||||
|
|
||||||
test("封号后 access token 和 refresh token 都失效", async () => {
|
test("封号后 access token 和 refresh token 都失效", async () => {
|
||||||
const id = makeUser("stu")
|
const id = makeUser("stu")
|
||||||
makeUser("boss", "super")
|
makeUser("boss", "super")
|
||||||
@@ -155,6 +171,8 @@ describe("评分", () => {
|
|||||||
expect(row.score).toBeCloseTo(4.25)
|
expect(row.score).toBeCloseTo(4.25)
|
||||||
expect(row.zone).toBe("pending")
|
expect(row.zone).toBe("pending")
|
||||||
|
|
||||||
|
// 学生要自己也交过这道题才能打分
|
||||||
|
for (let i = 0; i < 3; i++) makeSubmission(get(`SELECT id FROM account_user WHERE username = 's${i}'`).id, task)
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await (await client(`s${i}`))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 5 })
|
await (await client(`s${i}`))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 5 })
|
||||||
}
|
}
|
||||||
@@ -171,6 +189,26 @@ describe("评分", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("随机评分", () => {
|
||||||
|
test("不给自己的、已评过的;没有可评的返回 null", async () => {
|
||||||
|
const me = makeUser("me")
|
||||||
|
const other = makeUser("other")
|
||||||
|
const task = makeTask("T")
|
||||||
|
makeSubmission(me, task)
|
||||||
|
const subs = Array.from({ length: 5 }, () => makeSubmission(other, task))
|
||||||
|
const c = await client("me")
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await c("GET", "/api/submission/random-for-rating")
|
||||||
|
expect(subs.map(uuid)).toContain(res.json.submission_id)
|
||||||
|
expect(seen.has(res.json.submission_id)).toBe(false)
|
||||||
|
seen.add(res.json.submission_id)
|
||||||
|
await c("PUT", `/api/submission/${res.json.submission_id}/score`, { score: 4 })
|
||||||
|
}
|
||||||
|
expect((await c("GET", "/api/submission/random-for-rating")).json).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("提交与删除", () => {
|
describe("提交与删除", () => {
|
||||||
test("手写提示词提交会生成对话,删除提交连带删除提示词", async () => {
|
test("手写提示词提交会生成对话,删除提交连带删除提示词", async () => {
|
||||||
makeUser("stu")
|
makeUser("stu")
|
||||||
@@ -190,11 +228,89 @@ describe("提交与删除", () => {
|
|||||||
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("保存手改 / 外部 AI 版本,交这一版时关联到它", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
const task = makeTask("T")
|
||||||
|
const stu = await client("stu")
|
||||||
|
const edit = await stu("POST", "/api/prompt/versions", { task_id: task, html: "<p>1", css: "", js: "" })
|
||||||
|
expect(edit.json.source).toBe("edit")
|
||||||
|
const ext = await stu("POST", "/api/prompt/versions", {
|
||||||
|
task_id: task,
|
||||||
|
html: "<p>2",
|
||||||
|
css: "",
|
||||||
|
js: "",
|
||||||
|
prompt: "用外部 AI 做的",
|
||||||
|
})
|
||||||
|
expect(ext.json.source).toBe("manual")
|
||||||
|
const history = await stu("GET", `/api/prompt/history/${task}`)
|
||||||
|
expect(history.json.map((h: any) => [h.source, h.prompt])).toEqual([
|
||||||
|
["edit", "(手动修改了代码)"],
|
||||||
|
["manual", "用外部 AI 做的"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const sub = await stu("POST", "/api/submission/", { task_id: task, html: "<p>1", message_id: edit.json.message_id })
|
||||||
|
const linked = get(`SELECT submission_id FROM prompt_message WHERE id = $id`, { id: edit.json.message_id })
|
||||||
|
expect(linked.submission_id).toBe(sub.json.id.replaceAll("-", ""))
|
||||||
|
// 同一个版本再交一次,更新原来的提交,不新建
|
||||||
|
await stu("POST", "/api/submission/", { task_id: task, html: "<p>1!", message_id: edit.json.message_id })
|
||||||
|
expect(all(`SELECT html FROM submission_submission`)).toEqual([{ html: "<p>1!" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("删除一轮对话:连带提问和关联提交;被评过分的不能删", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
const other = makeUser("other")
|
||||||
|
const task = makeTask("T")
|
||||||
|
const otherSub = makeSubmission(other, task)
|
||||||
|
const stu = await client("stu")
|
||||||
|
const first = await stu("POST", "/api/submission/", { task_id: task, html: "<p>1", prompt: "第一版" })
|
||||||
|
const second = await stu("POST", "/api/submission/", { task_id: task, html: "<p>2", prompt: "第二版" })
|
||||||
|
const asstOf = (sub: string) =>
|
||||||
|
get(`SELECT id FROM prompt_message WHERE submission_id = $id`, { id: sub.replaceAll("-", "") })!.id
|
||||||
|
|
||||||
|
await (await client("other"))("PUT", `/api/submission/${second.json.id}/score`, { score: 4 })
|
||||||
|
expect((await stu("DELETE", `/api/prompt/messages/${asstOf(second.json.id)}/pair`)).status).toBe(400)
|
||||||
|
|
||||||
|
const res = await stu("DELETE", `/api/prompt/messages/${asstOf(first.json.id)}/pair`)
|
||||||
|
expect(res.json).toEqual({ deleted: true, submission_deleted: true })
|
||||||
|
expect(all(`SELECT content FROM prompt_message WHERE role = 'user'`)).toEqual([{ content: "第二版" }])
|
||||||
|
expect(all(`SELECT COUNT(*) AS n FROM submission_submission WHERE id != $id`, { id: otherSub })).toEqual([{ n: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("交的时候写的反思:再交没写就保留,只有老师和作者能看,删提交时一起删", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
makeUser("mate")
|
||||||
|
makeUser("teacher", "admin")
|
||||||
|
const task = makeTask("T")
|
||||||
|
const stu = await client("stu")
|
||||||
|
const edit = await stu("POST", "/api/prompt/versions", { task_id: task, html: "<p>1", css: "", js: "" })
|
||||||
|
const sub = await stu("POST", "/api/submission/", {
|
||||||
|
task_id: task,
|
||||||
|
html: "<p>1",
|
||||||
|
message_id: edit.json.message_id,
|
||||||
|
reflection: " 按钮改成三个,因为五个太乱 ",
|
||||||
|
})
|
||||||
|
const id = sub.json.id
|
||||||
|
await stu("POST", "/api/submission/", { task_id: task, html: "<p>1!", message_id: edit.json.message_id, reflection: "" })
|
||||||
|
expect((await stu("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱")
|
||||||
|
expect((await (await client("teacher"))("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱")
|
||||||
|
|
||||||
|
const mate = await client("mate")
|
||||||
|
await mate("POST", "/api/submission/", { task_id: task, html: "<p>m" })
|
||||||
|
const peer = await mate("GET", `/api/submission/${id}`)
|
||||||
|
expect(peer.status).toBe(200)
|
||||||
|
expect(peer.json.reflection).toBeNull()
|
||||||
|
|
||||||
|
expect((await stu("DELETE", `/api/submission/${id}`)).status).toBe(200)
|
||||||
|
expect(all(`SELECT * FROM submission_reflection`)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test("被评分的提交只有超管能删", async () => {
|
test("被评分的提交只有超管能删", async () => {
|
||||||
const author = makeUser("author")
|
const author = makeUser("author")
|
||||||
makeUser("other")
|
const other = makeUser("other")
|
||||||
makeUser("boss", "super")
|
makeUser("boss", "super")
|
||||||
const sub = makeSubmission(author, makeTask("T"))
|
const task = makeTask("T")
|
||||||
|
const sub = makeSubmission(author, task)
|
||||||
|
makeSubmission(other, task)
|
||||||
await (await client("other"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 4 })
|
await (await client("other"))("PUT", `/api/submission/${uuid(sub)}/score`, { score: 4 })
|
||||||
expect((await (await client("author"))("DELETE", `/api/submission/${uuid(sub)}`)).status).toBe(400)
|
expect((await (await client("author"))("DELETE", `/api/submission/${uuid(sub)}`)).status).toBe(400)
|
||||||
expect((await (await client("boss"))("DELETE", `/api/submission/${uuid(sub)}`)).status).toBe(200)
|
expect((await (await client("boss"))("DELETE", `/api/submission/${uuid(sub)}`)).status).toBe(200)
|
||||||
@@ -327,7 +443,7 @@ describe("作品展", () => {
|
|||||||
|
|
||||||
const author1 = await client("author")
|
const author1 = await client("author")
|
||||||
expect((await author1("GET", "/api/submission/showcase/")).json).toHaveLength(1)
|
expect((await author1("GET", "/api/submission/showcase/")).json).toHaveLength(1)
|
||||||
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).json.awards).toEqual(["最佳"])
|
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).json).toMatchObject({ awards: ["最佳"], task_type: "challenge" })
|
||||||
|
|
||||||
await adm("PUT", `/api/submission/showcase/manage/awards/${award.id}`, { name: "最佳", is_active: false })
|
await adm("PUT", `/api/submission/showcase/manage/awards/${award.id}`, { name: "最佳", is_active: false })
|
||||||
expect((await author1("GET", "/api/submission/showcase/")).json).toEqual([])
|
expect((await author1("GET", "/api/submission/showcase/")).json).toEqual([])
|
||||||
@@ -373,3 +489,234 @@ describe("自由创作", () => {
|
|||||||
expect((await me("POST", "/api/submission/", { task_id: c.task_id })).json.detail).toBe("自由创作不需要提交")
|
expect((await me("POST", "/api/submission/", { task_id: c.task_id })).json.detail).toBe("自由创作不需要提交")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("任务素材", () => {
|
||||||
|
test("同名上传原地覆盖,文件名不合法 422", async () => {
|
||||||
|
const media = await mkdtemp(join(tmpdir(), "media-"))
|
||||||
|
const saved = config.mediaRoot
|
||||||
|
config.mediaRoot = media
|
||||||
|
try {
|
||||||
|
makeUser("adm", "admin")
|
||||||
|
makeTask("挑战", "challenge", 3)
|
||||||
|
const adm = await client("adm")
|
||||||
|
const upload = (name: string, body: string) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("name", name)
|
||||||
|
form.append("file", new File([body], "x"))
|
||||||
|
return app.request("/api/assets/challenge/3", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${adm.tokens.access_token}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect((await upload("a.css", "v1")).status).toBe(200)
|
||||||
|
const res = await upload("a.css", "v2")
|
||||||
|
expect(((await res.json()) as any).url).toBe("/media/tasks/challenge/3/a.css")
|
||||||
|
const dir = join(media, "tasks/challenge/3")
|
||||||
|
expect(await readdir(dir)).toEqual(["a.css"])
|
||||||
|
expect(await readFile(join(dir, "a.css"), "utf8")).toBe("v2")
|
||||||
|
for (const bad of ["a b.png", "../x.png", "a/b.png", "x#.png"]) {
|
||||||
|
expect((await upload(bad, "x")).status).toBe(422)
|
||||||
|
}
|
||||||
|
expect((await adm("DELETE", "/api/assets/challenge/3/a.css")).status).toBe(200)
|
||||||
|
expect(await readdir(dir)).toEqual([])
|
||||||
|
} finally {
|
||||||
|
config.mediaRoot = saved
|
||||||
|
await rm(media, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("同学作品与批改", () => {
|
||||||
|
test("学生只能看自己的、作品展里的、自己也交过的题上同学(任何班)的提交", async () => {
|
||||||
|
const me = makeUser("me", "normal", "c1")
|
||||||
|
const mate = makeUser("mate", "normal", "c1")
|
||||||
|
const other = makeUser("other", "normal", "c2")
|
||||||
|
const task = makeTask("T")
|
||||||
|
const mateSub = makeSubmission(mate, task)
|
||||||
|
const otherSub = makeSubmission(other, task)
|
||||||
|
const stu = await client("me")
|
||||||
|
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(mateSub)}`)).status).toBe(403)
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(mateSub)}/prompt-chain`)).status).toBe(403)
|
||||||
|
const locked = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(locked.json).toEqual({ locked: true, items: [] })
|
||||||
|
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(otherSub)}`)).status).toBe(403)
|
||||||
|
makeSubmission(me, task)
|
||||||
|
const peer = await stu("GET", `/api/submission/${uuid(mateSub)}`)
|
||||||
|
expect(peer.status).toBe(200)
|
||||||
|
// 打分前不显示作者和得分
|
||||||
|
expect(peer.json).toMatchObject({ userid: null, username: null, score: null })
|
||||||
|
// 交过以后外班的也能看
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(otherSub)}`)).status).toBe(200)
|
||||||
|
// 没交过的题仍然看不到
|
||||||
|
const otherTask = makeTask("T2", "challenge", 2)
|
||||||
|
const otherTaskSub = makeSubmission(mate, otherTask)
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(otherTaskSub)}`)).status).toBe(403)
|
||||||
|
// 列表里只有自己的
|
||||||
|
const list = await stu("GET", "/api/submission/")
|
||||||
|
expect(list.json.items.map((s: any) => s.username)).toEqual(["me"])
|
||||||
|
expect((await stu("GET", `/api/submission/by-user-task?user_id=${mate}&task_id=${task}`)).status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("同学作品打分前匿名,打完分才显示作者", async () => {
|
||||||
|
const me = makeUser("me", "normal", "c1")
|
||||||
|
const mate = makeUser("mate", "normal", "c1")
|
||||||
|
makeUser("other", "normal", "c2")
|
||||||
|
const task = makeTask("T")
|
||||||
|
makeSubmission(me, task)
|
||||||
|
const mateSub = makeSubmission(mate, task)
|
||||||
|
const stu = await client("me")
|
||||||
|
|
||||||
|
// 没交过的同学既不能打分,也抽不到这道题
|
||||||
|
const other = await client("other")
|
||||||
|
expect((await other("PUT", `/api/submission/${uuid(mateSub)}/score`, { score: 3 })).status).toBe(403)
|
||||||
|
expect((await other("GET", "/api/submission/random-for-rating")).json).toBeNull()
|
||||||
|
|
||||||
|
let peers = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(peers.json.items).toHaveLength(1)
|
||||||
|
expect(peers.json.items[0]).toMatchObject({ id: uuid(mateSub), username: null, score: null, my_score: 0 })
|
||||||
|
|
||||||
|
const rated = await stu("PUT", `/api/submission/${uuid(mateSub)}/score`, { score: 4 })
|
||||||
|
expect(rated.json.username).toBe("mate")
|
||||||
|
peers = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(peers.json.items[0]).toMatchObject({ username: "mate", my_score: 4 })
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(mateSub)}`)).json).toMatchObject({ username: "mate" })
|
||||||
|
|
||||||
|
// 默认只看本班;scope=all 能看到外班,标着班级,同样匿名
|
||||||
|
const otherSub = makeSubmission(get(`SELECT id FROM account_user WHERE username = 'other'`).id, task)
|
||||||
|
const random = await other("GET", "/api/submission/random-for-rating")
|
||||||
|
expect(random.json.submission_id).toBeDefined()
|
||||||
|
expect(random.json.username).toBeUndefined()
|
||||||
|
const all = await stu("GET", `/api/submission/review/peers/${task}?scope=all`)
|
||||||
|
expect(all.json.items.map((w: any) => [w.classname, w.username])).toContainEqual(["c2", null])
|
||||||
|
expect(all.json.items.map((w: any) => w.id)).toContain(uuid(otherSub))
|
||||||
|
const mine = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(mine.json.items.map((w: any) => w.classname)).toEqual(["c1"])
|
||||||
|
|
||||||
|
// 老师没交过也能看,直接显示作者
|
||||||
|
makeUser("t", "admin")
|
||||||
|
const teacher = await (await client("t"))("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(teacher.json.locked).toBe(false)
|
||||||
|
expect(teacher.json.items.map((w: any) => w.username).sort()).toEqual(["mate", "me", "other"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("老师按班级 × 任务看作品墙;课上展示用 flag", async () => {
|
||||||
|
makeUser("t", "admin")
|
||||||
|
const a = makeUser("a", "normal", "c1")
|
||||||
|
makeUser("b", "normal", "c1")
|
||||||
|
const task = makeTask("T")
|
||||||
|
makeSubmission(a, task, 0, "2026-01-01 00:00:00.000000")
|
||||||
|
const latest = makeSubmission(a, task, 0, "2026-01-02 00:00:00.000000")
|
||||||
|
const teacher = await client("t")
|
||||||
|
|
||||||
|
expect((await (await client("a"))("GET", `/api/submission/review/class?classname=c1&task_id=${task}`)).status).toBe(403)
|
||||||
|
const wall = await teacher("GET", `/api/submission/review/class?classname=c1&task_id=${task}`)
|
||||||
|
expect(wall.json.map((s: any) => [s.username, s.submission?.id ?? null, s.submission?.submit_count])).toEqual([
|
||||||
|
["a", uuid(latest), 2],
|
||||||
|
["b", null, undefined],
|
||||||
|
])
|
||||||
|
const progress = await teacher("GET", "/api/submission/review/progress?classname=c1")
|
||||||
|
expect(progress.json).toEqual({ student_count: 2, submitted: { [task]: 1 } })
|
||||||
|
|
||||||
|
// 不传班级:所有班的学生都在,带着班级
|
||||||
|
const other = makeUser("z", "normal", "c2")
|
||||||
|
makeSubmission(other, task)
|
||||||
|
const allWall = await teacher("GET", `/api/submission/review/class?task_id=${task}`)
|
||||||
|
expect(allWall.json.map((s: any) => [s.classname, s.username])).toEqual([
|
||||||
|
["c1", "a"],
|
||||||
|
["c1", "b"],
|
||||||
|
["c2", "z"],
|
||||||
|
])
|
||||||
|
const allProgress = await teacher("GET", "/api/submission/review/progress")
|
||||||
|
expect(allProgress.json).toEqual({ student_count: 3, submitted: { [task]: 2 } })
|
||||||
|
const options = await teacher("GET", "/api/submission/review/options")
|
||||||
|
expect(options.json.classes).toEqual(["c1", "c2"])
|
||||||
|
|
||||||
|
expect((await teacher("PUT", `/api/submission/${uuid(latest)}/flag`, { flag: "present" })).json.flag).toBe("present")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("课堂总结", () => {
|
||||||
|
test("按班级统计,高分作品每人只取最高的一份", async () => {
|
||||||
|
makeUser("adm", "admin")
|
||||||
|
const task = makeTask("挑战", "challenge", 1)
|
||||||
|
const a = makeUser("weba1小明", "normal", "a1")
|
||||||
|
const b = makeUser("weba1小红", "normal", "a1")
|
||||||
|
makeUser("weba1小刚", "normal", "a1")
|
||||||
|
const other = makeUser("weba2小李", "normal", "a2")
|
||||||
|
makeSubmission(a, task, 3, "2026-01-01 00:00:00.000000")
|
||||||
|
const aBest = makeSubmission(a, task, 4.5, "2026-01-02 00:00:00.000000")
|
||||||
|
makeSubmission(a, task, 4, "2026-01-03 00:00:00.000000")
|
||||||
|
const bSub = makeSubmission(b, task, 0)
|
||||||
|
run(`UPDATE submission_submission SET flag = 'present' WHERE id = $id`, { id: bSub })
|
||||||
|
makeSubmission(other, task, 5)
|
||||||
|
|
||||||
|
const adm = await client("adm")
|
||||||
|
const res = await adm("GET", `/api/submission/stats/${task}?classname=a1`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.json).toMatchObject({
|
||||||
|
submitted_count: 2,
|
||||||
|
unsubmitted_count: 1,
|
||||||
|
average_score: 4,
|
||||||
|
unrated_count: 2,
|
||||||
|
present_count: 1,
|
||||||
|
classes: ["a1", "a2"],
|
||||||
|
submission_count_distribution: { count_1: 1, count_2: 0, count_3: 1, count_4_plus: 0 },
|
||||||
|
})
|
||||||
|
expect(res.json.unsubmitted_users).toEqual([{ username: "weba1小刚", classname: "a1" }])
|
||||||
|
expect(res.json.top_scored).toEqual([
|
||||||
|
{ username: "weba1小明", classname: "a1", score: 4.5, submission_id: uuid(aBest) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const all = await adm("GET", `/api/submission/stats/${task}`)
|
||||||
|
expect(all.json.top_scored.map((t: any) => t.username)).toEqual(["weba2小李", "weba1小明"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("统计每人最新一版的提示词链", async () => {
|
||||||
|
const { createConversation, insertMessage } = await import("../src/services/prompt")
|
||||||
|
makeUser("adm", "admin")
|
||||||
|
const task = makeTask("挑战")
|
||||||
|
const say = (conv: string, source: string, level: number | null, submission_id?: string) => {
|
||||||
|
const id = insertMessage({ conversation_id: conv, role: "user", source, content: "提问" })
|
||||||
|
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, { level, id })
|
||||||
|
insertMessage({ conversation_id: conv, role: "assistant", source, content: "回答", submission_id })
|
||||||
|
}
|
||||||
|
// 小明:AI 对话 → 引导 → 外部 AI,交最后这版;交了之后的提问不算
|
||||||
|
const a = makeUser("weba1小明", "normal", "a1")
|
||||||
|
const convA = createConversation(a, task)
|
||||||
|
say(convA, "conversation", 3)
|
||||||
|
say(convA, "guidance", 2)
|
||||||
|
say(convA, "manual", 6, makeSubmission(a, task))
|
||||||
|
say(convA, "conversation", 1)
|
||||||
|
// 小红:一轮就交
|
||||||
|
const b = makeUser("weba1小红", "normal", "a1")
|
||||||
|
say(createConversation(b, task), "conversation", 1, makeSubmission(b, task))
|
||||||
|
// 小刚:手动交的,没有提示词
|
||||||
|
makeSubmission(makeUser("weba1小刚", "normal", "a1"), task)
|
||||||
|
|
||||||
|
const res = await (await client("adm"))("GET", `/api/submission/stats/${task}`)
|
||||||
|
expect(res.json.prompts).toEqual({
|
||||||
|
with_chain: 2,
|
||||||
|
one_round: 1,
|
||||||
|
external: 1,
|
||||||
|
used_guidance: 1,
|
||||||
|
avg_rounds: 1.5,
|
||||||
|
levels: [1, 0, 1, 0, 0, 1],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("提示词分类", () => {
|
||||||
|
test("太短的提问不打层级,以前打的也清掉", async () => {
|
||||||
|
const { classifyMessage } = await import("../src/services/classifier")
|
||||||
|
const { createConversation, insertMessage } = await import("../src/services/prompt")
|
||||||
|
const conv = createConversation(makeUser("stu"), makeTask("T"))
|
||||||
|
const id = insertMessage({ conversation_id: conv, role: "user", content: " 1 " })
|
||||||
|
run(`UPDATE prompt_message SET prompt_level = 6 WHERE id = $id`, { id })
|
||||||
|
expect(await classifyMessage(id)).toBeNull()
|
||||||
|
const row = get<{ prompt_level: number | null }>(`SELECT prompt_level FROM prompt_message WHERE id = $id`, { id })
|
||||||
|
expect(row!.prompt_level).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { pyRound, pySum } from "../src/services/pyround"
|
import { pyRound, pySum } from "../src/services/pyround"
|
||||||
import { chatModel, extractCode, parseGuidanceResponse, streamChat } from "../src/services/llm"
|
import {
|
||||||
|
chatModel,
|
||||||
|
extractCode,
|
||||||
|
parseGuidanceResponse,
|
||||||
|
streamChat,
|
||||||
|
streamGuidance,
|
||||||
|
trimHistory,
|
||||||
|
userFacingError,
|
||||||
|
} from "../src/services/llm"
|
||||||
import { config } from "../src/config"
|
import { config } from "../src/config"
|
||||||
import { checkPassword, makePassword } from "../src/auth"
|
import { checkPassword, makePassword } from "../src/auth"
|
||||||
import { formatMicros, iso, uuidHex, uuidOut } from "../src/db"
|
import { formatMicros, iso, uuidHex, uuidOut } from "../src/db"
|
||||||
@@ -70,12 +78,13 @@ test("CSV 转义", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("streamChat", () => {
|
describe("streamChat", () => {
|
||||||
test("不认识的模型换成默认模型,最后一行没有换行也能读到", async () => {
|
test("不认识的模型换成默认模型,最后一行没有换行也能读到;生成代码开思考,引导不开", async () => {
|
||||||
let model = ""
|
let model = ""
|
||||||
|
let thinking: unknown
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
model = ((await req.json()) as any).model
|
;({ model, thinking } = (await req.json()) as any)
|
||||||
const line = (s: string) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}`
|
const line = (s: string) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}`
|
||||||
// 一行拆在两个分块之间,最后一行不带换行
|
// 一行拆在两个分块之间,最后一行不带换行
|
||||||
const body = `${line("你好")}\n\n${line("世")}\n${line("界")}`
|
const body = `${line("你好")}\n\n${line("世")}\n${line("界")}`
|
||||||
@@ -101,6 +110,9 @@ describe("streamChat", () => {
|
|||||||
for await (const s of streamChat([{ role: "user", content: "hi" }], "deepseek-v4-flash-thinking")) out += s
|
for await (const s of streamChat([{ role: "user", content: "hi" }], "deepseek-v4-flash-thinking")) out += s
|
||||||
expect(out).toBe("你好世界")
|
expect(out).toBe("你好世界")
|
||||||
expect(model).toBe(config.llmModel)
|
expect(model).toBe(config.llmModel)
|
||||||
|
expect(thinking).toEqual({ type: "enabled" })
|
||||||
|
for await (const _ of streamGuidance([{ role: "user", content: "hi" }], "题目:x"));
|
||||||
|
expect(thinking).toEqual({ type: "disabled" })
|
||||||
} finally {
|
} finally {
|
||||||
Object.assign(config, saved)
|
Object.assign(config, saved)
|
||||||
server.stop(true)
|
server.stop(true)
|
||||||
@@ -114,3 +126,44 @@ describe("streamChat", () => {
|
|||||||
expect(chatModel("")).toBe(config.llmModel)
|
expect(chatModel("")).toBe(config.llmModel)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("trimHistory 只留最近 20 条,旧回复的代码换成占位", () => {
|
||||||
|
const history = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
role: (i % 2 ? "assistant" : "user") as "user" | "assistant",
|
||||||
|
content: i % 2 ? `第${i}版\n\`\`\`html\n<p>${i}</p>\n\`\`\`` : `改一下 ${i}`,
|
||||||
|
}))
|
||||||
|
const out = trimHistory(history)
|
||||||
|
expect(out).toHaveLength(20)
|
||||||
|
expect(out[0]!.content).toBe("改一下 10")
|
||||||
|
expect(out.at(-1)!.content).toContain("<p>29</p>")
|
||||||
|
expect(out.at(-3)!.content).toBe("第27版\n```\n(旧版本代码已省略)\n```")
|
||||||
|
expect(out.filter((m) => m.content.includes("<p>"))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("trimHistory 最后一条回复没有代码时,保留之前最新的那份代码", () => {
|
||||||
|
const out = trimHistory([
|
||||||
|
{ role: "user", content: "做个页面" },
|
||||||
|
{ role: "assistant", content: "第1版\n```html\n<p>1</p>\n```" },
|
||||||
|
{ role: "user", content: "加个按钮" },
|
||||||
|
{ role: "assistant", content: "第2版\n```html\n<p>2</p>\n```" },
|
||||||
|
{ role: "user", content: "为什么按钮是圆的?" },
|
||||||
|
{ role: "assistant", content: "因为加了 border-radius。" },
|
||||||
|
])
|
||||||
|
expect(out[3]!.content).toContain("<p>2</p>")
|
||||||
|
expect(out[1]!.content).not.toContain("<p>1</p>")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("userFacingError 不透出接口原始返回", () => {
|
||||||
|
const quiet = console.error
|
||||||
|
console.error = () => {}
|
||||||
|
try {
|
||||||
|
expect(userFacingError(new Error('Error code: 401 - {"error":"invalid api key sk-xxx"}'))).toBe(
|
||||||
|
"AI 服务出错(401),请稍后重试",
|
||||||
|
)
|
||||||
|
expect(userFacingError(new Error("Error code: 429 - busy"))).toBe("AI 服务繁忙,请稍后重试")
|
||||||
|
expect(userFacingError(new Error("模型响应超时"))).toBe("AI 响应超时,请重试")
|
||||||
|
expect(userFacingError(new Error("fetch failed: ECONNREFUSED 10.0.0.1"))).toBe("AI 服务出错,请稍后重试")
|
||||||
|
} finally {
|
||||||
|
console.error = quiet
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import type { Server } from "bun"
|
||||||
|
import { app } from "../src/app"
|
||||||
|
import { config } from "../src/config"
|
||||||
|
import { all, run } from "../src/db"
|
||||||
|
import { upgrade, websocket, type WsData } from "../src/ws"
|
||||||
|
import { client, freshDb, makeTask, makeUser } from "./helpers"
|
||||||
|
|
||||||
|
// 假的模型接口:每段之间停一下,方便测试中途断开
|
||||||
|
const DEFAULT_PIECES = ["好的", "```html\n<p>hi</p>\n```"]
|
||||||
|
let PIECES = DEFAULT_PIECES
|
||||||
|
// 最近一次发给模型的请求;分类(非流式)单独记,固定回“3”
|
||||||
|
let lastRequest: any = null
|
||||||
|
let lastClassify: any = null
|
||||||
|
const llm = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req) {
|
||||||
|
const body: any = await req.json()
|
||||||
|
if (!body.stream) {
|
||||||
|
lastClassify = body
|
||||||
|
return Response.json({ choices: [{ message: { content: "3" } }] })
|
||||||
|
}
|
||||||
|
lastRequest = body
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(ctrl) {
|
||||||
|
for (const piece of PIECES) {
|
||||||
|
ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: piece } }] })}\n\n`))
|
||||||
|
await Bun.sleep(150)
|
||||||
|
}
|
||||||
|
ctrl.enqueue(enc.encode("data: [DONE]\n\n"))
|
||||||
|
ctrl.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let server: Server<WsData>
|
||||||
|
const saved = { ...config }
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.assign(config, { llmApiKey: "k", llmBaseUrl: `http://localhost:${llm.port}` })
|
||||||
|
server = Bun.serve<WsData>({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req, srv) {
|
||||||
|
const res = await upgrade(req, srv)
|
||||||
|
return res !== null ? res : app.fetch(req)
|
||||||
|
},
|
||||||
|
websocket,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Object.assign(config, saved)
|
||||||
|
server.stop(true)
|
||||||
|
llm.stop(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
freshDb()
|
||||||
|
PIECES = DEFAULT_PIECES
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Conn {
|
||||||
|
ws: WebSocket
|
||||||
|
msgs: any[]
|
||||||
|
next: (type: string) => Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 握手失败时返回 null
|
||||||
|
function connect(path: string): Promise<Conn | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ws = new WebSocket(`ws://localhost:${server.port}${path}`)
|
||||||
|
const msgs: any[] = []
|
||||||
|
const waiters: { type: string; resolve: (m: any) => void }[] = []
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const m = JSON.parse(String(e.data))
|
||||||
|
msgs.push(m)
|
||||||
|
const i = waiters.findIndex((w) => w.type === m.type)
|
||||||
|
if (i >= 0) waiters.splice(i, 1)[0]!.resolve(m)
|
||||||
|
}
|
||||||
|
const next = (type: string) =>
|
||||||
|
new Promise<any>((res) => {
|
||||||
|
const seen = msgs.find((m) => m.type === type && !m._taken)
|
||||||
|
if (seen) {
|
||||||
|
seen._taken = true
|
||||||
|
res(seen)
|
||||||
|
} else {
|
||||||
|
waiters.push({ type, resolve: (m) => ((m._taken = true), res(m)) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ws.onopen = () => resolve({ ws, msgs, next })
|
||||||
|
ws.onerror = () => resolve(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
makeUser("stu")
|
||||||
|
const task = makeTask("挑战")
|
||||||
|
const stu = await client("stu")
|
||||||
|
return { task, token: stu.tokens.access_token }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WebSocket 代码生成", () => {
|
||||||
|
test("正常生成:流式返回并保存一问一答", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
expect((await c.next("init")).messages).toEqual([])
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
const done = await c.next("complete")
|
||||||
|
expect(done.code.html).toBe("<p>hi</p>")
|
||||||
|
const streamed = c.msgs.filter((m) => m.type === "stream").map((m) => m.content).join("")
|
||||||
|
expect(streamed).toBe(PIECES.join(""))
|
||||||
|
expect(all(`SELECT role FROM prompt_message ORDER BY id`).map((m) => m.role)).toEqual(["user", "assistant"])
|
||||||
|
c.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("回复完后在后台给提问打层级", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await c.next("complete")
|
||||||
|
const level = () => all(`SELECT prompt_level FROM prompt_message WHERE role = 'user'`)[0]?.prompt_level
|
||||||
|
for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20)
|
||||||
|
expect(level()).toBe(3)
|
||||||
|
expect(lastClassify.thinking).toEqual({ type: "disabled" })
|
||||||
|
c.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("AI 回复中再发消息会被拒绝", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "第一条" }))
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "第二条" }))
|
||||||
|
expect((await c.next("error")).content).toContain("上一条消息还在处理")
|
||||||
|
await c.next("complete")
|
||||||
|
expect(all(`SELECT content FROM prompt_message WHERE role = 'user'`)).toEqual([{ content: "第一条" }])
|
||||||
|
c.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("中途断开:删掉没有回复的提问", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await c.next("stream")
|
||||||
|
c.ws.close()
|
||||||
|
await Bun.sleep(400)
|
||||||
|
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("手改的版本进入对话,AI 下一轮在它上面改", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
const task = makeTask("挑战")
|
||||||
|
const stu = await client("stu")
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${stu.tokens.access_token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await c.next("complete")
|
||||||
|
|
||||||
|
const saved = await stu("POST", "/api/prompt/versions", {
|
||||||
|
task_id: task,
|
||||||
|
html: "<p>我改过的</p>",
|
||||||
|
css: "p { color: red }",
|
||||||
|
js: "",
|
||||||
|
})
|
||||||
|
expect(saved.json.source).toBe("edit")
|
||||||
|
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "再加个按钮" }))
|
||||||
|
await c.next("complete")
|
||||||
|
const sent = lastRequest.messages.map((m: any) => `${m.role}:${m.content}`)
|
||||||
|
// system、第一轮、手改那一轮、这一轮的提问
|
||||||
|
expect(sent.at(-3)).toBe("user:(手动修改了代码)")
|
||||||
|
expect(sent.at(-2)).toContain("<p>我改过的</p>")
|
||||||
|
expect(sent.at(-2)).toContain("p { color: red }")
|
||||||
|
expect(sent.at(-1)).toBe("user:再加个按钮")
|
||||||
|
c.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("只回答问题、没给代码时 code 是 null", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
PIECES = ["因为加了 ", "border-radius。"]
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "为什么按钮是圆的?" }))
|
||||||
|
const done = await c.next("complete")
|
||||||
|
expect(done.code).toBeNull()
|
||||||
|
expect(done.message_id).toBeGreaterThan(0)
|
||||||
|
c.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("挑战的题目要求作为对照清单发给 AI,自由创作不发", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,有开始和暂停按钮", id: task })
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await c.next("complete")
|
||||||
|
expect(lastRequest.messages[0].content).toContain("它是对照清单")
|
||||||
|
expect(lastRequest.messages[0].content).toContain("题目要求:做一个计时器,有开始和暂停按钮")
|
||||||
|
c.ws.close()
|
||||||
|
|
||||||
|
const creation = makeTask("我的作品", "creation", 2)
|
||||||
|
run(`UPDATE task_task SET content = '我想做一个相册' WHERE id = $id`, { id: creation })
|
||||||
|
const d = (await connect(`/ws/prompt/${creation}/?token=${token}`))!
|
||||||
|
await d.next("init")
|
||||||
|
d.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await d.next("complete")
|
||||||
|
expect(lastRequest.messages[0].content).not.toContain("题目要求")
|
||||||
|
d.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("token 无效或任务不存在时握手失败", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
||||||
|
expect(await connect(`/ws/prompt/9999/?token=${token}`)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("WebSocket 帮我写清楚", () => {
|
||||||
|
test("教练能看到题目要求、之前的提问和 AI 上一次的说明", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器, 有开始和暂停按钮", id: task })
|
||||||
|
const p = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await p.next("init")
|
||||||
|
p.ws.send(JSON.stringify({ type: "message", content: "做个计时器" }))
|
||||||
|
await p.next("complete")
|
||||||
|
p.ws.close()
|
||||||
|
|
||||||
|
const g = (await connect(`/ws/guidance/${task}/?token=${token}`))!
|
||||||
|
await g.next("init")
|
||||||
|
g.ws.send(JSON.stringify({ type: "message", content: "改成粉色" }))
|
||||||
|
await g.next("complete")
|
||||||
|
const system = lastRequest.messages[0]
|
||||||
|
expect(system.role).toBe("system")
|
||||||
|
expect(system.content).toContain("题目要求:做一个计时器, 有开始和暂停按钮")
|
||||||
|
expect(system.content).toContain("1. 做个计时器")
|
||||||
|
expect(system.content).toContain("AI 上一次的说明:好的")
|
||||||
|
expect(system.content).not.toContain("<p>hi</p>")
|
||||||
|
expect(lastRequest.messages.at(-1)).toEqual({ role: "user", content: "改成粉色" })
|
||||||
|
// 等后台打完层级再结束,不然假接口关了它会报错
|
||||||
|
const level = () => all(`SELECT prompt_level FROM prompt_message WHERE source = 'guidance' AND role = 'user'`)[0]?.prompt_level
|
||||||
|
for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20)
|
||||||
|
g.ws.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user