Compare commits
332
Commits
ae32732cf6
...
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 | ||
|
|
40f3063b91 | ||
|
|
dc91b9b5db | ||
|
|
cdeb7c18ef | ||
|
|
73788c4595 | ||
|
|
4014aaabbf | ||
|
|
a69ee16004 | ||
|
|
0e3d97027a | ||
|
|
26827e31d5 | ||
|
|
dde976cacb | ||
|
|
351e4b9499 | ||
|
|
38137a828a | ||
|
|
4616bd9469 | ||
|
|
ceba1c68f2 | ||
|
|
e6a3da14b0 | ||
|
|
4b8109caaa | ||
|
|
26f05d1d76 | ||
|
|
47d4679984 | ||
|
|
70983a12a5 | ||
|
|
d1c6b8c201 | ||
|
|
0591eda56f | ||
|
|
e172ffc0b3 | ||
|
|
1f6935e31e | ||
|
|
2588ea42fd | ||
|
|
fd0fffb185 | ||
|
|
4497af940f | ||
|
|
663fb8d1dc | ||
|
|
9c652a6b3d | ||
|
|
29bb89a257 | ||
|
|
681c6ff4f4 | ||
|
|
5bb8a1eaa3 | ||
|
|
c12c77ac7e | ||
|
|
3102e1178a | ||
|
|
a15b3d9c76 | ||
|
|
05ecf6bebf | ||
|
|
54d9861bd8 | ||
|
|
596ceec880 | ||
|
|
6f99688667 | ||
|
|
4b32330b60 | ||
|
|
1566a4e275 | ||
|
|
f7817d8c26 | ||
|
|
53a0759507 | ||
|
|
e2b3f61863 | ||
|
|
2a93c4d19c | ||
|
|
1e160d192b | ||
|
|
7a72c94238 | ||
|
|
efb535eb3d | ||
|
|
819b7a82f4 | ||
|
|
0180f6e803 | ||
|
|
80aa091602 | ||
|
|
983e87403c | ||
|
|
5aaba42068 | ||
|
|
5ecf8caf83 | ||
|
|
2b216878ca | ||
|
|
77aca640ac | ||
|
|
9a8e5ad48e | ||
|
|
4af9ae90c9 | ||
|
|
f63f7cbbce | ||
|
|
21e3a7f39b | ||
|
|
f255367b08 | ||
|
|
edbf66874b | ||
|
|
e8992edabc | ||
|
|
f3eed84f7c | ||
|
|
b5e0421fd4 | ||
|
|
c0084462eb | ||
|
|
5fced6b4c2 | ||
|
|
2e6e3aacec | ||
|
|
64dc1c9234 | ||
|
|
2abf95888b | ||
|
|
eff635fb49 | ||
|
|
3136be2df7 | ||
|
|
efecef4e98 | ||
|
|
f025ebfa2e | ||
|
|
375a78b852 | ||
|
|
dd249c8753 | ||
|
|
7af5e3117d | ||
|
|
45b40f13ad | ||
|
|
567da331f4 | ||
|
|
e9781fdada | ||
|
|
266f042fb2 | ||
|
|
6591ca6574 | ||
|
|
ed8e016e30 | ||
|
|
b7f3c9c96d | ||
|
|
2fa90738e7 | ||
|
|
35ee7d69dd | ||
|
|
b22eb85f3a | ||
|
|
1d01415771 | ||
|
|
36fcf8427f | ||
|
|
754eea951c | ||
|
|
58f462bb3b | ||
|
|
3697078fc3 | ||
|
|
17e816761f | ||
|
|
93c816f3ab | ||
|
|
8a594446b6 | ||
|
|
134e2e8713 | ||
|
|
d628e94519 | ||
|
|
96e5bfd959 | ||
|
|
74bdef2236 | ||
|
|
b4c73411d6 | ||
|
|
eda968c250 | ||
|
|
7f0bfd67fa | ||
|
|
501f314aff | ||
|
|
6fb3bc0198 | ||
|
|
e4359e8093 | ||
|
|
40f361cf91 | ||
|
|
f2ddd4bc93 | ||
|
|
2179ff1daa | ||
|
|
a7b1a58449 | ||
|
|
8d510e19bb | ||
|
|
e539f9450a | ||
|
|
91e1b2b48b | ||
|
|
f16104b011 | ||
|
|
85e1681017 | ||
|
|
3c721224c8 | ||
|
|
67b1baede8 | ||
|
|
a97a40475a | ||
|
|
1df211c760 | ||
|
|
e9596e28a3 | ||
|
|
8db17aba12 | ||
|
|
9ff2edeecf | ||
|
|
35dbfda52c | ||
|
|
ecce21aaaf | ||
|
|
951e53c1dd | ||
|
|
f26b06877c | ||
|
|
1744c405a5 | ||
|
|
5f95b88914 | ||
|
|
83537face1 | ||
|
|
6495b77c8b | ||
|
|
597f1f0f93 | ||
|
|
4b58cee204 | ||
|
|
942332b6bd | ||
|
|
a7cfa66952 | ||
|
|
0636ad6f57 | ||
|
|
e0f1cdb337 | ||
|
|
88d6ffaf53 | ||
|
|
83cd62a110 | ||
|
|
4e95a2fad0 | ||
|
|
dd52e3e1f9 | ||
|
|
3c6b616d81 | ||
|
|
0dd5cbeee9 | ||
|
|
d68ef60ab9 | ||
|
|
98d8099b5d | ||
|
|
040e4e3253 | ||
|
|
7e65340f63 | ||
|
|
2bd9382c8c | ||
|
|
a7aa4f63ac | ||
|
|
9c577f9bc1 | ||
|
|
016e070fb9 | ||
|
|
0ee8b0d6ea | ||
|
|
334b2d77b1 | ||
|
|
b4bfc7706c | ||
|
|
f7e9d39bc2 | ||
|
|
46347ff99b | ||
|
|
65022968a5 | ||
|
|
04bb023c2e | ||
|
|
725fad4a55 | ||
|
|
4774c05809 | ||
|
|
33d75bf83a | ||
|
|
7b7f6ea81d | ||
|
|
7da4becf4f | ||
|
|
b6dc79b298 | ||
|
|
78a2d84335 | ||
|
|
564f0b3f21 | ||
|
|
18a27f82fd | ||
|
|
30f4195a8a | ||
|
|
ded044ca57 | ||
|
|
9386f2e152 | ||
|
|
44944944ad | ||
|
|
8f331419e7 | ||
|
|
73f09a9c99 | ||
|
|
aa0e71ce94 | ||
|
|
b77f90559a | ||
|
|
a1828a0aba | ||
|
|
cba1c5adb9 | ||
|
|
095626b857 | ||
|
|
8ce1c87dff | ||
|
|
912095b295 | ||
|
|
baa44acbee | ||
|
|
1ff887c94f | ||
|
|
33e4ead2c5 | ||
|
|
2ad2a971c0 | ||
|
|
616bb2bfec | ||
|
|
4caa3b061a | ||
|
|
4556eb71d8 | ||
|
|
4beab17b70 | ||
|
|
69c3a36957 | ||
|
|
a41ba2e85a | ||
|
|
330f69b21c | ||
|
|
b1b5a45bac | ||
|
|
dd9233a6d6 | ||
|
|
d7f33bfca2 | ||
|
|
ea1b114dd2 | ||
|
|
b1fcecb9d4 | ||
|
|
cce7920dcc | ||
|
|
696f1adf3f | ||
|
|
c8171a5bf2 | ||
|
|
33ed22434b | ||
|
|
14e7b2ba0d | ||
|
|
6eb63696c6 | ||
|
|
9eb18fedd4 | ||
|
|
16fb09021d | ||
|
|
a76b2f9bc9 | ||
|
|
e4e7507f85 | ||
|
|
3618b5a4b2 | ||
|
|
a89f6e8d70 | ||
|
|
42444fe3ab | ||
|
|
e10bc77978 | ||
|
|
5f8c96c1da | ||
|
|
32a14a9dd5 | ||
|
|
01e10388e4 | ||
|
|
a2d0f42ae6 | ||
|
|
544a046365 | ||
|
|
5e11796edd | ||
|
|
82623d141e | ||
|
|
6a5b480724 | ||
|
|
c202e0ea09 | ||
|
|
a48ea2cabe | ||
|
|
ebab966207 | ||
|
|
e39faea99a | ||
|
|
8b22e91f78 | ||
|
|
81aea950c6 | ||
|
|
00ce38eae3 | ||
|
|
73290291b3 | ||
|
|
af7139bf25 | ||
|
|
b1593b1c8e | ||
|
|
6d6c69cf37 | ||
|
|
838a703023 | ||
|
|
ea5393242a | ||
|
|
20bf212152 | ||
|
|
f4fa930423 | ||
|
|
24880e62ad | ||
|
|
d2d4c2974e | ||
|
|
c2988b6f28 | ||
|
|
2cd28f3a8e | ||
|
|
e4f1b31212 | ||
|
|
3716fec110 | ||
|
|
4cd73d9af8 | ||
|
|
bf94b0922c | ||
|
|
e6b7fe3848 | ||
|
|
175e4c0d68 | ||
|
|
dce79ef27d | ||
|
|
9fe551a42c | ||
|
|
fbc2d19b70 | ||
|
|
e7cbc71418 | ||
|
|
2af1109ced | ||
|
|
f91b4ab856 | ||
|
|
9ed8dda14c | ||
|
|
e04605c03e | ||
|
|
a7d8663f4d | ||
|
|
f01f8a174d | ||
|
|
586727cb99 | ||
|
|
350e8a7a3b | ||
|
|
bc360d868e | ||
|
|
a577fa4133 | ||
|
|
28abe1b429 | ||
|
|
81706fc48d | ||
|
|
8993455dfb | ||
|
|
1a2977974f | ||
|
|
186f957a5f | ||
|
|
ee57f1138b | ||
|
|
24db30ccec | ||
|
|
db87a1d675 | ||
|
|
e0f8235c4f | ||
|
|
84ffe1280b | ||
|
|
80ed033049 | ||
|
|
2d0dba9549 | ||
|
|
75c6f1dbd4 | ||
|
|
01ae14b445 | ||
|
|
056a40b31f | ||
|
|
382222314d | ||
|
|
377ac9b834 | ||
|
|
c0771bbc5d | ||
|
|
eec8c3d0ac | ||
|
|
fad4b2b867 | ||
|
|
df971ea418 | ||
|
|
9a289d8c39 | ||
|
|
942c1fb59d | ||
|
|
a6c0a5681f | ||
|
|
b3371676cb | ||
|
|
7957a717e3 | ||
|
|
010b05db38 | ||
|
|
06368bb8ec | ||
|
|
eed4f3a64c | ||
|
|
97907da0ba | ||
|
|
8c4e111db1 | ||
|
|
45eaa3f5fa | ||
|
|
ae160fa996 | ||
|
|
a0feeea600 | ||
|
|
af87c4990b | ||
|
|
93fbfd6683 | ||
|
|
5847bb4398 | ||
|
|
697cd50544 | ||
|
|
fc2126f364 | ||
|
|
73ad304863 | ||
|
|
e275da04ee | ||
|
|
d934111354 | ||
|
|
3b26fe4adc |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# 只有 server/Dockerfile 用仓库根目录做构建上下文;其余一概不发给 docker(数据库、media、node_modules 等)
|
||||
*
|
||||
!package.json
|
||||
!bun.lock
|
||||
!client/package.json
|
||||
!server/package.json
|
||||
!server/tsconfig.json
|
||||
!server/src
|
||||
!server/scripts
|
||||
@@ -1,29 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: debian
|
||||
remote_port: 22
|
||||
- name: school
|
||||
remote_port: 8822
|
||||
steps:
|
||||
- name: Deploy to ${{ matrix.name }}
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
port: ${{ matrix.remote_port }}
|
||||
username: root
|
||||
key: ${{ secrets.KEY }}
|
||||
script: sh /root/WebPreview/backend.sh
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: debian
|
||||
build_command: build
|
||||
remote_port: 22
|
||||
# school 暂时停用,需要时恢复。那台还没从 Postgres 迁到 SQLite:迁移脚本、webpreview-db 服务和
|
||||
# 步骤文档已删除,从提交 cdeb7c1 里找回(git show cdeb7c1:docs/migrate-to-bun.md)
|
||||
# - name: school
|
||||
# build_command: build:staging
|
||||
# remote_port: 8822
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.4
|
||||
|
||||
- name: Build client
|
||||
run: |
|
||||
bun install --frozen-lockfile --filter webpreview-client
|
||||
bun run ${{ matrix.build_command }}
|
||||
env:
|
||||
CI: false
|
||||
|
||||
- name: Upload client to ${{ matrix.name }}
|
||||
uses: easingthemes/ssh-deploy@main
|
||||
with:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.KEY }}
|
||||
REMOTE_HOST: ${{ secrets.HOST }}
|
||||
REMOTE_PORT: ${{ matrix.remote_port }}
|
||||
ARGS: "-avzr --delete"
|
||||
SOURCE: client/dist/
|
||||
REMOTE_USER: root
|
||||
TARGET: /root/WebPreview/build
|
||||
|
||||
- name: Update server on ${{ matrix.name }}
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
port: ${{ matrix.remote_port }}
|
||||
username: root
|
||||
key: ${{ secrets.KEY }}
|
||||
script: |
|
||||
set -e
|
||||
cd /root/WebPreview
|
||||
git pull --ff-only origin main
|
||||
docker compose up -d --build
|
||||
+18
-5
@@ -1,8 +1,21 @@
|
||||
venv
|
||||
.venv
|
||||
**/__pycache__
|
||||
db.sqlite3
|
||||
# 运行时数据(服务器上由 docker compose 挂载)
|
||||
.env
|
||||
/data/
|
||||
/build/
|
||||
/media/
|
||||
/redis/
|
||||
/sqlite/
|
||||
/db.sqlite3*
|
||||
|
||||
media/
|
||||
# 本地工具目录
|
||||
.agents/
|
||||
.codex
|
||||
.deepseek/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
|
||||
node_modules/
|
||||
.DS_Store
|
||||
|
||||
# 旧 Django 后端在本地留下的文件(虚拟环境、旧 worktree 等)
|
||||
/api/
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
@@ -0,0 +1,98 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
WebPreview is a web-based tutorial/challenge platform where users view tutorials and submit HTML/CSS/JS code for challenges. It is a Bun workspaces monorepo: a Bun backend (`server/`, package `webpreview-server`) and a Vue 3 frontend (`client/`, package `webpreview-client`) share one root `package.json` and one `bun.lock`, deployed via Docker Compose with Caddy as reverse proxy.
|
||||
|
||||
The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1–2 GB). It keeps Django's exact database schema (table names, column types, UTC `YYYY-MM-DD HH:MM:SS.ffffff` timestamps, 32-char hex UUIDs, `pbkdf2_sha256$...` password hashes) so old data loads unchanged. The Django code is in git history before that change.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`server/`** — Bun + **Hono** HTTP API, Bun's native WebSocket, **`bun:sqlite`** (single SQLite file, WAL). No Redis, no Postgres.
|
||||
- `src/index.ts` — `Bun.serve`: WebSocket upgrades go to `ws.ts`, everything else to the Hono app
|
||||
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, 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/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`), `review` (mounted under `/submission/review`: teacher class × task wall — no `classname` means all classes —, students' peer works with `scope=class|all`), `prompt`
|
||||
- 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/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)
|
||||
- `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`)
|
||||
|
||||
- **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
|
||||
- `vite.config.ts` — browser targets are Chrome ≥ 87 (lab machines run Chrome 105); `@vitejs/plugin-legacy` only injects usage-based core-js polyfills, no legacy chunks
|
||||
- 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/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; `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/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
||||
|
||||
- **`Caddyfile`** — serves SPA from `build/` and `/media`, proxies `/api*` and `/ws*` to the backend
|
||||
- **`docker-compose.yaml`** — `webpreview-web` (Caddy) and `webpreview-api` (Bun); the API reads the repo-root `.env` via `env_file` (`server/.env` is only for local dev). The one-time Postgres → SQLite migration (script, `webpreview-db` service, `docs/migrate-to-bun.md`) was removed after commit `cdeb7c1`; restore it from there if another server still needs the cutover
|
||||
- **`.github/workflows/deploy.yaml`** — on push to `main`: build client with Bun, rsync to server `build/`, then `git pull && docker compose up -d --build` on the server
|
||||
|
||||
## Development Commands
|
||||
|
||||
From the repo root (each script runs inside its workspace, via `bun run --filter`):
|
||||
```bash
|
||||
bun install # installs both workspaces into the single root bun.lock
|
||||
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 test # server tests
|
||||
bun run typecheck # server (tsc 7) + client (vue-tsc) typecheck
|
||||
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.
|
||||
|
||||
### Server (from `server/`)
|
||||
```bash
|
||||
bun run dev # dev server on :8000 with auto-reload
|
||||
bun test # API + unit tests (in-memory SQLite, LLM disabled)
|
||||
bun run typecheck
|
||||
bun scripts/create-superuser.ts <username> <password>
|
||||
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
||||
bun scripts/recalculate-scores.ts [--dry-run]
|
||||
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/`.
|
||||
|
||||
### Client (from `client/`)
|
||||
```bash
|
||||
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 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)
|
||||
```
|
||||
Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`).
|
||||
|
||||
### Docker (from project root)
|
||||
`server/Dockerfile` builds with the repo root as context (so it can use the root `bun.lock`) and installs only the server workspace; `.dockerignore` is an allowlist, so new top-level server files must be added there.
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username> <password>
|
||||
```
|
||||
Back up production by copying `sqlite/` (or `sqlite3 sqlite/db.sqlite3 ".backup backup.sqlite3"`).
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
- **Schema changes**: there is no migration framework. Edit the DDL in `src/db.ts` for new tables; for altering existing tables add an idempotent step guarded by `PRAGMA user_version`
|
||||
- **SQL style**: raw SQL with named params (`$id`), `strict: true` so params are passed without the `$` prefix (`{ id }`). Wrap multi-statement writes in `tx()`. Deleting tasks/submissions must go through `services/cascade.ts`
|
||||
- **Queries block the whole server**: `bun:sqlite` is synchronous in a single process, so a slow query stalls every HTTP request and WebSocket. Request-path queries must take milliseconds; batch work goes in `scripts/`. Never `await` inside `tx()` (it would commit early). Code with no `await` runs atomically, so read-check-write needs no locking; races only happen across an `await`
|
||||
- **Wide rows**: unlike Postgres (TOAST), SQLite keeps `html/css/js` and `prompt_message.content/code_*` inline, and Django's column order puts `user_id`, `task_id`, `flag`, `zone`, `raw_score` *after* them, so reading those columns from the table reads the whole code. List/scan queries must not select the big columns: page on indexes first (select `rowid`s with `ORDER BY ... LIMIT`), then fetch full rows for just that page (see `GET /submission/`, `random-for-rating`)
|
||||
- **Indexes**: SQLite uses one index per table for AND conditions (no BitmapAnd), so multi-column filters need a composite index (equality columns first, then range/sort). Hot scans on `submission_submission` must be covered by `submission_submission_slim`; compare `rowid` rather than `id` (`id` is a separate index). The planner only has stats via `PRAGMA optimize` (run on open and hourly), and may pick a poor single-column index — check new queries with `EXPLAIN QUERY PLAN` (no `SCAN` of big tables, ideally `COVERING INDEX`); a unary `+` (`+s.zone = $zone`) stops a column's index from being used. `CREATE INDEX IF NOT EXISTS` matches by name only, so a changed index needs a new name (drop the old one in a `user_version` step)
|
||||
- **Types**: booleans are `0/1`; timestamps are text compared as strings, so only write values from `now()` (never `CURRENT_TIMESTAMP` / `datetime('now')`); text sorts by code point; `ORDER BY ... ASC` puts NULLs first (Postgres: last), so add `NULLS LAST` where it matters; no `ILIKE` / `DISTINCT ON`
|
||||
- **API compatibility**: response shapes, status codes and trailing-slash tolerance (`strict: false`) mirror the old django-ninja API, so the client needed no changes besides user management. Paginated endpoints take `page` / `page_size` (default 10, max 100) and return `{ items, count }`
|
||||
- **Task `display` field**: integer used as URL slug for tutorials/challenges/creations (unique per task type), not the DB `id`
|
||||
- **Submission / conversation IDs**: UUIDs in the API, 32-char hex in the DB
|
||||
- Language/locale is Chinese; timestamps are stored in UTC and returned as ISO strings ending in `Z`
|
||||
- Client build output is served by Caddy from `build/` in production
|
||||
@@ -0,0 +1,30 @@
|
||||
:80 {
|
||||
encode gzip zstd
|
||||
|
||||
handle_path /* {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
try_files {path} /
|
||||
}
|
||||
|
||||
handle_path /media/* {
|
||||
# 上传的 html/svg 直接打开时不能当成本站页面跑脚本(会读到 localStorage 里的 token);
|
||||
# 作为图片、样式、脚本引用时不受 sandbox 影响。预览 iframe 没有 allow-same-origin,
|
||||
# 读字体或 fetch() 素材要跨域许可
|
||||
header {
|
||||
Content-Security-Policy "sandbox"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Access-Control-Allow-Origin "*"
|
||||
}
|
||||
root * /media
|
||||
file_server
|
||||
}
|
||||
|
||||
handle /api* {
|
||||
reverse_proxy webpreview-api:8000
|
||||
}
|
||||
|
||||
handle /ws* {
|
||||
reverse_proxy webpreview-api:8000
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
FROM python:3.12.2-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 配置apk使用清华镜像源
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories
|
||||
|
||||
# 安装构建依赖
|
||||
RUN apk add --no-cache build-base
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 使用清华镜像源安装依赖
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 最终阶段
|
||||
FROM python:3.12.2-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 从builder阶段复制Python包
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
|
||||
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
Whitespace-only changes.
@@ -1,24 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import Group
|
||||
from .models import User
|
||||
|
||||
admin.site.site_header = "前端开发网站管理"
|
||||
admin.site.site_index = "前端开发网站管理"
|
||||
admin.site.site_title = "前端开发网站管理"
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class AdminAccount(UserAdmin):
|
||||
fieldsets = ((None, {"fields": ("username", "password", "role", "is_active")}),)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("username", "password1", "password2", "role")},
|
||||
),
|
||||
)
|
||||
list_display = ("username", "role", "is_active")
|
||||
list_filter = ("role", "is_active")
|
||||
search_fields = ("username",)
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
from typing import List
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from ninja import Router
|
||||
from ninja.pagination import paginate
|
||||
from ninja.errors import HttpError
|
||||
from .schemas import (
|
||||
BatchUsersIn,
|
||||
ClassStudentEntry,
|
||||
UserListSchema,
|
||||
UserRegistrationSchema,
|
||||
UserLoginSchema,
|
||||
)
|
||||
from .models import RoleChoices, User
|
||||
from .decorators import super_required
|
||||
from django.db import transaction
|
||||
import secrets
|
||||
import string
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def generate_password(length=6):
|
||||
"""生成更安全的随机密码"""
|
||||
alphabet = string.digits[2:] # 只使用2-9的数字
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def user_register(request, payload: UserRegistrationSchema):
|
||||
if User.objects.filter(username=payload.username).exists():
|
||||
raise HttpError(400, "用户已存在")
|
||||
User.objects.create_user(
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
)
|
||||
return {"message": "创建成功"}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def user_login(request, payload: UserLoginSchema):
|
||||
user = authenticate(username=payload.username, password=payload.password)
|
||||
if user:
|
||||
login(request, user)
|
||||
return {"username": user.username, "role": user.role}
|
||||
else:
|
||||
raise HttpError(401, "账号密码错误")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@login_required
|
||||
def user_logout(request):
|
||||
logout(request)
|
||||
return {"message": "退出成功"}
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
def my_profile(request):
|
||||
# 首页获取用户状态
|
||||
if request.user.is_authenticated:
|
||||
return {"username": request.user.get_username(), "role": request.user.role}
|
||||
else:
|
||||
return {"username": "", "role": RoleChoices.NORMAL}
|
||||
|
||||
|
||||
@router.get("/list", response=List[UserListSchema])
|
||||
@super_required
|
||||
@paginate
|
||||
def list(request, username: str, role: str = None):
|
||||
# 用户列表
|
||||
users = User.objects.filter(username__icontains=username)
|
||||
if role:
|
||||
users = users.filter(role=role)
|
||||
return [UserListSchema.get(user) for user in users]
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
@super_required
|
||||
@transaction.atomic
|
||||
def batch_create(request, payload: BatchUsersIn):
|
||||
prefix = "web"
|
||||
usernames = []
|
||||
users_to_create = []
|
||||
|
||||
# 生成用户名列表
|
||||
for name in payload.names:
|
||||
username = prefix + payload.classname + name
|
||||
usernames.append(username)
|
||||
|
||||
# 检查是否存在重复用户名
|
||||
existing_users = User.objects.filter(username__in=usernames)
|
||||
if existing_users.exists():
|
||||
raise HttpError(400, "有些用户已经存在,创建失败")
|
||||
|
||||
# 批量创建用户
|
||||
for username in usernames:
|
||||
password = generate_password()
|
||||
user = User(username=username, classname=payload.classname)
|
||||
user.set_password(password)
|
||||
users_to_create.append(user)
|
||||
|
||||
# 使用 bulk_create 批量保存
|
||||
User.objects.bulk_create(users_to_create, ignore_conflicts=True)
|
||||
|
||||
# 返回创建的用户信息
|
||||
return {"message": "批量创建成功"}
|
||||
|
||||
|
||||
@router.put("/active/{id}")
|
||||
@super_required
|
||||
def toggle_user_is_active(request, id: int):
|
||||
# 封号和解封
|
||||
try:
|
||||
user = User.objects.get(id=id)
|
||||
user.is_active = not user.is_active
|
||||
user.save()
|
||||
return {
|
||||
"message": f"{user.username} {'解封' if user.is_active else '封号'}成功"
|
||||
}
|
||||
except User.DoesNotExist:
|
||||
raise HttpError(404, "查无此人")
|
||||
|
||||
|
||||
@router.get("/classes", response=List[str])
|
||||
def list_classes(request):
|
||||
"""返回所有不重复的非空班级名列表,按字典序升序"""
|
||||
return (
|
||||
User.objects.filter(classname__gt="")
|
||||
.values_list("classname", flat=True)
|
||||
.distinct()
|
||||
.order_by("classname")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/names", response=List[ClassStudentEntry])
|
||||
def list_names_by_class(request, classname: str):
|
||||
"""返回指定班级的学生姓名和用户名列表"""
|
||||
prefix = "web" + classname
|
||||
users = User.objects.filter(
|
||||
classname=classname,
|
||||
username__startswith=prefix,
|
||||
).order_by("username")
|
||||
return [
|
||||
ClassStudentEntry(name=u.username[len(prefix):], username=u.username)
|
||||
for u in users
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "account"
|
||||
verbose_name = "用户"
|
||||
@@ -1,28 +0,0 @@
|
||||
from ninja import NinjaAPI
|
||||
from functools import wraps
|
||||
from typing import Callable, List, Any
|
||||
from django.http import HttpRequest
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from .models import User, RoleChoices
|
||||
|
||||
api = NinjaAPI()
|
||||
|
||||
|
||||
def _require(roles: List[RoleChoices]) -> Callable:
|
||||
def check_role(user: User) -> bool:
|
||||
return user.is_authenticated and hasattr(user, "role") and user.role in roles
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
@login_required
|
||||
@user_passes_test(check_role, login_url=None)
|
||||
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
|
||||
return func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
admin_required = _require([RoleChoices.ADMIN, RoleChoices.SUPER])
|
||||
super_required = _require([RoleChoices.SUPER])
|
||||
@@ -1,54 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-07 01:32
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('role', models.CharField(choices=[('super', '超级管理员'), ('admin', '管理员'), ('normal', '普通')], default='normal', max_length=20, verbose_name='权限')),
|
||||
('raw_password', models.CharField(blank=True, max_length=20, null=True, verbose_name='明文密码')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '用户',
|
||||
'verbose_name_plural': '用户',
|
||||
'ordering': ('-id',),
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('total_score', models.IntegerField(default=0)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-09 11:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='total_score',
|
||||
field=models.FloatField(default=0.0),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-18 02:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0002_alter_profile_total_score'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='classname',
|
||||
field=models.CharField(blank=True, default='', max_length=50, verbose_name='班级'),
|
||||
),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-18 10:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0003_user_classname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Profile',
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -1,50 +0,0 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices):
|
||||
SUPER = "super", "超级管理员"
|
||||
ADMIN = "admin", "管理员"
|
||||
NORMAL = "normal", "普通"
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
first_name = None
|
||||
last_name = None
|
||||
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=RoleChoices.choices,
|
||||
default=RoleChoices.NORMAL,
|
||||
verbose_name="权限",
|
||||
)
|
||||
raw_password = models.CharField(
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="明文密码",
|
||||
)
|
||||
classname = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="班级",
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.username:
|
||||
if self.is_superuser:
|
||||
self.role = RoleChoices.SUPER
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def set_password(self, raw_password):
|
||||
super().set_password(raw_password)
|
||||
self.raw_password = raw_password
|
||||
self.save()
|
||||
|
||||
class Meta:
|
||||
ordering = ("-id",)
|
||||
verbose_name = "用户"
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
from typing import List
|
||||
from ninja import Schema, ModelSchema
|
||||
from pydantic import EmailStr, Field
|
||||
|
||||
from .models import User, RoleChoices
|
||||
|
||||
|
||||
class UserListSchema(ModelSchema):
|
||||
@classmethod
|
||||
def get(cls, obj):
|
||||
raw_password = obj.raw_password if obj.role != RoleChoices.SUPER else ""
|
||||
return cls(
|
||||
id=obj.id,
|
||||
username=obj.username,
|
||||
raw_password=raw_password,
|
||||
role=obj.role,
|
||||
date_joined=obj.date_joined,
|
||||
last_login=obj.last_login,
|
||||
is_active=obj.is_active,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"raw_password",
|
||||
"role",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"is_active",
|
||||
]
|
||||
|
||||
|
||||
class UserRegistrationSchema(Schema):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=6, max_length=20)
|
||||
|
||||
|
||||
class UserLoginSchema(Schema):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class BatchUsersIn(Schema):
|
||||
names: List[str]
|
||||
classname: str
|
||||
|
||||
|
||||
class ClassStudentEntry(Schema):
|
||||
name: str
|
||||
username: str
|
||||
Whitespace-only changes.
-30
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
ASGI config for api project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.security.websocket import AllowedHostsOriginValidator
|
||||
from prompt.url import websocket_urlpatterns
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
"http": django_asgi_app,
|
||||
"websocket": AllowedHostsOriginValidator(
|
||||
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
|
||||
),
|
||||
}
|
||||
)
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
Django settings for api project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.1.6.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.1/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
DEV = os.getenv("ENV") != "production"
|
||||
|
||||
if DEV:
|
||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:3000"]
|
||||
else:
|
||||
ALLOWED_HOSTS = ["web.xuyue.cc", "10.13.114.114"]
|
||||
CSRF_TRUSTED_ORIGINS = ["https://web.xuyue.cc", "http://10.13.114.114:91"]
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"daphne",
|
||||
"channels",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"ninja",
|
||||
"corsheaders",
|
||||
"django_extensions",
|
||||
"account",
|
||||
"task",
|
||||
"submission",
|
||||
"prompt",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "api.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "api.wsgi.application"
|
||||
ASGI_APPLICATION = "api.asgi.application"
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
DEV_DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
PROD_DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.getenv("POSTGRES_DB"),
|
||||
"USER": os.getenv("POSTGRES_USER"),
|
||||
"PASSWORD": os.getenv("POSTGRES_PASSWORD"),
|
||||
"HOST": os.getenv("POSTGRES_HOST"),
|
||||
"PORT": os.getenv("POSTGRES_PORT", "5432"),
|
||||
},
|
||||
}
|
||||
|
||||
PROD_CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": os.getenv("REDIS_URL"),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if DEV:
|
||||
DATABASES = DEV_DATABASES
|
||||
else:
|
||||
# DATABASES = DEV_DATABASES
|
||||
DATABASES = PROD_DATABASES
|
||||
# 使用 Redis 作为会话存储
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
# 配置缓存
|
||||
CACHES = PROD_CACHES
|
||||
|
||||
# WebSocket 的缓存
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(os.getenv("REDIS_HOST"), 6379)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
# },
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
"OPTIONS": {"min_length": 6},
|
||||
},
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
# },
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
# },
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "zh-hans"
|
||||
|
||||
TIME_ZONE = "Asia/Shanghai"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = "django_static/"
|
||||
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
AUTH_USER_MODEL = "account.User"
|
||||
|
||||
if DEV:
|
||||
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
|
||||
else:
|
||||
CORS_ALLOWED_ORIGINS = ["https://web.xuyue.cc"]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
NINJA_PAGINATION_CLASS = "ninja.pagination.PageNumberPagination"
|
||||
|
||||
NINJA_PAGINATION_PER_PAGE = 10
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# LLM Configuration
|
||||
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
|
||||
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-v4-flash")
|
||||
|
||||
# ARK (Volcengine) LLM Configuration
|
||||
ARK_API_KEY = os.environ.get("ARK_API_KEY", "")
|
||||
ARK_BASE_URL = os.environ.get("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
URL configuration for api project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from ninja import NinjaAPI
|
||||
|
||||
api = NinjaAPI()
|
||||
|
||||
api.add_router("account/", "account.api.router")
|
||||
api.add_router("tutorial/", "task.tutorial.router")
|
||||
api.add_router("challenge/", "task.challenge.router")
|
||||
api.add_router("submission/", "submission.api.router")
|
||||
api.add_router("upload/", "utils.upload.router")
|
||||
api.add_router("assets/", "task.assets.router")
|
||||
api.add_router("prompt/", "prompt.api.router")
|
||||
|
||||
|
||||
apis = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", api.urls),
|
||||
]
|
||||
|
||||
urlpatterns = apis + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
WSGI config for api project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'api.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,954 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "webpreview",
|
||||
},
|
||||
"client": {
|
||||
"name": "webpreview-client",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@vueuse/core": "^15.0.0",
|
||||
"axios": "^1.20.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"marked": "^18.1.1",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-code-preview": "^1.3.7",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"md-editor-v3": "^7.1.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
"vue": "^3.5.44",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-router": "^5.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.9.10",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.3.5",
|
||||
"vue-tsc": "^3.3.13",
|
||||
},
|
||||
},
|
||||
"server": {
|
||||
"name": "webpreview-server",
|
||||
"dependencies": {
|
||||
"hono": "^4.13.14",
|
||||
"zod": "^4.6.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "https://registry.npmjs.com/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "https://registry.npmjs.com/@babel/compat-data/-/compat-data-7.29.7.tgz", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "https://registry.npmjs.com/@babel/core/-/core-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.8", "https://registry.npmjs.com/@babel/generator/-/generator-7.29.8.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "https://registry.npmjs.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "https://registry.npmjs.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "https://registry.npmjs.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
|
||||
|
||||
"@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.10", "https://registry.npmjs.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.10.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.5.3", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-/YY9soQuWa8TURtHXHT+GbklpbAA+dZErxFGHIr6qgDg96xuGWrTAs1+MfjgVJPF0YLQsOVsOYs8lhgsPbOinQ=="],
|
||||
|
||||
"@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "https://registry.npmjs.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "https://registry.npmjs.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "https://registry.npmjs.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "https://registry.npmjs.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "https://registry.npmjs.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "https://registry.npmjs.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "https://registry.npmjs.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "https://registry.npmjs.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="],
|
||||
|
||||
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "https://registry.npmjs.com/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
|
||||
|
||||
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "https://registry.npmjs.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "https://registry.npmjs.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "https://registry.npmjs.com/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.10", "https://registry.npmjs.com/@babel/helpers/-/helpers-7.29.10.tgz", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.8", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.8.tgz", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w=="],
|
||||
|
||||
"@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ=="],
|
||||
|
||||
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ=="],
|
||||
|
||||
"@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": ["@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA=="],
|
||||
|
||||
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw=="],
|
||||
|
||||
"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw=="],
|
||||
|
||||
"@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "https://registry.npmjs.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="],
|
||||
|
||||
"@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.29.7", "https://registry.npmjs.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw=="],
|
||||
|
||||
"@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.29.7", "https://registry.npmjs.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg=="],
|
||||
|
||||
"@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "https://registry.npmjs.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="],
|
||||
|
||||
"@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="],
|
||||
|
||||
"@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="],
|
||||
|
||||
"@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="],
|
||||
|
||||
"@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA=="],
|
||||
|
||||
"@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.11", "https://registry.npmjs.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.11.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iARF4jjHSM0v8DImCACPXrwEltBP3DgqDpXTvjAMBAVpyEY0LiGss78wvI+KtmW+IJNEvZ5sOhPDCsOsXsIybA=="],
|
||||
|
||||
"@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="],
|
||||
|
||||
"@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="],
|
||||
|
||||
"@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.11", "https://registry.npmjs.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.11.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.10" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-f8kTDM/nTvpLeafb+TJ4jboFr/Y3BbC+QzgPpPGzSKJqWLnj9LW0WLqv4XKA11LX5sr+EgvLE6Ix3YsCEusodw=="],
|
||||
|
||||
"@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/template": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA=="],
|
||||
|
||||
"@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="],
|
||||
|
||||
"@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ=="],
|
||||
|
||||
"@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ=="],
|
||||
|
||||
"@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA=="],
|
||||
|
||||
"@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg=="],
|
||||
|
||||
"@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw=="],
|
||||
|
||||
"@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ=="],
|
||||
|
||||
"@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="],
|
||||
|
||||
"@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="],
|
||||
|
||||
"@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg=="],
|
||||
|
||||
"@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg=="],
|
||||
|
||||
"@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw=="],
|
||||
|
||||
"@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="],
|
||||
|
||||
"@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg=="],
|
||||
|
||||
"@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew=="],
|
||||
|
||||
"@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
|
||||
|
||||
"@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.29.8", "https://registry.npmjs.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.8" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA=="],
|
||||
|
||||
"@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA=="],
|
||||
|
||||
"@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="],
|
||||
|
||||
"@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A=="],
|
||||
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="],
|
||||
|
||||
"@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw=="],
|
||||
|
||||
"@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="],
|
||||
|
||||
"@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA=="],
|
||||
|
||||
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="],
|
||||
|
||||
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="],
|
||||
|
||||
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="],
|
||||
|
||||
"@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="],
|
||||
|
||||
"@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="],
|
||||
|
||||
"@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA=="],
|
||||
|
||||
"@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.8", "https://registry.npmjs.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg=="],
|
||||
|
||||
"@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ=="],
|
||||
|
||||
"@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA=="],
|
||||
|
||||
"@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="],
|
||||
|
||||
"@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.29.11", "https://registry.npmjs.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.11.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5xWGhfuMgbOfIjsk/I2c4uMnoqRwQdKYi4QjvybQrcEVE0G9/p3qmIkp2Sxe8YF8DGNNVs5whfwNqIM5684XoQ=="],
|
||||
|
||||
"@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA=="],
|
||||
|
||||
"@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="],
|
||||
|
||||
"@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A=="],
|
||||
|
||||
"@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA=="],
|
||||
|
||||
"@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw=="],
|
||||
|
||||
"@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="],
|
||||
|
||||
"@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg=="],
|
||||
|
||||
"@babel/preset-env": ["@babel/preset-env@7.29.7", "https://registry.npmjs.com/@babel/preset-env/-/preset-env-7.29.7.tgz", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.29.7", "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.29.7", "@babel/plugin-transform-async-generator-functions": "^7.29.7", "@babel/plugin-transform-async-to-generator": "^7.29.7", "@babel/plugin-transform-block-scoped-functions": "^7.29.7", "@babel/plugin-transform-block-scoping": "^7.29.7", "@babel/plugin-transform-class-properties": "^7.29.7", "@babel/plugin-transform-class-static-block": "^7.29.7", "@babel/plugin-transform-classes": "^7.29.7", "@babel/plugin-transform-computed-properties": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-dotall-regex": "^7.29.7", "@babel/plugin-transform-duplicate-keys": "^7.29.7", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-explicit-resource-management": "^7.29.7", "@babel/plugin-transform-exponentiation-operator": "^7.29.7", "@babel/plugin-transform-export-namespace-from": "^7.29.7", "@babel/plugin-transform-for-of": "^7.29.7", "@babel/plugin-transform-function-name": "^7.29.7", "@babel/plugin-transform-json-strings": "^7.29.7", "@babel/plugin-transform-literals": "^7.29.7", "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", "@babel/plugin-transform-member-expression-literals": "^7.29.7", "@babel/plugin-transform-modules-amd": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.7", "@babel/plugin-transform-modules-umd": "^7.29.7", "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-new-target": "^7.29.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", "@babel/plugin-transform-numeric-separator": "^7.29.7", "@babel/plugin-transform-object-rest-spread": "^7.29.7", "@babel/plugin-transform-object-super": "^7.29.7", "@babel/plugin-transform-optional-catch-binding": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/plugin-transform-private-methods": "^7.29.7", "@babel/plugin-transform-private-property-in-object": "^7.29.7", "@babel/plugin-transform-property-literals": "^7.29.7", "@babel/plugin-transform-regenerator": "^7.29.7", "@babel/plugin-transform-regexp-modifiers": "^7.29.7", "@babel/plugin-transform-reserved-words": "^7.29.7", "@babel/plugin-transform-shorthand-properties": "^7.29.7", "@babel/plugin-transform-spread": "^7.29.7", "@babel/plugin-transform-sticky-regex": "^7.29.7", "@babel/plugin-transform-template-literals": "^7.29.7", "@babel/plugin-transform-typeof-symbol": "^7.29.7", "@babel/plugin-transform-unicode-escapes": "^7.29.7", "@babel/plugin-transform-unicode-property-regex": "^7.29.7", "@babel/plugin-transform-unicode-regex": "^7.29.7", "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", "babel-plugin-polyfill-regenerator": "^0.6.6", "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA=="],
|
||||
|
||||
"@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "https://registry.npmjs.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "https://registry.npmjs.com/@babel/template/-/template-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.10", "https://registry.npmjs.com/@babel/traverse/-/traverse-7.29.10.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "https://registry.npmjs.com/@babel/types/-/types-7.29.8.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.3", "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
||||
|
||||
"@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "https://registry.npmmirror.com/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="],
|
||||
|
||||
"@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "https://registry.npmmirror.com/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="],
|
||||
|
||||
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "https://registry.npmmirror.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
|
||||
|
||||
"@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "https://registry.npmmirror.com/@codemirror/lang-go/-/lang-go-6.0.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="],
|
||||
|
||||
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "https://registry.npmjs.com/@codemirror/lang-html/-/lang-html-6.4.12.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="],
|
||||
|
||||
"@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-java/-/lang-java-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="],
|
||||
|
||||
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
|
||||
|
||||
"@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.1", "https://registry.npmmirror.com/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw=="],
|
||||
|
||||
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-json/-/lang-json-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
|
||||
|
||||
"@codemirror/lang-less": ["@codemirror/lang-less@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-less/-/lang-less-6.0.2.tgz", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ=="],
|
||||
|
||||
"@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.2", "https://registry.npmmirror.com/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw=="],
|
||||
|
||||
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "https://registry.npmmirror.com/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="],
|
||||
|
||||
"@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-php/-/lang-php-6.0.2.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="],
|
||||
|
||||
"@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="],
|
||||
|
||||
"@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="],
|
||||
|
||||
"@codemirror/lang-sass": ["@codemirror/lang-sass@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/sass": "^1.0.0" } }, "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q=="],
|
||||
|
||||
"@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="],
|
||||
|
||||
"@codemirror/lang-vue": ["@codemirror/lang-vue@0.1.3", "https://registry.npmmirror.com/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug=="],
|
||||
|
||||
"@codemirror/lang-wast": ["@codemirror/lang-wast@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q=="],
|
||||
|
||||
"@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "https://registry.npmmirror.com/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="],
|
||||
|
||||
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "https://registry.npmmirror.com/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.12.3", "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.3.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
|
||||
|
||||
"@codemirror/language-data": ["@codemirror/language-data@6.5.2", "https://registry.npmmirror.com/@codemirror/language-data/-/language-data-6.5.2.tgz", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="],
|
||||
|
||||
"@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="],
|
||||
|
||||
"@codemirror/lint": ["@codemirror/lint@6.9.5", "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.5.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="],
|
||||
|
||||
"@codemirror/search": ["@codemirror/search@6.7.0", "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.0.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/vue3-ssr": ["@css-render/vue3-ssr@0.15.14", "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", { "peerDependencies": { "vue": "^3.0.11" } }, "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g=="],
|
||||
|
||||
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "https://registry.npmjs.com/@jridgewell/source-map/-/source-map-0.3.11.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "https://registry.npmjs.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
||||
|
||||
"@lezer/common": ["@lezer/common@1.5.2", "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
|
||||
|
||||
"@lezer/cpp": ["@lezer/cpp@1.1.5", "https://registry.npmmirror.com/@lezer/cpp/-/cpp-1.1.5.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw=="],
|
||||
|
||||
"@lezer/css": ["@lezer/css@1.3.3", "https://registry.npmmirror.com/@lezer/css/-/css-1.3.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="],
|
||||
|
||||
"@lezer/go": ["@lezer/go@1.0.1", "https://registry.npmmirror.com/@lezer/go/-/go-1.0.1.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="],
|
||||
|
||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
"@lezer/html": ["@lezer/html@1.3.13", "https://registry.npmmirror.com/@lezer/html/-/html-1.3.13.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
|
||||
|
||||
"@lezer/java": ["@lezer/java@1.1.3", "https://registry.npmmirror.com/@lezer/java/-/java-1.1.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw=="],
|
||||
|
||||
"@lezer/javascript": ["@lezer/javascript@1.5.4", "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
|
||||
|
||||
"@lezer/json": ["@lezer/json@1.0.3", "https://registry.npmmirror.com/@lezer/json/-/json-1.0.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
|
||||
|
||||
"@lezer/lr": ["@lezer/lr@1.4.10", "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||
|
||||
"@lezer/markdown": ["@lezer/markdown@1.6.3", "https://registry.npmmirror.com/@lezer/markdown/-/markdown-1.6.3.tgz", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw=="],
|
||||
|
||||
"@lezer/php": ["@lezer/php@1.0.5", "https://registry.npmmirror.com/@lezer/php/-/php-1.0.5.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA=="],
|
||||
|
||||
"@lezer/python": ["@lezer/python@1.1.18", "https://registry.npmmirror.com/@lezer/python/-/python-1.1.18.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg=="],
|
||||
|
||||
"@lezer/rust": ["@lezer/rust@1.0.2", "https://registry.npmmirror.com/@lezer/rust/-/rust-1.0.2.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg=="],
|
||||
|
||||
"@lezer/sass": ["@lezer/sass@1.1.0", "https://registry.npmmirror.com/@lezer/sass/-/sass-1.1.0.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ=="],
|
||||
|
||||
"@lezer/xml": ["@lezer/xml@1.0.6", "https://registry.npmmirror.com/@lezer/xml/-/xml-1.0.6.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="],
|
||||
|
||||
"@lezer/yaml": ["@lezer/yaml@1.0.4", "https://registry.npmmirror.com/@lezer/yaml/-/yaml-1.0.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
|
||||
|
||||
"@lucide/vue": ["@lucide/vue@1.31.0", "https://registry.npmjs.com/@lucide/vue/-/vue-1.31.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg=="],
|
||||
|
||||
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.155.0", "https://registry.npmjs.com/@oxc-project/types/-/types-0.155.0.tgz", {}, "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.15", "https://registry.npmjs.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", { "os": "android", "cpu": "arm" }, "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.15", "https://registry.npmjs.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", { "os": "android", "cpu": "arm64" }, "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.15", "https://registry.npmjs.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.15", "https://registry.npmjs.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.15", "https://registry.npmjs.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.15", "https://registry.npmjs.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", { "os": "linux", "cpu": "arm" }, "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.15", "https://registry.npmjs.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.15", "https://registry.npmjs.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.15", "https://registry.npmjs.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.15", "https://registry.npmjs.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.15", "https://registry.npmjs.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.15", "https://registry.npmjs.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.15", "https://registry.npmjs.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", { "os": "none", "cpu": "arm64" }, "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.15", "https://registry.npmjs.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.15", "https://registry.npmjs.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.4", "https://registry.npmjs.com/@types/bun/-/bun-1.4.4.tgz", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmjs.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.24", "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
||||
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||
|
||||
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||
|
||||
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||
|
||||
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||
|
||||
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||
|
||||
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||
|
||||
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||
|
||||
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||
|
||||
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||
|
||||
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||
|
||||
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||
|
||||
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||
|
||||
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||
|
||||
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||
|
||||
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||
|
||||
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||
|
||||
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"@vavt/copy2clipboard": ["@vavt/copy2clipboard@1.0.3", "https://registry.npmmirror.com/@vavt/copy2clipboard/-/copy2clipboard-1.0.3.tgz", {}, "sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA=="],
|
||||
|
||||
"@vavt/markdown-theme": ["@vavt/markdown-theme@6.0.1", "https://registry.npmjs.com/@vavt/markdown-theme/-/markdown-theme-6.0.1.tgz", {}, "sha512-S6HClmYtnrBuAm+6aeAKRtMwy6cTv+7QlGQYlpNHwumoFr3x86X6uGtJbvrv3ReoKx40vCOJR7h+1OiflKJOWA=="],
|
||||
|
||||
"@vavt/util": ["@vavt/util@2.1.2", "https://registry.npmmirror.com/@vavt/util/-/util-2.1.2.tgz", {}, "sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw=="],
|
||||
|
||||
"@vitejs/plugin-legacy": ["@vitejs/plugin-legacy@8.2.3", "https://registry.npmjs.com/@vitejs/plugin-legacy/-/plugin-legacy-8.2.3.tgz", { "dependencies": { "@babel/core": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.8", "@babel/preset-env": "^7.29.7", "babel-plugin-polyfill-corejs3": "^0.14.2", "babel-plugin-polyfill-regenerator": "^0.6.8", "browserslist": "^4.28.7", "browserslist-to-esbuild": "^2.1.1", "core-js": "^3.49.0", "magic-string": "^1.1.0", "regenerator-runtime": "^0.14.1", "systemjs": "^6.15.1" }, "peerDependencies": { "terser": "^5.16.0", "vite": "^8.0.0" } }, "sha512-MMHLsn/0FrBkCQyb1x44cPYGwkfqcGr2FblcEP2ne79aXe7d5tFKoZ09xs5C4wuh8fOu2JRaNhgAS3K3uvUeJA=="],
|
||||
|
||||
"@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/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-dom": ["@vue/compiler-dom@3.5.44", "https://registry.npmjs.com/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "https://registry.npmjs.com/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@8.2.1", "https://registry.npmjs.com/@vue/devtools-api/-/devtools-api-8.2.1.tgz", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
|
||||
|
||||
"@vue/devtools-kit": ["@vue/devtools-kit@8.2.1", "https://registry.npmjs.com/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", { "dependencies": { "@vue/devtools-shared": "^8.2.1", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ=="],
|
||||
|
||||
"@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/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-dom": ["@vue/runtime-dom@3.5.44", "https://registry.npmjs.com/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "https://registry.npmjs.com/@vue/server-renderer/-/server-renderer-3.5.44.tgz", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.44", "https://registry.npmjs.com/@vue/shared/-/shared-3.5.44.tgz", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
|
||||
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@15.0.0", "https://registry.npmjs.com/@vueuse/core/-/core-15.0.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "15.0.0", "@vueuse/shared": "15.0.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@15.0.0", "https://registry.npmjs.com/@vueuse/metadata/-/metadata-15.0.0.tgz", {}, "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@15.0.0", "https://registry.npmjs.com/@vueuse/shared/-/shared-15.0.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-walker-scope": ["ast-walker-scope@0.10.0", "https://registry.npmjs.com/ast-walker-scope/-/ast-walker-scope-0.10.0.tgz", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "ast-kit": "^3.0.0" } }, "sha512-KzvoxDfXNz2Gm8KYLh47K3qcY3Zza3WKOuh2xoqa7LBu6MDKWAZLovJtU9DuAd/Er+rhQHCRn619lHS5U+qmhw=="],
|
||||
|
||||
"async-validator": ["async-validator@4.2.5", "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "https://registry.npmjs.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"attributes-parser": ["attributes-parser@2.2.3", "https://registry.npmmirror.com/attributes-parser/-/attributes-parser-2.2.3.tgz", { "dependencies": { "json-loose": "^1.2.4" } }, "sha512-zjOUWt95la8AdUO+kP1GBOonWrV5jy9NjJP+z9tva/DSA6FIzGKcN/gk3tdqQf/pOeB8dkyd3FCPrjhELMmrkg=="],
|
||||
|
||||
"axios": ["axios@1.20.0", "https://registry.npmjs.com/axios/-/axios-1.20.0.tgz", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
|
||||
|
||||
"babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "https://registry.npmjs.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="],
|
||||
|
||||
"babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.2", "https://registry.npmjs.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g=="],
|
||||
|
||||
"babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "https://registry.npmjs.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.33", "https://registry.npmjs.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w=="],
|
||||
|
||||
"birpc": ["birpc@2.9.0", "https://registry.npmjs.com/birpc/-/birpc-2.9.0.tgz", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.29.4", "https://registry.npmjs.com/browserslist/-/browserslist-4.29.4.tgz", { "dependencies": { "baseline-browser-mapping": "^2.11.29", "caniuse-lite": "^1.0.30001816", "electron-to-chromium": "^1.5.459", "node-releases": "^2.0.59", "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA=="],
|
||||
|
||||
"browserslist-to-esbuild": ["browserslist-to-esbuild@2.1.1", "https://registry.npmjs.com/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz", { "dependencies": { "meow": "^13.0.0" }, "peerDependencies": { "browserslist": "*" }, "bin": { "browserslist-to-esbuild": "cli/index.js" } }, "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.4", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.4.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmjs.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001819", "https://registry.npmjs.com/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", {}, "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"codemirror": ["codemirror@6.0.2", "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmjs.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"confbox": ["confbox@0.3.1", "https://registry.npmjs.com/confbox/-/confbox-0.3.1.tgz", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmjs.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"copy-text-to-clipboard": ["copy-text-to-clipboard@3.2.2", "https://registry.npmmirror.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz", {}, "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A=="],
|
||||
|
||||
"core-js": ["core-js@3.51.0", "https://registry.npmjs.com/core-js/-/core-js-3.51.0.tgz", { "hasInstallScript": true }, "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw=="],
|
||||
|
||||
"core-js-compat": ["core-js-compat@3.51.0", "https://registry.npmjs.com/core-js-compat/-/core-js-compat-3.51.0.tgz", { "dependencies": { "browserslist": "^4.29.3" } }, "sha512-52r6fkXgxv4GLMjmFcMWRx7X9bw071xTTdrDcdyPGRqU2oE+9pCG0bAOMifSBdLw0JRBZKEF0q3B55qPlLuE8g=="],
|
||||
|
||||
"crelt": ["crelt@1.0.6", "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
|
||||
|
||||
"css-render": ["css-render@0.15.14", "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
|
||||
|
||||
"cssfilter": ["cssfilter@0.0.10", "https://registry.npmmirror.com/cssfilter/-/cssfilter-0.0.10.tgz", {}, "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"date-fns-tz": ["date-fns-tz@3.2.0", "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmjs.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmjs.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmjs.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmjs.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.464", "https://registry.npmjs.com/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", {}, "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmjs.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmjs.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "https://registry.npmjs.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmjs.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmjs.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmjs.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "https://registry.npmjs.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"evtd": ["evtd@0.2.4", "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz", {}, "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw=="],
|
||||
|
||||
"exsolve": ["exsolve@1.1.1", "https://registry.npmjs.com/exsolve/-/exsolve-1.1.1.tgz", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.6", "https://registry.npmjs.com/form-data/-/form-data-4.0.6.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmjs.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin", "hasInstallScript": true }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmjs.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmjs.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "https://registry.npmjs.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"github-markdown-css": ["github-markdown-css@5.9.0", "https://registry.npmmirror.com/github-markdown-css/-/github-markdown-css-5.9.0.tgz", {}, "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "https://registry.npmjs.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmjs.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmjs.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "https://registry.npmjs.com/hasown/-/hasown-2.0.4.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.12.0", "https://registry.npmjs.com/highlight.js/-/highlight.js-11.12.0.tgz", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
|
||||
|
||||
"hono": ["hono@4.13.14", "https://registry.npmjs.com/hono/-/hono-4.13.14.tgz", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "https://registry.npmjs.com/hookable/-/hookable-5.5.3.tgz", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "https://registry.npmjs.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.17.0", "https://registry.npmjs.com/is-core-module/-/is-core-module-2.17.0.tgz", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "https://registry.npmjs.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-loose": ["json-loose@1.2.4", "https://registry.npmmirror.com/json-loose/-/json-loose-1.2.4.tgz", { "dependencies": { "moo": "^0.5.2" } }, "sha512-lwMWNC5pvVI33rhYWmAsmtICWE2IH7euDY/iIPeMFE5AuzAifYgqQrjqSMzwbrFV6MWPs41XD+CajElHI4cZMQ=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "https://registry.npmjs.com/lightningcss/-/lightningcss-1.33.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "https://registry.npmjs.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "https://registry.npmjs.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "https://registry.npmjs.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "https://registry.npmjs.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "https://registry.npmjs.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "https://registry.npmjs.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "https://registry.npmjs.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "https://registry.npmjs.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "https://registry.npmjs.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "https://registry.npmjs.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "https://registry.npmjs.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"linkify-it": ["linkify-it@5.0.0", "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||
|
||||
"local-pkg": ["local-pkg@1.2.1", "https://registry.npmjs.com/local-pkg/-/local-pkg-1.2.1.tgz", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "https://registry.npmjs.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.1", "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": "bin/markdown-it.mjs" }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
|
||||
"markdown-it-image-figures": ["markdown-it-image-figures@2.1.1", "https://registry.npmmirror.com/markdown-it-image-figures/-/markdown-it-image-figures-2.1.1.tgz", { "peerDependencies": { "markdown-it": "*" } }, "sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g=="],
|
||||
|
||||
"markdown-it-sub": ["markdown-it-sub@2.0.0", "https://registry.npmmirror.com/markdown-it-sub/-/markdown-it-sub-2.0.0.tgz", {}, "sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA=="],
|
||||
|
||||
"markdown-it-sup": ["markdown-it-sup@2.0.0", "https://registry.npmmirror.com/markdown-it-sup/-/markdown-it-sup-2.0.0.tgz", {}, "sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA=="],
|
||||
|
||||
"marked": ["marked@18.1.1", "https://registry.npmjs.com/marked/-/marked-18.1.1.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="],
|
||||
|
||||
"marked-alert": ["marked-alert@2.1.2", "https://registry.npmmirror.com/marked-alert/-/marked-alert-2.1.2.tgz", { "peerDependencies": { "marked": ">=7.0.0" } }, "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w=="],
|
||||
|
||||
"marked-code-preview": ["marked-code-preview@1.3.7", "https://registry.npmmirror.com/marked-code-preview/-/marked-code-preview-1.3.7.tgz", { "dependencies": { "attributes-parser": "^2.2.3" }, "peerDependencies": { "marked": ">=7.0.0" } }, "sha512-2XvsOKwunF0gWnAC2S4LijxhuKtfb8TpCuAboMs/6W+4lpkI7LgFQjcivUsActxMQbLuaaxOGIp8XkJJF3QmTQ=="],
|
||||
|
||||
"marked-highlight": ["marked-highlight@2.2.4", "https://registry.npmmirror.com/marked-highlight/-/marked-highlight-2.2.4.tgz", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmjs.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"md-editor-v3": ["md-editor-v3@7.1.0", "https://registry.npmjs.com/md-editor-v3/-/md-editor-v3-7.1.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.8.1", "@codemirror/lang-markdown": "^6.3.4", "@codemirror/language": "^6.11.3", "@codemirror/language-data": "^6.5.1", "@codemirror/search": "^6.5.11", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.2", "@lezer/highlight": "^1.2.1", "@lucide/vue": "^1.16.0", "@types/markdown-it": "^14.0.1", "@vavt/copy2clipboard": "^1.0.1", "@vavt/markdown-theme": "^6.0.0", "@vavt/util": "^2.1.2", "codemirror": "^6.0.2", "json5": "^2.2.3", "markdown-it": "^14.0.0", "markdown-it-image-figures": "^2.1.1", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "medium-zoom": "^1.1.0", "xss": "^1.0.15" }, "peerDependencies": { "vue": "^3.5.3" } }, "sha512-+byIdwES9aGEeDDxcxx7LiVX8g31VgwJqETtfFvGjsGuwEAuMeuPZ6vV8/vltNa+SCBrigbwQxLb1R47mxaWfg=="],
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"medium-zoom": ["medium-zoom@1.1.0", "https://registry.npmmirror.com/medium-zoom/-/medium-zoom-1.1.0.tgz", {}, "sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ=="],
|
||||
|
||||
"meow": ["meow@13.2.0", "https://registry.npmjs.com/meow/-/meow-13.2.0.tgz", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "https://registry.npmjs.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"mlly": ["mlly@1.8.2", "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
|
||||
|
||||
"moo": ["moo@0.5.3", "https://registry.npmmirror.com/moo/-/moo-0.5.3.tgz", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://registry.npmjs.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"naive-ui": ["naive-ui@2.45.3", "https://registry.npmjs.com/naive-ui/-/naive-ui-2.45.3.tgz", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.20", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.20.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.60", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.60.tgz", {}, "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ=="],
|
||||
|
||||
"nostics": ["nostics@1.3.0", "https://registry.npmjs.com/nostics/-/nostics-1.3.0.tgz", {}, "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@2.1.0", "https://registry.npmjs.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmjs.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.1", "https://registry.npmjs.com/pkg-types/-/pkg-types-2.3.1.tgz", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.31", "https://registry.npmjs.com/postcss/-/postcss-8.5.31.tgz", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
|
||||
|
||||
"prettier": ["prettier@3.9.10", "https://registry.npmjs.com/prettier/-/prettier-3.9.10.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode.js": ["punycode.js@2.3.1", "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "https://registry.npmjs.com/quansync/-/quansync-0.2.11.tgz", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"regenerate": ["regenerate@1.5.0", "https://registry.npmjs.com/regenerate/-/regenerate-1.5.0.tgz", {}, "sha512-1s+CxXjJ71i7LNVy8nYwg47XV4y6lAnOEhsMtSKq3WpmPfhl9LuKlTqKIn/3yUpE8lBz+W+TT88Bxyhnr0U4yw=="],
|
||||
|
||||
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.3.0", "https://registry.npmjs.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.3.0.tgz", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.14.1", "https://registry.npmjs.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
|
||||
|
||||
"regexpu-core": ["regexpu-core@6.5.3", "https://registry.npmjs.com/regexpu-core/-/regexpu-core-6.5.3.tgz", { "dependencies": { "regenerate": "^1.5.0", "regenerate-unicode-properties": "^10.3.0", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-E8VgUoSK+LMQrLboDxavm76MTBGrQhx9RiNuomRmYnZ10Uw12HrI8fkxOOncktb2hBpzIZ9SV/347euQHG5z+g=="],
|
||||
|
||||
"regjsgen": ["regjsgen@0.8.0", "https://registry.npmjs.com/regjsgen/-/regjsgen-0.8.0.tgz", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="],
|
||||
|
||||
"regjsparser": ["regjsparser@0.13.4", "https://registry.npmjs.com/regjsparser/-/regjsparser-0.13.4.tgz", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA=="],
|
||||
|
||||
"resolve": ["resolve@1.22.13", "https://registry.npmjs.com/resolve/-/resolve-1.22.13.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.17.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.15", "https://registry.npmjs.com/rolldown/-/rolldown-1.2.15.tgz", { "dependencies": { "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-x64-msvc": "1.2.15" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g=="],
|
||||
|
||||
"scule": ["scule@1.3.0", "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
|
||||
|
||||
"seemly": ["seemly@0.3.10", "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "https://registry.npmjs.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "https://registry.npmjs.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.2.tgz", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "https://registry.npmjs.com/source-map-support/-/source-map-support-0.5.21.tgz", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"style-mod": ["style-mod@4.1.3", "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmjs.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"systemjs": ["systemjs@6.15.1", "https://registry.npmjs.com/systemjs/-/systemjs-6.15.1.tgz", {}, "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA=="],
|
||||
|
||||
"terser": ["terser@5.51.2", "https://registry.npmjs.com/terser/-/terser-5.51.2.tgz", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmjs.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"treemate": ["treemate@0.3.11", "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ufo": ["ufo@1.6.4", "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"undici-types": ["undici-types@8.9.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.9.0.tgz", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "https://registry.npmjs.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
||||
|
||||
"unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "https://registry.npmjs.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="],
|
||||
|
||||
"unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "https://registry.npmjs.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="],
|
||||
|
||||
"unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "https://registry.npmjs.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="],
|
||||
|
||||
"unplugin": ["unplugin@3.3.0", "https://registry.npmjs.com/unplugin/-/unplugin-3.3.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="],
|
||||
|
||||
"unplugin-utils": ["unplugin-utils@0.3.2", "https://registry.npmjs.com/unplugin-utils/-/unplugin-utils-0.3.2.tgz", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.4" } }, "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA=="],
|
||||
|
||||
"unplugin-vue-components": ["unplugin-vue-components@32.1.0", "https://registry.npmjs.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", { "dependencies": { "chokidar": "^5.0.0", "local-pkg": "^1.2.0", "magic-string": "^0.30.21", "mlly": "^1.8.2", "obug": "^2.1.1", "picomatch": "^4.0.4", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "@nuxt/kit": "^3.2.2 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.4", "https://registry.npmjs.com/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
|
||||
|
||||
"vdirs": ["vdirs@0.1.8", "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
|
||||
|
||||
"vite": ["vite@8.3.5", "https://registry.npmjs.com/vite/-/vite-8.3.5.tgz", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
|
||||
|
||||
"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-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-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=="],
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"webpreview-client": ["webpreview-client@workspace:client"],
|
||||
|
||||
"webpreview-server": ["webpreview-server@workspace:server"],
|
||||
|
||||
"xss": ["xss@1.0.15", "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": "bin/xss" }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zod": ["zod@4.6.7", "https://registry.npmjs.com/zod/-/zod-4.6.7.tgz", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||
|
||||
"@babel/traverse/@babel/parser": ["@babel/parser@7.29.11", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.11.tgz", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "https://registry.npmjs.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.11", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.11.tgz", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag=="],
|
||||
|
||||
"@vue/compiler-core/entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.11", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.11.tgz", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag=="],
|
||||
|
||||
"@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/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/ast-kit": ["ast-kit@3.0.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-3.0.0.tgz", { "dependencies": { "@babel/parser": "^8.0.0", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ=="],
|
||||
|
||||
"css-render/csstype": ["csstype@3.0.11", "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
|
||||
|
||||
"magic-string-ast/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=="],
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"pkg-types/confbox": ["confbox@0.2.4", "https://registry.npmjs.com/confbox/-/confbox-0.2.4.tgz", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||
|
||||
"unplugin-vue-components/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=="],
|
||||
|
||||
"vite/picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"vue-router/picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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-dom": ["@vue/compiler-dom@3.5.41", "https://registry.npmjs.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.41", "https://registry.npmjs.com/@vue/shared/-/shared-3.5.41.tgz", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="],
|
||||
|
||||
"@vue-macros/common/@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-macros/common/@vue/compiler-sfc/postcss": ["postcss@8.5.26", "https://registry.npmjs.com/postcss/-/postcss-8.5.26.tgz", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"@vue/compiler-core/@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/compiler-sfc/@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/compiler-sfc/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"ast-walker-scope/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
|
||||
|
||||
"ast-walker-scope/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
|
||||
|
||||
"ast-walker-scope/ast-kit/estree-walker": ["estree-walker@3.0.3", "https://registry.npmjs.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"magic-string-ast/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"unplugin-vue-components/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@babel/traverse/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.11", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", {}, "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core/entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.18", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.18.tgz", { "bin": "bin/nanoid.cjs" }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.11", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", {}, "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.11", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", {}, "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
chrome >= 90
|
||||
@@ -0,0 +1,3 @@
|
||||
PUBLIC_WEB_URL=http://localhost:8000
|
||||
PUBLIC_BASE_URL=http://localhost:8000/api
|
||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=df542e305f27dee6
|
||||
@@ -0,0 +1,2 @@
|
||||
PUBLIC_WEB_URL=https://web.xuyue.cc
|
||||
PUBLIC_BASE_URL=https://web.xuyue.cc/api
|
||||
@@ -0,0 +1,2 @@
|
||||
PUBLIC_WEB_URL=http://10.13.114.114:91
|
||||
PUBLIC_BASE_URL=http://10.13.114.114:91/api
|
||||
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.worktrees/
|
||||
*.local
|
||||
components.d.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
web preview
|
||||
@@ -0,0 +1,2 @@
|
||||
[fmt]
|
||||
semi = false
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="/noto--honeybee.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>前端开发学习平台</title>
|
||||
<link rel="stylesheet" href="/normalize.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "webpreview-client",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --bun vite",
|
||||
"build": "bun --bun vite build",
|
||||
"build:staging": "bun --bun vite build --mode staging",
|
||||
"fmt": "bun fmt src/",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@vueuse/core": "^15.0.0",
|
||||
"axios": "^1.20.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"marked": "^18.1.1",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-code-preview": "^1.3.7",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"md-editor-v3": "^7.1.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
"vue": "^3.5.44",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-router": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.9.10",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.3.5",
|
||||
"vue-tsc": "^3.3.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>初级网络工程师岗位要求</title>
|
||||
<style>
|
||||
body {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin: 20px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
color: #000;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.content {
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="section">
|
||||
<div class="section-title">初级网络工程师岗位要求</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">1. 学历要求</div>
|
||||
<div class="content">
|
||||
通常要求计算机科学、信息技术、通信工程等相关专业的大专或本科学历。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">2. 技术技能</div>
|
||||
<div class="content">
|
||||
<div>熟悉OSI模型、TCP/IP协议、路由与交换等基础概念。</div>
|
||||
<div>能够配置和管理路由器、交换机、防火墙等网络设备。</div>
|
||||
<div>掌握常见网络协议,如HTTP/HTTPS、FTP、DNS、DHCP等。</div>
|
||||
<div>具备基本的网络故障诊断和解决能力。</div>
|
||||
<div>了解基础的网络安全知识,如防火墙配置、VPN、ACL等。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">3. 工具使用</div>
|
||||
<div class="content">
|
||||
<div>熟悉如Wireshark、Nagios、PRTG等工具。</div>
|
||||
<div>了解如Ansible、Puppet等自动化工具。</div>
|
||||
<div>熟悉Windows和Linux操作系统的基本操作。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">4. 软技能</div>
|
||||
<div class="content">
|
||||
<div>能够清晰表达技术问题并与团队有效沟通。</div>
|
||||
<div>具备快速学习新技术的能力。</div>
|
||||
<div>能够在团队中协作完成任务。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">5. 认证要求</div>
|
||||
<div class="content">持有CCNA、HCIA等初级网络认证者优先。</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">6. 工作经验</div>
|
||||
<div class="content">通常要求1-2年相关经验,优秀应届生也可考虑。</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">7. 其他要求</div>
|
||||
<div class="content">
|
||||
<div>具备较强的工作责任心和抗压能力。</div>
|
||||
<div>能够编写技术文档和报告。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">总结</div>
|
||||
<div class="content">
|
||||
初级网络工程师需具备扎实的网络基础知识、设备配置能力、故障排查技能及一定的网络安全知识,同时具备良好的沟通和学习能力,持有相关认证者更具竞争力。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
File renamed without changes.
Vendored
+1
@@ -0,0 +1 @@
|
||||
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||
import Login from "./components/Login.vue"
|
||||
import AppShell from "./components/layout/AppShell.vue"
|
||||
import { computed, onMounted } from "vue"
|
||||
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"
|
||||
|
||||
const route = useRoute()
|
||||
const theme = computed(() => (isDark.value ? darkTheme : null))
|
||||
const themeOverrides = computed(() => naiveOverrides(isDark.value ? dark : light))
|
||||
|
||||
onMounted(loadUser)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
class="myContainer"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:hljs="hljs"
|
||||
:theme="theme"
|
||||
:theme-overrides="themeOverrides"
|
||||
>
|
||||
<n-modal-provider>
|
||||
<n-message-provider :max="1">
|
||||
<n-dialog-provider>
|
||||
<!-- 全屏预览这类页面不要顶栏 -->
|
||||
<router-view v-if="route.meta.bare" />
|
||||
<AppShell v-else />
|
||||
<Login />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-modal-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
<style scoped>
|
||||
.myContainer {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,619 @@
|
||||
import axios from "axios"
|
||||
import { router } from "./router"
|
||||
import type {
|
||||
TutorialIn,
|
||||
ChallengeIn,
|
||||
FlagType,
|
||||
SubmissionOut,
|
||||
PromptMessage,
|
||||
PromptHistoryItem,
|
||||
TaskStatsOut,
|
||||
TaskAsset,
|
||||
AwardSection,
|
||||
AwardManageIn,
|
||||
AwardManageOut,
|
||||
AwardItemIn,
|
||||
AwardItemUpdateIn,
|
||||
AwardItemManageOut,
|
||||
GradebookOut,
|
||||
GradebookQuery,
|
||||
ShowcaseDetail,
|
||||
PromptRound,
|
||||
RandomRatingItem,
|
||||
CreationSlim,
|
||||
CreationDetail,
|
||||
CreationIn,
|
||||
ReviewOptions,
|
||||
ReviewProgress,
|
||||
ReviewStudent,
|
||||
PeerWorks,
|
||||
} from "./utils/type"
|
||||
import { BASE_URL } from "./utils/const"
|
||||
import {
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
hasLogin,
|
||||
refreshAccessToken,
|
||||
setTokens,
|
||||
} from "./token"
|
||||
import { user } from "./store/user"
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
})
|
||||
|
||||
http.interceptors.request.use(async (config) => {
|
||||
const token = await getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(res) => {
|
||||
return res
|
||||
},
|
||||
async (err) => {
|
||||
if (err.response) {
|
||||
// access token 失效(如服务端重启换了密钥):刷新一次后重试
|
||||
if (
|
||||
err.response.status === 401 &&
|
||||
hasLogin() &&
|
||||
!err.config._retried
|
||||
) {
|
||||
err.config._retried = true
|
||||
if (await refreshAccessToken()) return http(err.config)
|
||||
// 刷新失败但 refresh token 还在,说明只是网络问题:保留登录,下次请求再试
|
||||
if (hasLogin()) return Promise.reject(err)
|
||||
}
|
||||
switch (err.response.status) {
|
||||
case 401: // 未授权
|
||||
clearTokens()
|
||||
user.username = ""
|
||||
router.push("/")
|
||||
break
|
||||
case 403: // 禁止访问
|
||||
alert("权限不够,禁止访问")
|
||||
router.push("/")
|
||||
break
|
||||
default:
|
||||
console.error("出现错误:", err.response.status, err.response.data)
|
||||
}
|
||||
} else {
|
||||
console.error("出现错误:", err.message)
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export const Account = {
|
||||
async login(username: string, password: string) {
|
||||
const res = await http.post("/account/login", { username, password })
|
||||
setTokens(res.data)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async logout() {
|
||||
const refresh_token = getRefreshToken()
|
||||
clearTokens()
|
||||
await http.post("/account/logout", { refresh_token })
|
||||
},
|
||||
|
||||
async getMyProfile() {
|
||||
const res = await http.get("/account/profile")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async list(query: { username: string; page: number }) {
|
||||
const res = await http.get("/account/list", {
|
||||
params: query,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
|
||||
async create(payload: {
|
||||
username: string
|
||||
password: string
|
||||
role: string
|
||||
classname: string
|
||||
}) {
|
||||
const res = await http.post("/account/create", payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
payload: {
|
||||
username?: string
|
||||
password?: string
|
||||
role?: string
|
||||
classname?: string
|
||||
},
|
||||
) {
|
||||
const res = await http.put(`/account/${id}`, payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async toggleActive(id: number) {
|
||||
const res = await http.put(`/account/active/${id}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async batchCreate(payload: { classname: string; names: string[] }) {
|
||||
const res = await http.post("/account/batch", payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listClasses(): Promise<string[]> {
|
||||
const res = await http.get("/account/classes")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listNamesByClass(
|
||||
classname: string,
|
||||
): Promise<{ name: string; username: string }[]> {
|
||||
const res = await http.get("/account/names", { params: { classname } })
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export const Tutorial = {
|
||||
async list() {
|
||||
const res = await http.get("/tutorial/list")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async createOrUpdate(payload: TutorialIn) {
|
||||
const res = await http.post("/tutorial/", payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async togglePublic(display: number) {
|
||||
const res = await http.put(`/tutorial/public/${display}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async remove(display: number) {
|
||||
await http.delete(`/tutorial/${display}`)
|
||||
},
|
||||
|
||||
async get(display: number) {
|
||||
const res = await http.get(`/tutorial/${display}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listDisplay() {
|
||||
const res = await http.get("/tutorial/display")
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export const Challenge = {
|
||||
async list() {
|
||||
const res = await http.get("/challenge/list")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async createOrUpdate(payload: ChallengeIn) {
|
||||
const res = await http.post("/challenge/", payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async togglePublic(display: number) {
|
||||
const res = await http.put(`/challenge/public/${display}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async remove(display: number) {
|
||||
await http.delete(`/challenge/${display}`)
|
||||
},
|
||||
|
||||
async get(display: number) {
|
||||
const res = await http.get(`/challenge/${display}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listDisplay() {
|
||||
const res = await http.get("/challenge/display")
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export const Creation = {
|
||||
async listMine(): Promise<CreationSlim[]> {
|
||||
const res = await http.get("/creation/mine")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async create(payload: CreationIn): Promise<CreationDetail> {
|
||||
const res = await http.post("/creation/", payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async get(display: number): Promise<CreationDetail> {
|
||||
const res = await http.get(`/creation/${display}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async update(display: number, payload: CreationIn): Promise<CreationDetail> {
|
||||
const res = await http.put(`/creation/${display}`, payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async saveCode(
|
||||
display: number,
|
||||
code: { html: string; css: string; js: string },
|
||||
): Promise<CreationDetail> {
|
||||
const res = await http.put(`/creation/${display}/code`, code)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async remove(display: number) {
|
||||
const res = await http.delete(`/creation/${display}`)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export const Submission = {
|
||||
async create(
|
||||
taskId: number,
|
||||
code: {
|
||||
html?: string
|
||||
css?: string
|
||||
js?: string
|
||||
prompt?: string
|
||||
},
|
||||
messageId?: number,
|
||||
reflection?: string,
|
||||
) {
|
||||
const { prompt, ...rest } = code
|
||||
const data = {
|
||||
task_id: taskId,
|
||||
...rest,
|
||||
prompt: prompt || null,
|
||||
message_id: messageId ?? null,
|
||||
reflection: reflection || null,
|
||||
}
|
||||
const res = await http.post("/submission/", data)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async list(query: {
|
||||
page: number
|
||||
page_size?: number
|
||||
username?: string
|
||||
user_id?: number
|
||||
flag?: string | null
|
||||
zone?: string | null
|
||||
task_id?: number
|
||||
task_type?: string
|
||||
score_min?: number
|
||||
score_max_exclusive?: number
|
||||
score_lt_threshold?: number
|
||||
ordering?: string
|
||||
grouped?: boolean
|
||||
}) {
|
||||
const res = await http.get("/submission", {
|
||||
params: query,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listByUserTask(userId: number, taskId: number) {
|
||||
const res = await http.get("/submission/by-user-task", {
|
||||
params: { user_id: userId, task_id: taskId },
|
||||
})
|
||||
return res.data as SubmissionOut[]
|
||||
},
|
||||
|
||||
async get(id: string) {
|
||||
const res = await http.get("/submission/" + id)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getPromptChain(id: string): Promise<PromptRound[]> {
|
||||
const res = await http.get(`/submission/${id}/prompt-chain`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const res = await http.delete("/submission/" + id)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async incrementView(id: string) {
|
||||
await http.post(`/submission/${id}/view`)
|
||||
},
|
||||
|
||||
// 返回作者名:互评打分前是匿名的
|
||||
async updateScore(
|
||||
id: string,
|
||||
score: number,
|
||||
): Promise<{ message: string; username?: string }> {
|
||||
const res = await http.put(`/submission/${id}/score`, { score })
|
||||
return res.data
|
||||
},
|
||||
|
||||
async randomForRating(excludeId?: string) {
|
||||
const res = await http.get("/submission/random-for-rating/", {
|
||||
params: excludeId ? { exclude_id: excludeId } : {},
|
||||
})
|
||||
return res.data as RandomRatingItem | null
|
||||
},
|
||||
|
||||
async updateFlag(id: string, flag: FlagType) {
|
||||
const res = await http.put(`/submission/${id}/flag`, { flag })
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (classname) params.classname = classname
|
||||
const res = await http.get(`/submission/stats/${taskId}`, { params })
|
||||
return res.data as TaskStatsOut
|
||||
},
|
||||
}
|
||||
|
||||
function gradebookParams(query: GradebookQuery) {
|
||||
const params: Record<string, string | boolean> = {
|
||||
classname: query.classname,
|
||||
}
|
||||
if (query.task_type) params.task_type = query.task_type
|
||||
if (query.username) params.username = query.username
|
||||
if (query.include_all_tasks) params.include_all_tasks = true
|
||||
return params
|
||||
}
|
||||
|
||||
function filenameFromDisposition(
|
||||
disposition: string | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const match = disposition?.match(/filename\*=UTF-8''([^;]+)/)
|
||||
return match ? decodeURIComponent(match[1]) : fallback
|
||||
}
|
||||
|
||||
export const Gradebook = {
|
||||
async get(query: GradebookQuery): Promise<GradebookOut> {
|
||||
const res = await http.get("/submission/gradebook/", {
|
||||
params: gradebookParams(query),
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
|
||||
async downloadCsv(query: GradebookQuery) {
|
||||
const res = await http.get("/submission/gradebook/export/", {
|
||||
params: gradebookParams(query),
|
||||
responseType: "blob",
|
||||
})
|
||||
const blob = new Blob([res.data], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filenameFromDisposition(
|
||||
res.headers["content-disposition"],
|
||||
`gradebook-${query.classname}.csv`,
|
||||
)
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
|
||||
export const Prompt = {
|
||||
// 保存一个不是 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) {
|
||||
const params: Record<string, number> = {}
|
||||
if (taskId) params.task_id = taskId
|
||||
if (userId) params.user_id = userId
|
||||
return (await http.get("/prompt/conversations/", { params })).data
|
||||
},
|
||||
|
||||
async listHistory(taskId: number): Promise<PromptHistoryItem[]> {
|
||||
const res = await http.get(`/prompt/history/${taskId}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getMessages(conversationId: string): Promise<PromptMessage[]> {
|
||||
return (
|
||||
await http.get<PromptMessage[]>(
|
||||
`/prompt/conversations/${conversationId}/messages/`,
|
||||
)
|
||||
).data
|
||||
},
|
||||
|
||||
async getMessagesByUserTask(
|
||||
taskId: number,
|
||||
userId: number,
|
||||
): Promise<PromptMessage[]> {
|
||||
const convs = await this.listConversations(taskId, userId)
|
||||
if (!convs.length) return []
|
||||
return this.getMessages(convs[0].id)
|
||||
},
|
||||
|
||||
async deleteMessagePair(
|
||||
messageId: number,
|
||||
): Promise<{ deleted: boolean; submission_deleted: boolean }> {
|
||||
const res = await http.delete(`/prompt/messages/${messageId}/pair`)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export const Helper = {
|
||||
async upload(file: File) {
|
||||
const form = new window.FormData()
|
||||
form.append("image", file)
|
||||
const res = await http.post("/upload/", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
return !!res.data.url ? res.data.url : ""
|
||||
},
|
||||
}
|
||||
|
||||
// 老师按“班级 × 任务”批改;学生看本班同学的作品
|
||||
export const 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 = {
|
||||
async list(): Promise<AwardSection[]> {
|
||||
const res = await http.get("/submission/showcase/")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listManageAwards(): Promise<AwardManageOut[]> {
|
||||
const res = await http.get("/submission/showcase/manage/awards")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async createAward(payload: AwardManageIn): Promise<AwardManageOut> {
|
||||
const res = await http.post("/submission/showcase/manage/awards", payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async updateAward(
|
||||
id: number,
|
||||
payload: AwardManageIn,
|
||||
): Promise<AwardManageOut> {
|
||||
const res = await http.put(
|
||||
`/submission/showcase/manage/awards/${id}`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async deleteAward(id: number) {
|
||||
const res = await http.delete(`/submission/showcase/manage/awards/${id}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listAwardItems(id: number): Promise<AwardItemManageOut[]> {
|
||||
const res = await http.get(`/submission/showcase/manage/awards/${id}/items`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async addAwardItem(
|
||||
id: number,
|
||||
payload: AwardItemIn,
|
||||
): Promise<AwardItemManageOut> {
|
||||
const res = await http.post(
|
||||
`/submission/showcase/manage/awards/${id}/items`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async updateAwardItem(
|
||||
itemId: number,
|
||||
payload: AwardItemUpdateIn,
|
||||
): Promise<AwardItemManageOut> {
|
||||
const res = await http.put(
|
||||
`/submission/showcase/manage/items/${itemId}`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async deleteAwardItem(itemId: number) {
|
||||
const res = await http.delete(`/submission/showcase/manage/items/${itemId}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async refreshAwardItem(itemId: number): Promise<AwardItemManageOut> {
|
||||
const res = await http.put(
|
||||
`/submission/showcase/manage/items/${itemId}/refresh`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getDetail(submissionId: string): Promise<ShowcaseDetail> {
|
||||
const res = await http.get(`/submission/showcase/${submissionId}/`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getPromptChain(submissionId: string): Promise<PromptRound[]> {
|
||||
const res = await http.get(
|
||||
`/submission/showcase/${submissionId}/prompt-chain/`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export const TaskAssets = {
|
||||
async listChallenge(display: number): Promise<TaskAsset[]> {
|
||||
return (await http.get<TaskAsset[]>(`/assets/challenge/${display}`)).data
|
||||
},
|
||||
async uploadChallenge(
|
||||
display: number,
|
||||
name: string,
|
||||
file: File,
|
||||
): Promise<TaskAsset> {
|
||||
const form = new window.FormData()
|
||||
form.append("name", name)
|
||||
form.append("file", file)
|
||||
return (
|
||||
await http.post<TaskAsset>(`/assets/challenge/${display}`, form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
).data
|
||||
},
|
||||
async deleteChallenge(display: number, name: string) {
|
||||
return (
|
||||
await http.delete(
|
||||
`/assets/challenge/${display}/${encodeURIComponent(name)}`,
|
||||
)
|
||||
).data
|
||||
},
|
||||
async listTutorial(display: number): Promise<TaskAsset[]> {
|
||||
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
||||
},
|
||||
async uploadTutorial(
|
||||
display: number,
|
||||
name: string,
|
||||
file: File,
|
||||
): Promise<TaskAsset> {
|
||||
const form = new window.FormData()
|
||||
form.append("name", name)
|
||||
form.append("file", file)
|
||||
return (
|
||||
await http.post<TaskAsset>(`/assets/tutorial/${display}`, form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
).data
|
||||
},
|
||||
async deleteTutorial(display: number, name: string) {
|
||||
return (
|
||||
await http.delete(
|
||||
`/assets/tutorial/${display}/${encodeURIComponent(name)}`,
|
||||
)
|
||||
).data
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<n-modal
|
||||
preset="card"
|
||||
title="登录"
|
||||
style="width: min(400px, 92vw)"
|
||||
:bordered="false"
|
||||
v-model:show="loginModal"
|
||||
>
|
||||
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
|
||||
<n-tab-pane name="student" tab="学生">
|
||||
<n-form :show-feedback="false" class="form">
|
||||
<n-form-item label="班级">
|
||||
<n-select
|
||||
v-model:value="selectedClass"
|
||||
:options="classOptions"
|
||||
placeholder="选择班级"
|
||||
:loading="classesLoading"
|
||||
@update:value="onClassChange"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="姓名">
|
||||
<n-select
|
||||
v-model:value="selectedUsername"
|
||||
:options="nameOptions"
|
||||
filterable
|
||||
:placeholder="selectedClass ? '选择或输入姓名' : '先选班级'"
|
||||
:loading="namesLoading"
|
||||
:disabled="!selectedClass || namesLoading"
|
||||
@update:value="focusPassword"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
ref="studentPasswordRef"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
v-model:value="studentPassword"
|
||||
name="password"
|
||||
placeholder="输入密码,按 Enter 登录"
|
||||
@keyup.enter="submitStudent"
|
||||
/>
|
||||
</n-form-item>
|
||||
<div v-if="studentError" class="error">{{ studentError }}</div>
|
||||
<n-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="studentLoading"
|
||||
:disabled="!selectedClass || !selectedUsername || !studentPassword"
|
||||
@click="submitStudent"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
<div class="note">忘了密码找老师重置</div>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="admin" tab="老师">
|
||||
<n-form :show-feedback="false" class="form">
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="adminName" name="username" placeholder="用户名" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
v-model:value="adminPassword"
|
||||
name="password"
|
||||
placeholder="输入密码,按 Enter 登录"
|
||||
@keyup.enter="submitAdmin"
|
||||
/>
|
||||
</n-form-item>
|
||||
<div v-if="adminError" class="error">{{ adminError }}</div>
|
||||
<n-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="adminLoading"
|
||||
:disabled="!adminName || !adminPassword"
|
||||
@click="submitAdmin"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick, onMounted } from "vue"
|
||||
import { Account } from "../api"
|
||||
import { loginModal } from "../store/modal"
|
||||
import { user } from "../store/user"
|
||||
import { router } from "../router"
|
||||
|
||||
// 机房的电脑一节课一个班轮流用:记住班级,不记姓名
|
||||
const CLASS_KEY = "login-class"
|
||||
|
||||
const activeTab = ref("student")
|
||||
const studentPasswordRef = ref<{ focus: () => void } | null>(null)
|
||||
|
||||
// Student tab state
|
||||
const selectedClass = ref<string | null>(null)
|
||||
const selectedUsername = ref<string | null>(null)
|
||||
const studentPassword = ref("")
|
||||
const studentLoading = ref(false)
|
||||
const studentError = ref("")
|
||||
|
||||
const classes = ref<string[]>([])
|
||||
const classesLoading = ref(false)
|
||||
const classOptions = computed(() =>
|
||||
classes.value.map((c) => ({ label: c, value: c })),
|
||||
)
|
||||
|
||||
// Names data
|
||||
const names = ref<{ name: string; username: string }[]>([])
|
||||
const namesLoading = ref(false)
|
||||
const nameOptions = computed(() =>
|
||||
names.value.map((n) => ({ label: n.name, value: n.username })),
|
||||
)
|
||||
|
||||
// Admin tab state
|
||||
const adminName = ref("")
|
||||
const adminPassword = ref("")
|
||||
const adminLoading = ref(false)
|
||||
const adminError = ref("")
|
||||
|
||||
// 班级列表只在打开页面时取一次
|
||||
onMounted(async () => {
|
||||
classesLoading.value = true
|
||||
try {
|
||||
classes.value = await Account.listClasses()
|
||||
} catch {
|
||||
studentError.value = "班级列表没加载出来,请刷新页面重试"
|
||||
return
|
||||
} finally {
|
||||
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() {
|
||||
studentPassword.value = ""
|
||||
studentError.value = ""
|
||||
adminName.value = ""
|
||||
adminPassword.value = ""
|
||||
adminError.value = ""
|
||||
}
|
||||
|
||||
async function onClassChange(classname: string) {
|
||||
selectedUsername.value = null
|
||||
names.value = []
|
||||
studentError.value = ""
|
||||
try {
|
||||
localStorage.setItem(CLASS_KEY, classname)
|
||||
} catch {}
|
||||
namesLoading.value = true
|
||||
try {
|
||||
names.value = await Account.listNamesByClass(classname)
|
||||
} catch {
|
||||
studentError.value = "姓名列表没加载出来,请重新选一次班级"
|
||||
} finally {
|
||||
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() {
|
||||
if (!selectedUsername.value || !studentPassword.value) return
|
||||
studentLoading.value = true
|
||||
studentError.value = ""
|
||||
try {
|
||||
const data = await Account.login(
|
||||
selectedUsername.value,
|
||||
studentPassword.value,
|
||||
)
|
||||
user.username = data.username
|
||||
user.role = data.role
|
||||
user.loaded = true
|
||||
loginModal.value = false
|
||||
router.replace(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
)
|
||||
} catch (err: any) {
|
||||
studentError.value = loginError(err, "登录失败,请检查密码")
|
||||
} finally {
|
||||
studentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAdmin() {
|
||||
if (!adminName.value || !adminPassword.value) return
|
||||
adminLoading.value = true
|
||||
adminError.value = ""
|
||||
try {
|
||||
const data = await Account.login(adminName.value, adminPassword.value)
|
||||
user.username = data.username
|
||||
user.role = data.role
|
||||
user.loaded = true
|
||||
loginModal.value = false
|
||||
router.replace(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
)
|
||||
} catch (err: any) {
|
||||
adminError.value = loginError(err, "登录失败,请检查用户名和密码")
|
||||
} finally {
|
||||
adminLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
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>
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="isOpen"
|
||||
preset="card"
|
||||
title="帮我把要求写清楚"
|
||||
style="width: min(640px, 92vw)"
|
||||
:mask-closable="!streaming"
|
||||
@update:show="onModalClose"
|
||||
>
|
||||
<p class="intro">
|
||||
AI 会看看你的要求哪里还不够清楚,给你提建议。照着建议改好下面的要求,再让它看看;
|
||||
觉得可以了就点“用这段生成网页”。
|
||||
</p>
|
||||
|
||||
<div ref="messagesRef" class="messages">
|
||||
<div v-for="(msg, i) in messages" :key="msg.id ?? i" :class="['msg', msg.role]">
|
||||
<div class="who">
|
||||
{{ msg.role === "user" ? `你的要求 · 第 ${versionOf(i)} 版` : "AI 的建议" }}
|
||||
</div>
|
||||
<div class="content markdown-body" v-html="renderMarkdown(msg.content)"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="streaming" class="msg assistant">
|
||||
<div class="who">AI 的建议</div>
|
||||
<div v-if="!displayStreamingContent" class="typing">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="content markdown-body"
|
||||
v-html="renderMarkdown(displayStreamingContent)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady" class="ready">
|
||||
<Icon icon="lucide:check-circle" :width="16" />
|
||||
AI 觉得要求已经写清楚了,可以生成网页了
|
||||
</div>
|
||||
|
||||
<div class="draft">
|
||||
<div class="draft-label">你的要求</div>
|
||||
<n-input
|
||||
v-model:value="draftPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
placeholder="按 AI 的建议把要求改得更具体:要什么内容、什么颜色和布局、点了以后会怎样……"
|
||||
:disabled="streaming"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<n-flex justify="space-between" align="center" class="draft-actions">
|
||||
<n-flex align="center" :size="8">
|
||||
<n-button v-if="streaming" size="small" @click="stopGuidance">停止</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
size="small"
|
||||
:disabled="!draftPrompt.trim() || !connected || draftPrompt.trim() === lastSent"
|
||||
@click="send"
|
||||
>
|
||||
让 AI 再看看
|
||||
</n-button>
|
||||
<span class="muted">Enter 发送,Shift + Enter 换行</span>
|
||||
</n-flex>
|
||||
<n-button
|
||||
:type="isReady ? 'primary' : 'default'"
|
||||
:disabled="!draftPrompt.trim() || streaming"
|
||||
@click="generate"
|
||||
>
|
||||
<template #icon><Icon icon="lucide:play" :width="14" /></template>
|
||||
用这段生成网页
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue"
|
||||
import { Icon } from "../../icons"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
isReady,
|
||||
isOpen,
|
||||
connected,
|
||||
initialPrompt,
|
||||
sendGuidance,
|
||||
stopGuidance,
|
||||
closeGuidance,
|
||||
} from "../../store/guidance"
|
||||
|
||||
const emit = defineEmits<{
|
||||
generate: [prompt: string]
|
||||
}>()
|
||||
|
||||
const draftPrompt = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
|
||||
// 第几版要求:只数学生发的
|
||||
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(() =>
|
||||
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
)
|
||||
|
||||
function send() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text || streaming.value || text === lastSent.value) return
|
||||
sendGuidance(text)
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text) return
|
||||
emit("generate", text)
|
||||
closeGuidance()
|
||||
}
|
||||
|
||||
function onModalClose(show: boolean) {
|
||||
if (!show && !streaming.value) closeGuidance()
|
||||
}
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) draftPrompt.value = initialPrompt.value
|
||||
})
|
||||
|
||||
watch([() => messages.value.length, streamingContent], () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.messages {
|
||||
min-height: 120px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.who {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.msg.assistant .who {
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--wp-ink);
|
||||
}
|
||||
|
||||
.msg.user .content {
|
||||
border: 1px solid var(--wp-line);
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.msg.assistant .content {
|
||||
border-left: 3px solid var(--wp-brand);
|
||||
border-radius: 0 8px 8px 0;
|
||||
background: var(--wp-brand-soft);
|
||||
}
|
||||
|
||||
.content :deep(p) {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.content :deep(:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ready {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--wp-brand);
|
||||
background: var(--wp-brand-soft);
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 5px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.typing span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--wp-brand);
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div class="history-panel">
|
||||
<div class="toolbar">
|
||||
<span class="muted">共 {{ items.length }} 轮 · 点一条就把那一版放回编辑器</span>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button quaternary size="small" :loading="loading" @click="load(true)">
|
||||
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||
</n-button>
|
||||
</template>
|
||||
刷新
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<n-spin v-if="loading && !items.length" class="state" />
|
||||
<n-empty v-else-if="!items.length" class="state" description="还没有和 AI 对话过" />
|
||||
<n-scrollbar v-else class="scroll">
|
||||
<ol class="list">
|
||||
<li
|
||||
v-for="item in newestFirst"
|
||||
:key="item.assistant_message_id"
|
||||
:class="['item', { on: selectedAssistantMessageId === item.assistant_message_id }]"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<div class="head">
|
||||
<span class="num">{{ item.index }}</span>
|
||||
<span :class="['src', `src-${item.source || 'conversation'}`]">
|
||||
{{ sourceLabel(item.source) }}
|
||||
</span>
|
||||
<span v-if="item.prompt_level" class="level">{{ levelLabel(item.prompt_level) }}</span>
|
||||
<span v-if="item.submission_id" class="submitted">交过</span>
|
||||
<span class="time">{{ parseTime(item.created, "MM-DD HH:mm") }}</span>
|
||||
<n-popconfirm @positive-click="deleteItem(item)">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
quaternary
|
||||
size="tiny"
|
||||
class="del"
|
||||
:loading="deletingId === item.assistant_message_id"
|
||||
:disabled="deletingId !== null"
|
||||
@click.stop
|
||||
>
|
||||
<template #icon><Icon icon="lucide:trash-2" :width="14" /></template>
|
||||
</n-button>
|
||||
</template>
|
||||
<template v-if="item.submission_id">
|
||||
这一版交过,删掉这轮对话后交的那一份也会一起删掉(有人评过分的删不了)。
|
||||
</template>
|
||||
<template v-else>删掉这一轮对话和它生成的网页?</template>
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
<div class="q markdown-body" v-html="renderMarkdown(item.prompt)"></div>
|
||||
<div class="thumb">
|
||||
<iframe
|
||||
v-if="item.hasPage"
|
||||
title="页面缩略图"
|
||||
loading="lazy"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
tabindex="-1"
|
||||
:srcdoc="item.previewDoc"
|
||||
></iframe>
|
||||
<span v-else class="muted">这一轮没有生成网页</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { Icon } from "../../icons"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import { levelLabel, sourceLabel } from "../../utils/helper"
|
||||
import { Prompt } from "../../api"
|
||||
import type { PromptHistoryItem } from "../../utils/type"
|
||||
import { parseTime } from "../../utils/helper"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
active: boolean
|
||||
assetBaseUrl?: string
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [code: { html: string; css: string; js: string }]
|
||||
deleted: [assistantMessageId: number]
|
||||
}>()
|
||||
|
||||
type HistoryViewItem = PromptHistoryItem & {
|
||||
// 按时间先后的序号(从 1 开始)
|
||||
index: number
|
||||
hasPage: boolean
|
||||
previewDoc: string
|
||||
}
|
||||
|
||||
const items = ref<HistoryViewItem[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedAssistantMessageId = ref<number | null>(null)
|
||||
const deletingId = ref<number | null>(null)
|
||||
let loadedTaskId = 0
|
||||
let pendingRefresh = false
|
||||
|
||||
const naiveMessage = useMessage()
|
||||
|
||||
// 最新的放最上面,序号还是按时间先后
|
||||
const newestFirst = computed(() => [...items.value].reverse())
|
||||
|
||||
function toViewItem(item: PromptHistoryItem, i: number): HistoryViewItem {
|
||||
const html = item.code_html ?? ""
|
||||
const css = item.code_css ?? ""
|
||||
const js = item.code_js ?? ""
|
||||
return {
|
||||
...item,
|
||||
index: i + 1,
|
||||
hasPage: !!(html.trim() || css.trim() || js.trim()),
|
||||
previewDoc: buildPreviewDocument({
|
||||
html,
|
||||
css,
|
||||
js,
|
||||
assetBaseUrl: props.assetBaseUrl,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item: HistoryViewItem) {
|
||||
if (deletingId.value !== null) return
|
||||
deletingId.value = item.assistant_message_id
|
||||
try {
|
||||
await Prompt.deleteMessagePair(item.assistant_message_id)
|
||||
items.value = items.value
|
||||
.filter((i) => i.assistant_message_id !== item.assistant_message_id)
|
||||
.map((i, idx) => ({ ...i, index: idx + 1 }))
|
||||
if (selectedAssistantMessageId.value === item.assistant_message_id) {
|
||||
selectedAssistantMessageId.value = null
|
||||
}
|
||||
emit("deleted", item.assistant_message_id)
|
||||
naiveMessage.success("已删除")
|
||||
} catch (error: any) {
|
||||
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectItem(item: HistoryViewItem) {
|
||||
selectedAssistantMessageId.value = item.assistant_message_id
|
||||
emit("select", {
|
||||
html: item.code_html ?? "",
|
||||
css: item.code_css ?? "",
|
||||
js: item.code_js ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
// 加载中又要求刷新(比如自动提交了新代码、换了任务),等这次完成后再加载一次
|
||||
let reloadAfter = false
|
||||
|
||||
async function load(force = true) {
|
||||
if (!props.taskId) return
|
||||
if (loading.value) {
|
||||
reloadAfter = true
|
||||
return
|
||||
}
|
||||
if (!force && loadedTaskId === props.taskId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const taskChanged = loadedTaskId !== props.taskId
|
||||
const data = await Prompt.listHistory(props.taskId)
|
||||
items.value = data.map((item, i) => toViewItem(item, i))
|
||||
if (
|
||||
taskChanged ||
|
||||
!items.value.some(
|
||||
(item) =>
|
||||
item.assistant_message_id === selectedAssistantMessageId.value,
|
||||
)
|
||||
) {
|
||||
selectedAssistantMessageId.value = null
|
||||
}
|
||||
loadedTaskId = props.taskId
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (reloadAfter) {
|
||||
reloadAfter = false
|
||||
load(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.active, props.taskId] as const,
|
||||
([active]) => {
|
||||
if (active) {
|
||||
if (pendingRefresh) {
|
||||
pendingRefresh = false
|
||||
load(true)
|
||||
} else {
|
||||
load(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.refreshKey,
|
||||
() => {
|
||||
if (props.active) load(true)
|
||||
else pendingRefresh = true
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) load(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: 42px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
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 {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--wp-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
border-color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.item.on {
|
||||
border-color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 6px 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.num {
|
||||
min-width: 18px;
|
||||
font: 600 12px var(--wp-font-mono);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.src,
|
||||
.level,
|
||||
.submitted {
|
||||
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;
|
||||
padding: 0 10px 8px;
|
||||
background: transparent;
|
||||
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);
|
||||
}
|
||||
|
||||
.q :deep(p),
|
||||
.q :deep(ul),
|
||||
.q :deep(ol),
|
||||
.q :deep(pre),
|
||||
.q :deep(blockquote) {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.q :deep(pre) {
|
||||
padding: 6px 8px;
|
||||
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;
|
||||
transform: scale(0.5);
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,598 @@
|
||||
<template>
|
||||
<div class="prompt-panel">
|
||||
<div class="messages" ref="messagesRef">
|
||||
<div
|
||||
v-for="pair in pairs"
|
||||
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
||||
class="message-pair"
|
||||
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
||||
>
|
||||
<!-- Delete button: only shown on hover when pair has assistant id and not streaming -->
|
||||
<button
|
||||
v-if="pair.assistantMsg?.id && !streaming"
|
||||
class="pair-delete-btn"
|
||||
@click="deletePair(pair.assistantMsg!.id!)"
|
||||
title="删除这轮对话及关联提交"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" :width="14" />
|
||||
</button>
|
||||
|
||||
<!-- User message -->
|
||||
<div v-if="pair.userMsg.source === 'edit'" class="message note">
|
||||
你手动修改了代码
|
||||
</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
|
||||
class="message-content"
|
||||
v-html="renderContent(pair.userMsg)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Assistant message -->
|
||||
<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-content"
|
||||
v-html="renderContent(pair.assistantMsg)"
|
||||
></div>
|
||||
</template>
|
||||
<div v-if="versionOf(pair.assistantMsg)" class="version-line">
|
||||
<span class="version-chip">v{{ versionOf(pair.assistantMsg) }}</span>
|
||||
<span v-if="pair.assistantMsg.submitted">已提交</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Streaming indicator -->
|
||||
<div v-if="streaming" class="message assistant">
|
||||
<div class="message-role">AI</div>
|
||||
<template v-if="!streamingContent">
|
||||
<div class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="streaming-hint">
|
||||
AI 正在构思… 已 {{ elapsedText }}<template v-if="elapsed >= 20">,复杂的网页要想一两分钟</template>
|
||||
</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>
|
||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||
<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
|
||||
class="prompt-input-row"
|
||||
align="flex-start"
|
||||
:wrap="false"
|
||||
:size="8"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="input"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
placeholder="用你自己的话说说想做成什么样…"
|
||||
:disabled="streaming"
|
||||
style="flex: 1; min-width: 0"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button
|
||||
secondary
|
||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||
style="flex-shrink: 0"
|
||||
@click="startGuidance"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:lightbulb" :width="16" />
|
||||
</template>
|
||||
帮我写清楚
|
||||
</n-button>
|
||||
</template>
|
||||
AI 教练会问你几个问题,帮你把想要的效果描述清楚
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
<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
|
||||
v-model:value="selectedModel"
|
||||
:options="modelOptions"
|
||||
style="width: 150px"
|
||||
:disabled="streaming"
|
||||
/>
|
||||
<n-button v-if="streaming" type="error" @click="stopPrompt">
|
||||
停止
|
||||
</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
type="primary"
|
||||
:disabled="!input.trim() || !connected"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, nextTick, computed, onUnmounted } from "vue"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../../icons"
|
||||
import GuidancePanel from "./GuidancePanel.vue"
|
||||
import RatingCard from "../workbench/RatingCard.vue"
|
||||
import { openGuidance } from "../../store/guidance"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
streamStartedAt,
|
||||
connected,
|
||||
sendPrompt,
|
||||
stopPrompt,
|
||||
currentTaskId,
|
||||
removeMessagePair,
|
||||
type PromptMessage,
|
||||
} from "../../store/prompt"
|
||||
import { Prompt } from "../../api"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
|
||||
const props = defineProps<{
|
||||
// 发送前调用:工作台用它把还没存的手改先存成版本,AI 才会在手改后的代码上继续改
|
||||
beforeSend?: () => Promise<void>
|
||||
// AI 生成时在对话里显示同学作品让学生打分(挑战页才有)
|
||||
rating?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ deleted: [] }>()
|
||||
|
||||
const input = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
const naiveMessage = useMessage()
|
||||
|
||||
const modelOptions = [
|
||||
{ label: "豆包", value: "doubao-seed-2-1-lite-260915" },
|
||||
{ label: "DeepSeek", value: "deepseek-flash" },
|
||||
]
|
||||
const selectedModel = useStorage("prompt-model", "deepseek-flash")
|
||||
// 浏览器里可能还存着改版前的模型名
|
||||
if (!modelOptions.some((o) => o.value === selectedModel.value)) {
|
||||
selectedModel.value = "deepseek-flash"
|
||||
}
|
||||
|
||||
// Group messages into user+assistant pairs
|
||||
|
||||
const pairs = computed(() => {
|
||||
const result: Array<{
|
||||
userMsg: PromptMessage
|
||||
assistantMsg: PromptMessage | null
|
||||
index: number
|
||||
}> = []
|
||||
const msgs = messages.value
|
||||
let i = 0
|
||||
while (i < msgs.length) {
|
||||
if (msgs[i].role === "user") {
|
||||
const assistantMsg =
|
||||
msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
|
||||
result.push({ userMsg: msgs[i], assistantMsg, index: i })
|
||||
i += assistantMsg ? 2 : 1
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function hasCode(msg: { code?: any } | null): boolean {
|
||||
if (!msg?.code) return false
|
||||
return !!(msg.code.html || msg.code.css || msg.code.js)
|
||||
}
|
||||
|
||||
// 带代码的 AI 回复就是一个版本,按出现顺序编号(和工作台底部的版本条一致)
|
||||
const versionNumbers = computed(() => {
|
||||
const map = new Map<number, number>()
|
||||
for (const m of messages.value) {
|
||||
if (m.role === "assistant" && m.id && hasCode(m)) map.set(m.id, map.size + 1)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function versionOf(msg: { id?: number }) {
|
||||
return msg.id ? versionNumbers.value.get(msg.id) : undefined
|
||||
}
|
||||
|
||||
async function deletePair(assistantMsgId: number) {
|
||||
try {
|
||||
await Prompt.deleteMessagePair(assistantMsgId)
|
||||
removeMessagePair(assistantMsgId)
|
||||
naiveMessage.success("已删除")
|
||||
emit("deleted")
|
||||
} catch (error: any) {
|
||||
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = input.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
// 只说“不行”“没反应”,AI 只能瞎猜;提醒一次,学生选“直接发”以后这次就不再拦
|
||||
if (!skipVagueCheck && hasVersion.value && [...text].length <= 15 && VAGUE.test(text)) {
|
||||
vagueHint.value = true
|
||||
return
|
||||
}
|
||||
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() {
|
||||
const text = input.value.trim()
|
||||
if (!text || !currentTaskId.value) return
|
||||
openGuidance(currentTaskId.value, text)
|
||||
}
|
||||
|
||||
async function onGuidanceGenerate(finalPrompt: string) {
|
||||
input.value = ""
|
||||
await props.beforeSend?.()
|
||||
sendPrompt(finalPrompt, selectedModel.value)
|
||||
}
|
||||
|
||||
// 学生只关心成品:AI 回复里的代码块不在对话里展开(右边“代码”里能看),只说写了多少行。
|
||||
// 按 ``` 切开,奇数段是代码;段数是偶数说明最后一个代码块还没写完
|
||||
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], () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.message.user .message-role {
|
||||
color: var(--wp-css);
|
||||
}
|
||||
|
||||
.message.assistant .message-role {
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
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) {
|
||||
background: var(--wp-paper);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--wp-brand);
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 11px;
|
||||
color: var(--wp-faint);
|
||||
margin-top: 4px;
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.message-pair {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-pair .message {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pair-delete-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
color: var(--wp-faint);
|
||||
line-height: 1;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.pair-delete-btn:hover {
|
||||
color: var(--wp-bad);
|
||||
border-color: var(--wp-bad);
|
||||
}
|
||||
|
||||
.message-pair.has-delete:hover .pair-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<MdEditor
|
||||
v-bind="props"
|
||||
v-model="modelValue"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
@upload-img="uploadImg"
|
||||
/>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { MdEditor } from "md-editor-v3"
|
||||
//@ts-ignore
|
||||
import "md-editor-v3/lib/style.css"
|
||||
import { Helper } from "../../api"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { isDark } from "../../store/theme"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const modelValue = defineModel<string>()
|
||||
|
||||
const props = defineProps()
|
||||
|
||||
const uploadImg = async (files: File[], callback: (urls: string[]) => void) => {
|
||||
try {
|
||||
const res = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const path = await Helper.upload(file)
|
||||
if (!path) {
|
||||
message.error("图片上传失败")
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}),
|
||||
)
|
||||
callback(res.filter((url) => url !== ""))
|
||||
} catch (err) {
|
||||
message.error("图片上传失败")
|
||||
callback([])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button secondary v-if="user.role !== Role.Super" @click="edit">
|
||||
修改
|
||||
</n-button>
|
||||
<n-button
|
||||
secondary
|
||||
type="error"
|
||||
v-if="user.role !== Role.Super && user.is_active"
|
||||
@click="toggleUserActive"
|
||||
>
|
||||
封号
|
||||
</n-button>
|
||||
<n-button
|
||||
secondary
|
||||
type="primary"
|
||||
v-if="user.role !== Role.Super && !user.is_active"
|
||||
@click="toggleUserActive"
|
||||
>
|
||||
解封
|
||||
</n-button>
|
||||
<UserFormModal
|
||||
v-model:show="showEdit"
|
||||
:user="user"
|
||||
@saved="emits('reload')"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue"
|
||||
import { useDialog, useMessage } from "naive-ui"
|
||||
import { Account } from "../../api"
|
||||
import { Role, type User } from "../../utils/type"
|
||||
import UserFormModal from "./UserFormModal.vue"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emits = defineEmits(["reload"])
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const showEdit = ref(false)
|
||||
|
||||
function edit() {
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
async function doToggle() {
|
||||
const data = await Account.toggleActive(props.user.id)
|
||||
message.success(data.message)
|
||||
emits("reload")
|
||||
}
|
||||
|
||||
function toggleUserActive() {
|
||||
if (!props.user.is_active) return doToggle()
|
||||
dialog.warning({
|
||||
title: `封禁「${props.user.username}」?`,
|
||||
content: "封禁后这个账号不能登录,已经登录的也会被退出。之后可以再解封。",
|
||||
positiveText: "封禁",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: doToggle,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<n-modal
|
||||
style="width: 360px"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="user ? `修改 ${user.username}` : '新建用户'"
|
||||
:show="show"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form label-placement="left" label-width="60">
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="form.username" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
v-model:value="form.password"
|
||||
:placeholder="user ? '不修改请留空' : '6 到 20 位'"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="权限">
|
||||
<n-select v-model:value="form.role" :options="roles" />
|
||||
</n-form-item>
|
||||
<n-form-item label="班级">
|
||||
<n-input v-model:value="form.classname" placeholder="可不填" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="!form.username || (!user && !form.password)"
|
||||
@click="submit"
|
||||
>
|
||||
保存
|
||||
</n-button>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Account } from "../../api"
|
||||
import { Role, type User } from "../../utils/type"
|
||||
|
||||
const props = defineProps<{ show: boolean; user?: User | null }>()
|
||||
const emit = defineEmits(["update:show", "saved"])
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
role: Role.Normal,
|
||||
classname: "",
|
||||
})
|
||||
|
||||
const roles = [
|
||||
{ label: "普通用户", value: Role.Normal },
|
||||
{ label: "管理员", value: Role.Admin },
|
||||
]
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
form.username = props.user?.username ?? ""
|
||||
form.password = ""
|
||||
form.role = props.user?.role ?? Role.Normal
|
||||
form.classname = props.user?.classname ?? ""
|
||||
},
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = props.user
|
||||
? await Account.update(props.user.id, {
|
||||
username: form.username,
|
||||
role: form.role,
|
||||
classname: form.classname,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
})
|
||||
: await Account.create({ ...form })
|
||||
message.success(data.message)
|
||||
emit("update:show", false)
|
||||
emit("saved")
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail
|
||||
message.error(
|
||||
typeof detail === "string" ? detail : (detail?.[0]?.msg ?? "保存失败"),
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { Codemirror } from "vue-codemirror"
|
||||
import { css } from "@codemirror/lang-css"
|
||||
import { javascript } from "@codemirror/lang-javascript"
|
||||
import { html } from "@codemirror/lang-html"
|
||||
import { computed } from "vue"
|
||||
import { syntaxHighlighting } from "@codemirror/language"
|
||||
import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
|
||||
import { isDark } from "../../store/theme"
|
||||
|
||||
const styleTheme = EditorView.baseTheme({
|
||||
"& .cm-scroller": {
|
||||
"font-family": "var(--wp-font-mono)",
|
||||
},
|
||||
"&.cm-editor.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||
"font-family": "var(--wp-font-mono)",
|
||||
},
|
||||
})
|
||||
|
||||
const bgColors: Record<string, string> = {
|
||||
html: "var(--wp-html-soft)",
|
||||
css: "var(--wp-css-soft)",
|
||||
js: "var(--wp-js-soft)",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language?: "html" | "css" | "js"
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
language: "html",
|
||||
fontSize: 16,
|
||||
})
|
||||
|
||||
const code = defineModel<string>("value")
|
||||
|
||||
const lang = computed(() => {
|
||||
if (props.language === "html") return html()
|
||||
if (props.language === "css") return css()
|
||||
return javascript()
|
||||
})
|
||||
|
||||
const bgTheme = computed(() => {
|
||||
const bg = bgColors[props.language] ?? "var(--wp-surface)"
|
||||
return EditorView.theme(
|
||||
{
|
||||
"&": { 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 },
|
||||
)
|
||||
})
|
||||
|
||||
// 暗色时换成 One Dark 的语法配色(默认配色是给浅色背景设计的)
|
||||
const extensions = computed(() => [
|
||||
styleTheme,
|
||||
bgTheme.value,
|
||||
...(isDark.value ? [syntaxHighlighting(oneDarkHighlightStyle)] : []),
|
||||
lang.value,
|
||||
])
|
||||
</script>
|
||||
<template>
|
||||
<Codemirror
|
||||
v-model="code"
|
||||
indentWithTab
|
||||
:extensions="extensions"
|
||||
:tabSize="4"
|
||||
:style="{ height: '100%', fontSize: props.fontSize + 'px' }"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="editors-root">
|
||||
<n-tabs
|
||||
:value="tab"
|
||||
:class="`tab-active-${tab}`"
|
||||
pane-class="pane"
|
||||
style="height: 100%"
|
||||
type="card"
|
||||
@update:value="changeTab"
|
||||
>
|
||||
<n-tab-pane name="html" tab="HTML">
|
||||
<template #tab>
|
||||
<span class="lang-tab lang-html">HTML</span>
|
||||
</template>
|
||||
<Editor v-model:value="html" :font-size="size" language="html" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="css" tab="CSS">
|
||||
<template #tab>
|
||||
<span class="lang-tab lang-css">CSS</span>
|
||||
</template>
|
||||
<Editor v-model:value="css" :font-size="size" language="css" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="js" tab="JS">
|
||||
<template #tab>
|
||||
<span class="lang-tab lang-js">JS</span>
|
||||
</template>
|
||||
<Editor v-model:value="js" :font-size="size" language="js" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="actions" tab="选项">
|
||||
<template #tab>
|
||||
<span class="lang-tab">选项</span>
|
||||
</template>
|
||||
<n-flex class="wrapper" vertical>
|
||||
<n-flex align="center">
|
||||
<span class="label">重置</span>
|
||||
<n-button @click="reset('html')">HTML</n-button>
|
||||
<n-button @click="reset('css')">CSS</n-button>
|
||||
<n-button @click="reset('js')">JS</n-button>
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span class="label">字号</span>
|
||||
<n-flex align="center">
|
||||
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
||||
<n-button :disabled="size <= 12" @click="changeSize(size - 2)">
|
||||
调小
|
||||
</n-button>
|
||||
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
||||
调大
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span class="label">预加载</span>
|
||||
<n-tag type="success">Normalize.css</n-tag>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
<template #suffix>
|
||||
<Toolbar
|
||||
:submit-loading="submitLoading"
|
||||
@format="format"
|
||||
@submit="formatAndSubmit"
|
||||
/>
|
||||
</template>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import Editor from "./Editor.vue"
|
||||
import Toolbar from "./Toolbar.vue"
|
||||
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||
import { taskId } from "../../store/task"
|
||||
import { formatCode } from "../../utils/code"
|
||||
import { Submission } from "../../api"
|
||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||
import { h, ref } from "vue"
|
||||
|
||||
const dialog = useDialog()
|
||||
const message = useMessage()
|
||||
const submitLoading = ref(false)
|
||||
|
||||
function changeTab(name: string) {
|
||||
tab.value = name
|
||||
}
|
||||
|
||||
function changeSize(num: number) {
|
||||
size.value = num
|
||||
}
|
||||
|
||||
async function formatEditors() {
|
||||
const out = await formatCode({ html: html.value, css: css.value, js: js.value })
|
||||
html.value = out.html
|
||||
css.value = out.css
|
||||
js.value = out.js
|
||||
}
|
||||
|
||||
async function format() {
|
||||
try {
|
||||
await formatEditors()
|
||||
} catch (err: any) {
|
||||
dialog.error({
|
||||
title: "格式化失败",
|
||||
content: () => h(NCode, { code: err.message }),
|
||||
style: { width: "auto" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
try {
|
||||
await Submission.create(taskId.value, {
|
||||
html: html.value,
|
||||
css: css.value,
|
||||
js: js.value,
|
||||
})
|
||||
message.success("提交成功")
|
||||
} catch (err) {
|
||||
message.error("提交失败")
|
||||
}
|
||||
}
|
||||
|
||||
async function formatAndSubmit() {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await formatEditors()
|
||||
await doSubmit()
|
||||
} catch (err: any) {
|
||||
dialog.warning({
|
||||
title: "代码整理失败",
|
||||
content: () => h(NCode, { code: err.message }),
|
||||
positiveText: "忽略并提交",
|
||||
negativeText: "取消",
|
||||
style: { width: "auto" },
|
||||
onPositiveClick: async () => {
|
||||
await doSubmit()
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.pane {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
||||
background-color: var(--wp-html-soft) !important;
|
||||
}
|
||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
|
||||
background-color: var(--wp-css-soft) !important;
|
||||
}
|
||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
|
||||
background-color: var(--wp-js-soft) !important;
|
||||
}
|
||||
|
||||
.editors-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.tab-active-html .n-tab-pane) {
|
||||
background-color: var(--wp-html-soft);
|
||||
}
|
||||
:deep(.tab-active-css .n-tab-pane) {
|
||||
background-color: var(--wp-css-soft);
|
||||
}
|
||||
:deep(.tab-active-js .n-tab-pane) {
|
||||
background-color: var(--wp-js-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<n-flex align="center" justify="space-between" class="title">
|
||||
<div>
|
||||
<slot name="title"><n-text class="titleText">预览</n-text></slot>
|
||||
</div>
|
||||
<n-flex>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button quaternary @click="cycleLayout">
|
||||
<template #icon>
|
||||
<Icon :icon="layoutIcon" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
{{ layoutLabel }}
|
||||
</n-tooltip>
|
||||
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||
<n-button quaternary @click="open">全屏</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||
<iframe
|
||||
ref="frame"
|
||||
class="iframe"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:key="frameKey"
|
||||
:srcdoc="previewContent"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||
import { Icon } from "../../icons"
|
||||
import {
|
||||
buildPreviewDocument,
|
||||
PREVIEW_SANDBOX,
|
||||
} from "../../utils/previewDocument"
|
||||
import {
|
||||
listenPreviewStorage,
|
||||
loadPreviewStorage,
|
||||
} from "../../utils/previewStorage"
|
||||
import { user } from "../../store/user"
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assetBaseUrl?: string
|
||||
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
||||
storageTaskId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
type Layout = "desktop" | "mobile" | "tablet"
|
||||
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
||||
const layoutConfig: Record<
|
||||
Layout,
|
||||
{ icon: string; label: string; width: string }
|
||||
> = {
|
||||
desktop: {
|
||||
icon: "material-symbols:desktop-windows-outline",
|
||||
label: "桌面",
|
||||
width: "100%",
|
||||
},
|
||||
mobile: {
|
||||
icon: "material-symbols:smartphone-outline",
|
||||
label: "移动端 (375px)",
|
||||
width: "375px",
|
||||
},
|
||||
tablet: {
|
||||
icon: "material-symbols:tablet-outline",
|
||||
label: "平板 (768px)",
|
||||
width: "768px",
|
||||
},
|
||||
}
|
||||
const layoutIndex = ref(0)
|
||||
const layoutIcon = computed(
|
||||
() => layoutConfig[layouts[layoutIndex.value]!].icon,
|
||||
)
|
||||
const layoutLabel = computed(
|
||||
() => layoutConfig[layouts[layoutIndex.value]!].label,
|
||||
)
|
||||
const iframeWrapperStyle = computed(() => ({
|
||||
maxWidth: layoutConfig[layouts[layoutIndex.value]!].width,
|
||||
}))
|
||||
function cycleLayout() {
|
||||
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
||||
}
|
||||
|
||||
const showDL = computed(() => props.html || props.css || props.js)
|
||||
const previewContent = ref("")
|
||||
|
||||
const frame = useTemplateRef<HTMLIFrameElement>("frame")
|
||||
const storageScope = () =>
|
||||
props.storageTaskId ? `${user.username}:${props.storageTaskId}` : ""
|
||||
|
||||
function getContent(forDownload = false) {
|
||||
const scope = storageScope()
|
||||
return buildPreviewDocument({
|
||||
html: props.html,
|
||||
css: props.css,
|
||||
js: props.js,
|
||||
assetBaseUrl: props.assetBaseUrl,
|
||||
storage: forDownload ? false : scope ? loadPreviewStorage(scope) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 每次内容变了换一个新的 iframe:在同一个 iframe 里改 srcdoc 会进浏览器历史,
|
||||
// 按“后退”就成了在预览里倒退,而不是离开页面
|
||||
const frameKey = ref(0)
|
||||
function preview() {
|
||||
previewContent.value = getContent()
|
||||
frameKey.value++
|
||||
}
|
||||
|
||||
function download() {
|
||||
const content = getContent(true)
|
||||
const blob = new Blob([content], { type: "text/html" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "index.html"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function open() {
|
||||
const newTab = window.open("about:blank", "_blank")
|
||||
if (!newTab) return
|
||||
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
|
||||
const doc = newTab.document
|
||||
doc.open()
|
||||
doc.write(
|
||||
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
|
||||
)
|
||||
doc.close()
|
||||
const tabFrame = doc.querySelector("iframe")!
|
||||
// 全屏页里的写入也存到同一个地方
|
||||
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
||||
tabFrame.srcdoc = getContent()
|
||||
}
|
||||
|
||||
|
||||
watchDebounced(
|
||||
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||
preview,
|
||||
{
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
},
|
||||
)
|
||||
onMounted(preview)
|
||||
onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
||||
</script>
|
||||
<style scoped>
|
||||
.title {
|
||||
height: 43px;
|
||||
padding: 0 12px 0 16px;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
box-sizing: border-box;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.iframe-wrapper {
|
||||
width: 100%;
|
||||
/* 减去上面标题栏的高度,否则底部 43px 会被裁掉 */
|
||||
height: calc(100% - 43px);
|
||||
margin: 0 auto;
|
||||
transition: max-width 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<n-flex align="center" class="corner">
|
||||
<n-button quaternary v-if="!show" @click="showTutorial">显示教程</n-button>
|
||||
<template v-if="user.loaded && authed">
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button quaternary @click="emit('format')">整理代码</n-button>
|
||||
</template>
|
||||
自动整理缩进和换行
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="submitDisabled"
|
||||
:loading="submitLoading"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
提交
|
||||
</n-button>
|
||||
</template>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { authed, user } from "../../store/user"
|
||||
import { show, panelSize } from "../../store/panel"
|
||||
import { taskId } from "../../store/task"
|
||||
|
||||
defineProps<{
|
||||
submitLoading: boolean
|
||||
}>()
|
||||
const emit = defineEmits(["format", "submit"])
|
||||
|
||||
const submitDisabled = computed(() => {
|
||||
return taskId.value === 0
|
||||
})
|
||||
|
||||
function showTutorial() {
|
||||
show.value = true
|
||||
panelSize.value = 2 / 5
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.corner {
|
||||
margin-right: 12px;
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,542 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
style="width: 92vw; max-width: 1280px"
|
||||
:bordered="false"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<b>提示词</b>
|
||||
<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>
|
||||
<n-button size="small" quaternary style="margin-right: 8px">
|
||||
<template #icon><Icon icon="lucide:info" /></template>
|
||||
L1–L6 是什么
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="levels">
|
||||
<div class="levels-note">按布鲁姆认知层级给每次提问打的标签,越高越需要思考:</div>
|
||||
<div v-for="(l, n) in PROMPT_LEVELS" :key="n">
|
||||
<b>L{{ n }} {{ l.name }}</b>:{{ l.desc }}
|
||||
</div>
|
||||
</div>
|
||||
</n-popover>
|
||||
</template>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-empty
|
||||
v-if="!loading && !rounds.length"
|
||||
description="这份提交没有关联的提示词"
|
||||
style="padding: 80px 0"
|
||||
/>
|
||||
<div v-else class="body">
|
||||
<div class="left">
|
||||
<div v-if="showTrend" class="trend">
|
||||
<span class="muted">层级</span>
|
||||
<div class="bars">
|
||||
<button
|
||||
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 class="round-head">
|
||||
<span class="num">{{ nums[i] }}</span>
|
||||
<span :class="['src', `src-${r.source || 'conversation'}`]">
|
||||
{{ sourceLabel(r.source) }}
|
||||
</span>
|
||||
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
|
||||
<span v-if="repeats[i]" class="repeat" title="和前面某一轮的提问一字不差">重复提问</span>
|
||||
<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>
|
||||
<span v-else-if="!r.html" class="muted no-code">没有网页</span>
|
||||
</div>
|
||||
<div
|
||||
:class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
|
||||
v-html="renderMarkdown(r.question)"
|
||||
></div>
|
||||
<button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
|
||||
{{ expanded.has(i) ? "收起" : "展开全部" }}
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="stage">
|
||||
<div class="stage-head">
|
||||
<b>第 {{ nums[selected] }} 轮生成的网页</b>
|
||||
<span class="muted">↑ ↓ 切换</span>
|
||||
</div>
|
||||
<div class="frame">
|
||||
<iframe
|
||||
v-if="doc"
|
||||
:key="selected"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:srcdoc="doc"
|
||||
></iframe>
|
||||
<n-empty v-else description="这一轮没有生成网页" style="margin: auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue"
|
||||
import { Icon } from "../../icons"
|
||||
import { Showcase, Submission } from "../../api"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import { levelLabel, PROMPT_LEVELS, sourceLabel } from "../../utils/helper"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||
import type { PromptRound } from "../../utils/type"
|
||||
|
||||
// 一份提交是怎么一轮一轮问出来的:左边每轮的提问,右边这一轮生成的网页
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
submissionId: string
|
||||
// 例如“张三 · 个人名片”
|
||||
title?: string
|
||||
// 题目素材的地址,网页里的图片要用
|
||||
assetBaseUrl?: string
|
||||
// 作品展里的作品走作品展的接口(不用是老师或作者本人)
|
||||
fromShowcase?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ "update:show": [value: boolean] }>()
|
||||
|
||||
const loading = ref(false)
|
||||
const rounds = ref<PromptRound[]>([])
|
||||
const selected = ref(0)
|
||||
const expanded = ref(new Set<number>())
|
||||
|
||||
// 引导对话(问怎么写提示词)夹在 AI 对话中间,不算轮数,也不能选中
|
||||
const isGuide = (r: PromptRound) => r.source === "guidance"
|
||||
const steps = computed(() => rounds.value.filter((r) => !isGuide(r)).length)
|
||||
const nums = computed(() => {
|
||||
let n = 0
|
||||
return rounds.value.map((r) => (isGuide(r) ? "" : String(++n)))
|
||||
})
|
||||
|
||||
// 层级走势:每轮 AI 对话 / 外部 AI 一根柱子,两轮以上、有标过层级才显示
|
||||
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 lineDiff(before: string[], after: string[]) {
|
||||
const left = new Map<string, number>()
|
||||
for (const l of before) left.set(l, (left.get(l) ?? 0) + 1)
|
||||
let added = 0
|
||||
for (const l of after) {
|
||||
const n = left.get(l) ?? 0
|
||||
if (n) left.set(l, n - 1)
|
||||
else added++
|
||||
}
|
||||
const removed = before.length - (after.length - added)
|
||||
return added || removed ? `+${added} −${removed} 行` : "代码没变"
|
||||
}
|
||||
|
||||
const changes = computed(() => {
|
||||
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
|
||||
rounds.value = []
|
||||
expanded.value = new Set()
|
||||
try {
|
||||
const data = props.fromShowcase
|
||||
? await Showcase.getPromptChain(props.submissionId)
|
||||
: await Submission.getPromptChain(props.submissionId)
|
||||
if (s !== seq) return
|
||||
rounds.value = data
|
||||
// 默认看交的那一版
|
||||
selected.value = Math.max(0, data.length - 1)
|
||||
await nextTick()
|
||||
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" })
|
||||
} catch {
|
||||
if (s === seq) rounds.value = []
|
||||
} finally {
|
||||
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(
|
||||
() => [props.show, props.submissionId] as const,
|
||||
([visible]) => {
|
||||
if (visible) {
|
||||
load()
|
||||
window.addEventListener("keydown", onKey, true)
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKey, true)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKey, true))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.levels {
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.levels-note {
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.num {
|
||||
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<n-button @click="show = true">
|
||||
图片素材{{ assets.length ? `(${assets.length})` : "" }}
|
||||
</n-button>
|
||||
|
||||
<n-modal v-model:show="show" preset="card" title="图片素材" style="width: min(720px, 92vw)">
|
||||
<p class="hint">
|
||||
题目和学生的网页里直接写文件名就能用,例如
|
||||
<code><img src="{{ assets[0]?.name ?? "1.png" }}"></code>。
|
||||
上传同名文件会替换原来的。
|
||||
</p>
|
||||
|
||||
<label
|
||||
:class="['drop', { over: dragging }]"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input type="file" accept="image/*" multiple hidden @change="onPick" />
|
||||
<Icon icon="lucide:image" :width="22" />
|
||||
<span v-if="uploading">正在上传…</span>
|
||||
<span v-else>点这里选图片,或者把图片拖进来(可以一次选多张)</span>
|
||||
</label>
|
||||
|
||||
<div v-if="assets.length" class="grid">
|
||||
<div v-for="a in assets" :key="a.name" class="asset">
|
||||
<div class="img">
|
||||
<img :src="a.url" :alt="a.name" loading="lazy" />
|
||||
</div>
|
||||
<div class="name" :title="a.name">{{ a.name }}</div>
|
||||
<div class="tools">
|
||||
<n-button size="tiny" quaternary @click="copyName(a.name)">复制文件名</n-button>
|
||||
<n-popconfirm @positive-click="remove(a.name)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary type="error">删除</n-button>
|
||||
</template>
|
||||
删除 {{ a.name }}?用到它的网页里图片会显示不出来。
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="还没有素材" style="margin: 24px 0 8px" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import copy from "copy-text-to-clipboard"
|
||||
import { Icon } from "../../icons"
|
||||
import { TaskAssets } from "../../api"
|
||||
import type { TaskAsset } from "../../utils/type"
|
||||
|
||||
// 出题页的图片素材:存在 /media/tasks/<类型>/<序号>/ 下,预览时那里是网页的 base 地址
|
||||
const props = defineProps<{
|
||||
taskType: "challenge" | "tutorial"
|
||||
display: number
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const assets = ref<TaskAsset[]>([])
|
||||
const show = ref(false)
|
||||
const uploading = ref(false)
|
||||
const dragging = ref(false)
|
||||
|
||||
async function load() {
|
||||
if (!props.display) return
|
||||
try {
|
||||
assets.value =
|
||||
props.taskType === "challenge"
|
||||
? await TaskAssets.listChallenge(props.display)
|
||||
: await TaskAssets.listTutorial(props.display)
|
||||
} catch {
|
||||
assets.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 和服务端的 validFilename 一样:空格换成下划线,只留字母(含中文)、数字和 - _ .
|
||||
function cleanName(name: string) {
|
||||
return name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "").slice(-100)
|
||||
}
|
||||
|
||||
async function uploadAll(files: File[]) {
|
||||
const images = files.filter((f) => f.type.startsWith("image/"))
|
||||
if (images.length < files.length) message.warning("只能上传图片,其他文件跳过了")
|
||||
if (!images.length) return
|
||||
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 {
|
||||
const asset =
|
||||
props.taskType === "challenge"
|
||||
? await TaskAssets.uploadChallenge(props.display, name, file)
|
||||
: await TaskAssets.uploadTutorial(props.display, name, file)
|
||||
const i = assets.value.findIndex((a) => a.name === asset.name)
|
||||
// 同名替换后浏览器可能还用着旧图的缓存
|
||||
const fresh = { ...asset, url: `${asset.url}?t=${Date.now()}` }
|
||||
if (i >= 0) assets.value[i] = fresh
|
||||
else assets.value.push(fresh)
|
||||
done++
|
||||
} catch (err: any) {
|
||||
message.error(`${name}:${err?.response?.data?.detail ?? "上传失败"}`)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
props.taskType === "challenge"
|
||||
? await TaskAssets.deleteChallenge(props.display, name)
|
||||
: await TaskAssets.deleteTutorial(props.display, name)
|
||||
assets.value = assets.value.filter((a) => a.name !== name)
|
||||
message.success("已删除")
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.detail ?? "删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.display, load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<n-flex align="center" justify="space-between" class="title" :wrap="false">
|
||||
<n-flex align="center" :wrap="false">
|
||||
<span class="panel-name">教程</span>
|
||||
<template v-if="!hideNav">
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="prev()" :disabled="prevDisabled()">
|
||||
<Icon :width="18" icon="lucide:arrow-left" />
|
||||
</n-button>
|
||||
</template>
|
||||
上一节
|
||||
</n-tooltip>
|
||||
<span v-if="progressText" class="progress-text">
|
||||
{{ progressText }}
|
||||
</span>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="next()" :disabled="nextDisabled()">
|
||||
<Icon :width="18" icon="lucide:arrow-right" />
|
||||
</n-button>
|
||||
</template>
|
||||
下一节
|
||||
</n-tooltip>
|
||||
</template>
|
||||
</n-flex>
|
||||
<n-flex align="center" :wrap="false">
|
||||
<n-button
|
||||
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
|
||||
size="small"
|
||||
quaternary
|
||||
@click="showAssets = true"
|
||||
>
|
||||
<template #icon><Icon icon="lucide:image" /></template>
|
||||
素材
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="authed && taskTab === TASK_TYPE.Tutorial && taskId"
|
||||
size="small"
|
||||
quaternary
|
||||
@click="showPeers = true"
|
||||
>
|
||||
作品
|
||||
</n-button>
|
||||
<n-button v-if="roleSuper" size="small" quaternary @click="edit">
|
||||
<template #icon><Icon icon="lucide:edit" /></template>
|
||||
编辑
|
||||
</n-button>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="$emit('hide')">
|
||||
<Icon :width="18" icon="lucide:x" />
|
||||
</n-button>
|
||||
</template>
|
||||
收起教程,编辑器占满屏幕
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<TutorialContent />
|
||||
</div>
|
||||
<PeersDrawer
|
||||
v-model:show="showPeers"
|
||||
:task-id="taskId"
|
||||
:asset-base-url="`/media/tasks/tutorial/${step}/`"
|
||||
/>
|
||||
<n-modal
|
||||
v-model:show="showAssets"
|
||||
preset="card"
|
||||
title="图片素材"
|
||||
style="width: 570px"
|
||||
>
|
||||
<n-flex wrap>
|
||||
<n-card
|
||||
v-for="asset in tutorialAssets"
|
||||
:key="asset.name"
|
||||
:title="asset.name"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
>
|
||||
<template #cover>
|
||||
<n-image
|
||||
:src="asset.url"
|
||||
style="width: 100%; height: 100px; object-fit: contain"
|
||||
/>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "../../icons"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import {
|
||||
step,
|
||||
tutorialIds,
|
||||
prev,
|
||||
next,
|
||||
prevDisabled,
|
||||
nextDisabled,
|
||||
} from "../../store/tutorial"
|
||||
import { authed, roleSuper } from "../../store/user"
|
||||
import PeersDrawer from "../review/PeersDrawer.vue"
|
||||
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { TASK_TYPE } from "../../utils/const"
|
||||
import { TaskAssets } from "../../api"
|
||||
import type { TaskAsset } from "../../utils/type"
|
||||
import TutorialContent from "./TutorialContent.vue"
|
||||
|
||||
const tutorialAssets = ref<TaskAsset[]>([])
|
||||
const showAssets = ref(false)
|
||||
const showPeers = ref(false)
|
||||
|
||||
async function loadTutorialAssets(display: number) {
|
||||
if (!display) {
|
||||
tutorialAssets.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
tutorialAssets.value = await TaskAssets.listTutorial(display)
|
||||
} catch {
|
||||
tutorialAssets.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(step, loadTutorialAssets, { immediate: true })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 路由同步:初始执行 + watch 响应 SPA 内部导航
|
||||
// 这个面板只用在教程页(挑战有自己的页面)
|
||||
function syncRoute(routeName: string) {
|
||||
taskTab.value = TASK_TYPE.Tutorial
|
||||
if (routeName.startsWith("home-tutorial") && route.params.display) {
|
||||
step.value = Number(route.params.display)
|
||||
}
|
||||
}
|
||||
syncRoute(route.name as string)
|
||||
// 只换了 display(比如浏览器后退到上一节教程)也要同步
|
||||
watch(
|
||||
() => [route.name as string, route.params.display] as const,
|
||||
([name]) => syncRoute(name),
|
||||
)
|
||||
|
||||
defineEmits(["hide"])
|
||||
|
||||
const hideNav = computed(() => tutorialIds.value.length <= 1)
|
||||
|
||||
const progressText = computed(() => {
|
||||
const ids = tutorialIds.value
|
||||
if (!ids.length) return ""
|
||||
const i = ids.indexOf(step.value)
|
||||
return i === -1 ? "" : `${i + 1} / ${ids.length}`
|
||||
})
|
||||
|
||||
function edit() {
|
||||
const name =
|
||||
taskTab.value === TASK_TYPE.Tutorial
|
||||
? "tutorial-editor"
|
||||
: "challenge-editor"
|
||||
const display =
|
||||
taskTab.value === TASK_TYPE.Tutorial ? step.value : challengeDisplay.value
|
||||
router.push({ name, params: { display } })
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 43px;
|
||||
padding: 0 12px 0 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panel-name {
|
||||
font-weight: 600;
|
||||
color: var(--wp-ink);
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: var(--wp-faint);
|
||||
font-family: var(--wp-font-mono);
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,598 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
@update:show="$emit('update:show', $event)"
|
||||
preset="card"
|
||||
style="width: 90vw; max-width: 760px"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<b>课堂总结</b>
|
||||
<span v-if="title" class="task-title">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-select
|
||||
:value="classname"
|
||||
:options="classOptions"
|
||||
size="small"
|
||||
@update:value="selectClass"
|
||||
style="width: 140px; margin-right: 12px"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<n-flex v-if="!stats" justify="center" style="padding: 60px">
|
||||
<n-spin />
|
||||
</n-flex>
|
||||
|
||||
<n-spin v-else :show="loading">
|
||||
<div class="body">
|
||||
<!-- 关键数字 -->
|
||||
<div class="kpis">
|
||||
<div class="kpi wide">
|
||||
<div class="kpi-label">已交</div>
|
||||
<div class="kpi-value">
|
||||
{{ stats.submitted_count }}<small> / {{ total }} 人</small>
|
||||
<span class="pct">{{ pct(stats.submitted_count, total) }}</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<span :style="{ width: pct(stats.submitted_count, total) }"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">平均分</div>
|
||||
<div class="kpi-value">{{ stats.average_score?.toFixed(1) ?? "—" }}</div>
|
||||
<div class="kpi-note">每人最新一版</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">改过再交</div>
|
||||
<div class="kpi-value">{{ pct(multiSubmit, stats.submitted_count) }}</div>
|
||||
<div class="kpi-note">交了 2 次及以上</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">待讲评</div>
|
||||
<div class="kpi-value">{{ stats.present_count }}<small> 份</small></div>
|
||||
<div class="kpi-note">已加入课上展示</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- 未交 -->
|
||||
<section>
|
||||
<h4>
|
||||
<i class="dot bad"></i>未交
|
||||
<span class="count">{{ stats.unsubmitted_count }} 人</span>
|
||||
</h4>
|
||||
<div class="names">
|
||||
<template v-for="g in unsubmittedGroups" :key="g.classname">
|
||||
<div v-if="isAll" class="class-label">{{ g.classname || "未分班" }}</div>
|
||||
<div class="chips">
|
||||
<span v-for="n in g.names" :key="n" class="chip">{{ n }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!stats.unsubmitted_count" class="empty">都交了</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 交了没人评 -->
|
||||
<section>
|
||||
<h4>
|
||||
<i class="dot warn"></i>交了但还没人评
|
||||
<span class="count">{{ stats.unrated_count }} 人</span>
|
||||
</h4>
|
||||
<div class="names">
|
||||
<template v-for="g in unratedGroups" :key="g.classname">
|
||||
<div v-if="isAll" class="class-label">{{ g.classname || "未分班" }}</div>
|
||||
<div class="chips">
|
||||
<span v-for="n in g.names" :key="n" class="chip">{{ n }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!stats.unrated_count" class="empty">
|
||||
{{ stats.submitted_count ? "每份作品都有人评过" : "还没有人交" }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 交了几次 -->
|
||||
<section>
|
||||
<h4>交了几次</h4>
|
||||
<div v-if="stats.submitted_count" class="hist">
|
||||
<template v-for="b in buckets" :key="b.label">
|
||||
<span class="hist-label">{{ b.label }}</span>
|
||||
<span class="hist-bar">
|
||||
<span :style="{ width: pct(b.value, maxBucket) }"></span>
|
||||
</span>
|
||||
<span class="hist-value">{{ b.value }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="empty">还没有人交</div>
|
||||
</section>
|
||||
|
||||
<!-- 高分作品 -->
|
||||
<section>
|
||||
<h4>高分作品<span class="count">每人最高的一版</span></h4>
|
||||
<div v-if="stats.top_scored.length" class="top">
|
||||
<button
|
||||
v-for="(item, i) in stats.top_scored"
|
||||
:key="item.submission_id"
|
||||
class="top-row"
|
||||
@click="openSubmission(item)"
|
||||
>
|
||||
<span class="rank">{{ i + 1 }}</span>
|
||||
<span class="top-name">
|
||||
{{ shortName(item.username, item.classname) }}
|
||||
<small v-if="isAll">{{ item.classname }}</small>
|
||||
</span>
|
||||
<span class="top-score">{{ item.score.toFixed(1) }}</span>
|
||||
<Icon icon="lucide:chevron-right" :width="14" class="go" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty">还没有打过分的作品</div>
|
||||
</section>
|
||||
|
||||
<!-- 怎么问的 AI -->
|
||||
<section class="span">
|
||||
<h4>
|
||||
怎么问的 AI
|
||||
<span class="count">每人最新一版 · {{ stats.prompts.with_chain }} 份有提示词记录</span>
|
||||
</h4>
|
||||
<div v-if="stats.prompts.with_chain" class="prompt">
|
||||
<div class="facts">
|
||||
<div><b>{{ stats.prompts.avg_rounds }}</b>平均几轮</div>
|
||||
<div><b>{{ pct(stats.prompts.one_round, stats.prompts.with_chain) }}</b>一轮就交</div>
|
||||
<div><b>{{ pct(stats.prompts.external, stats.prompts.with_chain) }}</b>最后一版用外部 AI</div>
|
||||
<div><b>{{ pct(stats.prompts.used_guidance, stats.prompts.with_chain) }}</b>问过怎么写提示词</div>
|
||||
</div>
|
||||
<div class="hist">
|
||||
<template v-for="(n, i) in stats.prompts.levels" :key="i">
|
||||
<span class="hist-label">{{ levelLabel(i + 1) }}</span>
|
||||
<span class="hist-bar">
|
||||
<span :style="{ width: pct(n, maxLevel) }"></span>
|
||||
</span>
|
||||
<span class="hist-value">{{ n }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
{{ stats.submitted_count ? "都是直接交的代码,没有提示词记录" : "还没有人交" }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<template v-if="!inReview" #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" icon-placement="right" @click="openReview">
|
||||
去批改{{ isAll ? "" : " " + classname + " 班" }}
|
||||
<template #icon><Icon icon="lucide:arrow-right" /></template>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Icon } from "../../icons"
|
||||
import { Submission } from "../../api"
|
||||
import { levelLabel } from "../../utils/helper"
|
||||
import type { TaskStatsOut, TopScoredItem, UserTag } from "../../utils/type"
|
||||
|
||||
// 老师下课前看的这道题的总结:默认看批改页正在看的班
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
show: boolean
|
||||
title?: string
|
||||
// 在批改页里打开:不显示“去批改”,点高分作品直接在批改页里打开
|
||||
inReview?: boolean
|
||||
// 批改页正在看的班级(“__all__” 是全部班级);不传就用记住的
|
||||
initialClass?: string | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [v: boolean]
|
||||
open: [submissionId: string, classname: string]
|
||||
}>()
|
||||
|
||||
// 和批改页共用“当前班级”
|
||||
const ALL = "__all__"
|
||||
const CLASS_KEY = "review-class"
|
||||
|
||||
const router = useRouter()
|
||||
const stats = ref<TaskStatsOut | null>(null)
|
||||
const loading = ref(false)
|
||||
const classname = ref<string>(ALL)
|
||||
const isAll = computed(() => classname.value === ALL)
|
||||
|
||||
const classOptions = computed(() => [
|
||||
{ label: "全部班级", value: ALL },
|
||||
...(stats.value?.classes ?? []).map((c) => ({ label: `${c} 班`, value: c })),
|
||||
])
|
||||
|
||||
const total = computed(
|
||||
() => (stats.value?.submitted_count ?? 0) + (stats.value?.unsubmitted_count ?? 0),
|
||||
)
|
||||
const multiSubmit = computed(() => {
|
||||
const d = stats.value?.submission_count_distribution
|
||||
return d ? d.count_2 + d.count_3 + d.count_4_plus : 0
|
||||
})
|
||||
const buckets = computed(() => {
|
||||
const d = stats.value?.submission_count_distribution
|
||||
if (!d) return []
|
||||
return [
|
||||
{ label: "1 次", value: d.count_1 },
|
||||
{ label: "2 次", value: d.count_2 },
|
||||
{ label: "3 次", value: d.count_3 },
|
||||
{ label: "4 次+", value: d.count_4_plus },
|
||||
]
|
||||
})
|
||||
const maxBucket = computed(() => Math.max(...buckets.value.map((b) => b.value)))
|
||||
const maxLevel = computed(() => Math.max(0, ...(stats.value?.prompts.levels ?? [])))
|
||||
|
||||
const unsubmittedGroups = computed(() => byClass(stats.value?.unsubmitted_users ?? []))
|
||||
const unratedGroups = computed(() => byClass(stats.value?.unrated_users ?? []))
|
||||
|
||||
function byClass(users: UserTag[]) {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const u of users) {
|
||||
const names = groups.get(u.classname) ?? []
|
||||
names.push(shortName(u.username, u.classname))
|
||||
groups.set(u.classname, names)
|
||||
}
|
||||
// 没分班的(测试账号等)放最后
|
||||
return [...groups]
|
||||
.sort(([a], [b]) => Number(a === "") - Number(b === "") || (a < b ? -1 : 1))
|
||||
.map(([classname, names]) => ({ classname, names }))
|
||||
}
|
||||
|
||||
// 账号是“web + 班级 + 名字”,班级已经单独显示了
|
||||
function shortName(username: string, classname: string) {
|
||||
const prefix = "web" + classname
|
||||
return username.startsWith(prefix) ? username.slice(prefix.length) : username
|
||||
}
|
||||
|
||||
function pct(value: number, of: number) {
|
||||
return of ? Math.round((value / of) * 100) + "%" : "0%"
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await Submission.getStats(props.taskId, isAll.value ? undefined : classname.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectClass(c: string) {
|
||||
classname.value = c
|
||||
try {
|
||||
localStorage.setItem(CLASS_KEY, c)
|
||||
} catch {}
|
||||
load()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (open) => {
|
||||
if (!open || !props.taskId) return
|
||||
let saved: string | null = props.initialClass ?? null
|
||||
try {
|
||||
saved ||= localStorage.getItem(CLASS_KEY)
|
||||
} catch {}
|
||||
classname.value = saved || ALL
|
||||
await load()
|
||||
// 记住的班级已经没有学生了
|
||||
if (!isAll.value && !stats.value?.classes.includes(classname.value)) {
|
||||
classname.value = ALL
|
||||
await load()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function openInNewTab(query: Record<string, string>) {
|
||||
const { href } = router.resolve({ name: "review", query })
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
|
||||
function openSubmission(item: TopScoredItem) {
|
||||
if (props.inReview) {
|
||||
emit("open", item.submission_id, item.classname)
|
||||
return
|
||||
}
|
||||
openInNewTab({
|
||||
class: isAll.value ? item.classname : classname.value,
|
||||
task: String(props.taskId),
|
||||
sub: item.submission_id,
|
||||
})
|
||||
}
|
||||
|
||||
function openReview() {
|
||||
openInNewTab({ class: classname.value, task: String(props.taskId) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--wp-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: 64vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 1fr 1fr;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
padding: 12px 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kpi + .kpi {
|
||||
border-left: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.kpi.wide {
|
||||
background: var(--wp-brand-soft);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
margin-top: 2px;
|
||||
font: 600 22px/1.2 var(--wp-font-mono);
|
||||
color: var(--wp-ink);
|
||||
}
|
||||
|
||||
.kpi-value small {
|
||||
font: 400 13px var(--wp-font-sans);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.kpi.wide .kpi-value {
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.pct {
|
||||
float: right;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--wp-muted);
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.kpi-note {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.bar {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--wp-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--wp-brand);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 20px;
|
||||
}
|
||||
|
||||
section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-ink);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.bad {
|
||||
background: var(--wp-bad);
|
||||
}
|
||||
|
||||
.dot.warn {
|
||||
background: var(--wp-warn);
|
||||
}
|
||||
|
||||
.names {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.class-label {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 11px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.class-label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--wp-ink);
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 12px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.hist {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.hist-label {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.hist-bar {
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: var(--wp-paper);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hist-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--wp-brand);
|
||||
}
|
||||
|
||||
.hist-value {
|
||||
min-width: 2ch;
|
||||
text-align: right;
|
||||
font: 12px var(--wp-font-mono);
|
||||
color: var(--wp-ink);
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--wp-ink);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.top-row:hover {
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 16px;
|
||||
font: 12px var(--wp-font-mono);
|
||||
color: var(--wp-faint);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.top-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-name small {
|
||||
margin-left: 4px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.top-score {
|
||||
font: 600 13px var(--wp-font-mono);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.go {
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 16px;
|
||||
align-content: start;
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.facts b {
|
||||
display: block;
|
||||
font: 600 18px/1.3 var(--wp-font-mono);
|
||||
color: var(--wp-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="markdown-body" v-html="content" ref="$content" />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, useTemplateRef, watch } from "vue"
|
||||
import { Marked } from "marked"
|
||||
import { trustedExtensions } from "../../utils/markdown"
|
||||
import copyFn from "copy-text-to-clipboard"
|
||||
import { css, html, js, tab } from "../../store/editors"
|
||||
import { Tutorial } from "../../api"
|
||||
import { readTaskCode } from "../../composables/useTaskCode"
|
||||
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
||||
import { taskId, assetBaseUrl } from "../../store/task"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
// 独立实例:带“复制/替换”按钮的代码块只用在教程里,不能改全局的 marked
|
||||
const tutorialMarked = new Marked(...trustedExtensions, {
|
||||
renderer: {
|
||||
code({ text, lang }) {
|
||||
const language = lang?.toLowerCase() ?? "html"
|
||||
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||
<div class="codeblock-action">
|
||||
<span class="lang">${language.toUpperCase()}</span>
|
||||
<div class="btn-group">
|
||||
<button class="action-btn" data-action="copy">复制</button>
|
||||
<button class="action-btn" data-action="replace">替换</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre><code class="language-${language}">${text}</code></pre>
|
||||
</div>`
|
||||
},
|
||||
link({ href, text }) {
|
||||
return `<a href="${href}" target="_blank">${text}</a>`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const content = ref("")
|
||||
const $content = useTemplateRef<any>("$content")
|
||||
|
||||
// 连点“下一节”时只用最后一次请求的结果
|
||||
let renderSeq = 0
|
||||
|
||||
async function render() {
|
||||
const seq = ++renderSeq
|
||||
const data = await Tutorial.get(step.value)
|
||||
if (seq !== renderSeq) return
|
||||
// 这一节写过代码就接着写,没写过才放示例代码(以前每次切换都会被示例覆盖)
|
||||
const saved = readTaskCode(data.task_ptr)
|
||||
taskId.value = data.task_ptr
|
||||
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||
html.value = saved?.html ?? data.example_html ?? ""
|
||||
css.value = saved?.css ?? data.example_css ?? ""
|
||||
js.value = saved?.js ?? data.example_js ?? ""
|
||||
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||
const rendered = await tutorialMarked.parse(merged, { async: true })
|
||||
if (seq !== renderSeq) return
|
||||
content.value = rendered
|
||||
}
|
||||
|
||||
function flash(btn: HTMLButtonElement, done: string, original: string) {
|
||||
btn.textContent = done
|
||||
setTimeout(() => {
|
||||
btn.textContent = original
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function setupCodeActions() {
|
||||
$content.value?.addEventListener("click", (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
|
||||
"[data-action]",
|
||||
)
|
||||
if (!btn) return
|
||||
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
||||
const lang = wrapper.dataset.lang ?? "html"
|
||||
const code = wrapper.querySelector("code")?.textContent ?? ""
|
||||
|
||||
if (btn.dataset.action === "copy") {
|
||||
copyFn(code)
|
||||
flash(btn, "已复制", "复制")
|
||||
} else if (btn.dataset.action === "replace") {
|
||||
tab.value = lang
|
||||
if (lang === "html") html.value = code
|
||||
if (lang === "css") css.value = code
|
||||
if (lang === "js") js.value = code
|
||||
flash(btn, "已替换", "替换")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadTutorials()
|
||||
if (!tutorialIds.value.length) {
|
||||
content.value = "暂无教程"
|
||||
return
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupCodeActions()
|
||||
init()
|
||||
})
|
||||
watch(step, (v) => {
|
||||
router.push({ name: "home-tutorial", params: { display: v } })
|
||||
render()
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.markdown-body {
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.markdown-body pre {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-family: var(--wp-font-mono);
|
||||
}
|
||||
|
||||
.markdown-body .codeblock-wrapper {
|
||||
padding: 1rem;
|
||||
background-color: var(--wp-paper);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-body .codeblock-wrapper pre {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.codeblock-action {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family:
|
||||
v-sans,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.codeblock-action .lang {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.codeblock-action .btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.codeblock-action .action-btn {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--wp-line);
|
||||
background-color: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.codeblock-action .action-btn:hover {
|
||||
border-color: var(--wp-brand);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
</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
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_WEB_URL: string
|
||||
readonly PUBLIC_BASE_URL: string
|
||||
readonly PUBLIC_WS_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--wp-paper);
|
||||
color: var(--wp-ink);
|
||||
font-family: var(--wp-font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--wp-line);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
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" })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createApp } from "vue"
|
||||
import { create } from "naive-ui"
|
||||
import App from "./App.vue"
|
||||
import "@fontsource/jetbrains-mono/400.css"
|
||||
import "@fontsource/jetbrains-mono/600.css"
|
||||
import "./global.css"
|
||||
import "./store/theme"
|
||||
|
||||
//@ts-ignore
|
||||
import "github-markdown-css/github-markdown-light.css"
|
||||
|
||||
import { marked } from "marked"
|
||||
import { trustedExtensions } from "./utils/markdown"
|
||||
//@ts-ignore
|
||||
import "highlight.js/styles/github.min.css"
|
||||
// 暗色时覆盖上面两份亮色样式
|
||||
import "./styles/dark-content.css"
|
||||
import { router } from "./router"
|
||||
|
||||
marked.use(...trustedExtensions)
|
||||
|
||||
const app = createApp(App)
|
||||
const naive = create()
|
||||
|
||||
app.use(naive)
|
||||
app.use(router)
|
||||
app.mount("#app")
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<Workbench
|
||||
ref="wb"
|
||||
kind="challenge"
|
||||
:task-id="taskId"
|
||||
:title="title"
|
||||
back-label="返回挑战列表"
|
||||
:back-to="{ name: 'home-challenge-list' }"
|
||||
brief-label="题目"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
>
|
||||
<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" />
|
||||
</n-button>
|
||||
</template>
|
||||
课堂总结
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="roleSuper">
|
||||
<template #trigger>
|
||||
<n-button text @click="edit">
|
||||
<Icon :width="16" icon="lucide:edit" />
|
||||
</n-button>
|
||||
</template>
|
||||
编辑这道题
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<template #brief>
|
||||
<div class="brief">
|
||||
<n-flex align="center" justify="space-between" class="meta">
|
||||
<n-text depth="3">出题人:{{ author || "未设置" }}</n-text>
|
||||
<n-button v-if="exampleCode" size="tiny" @click="useExample">
|
||||
放入示例代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div
|
||||
class="markdown-body challenge-desc no-select"
|
||||
v-html="content"
|
||||
@copy.prevent
|
||||
@click="onCopyClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Workbench>
|
||||
<TaskStatsModal v-model:show="showStats" :task-id="taskId" :title="title" />
|
||||
<PeersDrawer v-model:show="showPeers" :task-id="taskId" :asset-base-url="assetBaseUrl" />
|
||||
<n-modal
|
||||
v-model:show="showAssets"
|
||||
preset="card"
|
||||
title="素材"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||
<n-gi v-for="asset in assets" :key="asset.name">
|
||||
<n-card size="small" :title="asset.name">
|
||||
<n-image
|
||||
:src="asset.url"
|
||||
style="width: 100%; height: 100px; object-fit: contain"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Marked } from "marked"
|
||||
import copyFn from "copy-text-to-clipboard"
|
||||
import { Icon } from "../icons"
|
||||
import Workbench from "../components/workbench/Workbench.vue"
|
||||
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||
import PeersDrawer from "../components/review/PeersDrawer.vue"
|
||||
import { Challenge, TaskAssets } from "../api"
|
||||
import type { TaskAsset } from "../utils/type"
|
||||
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
import { roleStaff, roleSuper } from "../store/user"
|
||||
import { trustedExtensions } from "../utils/markdown"
|
||||
import type { Code } from "../utils/code"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 题目里的代码块带“复制”按钮;题目正文本身不让复制
|
||||
const challengeMarked = new Marked(...trustedExtensions, {
|
||||
renderer: {
|
||||
code({ text, lang }) {
|
||||
const language = lang?.toLowerCase() ?? "text"
|
||||
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||
<div class="codeblock-action">
|
||||
<span class="lang">${language.toUpperCase()}</span>
|
||||
<button class="action-btn" data-action="copy">复制</button>
|
||||
</div>
|
||||
<pre><code class="language-${language}">${text}</code></pre>
|
||||
</div>`
|
||||
},
|
||||
link({ href, text }) {
|
||||
return `<a href="${href}" target="_blank">${text}</a>`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||
const title = ref("")
|
||||
const score = ref(0)
|
||||
const author = ref("")
|
||||
const content = ref("")
|
||||
const assets = ref<TaskAsset[]>([])
|
||||
const exampleCode = ref<Code | null>(null)
|
||||
const showStats = ref(false)
|
||||
const showAssets = ref(false)
|
||||
const showPeers = ref(false)
|
||||
|
||||
const assetBaseUrl = computed(
|
||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||
)
|
||||
|
||||
// 加载还没完成就离开页面时,不要再改全局状态
|
||||
let disposed = false
|
||||
|
||||
async function load() {
|
||||
const display = Number(route.params.display)
|
||||
taskTab.value = TASK_TYPE.Challenge
|
||||
challengeDisplay.value = display
|
||||
const [data, fetchedAssets] = await Promise.all([
|
||||
Challenge.get(display),
|
||||
TaskAssets.listChallenge(display),
|
||||
])
|
||||
if (disposed) return
|
||||
title.value = `${data.display}. ${data.title}`
|
||||
score.value = data.score
|
||||
author.value = data.author_name ?? ""
|
||||
assets.value = fetchedAssets
|
||||
exampleCode.value =
|
||||
data.example_html || data.example_css || data.example_js
|
||||
? {
|
||||
html: data.example_html ?? "",
|
||||
css: data.example_css ?? "",
|
||||
js: data.example_js ?? "",
|
||||
}
|
||||
: null
|
||||
content.value = await challengeMarked.parse(data.content, { async: true })
|
||||
if (disposed) return
|
||||
taskId.value = data.task_ptr
|
||||
}
|
||||
|
||||
function useExample() {
|
||||
if (exampleCode.value) wb.value?.setCode({ ...exampleCode.value })
|
||||
}
|
||||
|
||||
function onCopyClick(e: MouseEvent) {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||
if (!btn) return
|
||||
const code = btn.closest<HTMLElement>("[data-lang]")?.querySelector("code")?.textContent ?? ""
|
||||
copyFn(code)
|
||||
btn.textContent = "已复制"
|
||||
setTimeout(() => {
|
||||
btn.textContent = "复制"
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function edit() {
|
||||
router.push({ name: "challenge-editor", params: { display: route.params.display } })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
taskId.value = 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.brief {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.challenge-desc {
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.challenge-desc .codeblock-wrapper {
|
||||
padding: 12px;
|
||||
background-color: var(--wp-paper);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.challenge-desc .codeblock-wrapper pre {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.challenge-desc .codeblock-wrapper pre code {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-family: var(--wp-font-mono);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.challenge-desc .codeblock-action {
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.challenge-desc .codeblock-action .lang {
|
||||
font: 600 12px var(--wp-font-mono);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.challenge-desc .action-btn {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--wp-line);
|
||||
background-color: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.challenge-desc .action-btn:hover {
|
||||
border-color: var(--wp-brand);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<Workbench
|
||||
ref="wb"
|
||||
kind="creation"
|
||||
:task-id="taskId"
|
||||
:title="title || '自由创作'"
|
||||
back-label="返回我的创作"
|
||||
:back-to="{ name: 'creation-list' }"
|
||||
brief-label="我的主题"
|
||||
:save-work="saveWork"
|
||||
>
|
||||
<template #brief>
|
||||
<div class="topic">
|
||||
<n-form-item label="主题名称" :show-feedback="false">
|
||||
<n-input
|
||||
v-model:value="title"
|
||||
placeholder="给你的主题起个名字"
|
||||
maxlength="100"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="主题描述" :show-feedback="false" style="margin-top: 10px">
|
||||
<n-input
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-flex style="margin-top: 12px">
|
||||
<n-button
|
||||
size="small"
|
||||
:loading="savingTopic"
|
||||
:disabled="!title.trim()"
|
||||
@click="saveTopic"
|
||||
>
|
||||
保存主题
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!content.trim() || streaming"
|
||||
@click="generate"
|
||||
>
|
||||
用这个主题生成
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-text depth="3" class="hint">
|
||||
自由创作只属于你自己,不会进入提交记录和评分。
|
||||
</n-text>
|
||||
</div>
|
||||
</template>
|
||||
</Workbench>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useMessage } from "naive-ui"
|
||||
import Workbench from "../components/workbench/Workbench.vue"
|
||||
import { Creation } from "../api"
|
||||
import { streaming } from "../store/prompt"
|
||||
import type { Code } from "../utils/code"
|
||||
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||
|
||||
const display = Number(route.params.display)
|
||||
const taskId = ref(0)
|
||||
const title = ref("")
|
||||
const content = ref("")
|
||||
const savingTopic = ref(false)
|
||||
|
||||
// 加载还没完成就离开页面时,不要再改状态
|
||||
let disposed = false
|
||||
|
||||
async function load() {
|
||||
const data = await Creation.get(display)
|
||||
if (disposed) return
|
||||
title.value = data.title
|
||||
content.value = data.content
|
||||
taskId.value = data.task_id
|
||||
// 本地没有存着修改到一半的代码,就用服务器上保存的作品
|
||||
if (!wb.value?.restored) {
|
||||
wb.value?.setCode({ html: data.html, css: data.css, js: data.js })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWork(code: Code) {
|
||||
await Creation.saveCode(display, code)
|
||||
}
|
||||
|
||||
async function saveTopic() {
|
||||
if (!title.value.trim()) return
|
||||
savingTopic.value = true
|
||||
try {
|
||||
await Creation.update(display, {
|
||||
title: title.value.trim(),
|
||||
content: content.value,
|
||||
})
|
||||
message.success("主题已保存")
|
||||
} catch {
|
||||
message.error("保存失败,请重试")
|
||||
} finally {
|
||||
savingTopic.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const text = content.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
wb.value?.send(text)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.topic {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<main class="creation-home">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h2>自由创作</h2>
|
||||
<p class="sub">自己定一个主题,和 AI 一起把它做出来。不用交,也不打分</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="openCreate()">
|
||||
<template #icon><Icon icon="lucide:plus" /></template>
|
||||
新建主题
|
||||
</n-button>
|
||||
</header>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<div v-if="!loading && !creations.length" class="empty">
|
||||
<p>还没有创作。不知道做什么?从这些开始:</p>
|
||||
<div class="ideas">
|
||||
<button v-for="idea in IDEAS" :key="idea.title" class="idea" @click="openCreate(idea)">
|
||||
<b>{{ idea.title }}</b>
|
||||
<span>{{ idea.content }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ol v-else class="list">
|
||||
<li v-for="item in creations" :key="item.display">
|
||||
<router-link class="row" :to="{ name: 'creation', params: { display: item.display } }">
|
||||
<span class="main">
|
||||
<span class="title">{{ item.title }}</span>
|
||||
<span class="desc">{{ item.content || "还没写主题描述" }}</span>
|
||||
</span>
|
||||
<span :class="['state', { done: item.has_code }]">
|
||||
{{ item.has_code ? "已有作品" : "还没开始" }}
|
||||
</span>
|
||||
<span class="time">{{ formatTime(item.modified) }}</span>
|
||||
<n-popconfirm @positive-click="remove(item)">
|
||||
<template #trigger>
|
||||
<n-button text class="del" title="删除" @click.prevent.stop>
|
||||
<Icon icon="lucide:trash-2" :width="15" />
|
||||
</n-button>
|
||||
</template>
|
||||
删除「{{ item.title }}」?和它相关的对话也会一起删掉。
|
||||
</n-popconfirm>
|
||||
</router-link>
|
||||
</li>
|
||||
</ol>
|
||||
</n-spin>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showCreate"
|
||||
preset="card"
|
||||
title="新建主题"
|
||||
style="width: 520px"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="主题名称">
|
||||
<n-input
|
||||
ref="titleRef"
|
||||
v-model:value="form.title"
|
||||
placeholder="例如:我的番茄钟"
|
||||
maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="主题描述(可以之后再补充)">
|
||||
<n-input
|
||||
v-model:value="form.content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="flex-end">
|
||||
<n-button @click="showCreate = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="creating"
|
||||
:disabled="!form.title.trim()"
|
||||
@click="submit"
|
||||
>
|
||||
创建并开始
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, reactive, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../icons"
|
||||
import { Creation } from "../api"
|
||||
import type { CreationSlim } from "../utils/type"
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const creating = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const creations = ref<CreationSlim[]>([])
|
||||
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) {
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function openCreate(idea?: { title: string; content: string }) {
|
||||
form.title = idea?.title ?? ""
|
||||
form.content = idea?.content ?? ""
|
||||
showCreate.value = true
|
||||
nextTick(() => titleRef.value?.focus())
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const title = form.title.trim()
|
||||
if (!title) return
|
||||
creating.value = true
|
||||
try {
|
||||
const created = await Creation.create({ title, content: form.content })
|
||||
showCreate.value = false
|
||||
router.push({ name: "creation", params: { display: created.display } })
|
||||
} catch {
|
||||
message.error("创建失败,请重试")
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: CreationSlim) {
|
||||
try {
|
||||
await Creation.remove(item.display)
|
||||
creations.value = creations.value.filter((c) => c.display !== item.display)
|
||||
message.success("已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
creations.value = await Creation.listMine()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.creation-home {
|
||||
max-width: 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: 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;
|
||||
}
|
||||
|
||||
.idea:hover {
|
||||
border-color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.idea b {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.idea span {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="teach">
|
||||
<nav class="sidebar">
|
||||
<template v-for="group in groups" :key="group.label">
|
||||
<div class="group-label">{{ group.label }}</div>
|
||||
<router-link
|
||||
v-for="item in group.items"
|
||||
:key="item.label"
|
||||
:to="item.route"
|
||||
:class="['nav-item', { active: item.names.includes(String($route.name)) }]"
|
||||
>
|
||||
<Icon :icon="item.icon" :width="16" />
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="content">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { Icon } from "../icons"
|
||||
import { step } from "../store/tutorial"
|
||||
import { roleSuper } from "../store/user"
|
||||
|
||||
// admin 只能出挑战、看提交、管作品展;其余是超管的(和后端权限一致)
|
||||
const groups = computed(() =>
|
||||
[
|
||||
{
|
||||
label: "出题",
|
||||
items: [
|
||||
{
|
||||
label: "教程",
|
||||
icon: "lucide:book-open",
|
||||
route: { name: "tutorial-editor", params: { display: step.value } },
|
||||
names: ["tutorial-editor"],
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "挑战",
|
||||
icon: "lucide:swords",
|
||||
route: { name: "challenge-editor", params: { display: 0 } },
|
||||
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: "用户",
|
||||
icon: "lucide:users",
|
||||
route: { name: "user-manage", params: { page: 1 } },
|
||||
names: ["user-manage"],
|
||||
show: roleSuper.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => i.show) }))
|
||||
.filter((g) => g.items.length),
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.teach {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 148px;
|
||||
flex: none;
|
||||
padding: 8px;
|
||||
border-right: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
padding: 12px 10px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-faint);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--wp-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--wp-paper);
|
||||
color: var(--wp-ink);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--wp-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="gradebook">
|
||||
<div class="toolbar">
|
||||
<n-select
|
||||
v-model:value="query.classname"
|
||||
class="class-select"
|
||||
:options="classOptions"
|
||||
placeholder="班级"
|
||||
size="small"
|
||||
:loading="classesLoading"
|
||||
/>
|
||||
<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-input
|
||||
v-model:value="query.username"
|
||||
class="search-input"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索学生"
|
||||
>
|
||||
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
|
||||
</n-input>
|
||||
<n-checkbox v-model:checked="query.include_all_tasks" size="small">
|
||||
显示不计入的任务
|
||||
</n-checkbox>
|
||||
|
||||
<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
|
||||
size="small"
|
||||
quaternary
|
||||
:disabled="!query.classname"
|
||||
:loading="loading"
|
||||
@click="loadGradebook"
|
||||
>
|
||||
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||
</n-button>
|
||||
</template>
|
||||
刷新
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
type="primary"
|
||||
:disabled="!gradebook"
|
||||
:loading="exporting"
|
||||
@click="exportCsv"
|
||||
>
|
||||
<template #icon><Icon icon="lucide:download" /></template>
|
||||
导出 CSV
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
||||
{{ loadError }}
|
||||
</n-alert>
|
||||
|
||||
<n-data-table
|
||||
class="table gradebook-table"
|
||||
size="small"
|
||||
flex-height
|
||||
:single-line="false"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(row: GradebookRow) => row.user_id"
|
||||
:scroll-x="scrollX"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||
import { Icon } from "../icons"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { useMessage, type DataTableColumn } from "naive-ui"
|
||||
import { Account, Gradebook } from "../api"
|
||||
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||
import type {
|
||||
GradebookCell,
|
||||
GradebookOut,
|
||||
GradebookQuery,
|
||||
GradebookRow,
|
||||
GradebookTask,
|
||||
} from "../utils/type"
|
||||
|
||||
// 和批改页共用“当前班级”(那边的“全部班级”在这里不适用)
|
||||
const CLASS_KEY = "review-class"
|
||||
|
||||
const message = useMessage()
|
||||
const classesLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const loadError = ref("")
|
||||
const gradebook = ref<GradebookOut | null>(null)
|
||||
const classes = ref<string[]>([])
|
||||
|
||||
const query = reactive<GradebookQuery>({
|
||||
classname: "",
|
||||
task_type: "",
|
||||
username: "",
|
||||
include_all_tasks: false,
|
||||
})
|
||||
|
||||
const classOptions = computed(() =>
|
||||
classes.value.map((classname) => ({ label: `${classname} 班`, value: classname })),
|
||||
)
|
||||
const rows = computed(() => gradebook.value?.rows ?? [])
|
||||
|
||||
const TASK_WIDTH = 66
|
||||
const scrollX = computed(() => 380 + (gradebook.value?.tasks.length ?? 0) * TASK_WIDTH)
|
||||
|
||||
function formatScore(value: number | null) {
|
||||
if (value === null) return "—"
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
|
||||
// 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
|
||||
const reviewId = ref<string | null>(null)
|
||||
function openSubmission(cell: GradebookCell) {
|
||||
if (!cell.submitted || !cell.submission_id) return
|
||||
reviewId.value = cell.submission_id
|
||||
}
|
||||
|
||||
function renderGrade(grade: string, extra?: string | number) {
|
||||
return h("span", { class: ["grade", `g-${grade}`] }, [
|
||||
grade,
|
||||
extra === undefined ? null : h("small", String(extra)),
|
||||
])
|
||||
}
|
||||
|
||||
function renderTaskHeader(task: GradebookTask) {
|
||||
const pct = Math.round(task.coverage * 100)
|
||||
const studentCount = gradebook.value?.student_count ?? 0
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: ["task-head", { excluded: !task.included }],
|
||||
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) {
|
||||
const cell = row.scores[task.id]
|
||||
if (!cell?.submitted)
|
||||
return h("span", { class: ["cell", "missing", { excluded: !task.included }] }, "缺")
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
class: ["cell", "score", { unrated: cell.score === 0, excluded: !task.included }],
|
||||
title: cell.score === 0 ? "交了,还没人评分" : `${formatScore(cell.score)},点开批改`,
|
||||
onClick: () => openSubmission(cell),
|
||||
},
|
||||
cell.score === 0 ? "未评" : cell.score.toFixed(1),
|
||||
)
|
||||
}
|
||||
|
||||
function taskColumn(task: GradebookTask): DataTableColumn<GradebookRow> {
|
||||
return {
|
||||
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 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 [
|
||||
{
|
||||
title: "#",
|
||||
key: "rank",
|
||||
width: 40,
|
||||
fixed: "left",
|
||||
align: "right",
|
||||
sorter: (a, b) => a.rank - b.rank,
|
||||
render: (row) => h("span", { class: "rank" }, row.rank),
|
||||
},
|
||||
{
|
||||
title: "学生",
|
||||
key: "username",
|
||||
width: 92,
|
||||
fixed: "left",
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => displayGradebookStudentName(row),
|
||||
},
|
||||
{
|
||||
title: "平时",
|
||||
key: "grade",
|
||||
width: 52,
|
||||
fixed: "left",
|
||||
align: "center",
|
||||
render: (row) => renderGrade(row.grade),
|
||||
},
|
||||
{
|
||||
title: "考核",
|
||||
key: "assessment_score",
|
||||
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,
|
||||
children: g.items.map(taskColumn),
|
||||
})),
|
||||
{
|
||||
title: "总分",
|
||||
key: "total_score",
|
||||
width: 72,
|
||||
fixed: "right",
|
||||
align: "right",
|
||||
sorter: (a, b) => a.total_score - b.total_score,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: "total",
|
||||
title: `教程合计 ${formatScore(row.tutorial_total)}\n挑战合计 ${formatScore(row.challenge_total)}\n平均 ${formatScore(row.average_score)}`,
|
||||
},
|
||||
formatScore(row.total_score),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "已交",
|
||||
key: "submitted_task_count",
|
||||
width: 64,
|
||||
fixed: "right",
|
||||
align: "right",
|
||||
sorter: (a, b) => a.missing_task_count - b.missing_task_count,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: ["done", { short: row.missing_task_count > 0 }],
|
||||
title: row.missing_task_count ? `缺交 ${row.missing_task_count} 个` : "都交了",
|
||||
},
|
||||
`${row.submitted_task_count}/${row.submitted_task_count + row.missing_task_count}`,
|
||||
),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
async function loadClasses() {
|
||||
classesLoading.value = true
|
||||
try {
|
||||
classes.value = await Account.listClasses()
|
||||
if (!query.classname && classes.value.length > 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 {
|
||||
classesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 快速切换班级时只用最后一次请求的结果
|
||||
let loadSeq = 0
|
||||
|
||||
async function loadGradebook() {
|
||||
const seq = ++loadSeq
|
||||
if (!query.classname) {
|
||||
gradebook.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = ""
|
||||
try {
|
||||
const data = await Gradebook.get(query)
|
||||
if (seq !== loadSeq) return
|
||||
gradebook.value = data
|
||||
classes.value = data.classes
|
||||
} catch (err: any) {
|
||||
if (seq !== loadSeq) return
|
||||
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (!query.classname) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await Gradebook.downloadCsv(query)
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "导出失败")
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => query.classname,
|
||||
(c) => {
|
||||
if (!c) return
|
||||
try {
|
||||
localStorage.setItem(CLASS_KEY, c)
|
||||
} catch {}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [query.classname, query.task_type, query.include_all_tasks],
|
||||
() => loadGradebook(),
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => loadGradebook(),
|
||||
{ debounce: 400, maxWait: 1000 },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const before = query.classname
|
||||
await loadClasses()
|
||||
// loadClasses 选了默认班级时,上面的 watch 已经会加载
|
||||
if (query.classname === before) loadGradebook()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gradebook {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.class-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.rules {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rules p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.rules p:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<main class="showcase">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>作品展</h2>
|
||||
<p class="muted">老师从大家交的作品里挑出来的,点开能看大图和它是怎么问出来的</p>
|
||||
</div>
|
||||
<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-empty
|
||||
v-if="!loading && !awards.length"
|
||||
description="还没有展出的作品"
|
||||
style="margin-top: 72px"
|
||||
/>
|
||||
|
||||
<section v-for="(section, si) in awards" :id="`award-${section.id}`" :key="section.id">
|
||||
<div class="section-head">
|
||||
<h3>{{ section.name }}</h3>
|
||||
<span class="muted">{{ section.items.length }} 件</span>
|
||||
</div>
|
||||
<p v-if="section.description" class="desc">{{ section.description }}</p>
|
||||
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="(item, ii) in section.items"
|
||||
:key="item.submission_id"
|
||||
class="card"
|
||||
@click="open(si, ii)"
|
||||
>
|
||||
<div class="thumb">
|
||||
<iframe
|
||||
:srcdoc="docOf(item)"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
loading="lazy"
|
||||
scrolling="no"
|
||||
tabindex="-1"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="cap">
|
||||
<div class="who">
|
||||
<b>{{ item.username }}</b>
|
||||
<span v-if="item.score" class="score">
|
||||
<Icon icon="lucide:star" :width="12" />{{ item.score.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task">{{ item.task_display }}. {{ item.task_title }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</n-spin>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Icon } from "../icons"
|
||||
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"
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const awards = ref<AwardSection[]>([])
|
||||
// 正在看第几个奖项的第几件
|
||||
const viewing = ref<{ section: number; item: number } | null>(null)
|
||||
const showChain = ref(false)
|
||||
|
||||
const current = computed(() =>
|
||||
viewing.value ? (awards.value[viewing.value.section]?.items[viewing.value.item] ?? null) : null,
|
||||
)
|
||||
|
||||
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 awardsOf(submissionId: string) {
|
||||
return awards.value
|
||||
.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")
|
||||
}
|
||||
|
||||
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 {
|
||||
awards.value = await Showcase.list()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.showcase {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 48px;
|
||||
}
|
||||
|
||||
.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;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--wp-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
/* 按 2 倍大小渲染再缩小 */
|
||||
.thumb iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
border: 0;
|
||||
transform: scale(0.5);
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cap {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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>
|
||||
@@ -0,0 +1,636 @@
|
||||
<template>
|
||||
<div class="manage">
|
||||
<aside class="awards">
|
||||
<div class="awards-head">
|
||||
<b>奖项</b>
|
||||
<n-button size="small" quaternary @click="startCreate">
|
||||
<template #icon><Icon icon="lucide:plus" /></template>
|
||||
新建
|
||||
</n-button>
|
||||
</div>
|
||||
<n-spin :show="awardsLoading" class="award-list">
|
||||
<div
|
||||
v-for="(award, i) in awards"
|
||||
:key="award.id"
|
||||
:class="['award', { on: award.id === currentAwardId && !creating }]"
|
||||
@click="selectAward(award)"
|
||||
>
|
||||
<span class="award-name">{{ award.name }}</span>
|
||||
<span v-if="!award.is_active" class="off">停用</span>
|
||||
<span class="count">{{ award.item_count }}</span>
|
||||
<span v-if="award.id === currentAwardId && !creating" class="move" @click.stop>
|
||||
<button :disabled="i === 0" title="往前排" @click="moveAward(i, -1)">
|
||||
<Icon icon="lucide:chevron-up" :width="14" />
|
||||
</button>
|
||||
<button :disabled="i === awards.length - 1" title="往后排" @click="moveAward(i, 1)">
|
||||
<Icon icon="lucide:chevron-down" :width="14" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<n-empty
|
||||
v-if="!awardsLoading && !awards.length"
|
||||
description="还没有奖项"
|
||||
size="small"
|
||||
style="margin-top: 40px"
|
||||
/>
|
||||
</n-spin>
|
||||
<p class="note">排在前面的奖项在作品展里也排在前面。</p>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="form">
|
||||
<div class="row">
|
||||
<n-input
|
||||
v-model:value="draft.name"
|
||||
class="name"
|
||||
placeholder="奖项名称,例如“最佳创意”"
|
||||
/>
|
||||
<label class="active">
|
||||
<n-switch v-model:value="draft.is_active" />
|
||||
{{ draft.is_active ? "展出中" : "已停用" }}
|
||||
</label>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!canSave"
|
||||
:loading="saving"
|
||||
@click="saveAward"
|
||||
>
|
||||
{{ creating ? "创建" : "保存" }}
|
||||
</n-button>
|
||||
<n-popconfirm v-if="!creating && currentAwardId" @positive-click="deleteAward">
|
||||
<template #trigger>
|
||||
<n-button quaternary type="error" :loading="deleting">删除奖项</n-button>
|
||||
</template>
|
||||
删除后,这个奖项下的 {{ currentAward?.item_count ?? 0 }} 件作品也会从作品展里撤下(作品本身不受影响)。
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
<div class="row">
|
||||
<n-input v-model:value="draft.description" placeholder="一句话介绍(可以不填)" />
|
||||
<span class="label">作品顺序</span>
|
||||
<n-select v-model:value="draft.item_ordering" :options="orderingOptions" style="width: 140px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!creating && currentAwardId">
|
||||
<div class="items-head">
|
||||
<b>作品 <span class="muted">{{ items.length }} 件</span></b>
|
||||
<span class="muted">
|
||||
在“批改”里打开学生的作品,点“加入作品展”就能加到这里
|
||||
<template v-if="currentAward?.item_ordering === 'manual'">;用 ← → 调整顺序</template>
|
||||
<template v-else>;现在按{{ orderingLabel(currentAward?.item_ordering) }}自动排序</template>
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<n-button size="small" quaternary @click="router.push({ name: 'review' })">
|
||||
去批改
|
||||
<template #icon><Icon icon="lucide:arrow-right" /></template>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<n-spin :show="itemsLoading">
|
||||
<div v-if="items.length" class="grid">
|
||||
<div v-for="(item, i) in items" :key="item.id" class="card">
|
||||
<button class="thumb" title="打开看看" @click="reviewId = item.submission_id">
|
||||
<iframe
|
||||
:srcdoc="docOf(item)"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
loading="lazy"
|
||||
scrolling="no"
|
||||
tabindex="-1"
|
||||
></iframe>
|
||||
<span v-if="item.is_stale" class="stale">有新提交</span>
|
||||
</button>
|
||||
<div class="cap">
|
||||
<div class="who">
|
||||
<b>{{ item.username }}</b>
|
||||
<span class="score">{{ item.score ? item.score.toFixed(1) : "未评" }}</span>
|
||||
</div>
|
||||
<div class="task">{{ item.task_display }}. {{ item.task_title }}</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<template v-if="currentAward?.item_ordering === 'manual'">
|
||||
<n-button size="tiny" quaternary :disabled="i === 0" @click="moveItem(i, -1)">
|
||||
<template #icon><Icon icon="lucide:arrow-left" /></template>
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="i === items.length - 1"
|
||||
@click="moveItem(i, 1)"
|
||||
>
|
||||
<template #icon><Icon icon="lucide:arrow-right" /></template>
|
||||
</n-button>
|
||||
</template>
|
||||
<n-tooltip v-if="item.is_stale">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary type="warning" @click="refreshItem(item)">
|
||||
换成最新的
|
||||
</n-button>
|
||||
</template>
|
||||
这个学生后来又交了新的一版,换成最新交的那一版来展出
|
||||
</n-tooltip>
|
||||
<span class="spacer"></span>
|
||||
<n-popconfirm @positive-click="removeItem(item)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary type="error">撤下</n-button>
|
||||
</template>
|
||||
从这个奖项里撤下这件作品?
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty
|
||||
v-else-if="!itemsLoading"
|
||||
description="这个奖项还没有作品"
|
||||
style="margin-top: 60px"
|
||||
/>
|
||||
</n-spin>
|
||||
</template>
|
||||
<n-empty v-else-if="creating" description="先填好名称,创建后再往里加作品" style="margin-top: 60px" />
|
||||
</main>
|
||||
</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" />
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../icons"
|
||||
import { Showcase } from "../api"
|
||||
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||
import type { AwardItemManageOut, AwardManageIn, AwardManageOut, ItemOrdering } from "../utils/type"
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const awards = ref<AwardManageOut[]>([])
|
||||
const items = ref<AwardItemManageOut[]>([])
|
||||
const currentAwardId = ref<number | null>(null)
|
||||
const creating = ref(false)
|
||||
const awardsLoading = ref(false)
|
||||
const itemsLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const reviewId = ref<string | null>(null)
|
||||
|
||||
const draft = reactive<AwardManageIn>(emptyAward())
|
||||
|
||||
const orderingOptions: { label: string; value: ItemOrdering }[] = [
|
||||
{ label: "手动排序", value: "manual" },
|
||||
{ label: "最新加入的在前", value: "awarded_at" },
|
||||
{ label: "得分高的在前", value: "score" },
|
||||
{ label: "看的人多的在前", value: "view_count" },
|
||||
]
|
||||
const orderingLabel = (v?: ItemOrdering) =>
|
||||
({ awarded_at: "加入时间", score: "得分", view_count: "浏览次数", manual: "手动" })[v ?? "manual"]
|
||||
|
||||
const currentAward = computed(() => awards.value.find((a) => a.id === currentAwardId.value) ?? null)
|
||||
|
||||
// 有改动、名称不空才能保存
|
||||
const canSave = computed(() => {
|
||||
if (!draft.name.trim()) return false
|
||||
const a = currentAward.value
|
||||
if (creating.value || !a) return true
|
||||
return (
|
||||
draft.name.trim() !== a.name ||
|
||||
draft.description.trim() !== a.description ||
|
||||
draft.is_active !== a.is_active ||
|
||||
draft.item_ordering !== a.item_ordering
|
||||
)
|
||||
})
|
||||
|
||||
function emptyAward(): AwardManageIn {
|
||||
return { name: "", description: "", sort_order: 0, is_active: true, item_ordering: "manual" }
|
||||
}
|
||||
|
||||
function fill(a: AwardManageIn) {
|
||||
Object.assign(draft, {
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
sort_order: a.sort_order,
|
||||
is_active: a.is_active,
|
||||
item_ordering: a.item_ordering,
|
||||
})
|
||||
}
|
||||
|
||||
function docOf(item: AwardItemManageOut) {
|
||||
return buildPreviewDocument({
|
||||
html: item.html ?? "",
|
||||
css: item.css ?? "",
|
||||
js: item.js ?? "",
|
||||
assetBaseUrl: `/media/tasks/${item.task_type}/${item.task_display}/`,
|
||||
})
|
||||
}
|
||||
|
||||
const errText = (err: any, fallback: string) => err.response?.data?.detail ?? fallback
|
||||
|
||||
async function loadAwards() {
|
||||
awardsLoading.value = true
|
||||
try {
|
||||
awards.value = await Showcase.listManageAwards()
|
||||
} finally {
|
||||
awardsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
if (!currentAwardId.value) return
|
||||
itemsLoading.value = true
|
||||
try {
|
||||
items.value = await Showcase.listAwardItems(currentAwardId.value)
|
||||
} finally {
|
||||
itemsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectAward(a: AwardManageOut) {
|
||||
if (a.id === currentAwardId.value && !creating.value) return
|
||||
currentAwardId.value = a.id
|
||||
creating.value = false
|
||||
fill(a)
|
||||
items.value = []
|
||||
loadItems()
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
creating.value = true
|
||||
currentAwardId.value = null
|
||||
items.value = []
|
||||
// 新建的奖项排在最后
|
||||
fill({ ...emptyAward(), sort_order: Math.max(-1, ...awards.value.map((a) => a.sort_order)) + 1 })
|
||||
}
|
||||
|
||||
async function saveAward() {
|
||||
if (!canSave.value) return
|
||||
const payload = { ...draft, name: draft.name.trim(), description: draft.description.trim() }
|
||||
const isNew = creating.value || !currentAwardId.value
|
||||
saving.value = true
|
||||
try {
|
||||
const saved = isNew
|
||||
? await Showcase.createAward(payload)
|
||||
: await Showcase.updateAward(currentAwardId.value!, payload)
|
||||
await loadAwards()
|
||||
const next = awards.value.find((a) => a.id === saved.id)
|
||||
if (next) {
|
||||
creating.value = false
|
||||
currentAwardId.value = next.id
|
||||
fill(next)
|
||||
loadItems()
|
||||
}
|
||||
message.success(isNew ? "已创建" : "已保存")
|
||||
} catch (err) {
|
||||
message.error(errText(err, "保存失败"))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAward() {
|
||||
if (!currentAwardId.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await Showcase.deleteAward(currentAwardId.value)
|
||||
await loadAwards()
|
||||
message.success("已删除")
|
||||
if (awards.value[0]) {
|
||||
currentAwardId.value = null
|
||||
selectAward(awards.value[0])
|
||||
} else startCreate()
|
||||
} catch (err) {
|
||||
message.error(errText(err, "删除失败"))
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 调整奖项顺序:按新的先后重新编号,只改编号变了的
|
||||
async function moveAward(i: number, d: number) {
|
||||
const list = [...awards.value]
|
||||
const [a] = list.splice(i, 1)
|
||||
list.splice(i + d, 0, a!)
|
||||
try {
|
||||
await Promise.all(
|
||||
list.map((x, idx) =>
|
||||
x.sort_order === idx
|
||||
? null
|
||||
: Showcase.updateAward(x.id, {
|
||||
name: x.name,
|
||||
description: x.description,
|
||||
sort_order: idx,
|
||||
is_active: x.is_active,
|
||||
item_ordering: x.item_ordering,
|
||||
}),
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
message.error(errText(err, "调整顺序失败"))
|
||||
}
|
||||
await loadAwards()
|
||||
// 表单里的编号也要跟着变,不然接着保存会把顺序改回去
|
||||
if (currentAward.value) draft.sort_order = currentAward.value.sort_order
|
||||
}
|
||||
|
||||
async function moveItem(i: number, d: number) {
|
||||
const list = [...items.value]
|
||||
const [x] = list.splice(i, 1)
|
||||
list.splice(i + d, 0, x!)
|
||||
items.value = list
|
||||
try {
|
||||
await Promise.all(
|
||||
list.map((it, idx) => {
|
||||
if (it.sort_order === idx) return null
|
||||
it.sort_order = idx
|
||||
return Showcase.updateAwardItem(it.id, { sort_order: idx })
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
message.error(errText(err, "调整顺序失败"))
|
||||
loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshItem(item: AwardItemManageOut) {
|
||||
try {
|
||||
const updated = await Showcase.refreshAwardItem(item.id)
|
||||
items.value = items.value.map((it) => (it.id === item.id ? updated : it))
|
||||
message.success("已换成最新交的一版")
|
||||
} catch (err) {
|
||||
message.error(errText(err, "替换失败"))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: AwardItemManageOut) {
|
||||
try {
|
||||
await Showcase.deleteAwardItem(item.id)
|
||||
items.value = items.value.filter((it) => it.id !== item.id)
|
||||
loadAwards()
|
||||
message.success("已撤下")
|
||||
} catch (err) {
|
||||
message.error(errText(err, "撤下失败"))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAwards()
|
||||
if (awards.value[0]) selectAward(awards.value[0])
|
||||
else startCreate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.awards {
|
||||
width: 230px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.awards-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 8px 6px 14px;
|
||||
}
|
||||
|
||||
.award-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.award {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.award:hover {
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.award.on {
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.award-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.off {
|
||||
font-size: 11px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.count {
|
||||
font: 12px var(--wp-font-mono);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.move {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.move button {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.move button:hover:not(:disabled) {
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
|
||||
.move button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px 24px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 860px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.name :deep(input) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--wp-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.items-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin: 14px 0 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 按 2 倍大小渲染再缩小 */
|
||||
.thumb iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
border: 0;
|
||||
transform: scale(0.5);
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stale {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-warn);
|
||||
background: var(--wp-warn-soft);
|
||||
}
|
||||
|
||||
.cap {
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
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;
|
||||
font: 12px var(--wp-font-mono);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.task {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 6px 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue"
|
||||
import { Submission } from "../api"
|
||||
import type { SubmissionAll } from "../utils/type"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const content = ref("")
|
||||
|
||||
async function init() {
|
||||
const submission: SubmissionAll = await Submission.get(props.id)
|
||||
Submission.incrementView(props.id)
|
||||
content.value = buildPreviewDocument({
|
||||
html: submission.html,
|
||||
css: submission.css,
|
||||
js: submission.js,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
<template>
|
||||
<iframe v-if="content" class="iframe" :sandbox="PREVIEW_SANDBOX" :srcdoc="content"></iframe>
|
||||
</template>
|
||||
<style scoped>
|
||||
.iframe {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="subs">
|
||||
<main class="list">
|
||||
<div class="toolbar">
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索学生"
|
||||
style="width: 150px"
|
||||
>
|
||||
<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
|
||||
v-model:value="query.zone"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="得分分区"
|
||||
:options="zoneOptions"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.ordering"
|
||||
size="small"
|
||||
:options="orderOptions"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">共 {{ count }} 条</span>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" quaternary :loading="loading" @click="load">
|
||||
<template #icon><Icon icon="lucide:refresh-cw" /></template>
|
||||
</n-button>
|
||||
</template>
|
||||
刷新
|
||||
</n-tooltip>
|
||||
</div>
|
||||
|
||||
<n-data-table
|
||||
class="table"
|
||||
size="small"
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: SubmissionOut) => row.id"
|
||||
:row-props="rowProps"
|
||||
:row-class-name="(row: SubmissionOut) => (row.id === selectedId ? 'row-active' : '')"
|
||||
/>
|
||||
|
||||
<n-flex justify="end">
|
||||
<n-pagination
|
||||
v-model:page="query.page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:item-count="count"
|
||||
size="small"
|
||||
/>
|
||||
</n-flex>
|
||||
</main>
|
||||
|
||||
<aside v-if="selectedId" class="detail">
|
||||
<ReviewPanel
|
||||
:submission-id="selectedId"
|
||||
can-delete
|
||||
@close="selectedId = null"
|
||||
@changed="applyPatch"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
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"
|
||||
|
||||
// 全站提交列表,留给超管查某个学生、处理有问题的提交;平时批改用“批改”页
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const data = ref<SubmissionOut[]>([])
|
||||
const count = ref(0)
|
||||
const loading = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
const query = reactive({
|
||||
page: Number(route.params.page) || 1,
|
||||
username: typeof route.query.username === "string" ? route.query.username : "",
|
||||
task_type: "" as "" | "tutorial" | "challenge",
|
||||
zone: null as string | null,
|
||||
ordering: "-created",
|
||||
grouped: true,
|
||||
})
|
||||
|
||||
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
|
||||
const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
|
||||
const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
|
||||
const orderOptions = [
|
||||
{ label: "最新的在前", value: "-created" },
|
||||
{ label: "得分从高到低", value: "-score" },
|
||||
{ label: "得分从低到高", value: "score" },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<SubmissionOut>[] = [
|
||||
{
|
||||
title: "时间",
|
||||
key: "created",
|
||||
width: 100,
|
||||
render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
|
||||
},
|
||||
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "任务",
|
||||
key: "task_title",
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => [
|
||||
h("span", { class: "muted" }, row.task_type === TASK_TYPE.Tutorial ? "教程 " : "挑战 "),
|
||||
`${row.task_display}. ${row.task_title}`,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "得分",
|
||||
key: "score",
|
||||
width: 70,
|
||||
align: "right",
|
||||
render: (row) =>
|
||||
row.score > 0
|
||||
? h("span", { class: "mono" }, row.score.toFixed(2))
|
||||
: h("span", { class: "muted" }, "未评"),
|
||||
},
|
||||
{
|
||||
title: "分区",
|
||||
key: "zone",
|
||||
width: 70,
|
||||
render: (row) => (row.zone ? h("span", { class: `zone z-${row.zone}` }, ZONES[row.zone]) : null),
|
||||
},
|
||||
{
|
||||
title: "共交",
|
||||
key: "submit_count",
|
||||
width: 60,
|
||||
align: "right",
|
||||
render: (row) => h("span", { class: "mono muted" }, `${row.submit_count} 次`),
|
||||
},
|
||||
]
|
||||
|
||||
function rowProps(row: SubmissionOut) {
|
||||
return {
|
||||
style: { cursor: "pointer" },
|
||||
onClick: () => (selectedId.value = row.id),
|
||||
}
|
||||
}
|
||||
|
||||
// 快速翻页时只用最后一次请求的结果
|
||||
let seq = 0
|
||||
|
||||
async function load() {
|
||||
const s = ++seq
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await Submission.list({
|
||||
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
|
||||
count.value = res.count
|
||||
} finally {
|
||||
if (s === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
|
||||
const row = data.value.find((d) => d.id === patch.id)
|
||||
if (row) Object.assign(row, patch)
|
||||
}
|
||||
|
||||
function onDeleted() {
|
||||
selectedId.value = null
|
||||
load()
|
||||
}
|
||||
|
||||
// 换了筛选条件就回到第一页
|
||||
function reset() {
|
||||
if (query.page !== 1) query.page = 1
|
||||
else load()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => query.page,
|
||||
(page) => {
|
||||
load()
|
||||
router.push({ params: { page } })
|
||||
},
|
||||
)
|
||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||
watch(
|
||||
() => route.params.page,
|
||||
(v) => {
|
||||
const page = Number(v) || 1
|
||||
if (page !== query.page) query.page = page
|
||||
},
|
||||
)
|
||||
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
|
||||
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<n-flex vertical class="container">
|
||||
<n-flex>
|
||||
<div>
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
v-model:value="query.username"
|
||||
placeholder="搜索用户名"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
v-model:value="query.role"
|
||||
:options="roles"
|
||||
/>
|
||||
</div>
|
||||
<n-button @click="showCreate = true">新建一个</n-button>
|
||||
<n-button @click="showBatch = true">批量新建</n-button>
|
||||
<n-pagination
|
||||
v-model:page="query.page"
|
||||
:page-size="10"
|
||||
:item-count="count"
|
||||
simple
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
||||
<UserFormModal v-model:show="showCreate" @saved="init" />
|
||||
<n-modal
|
||||
style="width: 440px"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="批量新建学生"
|
||||
v-model:show="showBatch"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<n-text depth="3" style="font-size: 13px">
|
||||
每行一个名字。用户名是 web + 班级 + 名字(比如
|
||||
web{{ classname || "241" }}{{ names[0] || "张三" }}),密码随机生成,建好后在列表里点“显示”查看。
|
||||
</n-text>
|
||||
<n-input
|
||||
placeholder="班级,比如 241"
|
||||
v-model:value="classname"
|
||||
:disabled="batchLoading"
|
||||
/>
|
||||
<n-input
|
||||
rows="16"
|
||||
type="textarea"
|
||||
placeholder="张三 李四 王五"
|
||||
v-model:value="namesStr"
|
||||
:disabled="batchLoading"
|
||||
/>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!(classname && !!names.length)"
|
||||
:loading="batchLoading"
|
||||
@click="batchCreateUsers"
|
||||
>
|
||||
创建 {{ names.length }} 个账号
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref, h, watch, computed } from "vue"
|
||||
import { Account } from "../api"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import { NButton, useMessage, type DataTableColumn } from "naive-ui"
|
||||
import { getRole, Role, type User } from "../utils/type"
|
||||
import UserActions from "../components/dashboard/UserActions.vue"
|
||||
import UserFormModal from "../components/dashboard/UserFormModal.vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
|
||||
const users = ref([])
|
||||
const count = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const query = reactive({
|
||||
username: "",
|
||||
page: Number(route.params.page),
|
||||
role: "",
|
||||
})
|
||||
const showBatch = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const classname = ref("")
|
||||
const namesStr = ref("")
|
||||
const names = computed(() =>
|
||||
namesStr.value
|
||||
.split("\n")
|
||||
.map((it) => it.trim())
|
||||
.filter((it) => !!it),
|
||||
)
|
||||
const revealed = reactive(new Set<number>())
|
||||
|
||||
const batchLoading = ref(false)
|
||||
|
||||
const roles = [
|
||||
{ label: "全部权限", value: "" },
|
||||
{ label: "普通用户", value: Role.Normal },
|
||||
{ label: "管理员", value: Role.Admin },
|
||||
{ label: "超级管理员", value: Role.Super },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<User>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
},
|
||||
{
|
||||
// 密码默认不显示,投屏时不会被学生看到
|
||||
title: "初始密码",
|
||||
key: "raw_password",
|
||||
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: "创建时间",
|
||||
key: "date_joined",
|
||||
render: (row) => parseTime(row.date_joined, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "上次登录",
|
||||
key: "last_login",
|
||||
render: (row) =>
|
||||
row.last_login
|
||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
},
|
||||
{
|
||||
title: "班级",
|
||||
key: "classname",
|
||||
},
|
||||
{
|
||||
title: "权限",
|
||||
key: "role",
|
||||
render: (row) => getRole(row.role),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
render: (row) => h(UserActions, { user: row, onReload: init }),
|
||||
},
|
||||
]
|
||||
|
||||
async function batchCreateUsers() {
|
||||
if (!names.value.length) return
|
||||
try {
|
||||
batchLoading.value = true
|
||||
const data = await Account.batchCreate({
|
||||
classname: classname.value,
|
||||
names: names.value,
|
||||
})
|
||||
batchLoading.value = false
|
||||
message.success(data.message)
|
||||
showBatch.value = false
|
||||
query.page = 1
|
||||
query.role = ""
|
||||
init()
|
||||
} catch (err: any) {
|
||||
batchLoading.value = false
|
||||
message.error(err.response?.data?.detail ?? "批量创建失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 快速翻页时只用最后一次请求的结果
|
||||
let initSeq = 0
|
||||
|
||||
async function init() {
|
||||
const seq = ++initSeq
|
||||
const data = await Account.list(query)
|
||||
if (seq !== initSeq) return
|
||||
users.value = data.items
|
||||
count.value = data.count
|
||||
}
|
||||
|
||||
watch(
|
||||
() => query.role,
|
||||
() => {
|
||||
if (query.page !== 1) query.page = 1
|
||||
else init()
|
||||
},
|
||||
)
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => {
|
||||
if (query.page !== 1) query.page = 1
|
||||
else init()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watch(
|
||||
() => query.page,
|
||||
(v) => {
|
||||
init()
|
||||
router.push({ params: { page: v } })
|
||||
},
|
||||
)
|
||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||
watch(
|
||||
() => route.params.page,
|
||||
(v) => {
|
||||
const page = Number(v) || 1
|
||||
if (page !== query.page) query.page = page
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
<style scoped>
|
||||
.container {
|
||||
padding: 10px 10px 10px 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<n-split :size="panelSize" @update-size="changeSize" min="400px" max="900px">
|
||||
<template #1>
|
||||
<TaskPanel @hide="hide" />
|
||||
</template>
|
||||
<template #2>
|
||||
<n-split direction="vertical" min="200px">
|
||||
<template #1>
|
||||
<Editors />
|
||||
</template>
|
||||
<template #2>
|
||||
<Preview
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:storage-task-id="taskId"
|
||||
/>
|
||||
</template>
|
||||
</n-split>
|
||||
</template>
|
||||
</n-split>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { watch } from "vue"
|
||||
import { useMagicKeys, whenever } from "@vueuse/core"
|
||||
import Editors from "../components/editor/Editors.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import TaskPanel from "../components/task/TaskPanel.vue"
|
||||
import { show, panelSize } from "../store/panel"
|
||||
import { html, css, js } from "../store/editors"
|
||||
import { assetBaseUrl, 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({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.ctrlKey && e.key === "s" && e.type === "keydown") e.preventDefault()
|
||||
},
|
||||
})
|
||||
|
||||
const { ctrl_r } = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.ctrlKey && e.key === "r" && e.type === "keydown") e.preventDefault()
|
||||
},
|
||||
})
|
||||
|
||||
function changeSize(n: number) {
|
||||
panelSize.value = n
|
||||
if (n > 0) show.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
panelSize.value = 0
|
||||
show.value = false
|
||||
}
|
||||
|
||||
whenever(ctrl_s!, () => {})
|
||||
whenever(ctrl_r!, () => {})
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createWebHistory, createRouter, type RouteRecordRaw } from "vue-router"
|
||||
import { loginModal } from "./store/modal"
|
||||
|
||||
// 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
|
||||
const Workspace = () => import("./pages/Workspace.vue")
|
||||
import { hasLogin } from "./token"
|
||||
import { authed, roleStaff, roleSuper } from "./store/user"
|
||||
import { loadUser } from "./store/session"
|
||||
|
||||
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: "/tutorial",
|
||||
name: "home-tutorial-list",
|
||||
component: Workspace,
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/tutorial/:display",
|
||||
name: "home-tutorial",
|
||||
component: Workspace,
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/challenge",
|
||||
name: "home-challenge-list",
|
||||
component: () => import("./pages/ChallengeHome.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/challenge/:display",
|
||||
name: "home-challenge",
|
||||
component: () => import("./pages/ChallengeDetail.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
// 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
|
||||
path: "/submissions/:page?",
|
||||
name: "submissions",
|
||||
component: () => import("./pages/MySubmissions.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/submission/:id",
|
||||
name: "submission",
|
||||
component: () => import("./pages/Submission.vue"),
|
||||
props: true,
|
||||
meta: { auth: true, bare: true },
|
||||
},
|
||||
{
|
||||
path: "/creation",
|
||||
name: "creation-list",
|
||||
component: () => import("./pages/CreationHome.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/creation/:display",
|
||||
name: "creation",
|
||||
component: () => import("./pages/CreationDetail.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/showcase",
|
||||
name: "showcase",
|
||||
component: () => import("./pages/Showcase.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "dashboard",
|
||||
component: () => import("./pages/Dashboard.vue"),
|
||||
meta: { auth: true, role: "staff" },
|
||||
children: [
|
||||
{
|
||||
path: "tutorial/:display",
|
||||
name: "tutorial-editor",
|
||||
component: () => import("./pages/TaskEditor.vue"),
|
||||
props: { kind: "tutorial" },
|
||||
meta: { role: "super" },
|
||||
},
|
||||
{
|
||||
path: "challenge/:display",
|
||||
name: "challenge-editor",
|
||||
component: () => import("./pages/TaskEditor.vue"),
|
||||
props: { kind: "challenge" },
|
||||
},
|
||||
{
|
||||
path: "user-manage/:page",
|
||||
name: "user-manage",
|
||||
component: () => import("./pages/UserManage.vue"),
|
||||
meta: { role: "super" },
|
||||
},
|
||||
{
|
||||
path: "showcase",
|
||||
name: "showcase-manage",
|
||||
component: () => import("./pages/ShowcaseManage.vue"),
|
||||
},
|
||||
{
|
||||
path: "gradebook",
|
||||
name: "gradebook",
|
||||
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" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const isLoggedIn = authed.value || hasLogin()
|
||||
if (to.meta.auth && !isLoggedIn) {
|
||||
loginModal.value = true
|
||||
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" }
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { STORAGE_KEY } from "../utils/const"
|
||||
|
||||
const defaultHTML = ``
|
||||
const defaultCSS = ``
|
||||
|
||||
export const html = useStorage(STORAGE_KEY.HTML, defaultHTML)
|
||||
export const css = useStorage(STORAGE_KEY.CSS, defaultCSS)
|
||||
export const js = useStorage(STORAGE_KEY.JS, "")
|
||||
|
||||
export const tab = useStorage(STORAGE_KEY.TAB, "html")
|
||||
export const size = useStorage(STORAGE_KEY.FONTSIZE, 16)
|
||||
|
||||
export function reset(lang: "html" | "css" | "js") {
|
||||
if (lang === "html") {
|
||||
html.value = defaultHTML
|
||||
} else if (lang === "css") {
|
||||
css.value = defaultCSS
|
||||
} else {
|
||||
js.value = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ref } from "vue"
|
||||
import { ReconnectingSocket } from "../utils/socket"
|
||||
|
||||
export interface GuidanceMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
export const messages = ref<GuidanceMessage[]>([])
|
||||
export const connected = ref(false)
|
||||
export const streaming = ref(false)
|
||||
export const streamingContent = ref("")
|
||||
export const isReady = ref(false)
|
||||
export const isOpen = ref(false)
|
||||
export const initialPrompt = ref("")
|
||||
|
||||
let _taskId = 0
|
||||
// 打开弹窗时带过来的提示词,连上后发一次;重连时不再重发
|
||||
let unsentPrompt = ""
|
||||
|
||||
const socket = new ReconnectingSocket({
|
||||
onOpen() {
|
||||
connected.value = true
|
||||
if (unsentPrompt) {
|
||||
sendToSocket(unsentPrompt)
|
||||
unsentPrompt = ""
|
||||
}
|
||||
},
|
||||
onMessage(data) {
|
||||
if (data.type === "init") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
} else if (data.type === "stream") {
|
||||
streaming.value = true
|
||||
streamingContent.value += data.content
|
||||
} else if (data.type === "complete") {
|
||||
streaming.value = false
|
||||
const content = streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
messages.value.push({ role: "assistant", content, id: data.message_id })
|
||||
streamingContent.value = ""
|
||||
if (data.is_ready) isReady.value = true
|
||||
} else if (data.type === "error") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
messages.value.push({ role: "assistant", content: data.content })
|
||||
}
|
||||
},
|
||||
onDrop() {
|
||||
connected.value = false
|
||||
if (streaming.value) dropPendingMessage()
|
||||
},
|
||||
onGiveUp() {
|
||||
messages.value.push({
|
||||
role: "assistant",
|
||||
content: "与服务器的连接已断开,请刷新页面重试",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function connect(taskId: number) {
|
||||
socket.connect(`/ws/guidance/${taskId}/`)
|
||||
}
|
||||
|
||||
function sendToSocket(content: string) {
|
||||
if (!socket.send({ type: "message", content })) return
|
||||
streaming.value = true
|
||||
messages.value.push({ role: "user", content })
|
||||
}
|
||||
|
||||
function dropPendingMessage() {
|
||||
if (
|
||||
messages.value.length > 0 &&
|
||||
messages.value[messages.value.length - 1].role === "user"
|
||||
) {
|
||||
messages.value.pop()
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
}
|
||||
|
||||
export function openGuidance(taskId: number, prompt: string) {
|
||||
if (!taskId) return
|
||||
|
||||
_taskId = taskId
|
||||
initialPrompt.value = prompt
|
||||
unsentPrompt = prompt
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
streamingContent.value = ""
|
||||
isOpen.value = true
|
||||
|
||||
connect(taskId)
|
||||
}
|
||||
|
||||
export function sendGuidance(content: string) {
|
||||
sendToSocket(content)
|
||||
}
|
||||
|
||||
export function stopGuidance() {
|
||||
dropPendingMessage()
|
||||
if (_taskId) {
|
||||
initialPrompt.value = ""
|
||||
unsentPrompt = ""
|
||||
connect(_taskId)
|
||||
}
|
||||
}
|
||||
|
||||
export function closeGuidance() {
|
||||
socket.close()
|
||||
unsentPrompt = ""
|
||||
isOpen.value = false
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
connected.value = false
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
initialPrompt.value = ""
|
||||
_taskId = 0
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ref } from "vue"
|
||||
|
||||
export const loginModal = ref(false)
|
||||
@@ -0,0 +1,5 @@
|
||||
// client/src/store/panel.ts
|
||||
import { ref } from "vue"
|
||||
|
||||
export const show = ref(true)
|
||||
export const panelSize = ref(2 / 5)
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ref } from "vue"
|
||||
import { ReconnectingSocket } from "../utils/socket"
|
||||
|
||||
export interface PromptMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
id?: number // assistant message backend pk (for deletion)
|
||||
code?: { html: string | null; css: string | null; js: string | null }
|
||||
submitted?: boolean // whether this assistant message's code has been submitted
|
||||
created?: string
|
||||
// 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 conversationId = ref<string>("")
|
||||
export const connected = ref(false)
|
||||
export const streaming = ref(false)
|
||||
export const streamingContent = ref("")
|
||||
// 这次提问发出的时间:AI 先思考,第一个字可能一两分钟后才出来,界面据此显示等了多久
|
||||
export const streamStartedAt = ref(0)
|
||||
export const currentTaskId = ref(0)
|
||||
// 新代码交给工作台处理:init 是连上时对话里最新的那一版,complete 是 AI 刚生成的
|
||||
let _codeSink:
|
||||
| ((code: CodeOut, messageId: number, reason: "init" | "complete") => void)
|
||||
| null = null
|
||||
|
||||
export function setCodeSink(fn: typeof _codeSink) {
|
||||
_codeSink = fn
|
||||
}
|
||||
|
||||
function onMessage(data: any) {
|
||||
if (data.type === "init") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
const alreadyLoaded = conversationId.value === data.conversation_id
|
||||
conversationId.value = data.conversation_id
|
||||
if (!alreadyLoaded) {
|
||||
messages.value = data.messages || []
|
||||
const lastAssistant = [...messages.value]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant" && m.code)
|
||||
if (lastAssistant?.code && lastAssistant.id) {
|
||||
_codeSink?.(lastAssistant.code, lastAssistant.id, "init")
|
||||
}
|
||||
}
|
||||
} else if (data.type === "stream") {
|
||||
streaming.value = true
|
||||
streamingContent.value += data.content
|
||||
} else if (data.type === "complete") {
|
||||
streaming.value = false
|
||||
messages.value.push({
|
||||
role: "assistant",
|
||||
content: streamingContent.value,
|
||||
id: data.message_id,
|
||||
code: data.code,
|
||||
submitted: false,
|
||||
source: "conversation",
|
||||
})
|
||||
streamingContent.value = ""
|
||||
if (data.code) _codeSink?.(data.code, data.message_id, "complete")
|
||||
} else if (data.type === "error") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
messages.value.push({
|
||||
role: "assistant",
|
||||
content: data.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const socket = new ReconnectingSocket({
|
||||
onOpen() {
|
||||
connected.value = true
|
||||
},
|
||||
onMessage,
|
||||
onDrop() {
|
||||
connected.value = false
|
||||
// 断开时服务端会删掉还没有回复的提问,这边也去掉
|
||||
if (streaming.value) dropPendingPrompt()
|
||||
},
|
||||
onGiveUp() {
|
||||
messages.value.push({
|
||||
role: "assistant",
|
||||
content: "与服务器的连接已断开,请刷新页面重试",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export function connectPrompt(taskId: number) {
|
||||
currentTaskId.value = taskId
|
||||
socket.connect(`/ws/prompt/${taskId}/`)
|
||||
}
|
||||
|
||||
export function disconnectPrompt() {
|
||||
socket.close()
|
||||
messages.value = []
|
||||
conversationId.value = ""
|
||||
connected.value = false
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
currentTaskId.value = 0
|
||||
_codeSink = null
|
||||
}
|
||||
|
||||
export function sendPrompt(content: string, model: string = "") {
|
||||
if (!socket.send({ type: "message", content, model })) return
|
||||
streaming.value = true
|
||||
streamStartedAt.value = Date.now()
|
||||
messages.value.push({ role: "user", content })
|
||||
}
|
||||
|
||||
function dropPendingPrompt() {
|
||||
if (
|
||||
messages.value.length > 0 &&
|
||||
messages.value[messages.value.length - 1].role === "user"
|
||||
) {
|
||||
messages.value.pop()
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
}
|
||||
|
||||
export function stopPrompt() {
|
||||
dropPendingPrompt()
|
||||
if (currentTaskId.value) {
|
||||
connectPrompt(currentTaskId.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 手改或粘贴外部代码存成版本后,把这一问一答加到当前对话里显示
|
||||
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) {
|
||||
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
||||
if (msg) msg.submitted = true
|
||||
}
|
||||
|
||||
export function removeMessagePair(assistantMsgId: number) {
|
||||
const idx = messages.value.findIndex((m) => m.id === assistantMsgId)
|
||||
if (idx >= 1) {
|
||||
messages.value.splice(idx - 1, 2)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref } from "vue"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
|
||||
const currentTask = window.location.pathname.startsWith("/challenge")
|
||||
? TASK_TYPE.Challenge
|
||||
: TASK_TYPE.Tutorial
|
||||
|
||||
export const taskTab = ref(currentTask)
|
||||
export const taskId = ref(0)
|
||||
export const challengeDisplay = useStorage("challenge-display", 0)
|
||||
export const assetBaseUrl = ref("")
|
||||
@@ -0,0 +1,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 },
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { ref } from "vue"
|
||||
import { Tutorial } from "../api"
|
||||
|
||||
export const step = useStorage("tutorial-step", 1)
|
||||
export const tutorialIds = ref<number[]>([])
|
||||
|
||||
export async function loadTutorials(): Promise<void> {
|
||||
tutorialIds.value = await Tutorial.listDisplay()
|
||||
if (tutorialIds.value.length && !tutorialIds.value.includes(step.value)) {
|
||||
step.value = tutorialIds.value[0] as number
|
||||
}
|
||||
}
|
||||
|
||||
export function prevDisabled(): boolean {
|
||||
const i = tutorialIds.value.indexOf(step.value)
|
||||
return i <= 0
|
||||
}
|
||||
|
||||
export function nextDisabled(): boolean {
|
||||
const i = tutorialIds.value.indexOf(step.value)
|
||||
return i === -1 || i === tutorialIds.value.length - 1
|
||||
}
|
||||
|
||||
export function prev(): void {
|
||||
const i = tutorialIds.value.indexOf(step.value)
|
||||
if (i > 0) step.value = tutorialIds.value[i - 1] as number
|
||||
}
|
||||
|
||||
export function next(): void {
|
||||
const i = tutorialIds.value.indexOf(step.value)
|
||||
if (i !== -1 && i < tutorialIds.value.length - 1)
|
||||
step.value = tutorialIds.value[i + 1] as number
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { computed, reactive } from "vue"
|
||||
import { Role } from "../utils/type"
|
||||
|
||||
export const user = reactive({
|
||||
loaded: false,
|
||||
username: "",
|
||||
role: Role.Normal,
|
||||
})
|
||||
export const authed = computed(() => !!user.username)
|
||||
export const roleNormal = computed(
|
||||
() => authed.value && user.role === Role.Normal,
|
||||
)
|
||||
export const roleAdmin = computed(
|
||||
() => authed.value && user.role === Role.Admin,
|
||||
)
|
||||
export const roleSuper = computed(
|
||||
() => authed.value && user.role === Role.Super,
|
||||
)
|
||||
// 老师端(教学):admin 和 super
|
||||
export const roleStaff = computed(() => roleAdmin.value || roleSuper.value)
|
||||
Loaded 100 of 403 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user