Compare commits
3
Commits
ff1eb2982e
...
8653d411a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8653d411a6 | ||
|
|
df3f842dac | ||
|
|
9c6c01eba1 |
No files matched your search
@@ -15,9 +15,10 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
|
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
|
||||||
- `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions
|
- `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions
|
||||||
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
|
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
|
||||||
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt`
|
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `review` (mounted under `/submission/review`: teacher class × task wall, students' same-class peer works), `prompt`
|
||||||
|
- Who can read a submission (`canView` in `routes/submission.ts`): staff, the owner, anyone if it's in the showcase, or a same-class student who has submitted the same task. 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 older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `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/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 older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `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) 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
|
- `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`
|
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
|
||||||
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
||||||
|
|
||||||
@@ -27,8 +28,11 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
|
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
|
||||||
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
|
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
|
||||||
- `src/router.ts` — Vue Router; `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/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. No `color-mix()` (Chrome 105)
|
- `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`)
|
- `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. 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) — keys: J/K next/prev student, 1–5 rate, P present; `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`
|
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
|
||||||
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
||||||
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.12",
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/language": "6.12.3",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.1",
|
"@codemirror/view": "^6.41.1",
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@vueuse/core": "^15.0.0",
|
"@vueuse/core": "^15.0.0",
|
||||||
@@ -295,6 +297,8 @@
|
|||||||
|
|
||||||
"@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
|
"@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
|
||||||
|
|
||||||
|
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
|
||||||
|
|
||||||
"@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
|
"@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
|
||||||
|
|
||||||
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.12",
|
"@codemirror/lang-html": "^6.4.12",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/language": "6.12.3",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.1",
|
"@codemirror/view": "^6.41.1",
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@vueuse/core": "^15.0.0",
|
"@vueuse/core": "^15.0.0",
|
||||||
|
|||||||
+7
-4
@@ -1,15 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { dateZhCN, zhCN } from "naive-ui"
|
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||||
import Login from "./components/Login.vue"
|
import Login from "./components/Login.vue"
|
||||||
import AppShell from "./components/layout/AppShell.vue"
|
import AppShell from "./components/layout/AppShell.vue"
|
||||||
import { onMounted } from "vue"
|
import { computed, onMounted } from "vue"
|
||||||
import { useRoute } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
import { loadUser } from "./store/session"
|
import { loadUser } from "./store/session"
|
||||||
import { light, naiveOverrides } from "./theme"
|
import { dark, light, naiveOverrides } from "./theme"
|
||||||
|
import { isDark } from "./store/theme"
|
||||||
import hljs from "highlight.js/lib/core"
|
import hljs from "highlight.js/lib/core"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const themeOverrides = naiveOverrides(light)
|
const theme = computed(() => (isDark.value ? darkTheme : null))
|
||||||
|
const themeOverrides = computed(() => naiveOverrides(isDark.value ? dark : light))
|
||||||
|
|
||||||
onMounted(loadUser)
|
onMounted(loadUser)
|
||||||
</script>
|
</script>
|
||||||
@@ -20,6 +22,7 @@ onMounted(loadUser)
|
|||||||
:locale="zhCN"
|
:locale="zhCN"
|
||||||
:date-locale="dateZhCN"
|
:date-locale="dateZhCN"
|
||||||
:hljs="hljs"
|
:hljs="hljs"
|
||||||
|
:theme="theme"
|
||||||
:theme-overrides="themeOverrides"
|
:theme-overrides="themeOverrides"
|
||||||
>
|
>
|
||||||
<n-modal-provider>
|
<n-modal-provider>
|
||||||
|
|||||||
+43
-6
@@ -24,6 +24,10 @@ import type {
|
|||||||
CreationSlim,
|
CreationSlim,
|
||||||
CreationDetail,
|
CreationDetail,
|
||||||
CreationIn,
|
CreationIn,
|
||||||
|
ReviewOptions,
|
||||||
|
ReviewProgress,
|
||||||
|
ReviewStudent,
|
||||||
|
PeerWorks,
|
||||||
} from "./utils/type"
|
} from "./utils/type"
|
||||||
import { BASE_URL } from "./utils/const"
|
import { BASE_URL } from "./utils/const"
|
||||||
import {
|
import {
|
||||||
@@ -320,7 +324,11 @@ export const Submission = {
|
|||||||
await http.post(`/submission/${id}/view`)
|
await http.post(`/submission/${id}/view`)
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateScore(id: string, score: number) {
|
// 返回作者名:互评打分前是匿名的
|
||||||
|
async updateScore(
|
||||||
|
id: string,
|
||||||
|
score: number,
|
||||||
|
): Promise<{ message: string; username?: string }> {
|
||||||
const res = await http.put(`/submission/${id}/score`, { score })
|
const res = await http.put(`/submission/${id}/score`, { score })
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
@@ -337,11 +345,6 @@ export const Submission = {
|
|||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearAllFlags() {
|
|
||||||
const res = await http.delete(`/submission/flags`)
|
|
||||||
return res.data as { cleared: number }
|
|
||||||
},
|
|
||||||
|
|
||||||
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
|
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
|
||||||
const params: Record<string, string | number> = {}
|
const params: Record<string, string | number> = {}
|
||||||
if (classname) params.classname = classname
|
if (classname) params.classname = classname
|
||||||
@@ -395,6 +398,20 @@ export const Gradebook = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Prompt = {
|
export const Prompt = {
|
||||||
|
// 保存一个不是 AI 生成的版本:手改的代码,或者带提示词的外部 AI 代码
|
||||||
|
async saveVersion(
|
||||||
|
taskId: number,
|
||||||
|
code: { html: string; css: string; js: string },
|
||||||
|
prompt?: string,
|
||||||
|
): Promise<{ message_id: number; user_message_id: number; source: string }> {
|
||||||
|
const res = await http.post("/prompt/versions", {
|
||||||
|
task_id: taskId,
|
||||||
|
...code,
|
||||||
|
prompt: prompt || undefined,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
async listConversations(taskId?: number, userId?: number) {
|
async listConversations(taskId?: number, userId?: number) {
|
||||||
const params: Record<string, number> = {}
|
const params: Record<string, number> = {}
|
||||||
if (taskId) params.task_id = taskId
|
if (taskId) params.task_id = taskId
|
||||||
@@ -443,6 +460,26 @@ export const Helper = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 老师按“班级 × 任务”批改;学生看本班同学的作品
|
||||||
|
export const Review = {
|
||||||
|
async options(): Promise<ReviewOptions> {
|
||||||
|
return (await http.get("/submission/review/options")).data
|
||||||
|
},
|
||||||
|
async progress(classname: string): Promise<ReviewProgress> {
|
||||||
|
return (await http.get("/submission/review/progress", { params: { classname } })).data
|
||||||
|
},
|
||||||
|
async classWall(classname: string, taskId: number): Promise<ReviewStudent[]> {
|
||||||
|
return (
|
||||||
|
await http.get("/submission/review/class", {
|
||||||
|
params: { classname, task_id: taskId },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
},
|
||||||
|
async peers(taskId: number): Promise<PeerWorks> {
|
||||||
|
return (await http.get(`/submission/review/peers/${taskId}`)).data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const Showcase = {
|
export const Showcase = {
|
||||||
async list(): Promise<AwardSection[]> {
|
async list(): Promise<AwardSection[]> {
|
||||||
const res = await http.get("/submission/showcase/")
|
const res = await http.get("/submission/showcase/")
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="external-panel">
|
|
||||||
<div class="content-area">
|
|
||||||
<div class="field-label">提示词</div>
|
|
||||||
<n-input
|
|
||||||
v-model:value="promptText"
|
|
||||||
type="textarea"
|
|
||||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
|
||||||
placeholder="粘贴你发给外部 AI 的提示词..."
|
|
||||||
/>
|
|
||||||
<div class="code-field">
|
|
||||||
<div class="field-label" style="margin-top: 12px">完整的代码</div>
|
|
||||||
<n-input
|
|
||||||
v-model:value="rawCode"
|
|
||||||
type="textarea"
|
|
||||||
class="code-input"
|
|
||||||
placeholder="粘贴完整的前端代码..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="splitResult" class="split-result">
|
|
||||||
<n-tag size="small" type="success"
|
|
||||||
>HTML · {{ splitResult.html.length }} 字符</n-tag
|
|
||||||
>
|
|
||||||
<n-tag size="small" type="info"
|
|
||||||
>CSS · {{ splitResult.css.length }} 字符</n-tag
|
|
||||||
>
|
|
||||||
<n-tag size="small" type="warning"
|
|
||||||
>JS · {{ splitResult.js.length }} 字符</n-tag
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="action-bar">
|
|
||||||
<n-button :disabled="!rawCode.trim()" @click="applyPreview"
|
|
||||||
>应用预览</n-button
|
|
||||||
>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="!splitResult || !promptText.trim()"
|
|
||||||
:loading="submitting"
|
|
||||||
@click="submit"
|
|
||||||
>
|
|
||||||
提交
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from "vue"
|
|
||||||
import { useMessage } from "naive-ui"
|
|
||||||
import { html, css, js } from "../../store/editors"
|
|
||||||
import { Submission } from "../../api"
|
|
||||||
|
|
||||||
const props = defineProps<{ taskId: number }>()
|
|
||||||
const emit = defineEmits<{
|
|
||||||
submitted: []
|
|
||||||
}>()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const promptText = ref("")
|
|
||||||
const rawCode = ref("")
|
|
||||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
|
||||||
const submitting = ref(false)
|
|
||||||
|
|
||||||
watch(rawCode, () => {
|
|
||||||
splitResult.value = null
|
|
||||||
})
|
|
||||||
|
|
||||||
function splitHtml(raw: string): { html: string; css: string; js: string } {
|
|
||||||
let result = raw
|
|
||||||
const cssBlocks: string[] = []
|
|
||||||
const jsBlocks: string[] = []
|
|
||||||
|
|
||||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
|
|
||||||
cssBlocks.push(content.trim())
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
result = result.replace(
|
|
||||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
|
||||||
(_, content) => {
|
|
||||||
jsBlocks.push(content.trim())
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
html: result.trim(),
|
|
||||||
css: cssBlocks.join("\n\n"),
|
|
||||||
js: jsBlocks.join("\n\n"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPreview() {
|
|
||||||
const result = splitHtml(rawCode.value)
|
|
||||||
splitResult.value = result
|
|
||||||
html.value = result.html
|
|
||||||
css.value = result.css
|
|
||||||
js.value = result.js
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!splitResult.value) return
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await Submission.create(props.taskId, {
|
|
||||||
html: splitResult.value.html,
|
|
||||||
css: splitResult.value.css,
|
|
||||||
js: splitResult.value.js,
|
|
||||||
prompt: promptText.value.trim(),
|
|
||||||
})
|
|
||||||
emit("submitted")
|
|
||||||
message.success("提交成功")
|
|
||||||
promptText.value = ""
|
|
||||||
rawCode.value = ""
|
|
||||||
splitResult.value = null
|
|
||||||
} catch {
|
|
||||||
message.error("提交失败,请重试")
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.external-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-area {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-field {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-input {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-input :deep(.n-input__textarea) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-input :deep(.n-input__textarea-el) {
|
|
||||||
height: 100%;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-result {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-bar {
|
|
||||||
padding: 12px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -160,11 +160,11 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: #888;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.user .msg-role {
|
.guidance-msg.user .msg-role {
|
||||||
color: #2080f0;
|
color: var(--wp-css);
|
||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.assistant .msg-role {
|
.guidance-msg.assistant .msg-role {
|
||||||
@@ -172,8 +172,8 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guidance-msg.assistant .msg-content {
|
.guidance-msg.assistant .msg-content {
|
||||||
background: #f0f7ff;
|
background: var(--wp-css-soft);
|
||||||
border-left: 3px solid #2080f0;
|
border-left: 3px solid var(--wp-css);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 0 6px 6px 0;
|
border-radius: 0 6px 6px 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -183,7 +183,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
.guidance-msg.user .msg-content {
|
.guidance-msg.user .msg-content {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-content :deep(p) {
|
.msg-content :deep(p) {
|
||||||
@@ -198,9 +198,9 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: #f0fff4;
|
background: var(--wp-brand-soft);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #b8e8cc;
|
border: 1px solid var(--wp-brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
@@ -213,7 +213,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #2080f0;
|
background: var(--wp-css);
|
||||||
animation: bounce 1.4s ease-in-out infinite;
|
animation: bounce 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,9 @@
|
|||||||
</n-tag>
|
</n-tag>
|
||||||
<n-tag
|
<n-tag
|
||||||
size="small"
|
size="small"
|
||||||
:type="item.source === 'manual' ? 'info' : 'success'"
|
:type="item.source === 'edit' ? 'warning' : item.source === 'manual' ? 'info' : 'success'"
|
||||||
>
|
>
|
||||||
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
{{ sourceLabel(item.source) }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex align="center" :wrap="false" :size="4">
|
<n-flex align="center" :wrap="false" :size="4">
|
||||||
@@ -133,6 +133,7 @@ import { onMounted, ref, watch } from "vue"
|
|||||||
import { Icon } from "../../icons"
|
import { Icon } from "../../icons"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
import { sourceLabel } from "../../utils/helper"
|
||||||
import { Prompt } from "../../api"
|
import { Prompt } from "../../api"
|
||||||
import type { PromptHistoryItem } from "../../utils/type"
|
import type { PromptHistoryItem } from "../../utils/type"
|
||||||
import { parseTime } from "../../utils/helper"
|
import { parseTime } from "../../utils/helper"
|
||||||
@@ -282,7 +283,7 @@ onMounted(() => {
|
|||||||
.history-toolbar {
|
.history-toolbar {
|
||||||
height: 42px;
|
height: 42px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
border-bottom: 1px solid var(--n-border-color, var(--wp-line));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,12 +307,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
.history-card.is-selected {
|
.history-card.is-selected {
|
||||||
--n-color: #f7fffa;
|
--n-color: var(--wp-brand-soft);
|
||||||
box-shadow: 0 10px 24px rgba(13, 122, 82, 0.14);
|
box-shadow: 0 10px 24px rgba(13, 122, 82, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card.is-selected .history-main {
|
.history-card.is-selected .history-main {
|
||||||
background: #eefaf3;
|
background: var(--wp-brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-main {
|
.history-main {
|
||||||
@@ -322,7 +323,7 @@ onMounted(() => {
|
|||||||
.prompt-markdown {
|
.prompt-markdown {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 12px 12px;
|
padding: 0 12px 12px;
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -355,8 +356,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
height: 160px;
|
height: 160px;
|
||||||
border-top: 1px solid var(--n-border-color, #efeff5);
|
border-top: 1px solid var(--n-border-color, var(--wp-line));
|
||||||
background: #f7f7f9;
|
background: var(--wp-paper);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -364,15 +365,15 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-card.is-selected .thumbnail {
|
.history-card.is-selected .thumbnail {
|
||||||
border-top-color: #d8f1e2;
|
border-top-color: var(--wp-brand-soft);
|
||||||
background: #f4fbf6;
|
background: var(--wp-brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail iframe {
|
.thumbnail iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: #fff;
|
background: var(--wp-surface);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -18,8 +18,15 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- User message -->
|
<!-- User message -->
|
||||||
<div class="message user">
|
<div v-if="pair.userMsg.source === 'edit'" class="message note">
|
||||||
<div class="message-role">我</div>
|
你手动修改了代码
|
||||||
|
</div>
|
||||||
|
<div v-else class="message user">
|
||||||
|
<div class="message-role">
|
||||||
|
我<span v-if="pair.userMsg.source === 'manual'" class="role-tag">
|
||||||
|
用外部 AI</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="message-content"
|
class="message-content"
|
||||||
v-html="renderContent(pair.userMsg)"
|
v-html="renderContent(pair.userMsg)"
|
||||||
@@ -28,23 +35,16 @@
|
|||||||
|
|
||||||
<!-- Assistant message -->
|
<!-- Assistant message -->
|
||||||
<div v-if="pair.assistantMsg" class="message assistant">
|
<div v-if="pair.assistantMsg" class="message assistant">
|
||||||
|
<template v-if="pair.assistantMsg.source === 'conversation' || !pair.assistantMsg.source">
|
||||||
<div class="message-role">AI</div>
|
<div class="message-role">AI</div>
|
||||||
<div
|
<div
|
||||||
class="message-content"
|
class="message-content"
|
||||||
v-html="renderContent(pair.assistantMsg)"
|
v-html="renderContent(pair.assistantMsg)"
|
||||||
></div>
|
></div>
|
||||||
<div
|
</template>
|
||||||
v-if="allowSubmit && hasCode(pair.assistantMsg)"
|
<div v-if="versionOf(pair.assistantMsg)" class="version-line">
|
||||||
class="message-actions"
|
<span class="version-chip">v{{ versionOf(pair.assistantMsg) }}</span>
|
||||||
>
|
<span v-if="pair.assistantMsg.submitted">已提交</span>
|
||||||
<n-button
|
|
||||||
size="tiny"
|
|
||||||
:disabled="pair.assistantMsg.submitted"
|
|
||||||
:loading="submittingId === pair.assistantMsg.id"
|
|
||||||
@click="submitVersion(pair.assistantMsg)"
|
|
||||||
>
|
|
||||||
{{ pair.assistantMsg.submitted ? "已提交" : "提交此版本" }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
></div>
|
></div>
|
||||||
<div class="streaming-hint">AI 正在生成中…</div>
|
<div class="streaming-hint">AI 正在生成中…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RatingCard v-if="rating" :active="streaming" />
|
||||||
</div>
|
</div>
|
||||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
@@ -80,18 +81,22 @@
|
|||||||
style="flex: 1; min-width: 0"
|
style="flex: 1; min-width: 0"
|
||||||
@keydown.enter.exact.prevent="send"
|
@keydown.enter.exact.prevent="send"
|
||||||
/>
|
/>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
<n-button
|
<n-button
|
||||||
secondary
|
secondary
|
||||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||||
style="flex-shrink: 0"
|
style="flex-shrink: 0"
|
||||||
title="调教提示词"
|
|
||||||
aria-label="调教提示词"
|
|
||||||
@click="startGuidance"
|
@click="startGuidance"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="lucide:lightbulb" :width="16" />
|
<Icon icon="lucide:lightbulb" :width="16" />
|
||||||
</template>
|
</template>
|
||||||
|
帮我写清楚
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
|
AI 教练会问你几个问题,帮你把想要的效果描述清楚
|
||||||
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
||||||
<n-select
|
<n-select
|
||||||
@@ -122,6 +127,7 @@ import { useStorage } from "@vueuse/core"
|
|||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "../../icons"
|
import { Icon } from "../../icons"
|
||||||
import GuidancePanel from "./GuidancePanel.vue"
|
import GuidancePanel from "./GuidancePanel.vue"
|
||||||
|
import RatingCard from "../workbench/RatingCard.vue"
|
||||||
import { openGuidance } from "../../store/guidance"
|
import { openGuidance } from "../../store/guidance"
|
||||||
import {
|
import {
|
||||||
messages,
|
messages,
|
||||||
@@ -132,14 +138,19 @@ import {
|
|||||||
stopPrompt,
|
stopPrompt,
|
||||||
currentTaskId,
|
currentTaskId,
|
||||||
removeMessagePair,
|
removeMessagePair,
|
||||||
markMessageSubmitted,
|
type PromptMessage,
|
||||||
} from "../../store/prompt"
|
} from "../../store/prompt"
|
||||||
import { Prompt, Submission } from "../../api"
|
import { Prompt } from "../../api"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
|
||||||
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>()
|
const props = defineProps<{
|
||||||
|
// 发送前调用:工作台用它把还没存的手改先存成版本,AI 才会在手改后的代码上继续改
|
||||||
|
beforeSend?: () => Promise<void>
|
||||||
|
// AI 生成时在对话里显示同学作品让学生打分(挑战页才有)
|
||||||
|
rating?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ deleted: []; submitted: [] }>()
|
const emit = defineEmits<{ deleted: [] }>()
|
||||||
|
|
||||||
const input = ref("")
|
const input = ref("")
|
||||||
const messagesRef = ref<HTMLElement>()
|
const messagesRef = ref<HTMLElement>()
|
||||||
@@ -156,18 +167,11 @@ if (!modelOptions.some((o) => o.value === selectedModel.value)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group messages into user+assistant pairs
|
// Group messages into user+assistant pairs
|
||||||
const submittingId = ref<number | null>(null)
|
|
||||||
|
|
||||||
const pairs = computed(() => {
|
const pairs = computed(() => {
|
||||||
const result: Array<{
|
const result: Array<{
|
||||||
userMsg: { role: string; content: string; id?: number }
|
userMsg: PromptMessage
|
||||||
assistantMsg: {
|
assistantMsg: PromptMessage | null
|
||||||
role: string
|
|
||||||
content: string
|
|
||||||
id?: number
|
|
||||||
code?: any
|
|
||||||
submitted?: boolean
|
|
||||||
} | null
|
|
||||||
index: number
|
index: number
|
||||||
}> = []
|
}> = []
|
||||||
const msgs = messages.value
|
const msgs = messages.value
|
||||||
@@ -190,30 +194,17 @@ function hasCode(msg: { code?: any } | null): boolean {
|
|||||||
return !!(msg.code.html || msg.code.css || msg.code.js)
|
return !!(msg.code.html || msg.code.css || msg.code.js)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitVersion(msg: {
|
// 带代码的 AI 回复就是一个版本,按出现顺序编号(和工作台底部的版本条一致)
|
||||||
id?: number
|
const versionNumbers = computed(() => {
|
||||||
code?: { html: string | null; css: string | null; js: string | null }
|
const map = new Map<number, number>()
|
||||||
}) {
|
for (const m of messages.value) {
|
||||||
if (!msg.id || !msg.code || !currentTaskId.value) return
|
if (m.role === "assistant" && m.id && hasCode(m)) map.set(m.id, map.size + 1)
|
||||||
submittingId.value = msg.id
|
|
||||||
try {
|
|
||||||
await Submission.create(
|
|
||||||
currentTaskId.value,
|
|
||||||
{
|
|
||||||
html: msg.code.html ?? "",
|
|
||||||
css: msg.code.css ?? "",
|
|
||||||
js: msg.code.js ?? "",
|
|
||||||
},
|
|
||||||
msg.id,
|
|
||||||
)
|
|
||||||
markMessageSubmitted(msg.id)
|
|
||||||
naiveMessage.success("提交成功")
|
|
||||||
emit("submitted")
|
|
||||||
} catch {
|
|
||||||
naiveMessage.error("提交失败,请重试")
|
|
||||||
} finally {
|
|
||||||
submittingId.value = null
|
|
||||||
}
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
function versionOf(msg: { id?: number }) {
|
||||||
|
return msg.id ? versionNumbers.value.get(msg.id) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePair(assistantMsgId: number) {
|
async function deletePair(assistantMsgId: number) {
|
||||||
@@ -227,11 +218,12 @@ async function deletePair(assistantMsgId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function send() {
|
async function send() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value) return
|
||||||
sendPrompt(text, selectedModel.value)
|
|
||||||
input.value = ""
|
input.value = ""
|
||||||
|
await props.beforeSend?.()
|
||||||
|
sendPrompt(text, selectedModel.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startGuidance() {
|
function startGuidance() {
|
||||||
@@ -240,9 +232,10 @@ function startGuidance() {
|
|||||||
openGuidance(currentTaskId.value, text)
|
openGuidance(currentTaskId.value, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGuidanceGenerate(finalPrompt: string) {
|
async function onGuidanceGenerate(finalPrompt: string) {
|
||||||
sendPrompt(finalPrompt, selectedModel.value)
|
|
||||||
input.value = ""
|
input.value = ""
|
||||||
|
await props.beforeSend?.()
|
||||||
|
sendPrompt(finalPrompt, selectedModel.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(msg: { role: string; content: string }): string {
|
function renderContent(msg: { role: string; content: string }): string {
|
||||||
@@ -281,11 +274,11 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: #666;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.user .message-role {
|
.message.user .message-role {
|
||||||
color: #2080f0;
|
color: var(--wp-css);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.assistant .message-role {
|
.message.assistant .message-role {
|
||||||
@@ -297,8 +290,38 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手改代码这类不是对话的一行说明 */
|
||||||
|
.message.note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-chip {
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid var(--wp-brand);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font: 600 12px/18px var(--wp-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
.message-content :deep(pre) {
|
.message-content :deep(pre) {
|
||||||
background: #f5f5f5;
|
background: var(--wp-paper);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -356,7 +379,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
|
|
||||||
.streaming-hint {
|
.streaming-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #aaa;
|
color: var(--wp-faint);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -364,7 +387,7 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
.input-area {
|
.input-area {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid var(--wp-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-pair {
|
.message-pair {
|
||||||
@@ -382,11 +405,11 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--wp-line);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #bbb;
|
color: var(--wp-faint);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition:
|
transition:
|
||||||
color 0.15s,
|
color 0.15s,
|
||||||
@@ -394,8 +417,8 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pair-delete-btn:hover {
|
.pair-delete-btn:hover {
|
||||||
color: #e03e3e;
|
color: var(--wp-bad);
|
||||||
border-color: #e03e3e;
|
border-color: var(--wp-bad);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-pair.has-delete:hover .pair-delete-btn {
|
.message-pair.has-delete:hover .pair-delete-btn {
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-modal
|
|
||||||
preset="card"
|
|
||||||
:show="show"
|
|
||||||
title="等待 AI 回复时,给同学的作品打个分吧"
|
|
||||||
style="width: 90vw; max-width: 960px"
|
|
||||||
@update:show="onUpdateShow"
|
|
||||||
>
|
|
||||||
<n-text
|
|
||||||
v-if="current"
|
|
||||||
depth="3"
|
|
||||||
style="font-size: 12px; display: block; margin-bottom: 8px"
|
|
||||||
>
|
|
||||||
{{ current.task_title }} · 提交者 {{ current.username }}
|
|
||||||
</n-text>
|
|
||||||
<div class="preview-wrapper">
|
|
||||||
<iframe
|
|
||||||
class="preview-iframe"
|
|
||||||
:sandbox="PREVIEW_SANDBOX"
|
|
||||||
:srcdoc="previewContent"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
<div class="rate-row">
|
|
||||||
<n-rate
|
|
||||||
v-if="current"
|
|
||||||
:key="current.submission_id"
|
|
||||||
:size="32"
|
|
||||||
@update:value="onRate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<n-text
|
|
||||||
depth="3"
|
|
||||||
style="font-size: 11px; display: block; text-align: center; margin-top: 8px"
|
|
||||||
>
|
|
||||||
打分后自动换下一个作品;AI 回复完成后弹窗会自动关闭
|
|
||||||
</n-text>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, onUnmounted } from "vue"
|
|
||||||
import { useMessage } from "naive-ui"
|
|
||||||
import { Submission } from "../../api"
|
|
||||||
import { streaming } from "../../store/prompt"
|
|
||||||
import {
|
|
||||||
buildPreviewDocument,
|
|
||||||
PREVIEW_SANDBOX,
|
|
||||||
} from "../../utils/previewDocument"
|
|
||||||
import type { RandomRatingItem } from "../../utils/type"
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const show = ref(false)
|
|
||||||
const current = ref<RandomRatingItem | null>(null)
|
|
||||||
const previewContent = ref("")
|
|
||||||
let reshowTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function clearReshowTimer() {
|
|
||||||
if (reshowTimer !== null) {
|
|
||||||
clearTimeout(reshowTimer)
|
|
||||||
reshowTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAndShow() {
|
|
||||||
try {
|
|
||||||
const item = await Submission.randomForRating(current.value?.submission_id)
|
|
||||||
// 请求期间 AI 已经回复完了,就不要再弹出来
|
|
||||||
if (!streaming.value) return
|
|
||||||
if (item) {
|
|
||||||
current.value = item
|
|
||||||
previewContent.value = buildPreviewDocument({
|
|
||||||
html: item.html ?? "",
|
|
||||||
css: item.css ?? "",
|
|
||||||
js: item.js ?? "",
|
|
||||||
})
|
|
||||||
show.value = true
|
|
||||||
} else {
|
|
||||||
show.value = false
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(err.response?.data?.detail ?? "获取作品失败,请重试")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRate(score: number) {
|
|
||||||
if (!current.value) return
|
|
||||||
try {
|
|
||||||
await Submission.updateScore(current.value.submission_id, score)
|
|
||||||
message.success("感谢评分!")
|
|
||||||
await fetchAndShow()
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUpdateShow(value: boolean) {
|
|
||||||
show.value = value
|
|
||||||
if (!value) {
|
|
||||||
clearReshowTimer()
|
|
||||||
if (streaming.value) {
|
|
||||||
reshowTimer = setTimeout(fetchAndShow, 30_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
streaming,
|
|
||||||
(val) => {
|
|
||||||
clearReshowTimer()
|
|
||||||
if (val) {
|
|
||||||
fetchAndShow()
|
|
||||||
} else {
|
|
||||||
show.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearReshowTimer()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.preview-wrapper {
|
|
||||||
height: 70vh;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rate-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<MdEditor v-bind="props" v-model="modelValue" @upload-img="uploadImg" />
|
<MdEditor
|
||||||
|
v-bind="props"
|
||||||
|
v-model="modelValue"
|
||||||
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
|
@upload-img="uploadImg"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { MdEditor } from "md-editor-v3"
|
import { MdEditor } from "md-editor-v3"
|
||||||
@@ -7,6 +12,7 @@ import { MdEditor } from "md-editor-v3"
|
|||||||
import "md-editor-v3/lib/style.css"
|
import "md-editor-v3/lib/style.css"
|
||||||
import { Helper } from "../../api"
|
import { Helper } from "../../api"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
|
import { isDark } from "../../store/theme"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { useMessage } from "naive-ui"
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
import { Account } from "../../api"
|
import { Account } from "../../api"
|
||||||
import { Role, type User } from "../../utils/type"
|
import { Role, type User } from "../../utils/type"
|
||||||
import UserFormModal from "./UserFormModal.vue"
|
import UserFormModal from "./UserFormModal.vue"
|
||||||
@@ -41,15 +41,27 @@ const props = defineProps<Props>()
|
|||||||
const emits = defineEmits(["reload"])
|
const emits = defineEmits(["reload"])
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
|
|
||||||
function edit() {
|
function edit() {
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleUserActive() {
|
async function doToggle() {
|
||||||
const data = await Account.toggleActive(props.user.id)
|
const data = await Account.toggleActive(props.user.id)
|
||||||
message.success(data.message)
|
message.success(data.message)
|
||||||
emits("reload")
|
emits("reload")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleUserActive() {
|
||||||
|
if (!props.user.is_active) return doToggle()
|
||||||
|
dialog.warning({
|
||||||
|
title: `封禁「${props.user.username}」?`,
|
||||||
|
content: "封禁后这个账号不能登录,已经登录的也会被退出。之后可以再解封。",
|
||||||
|
positiveText: "封禁",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: doToggle,
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -5,6 +5,9 @@ import { css } from "@codemirror/lang-css"
|
|||||||
import { javascript } from "@codemirror/lang-javascript"
|
import { javascript } from "@codemirror/lang-javascript"
|
||||||
import { html } from "@codemirror/lang-html"
|
import { html } from "@codemirror/lang-html"
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
|
import { syntaxHighlighting } from "@codemirror/language"
|
||||||
|
import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
|
||||||
|
import { isDark } from "../../store/theme"
|
||||||
|
|
||||||
const styleTheme = EditorView.baseTheme({
|
const styleTheme = EditorView.baseTheme({
|
||||||
"& .cm-scroller": {
|
"& .cm-scroller": {
|
||||||
@@ -44,13 +47,24 @@ const lang = computed(() => {
|
|||||||
|
|
||||||
const bgTheme = computed(() => {
|
const bgTheme = computed(() => {
|
||||||
const bg = bgColors[props.language] ?? "var(--wp-surface)"
|
const bg = bgColors[props.language] ?? "var(--wp-surface)"
|
||||||
return EditorView.theme({
|
return EditorView.theme(
|
||||||
"&": { backgroundColor: bg },
|
{
|
||||||
".cm-gutters": { backgroundColor: bg },
|
"&": { backgroundColor: bg, color: "var(--wp-ink)" },
|
||||||
})
|
".cm-gutters": { backgroundColor: bg, color: "var(--wp-faint)", borderColor: "var(--wp-line)" },
|
||||||
|
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "rgba(127, 127, 127, 0.08)" },
|
||||||
|
".cm-cursor": { borderLeftColor: "var(--wp-ink)" },
|
||||||
|
},
|
||||||
|
{ dark: isDark.value },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const extensions = computed(() => [styleTheme, bgTheme.value, lang.value])
|
// 暗色时换成 One Dark 的语法配色(默认配色是给浅色背景设计的)
|
||||||
|
const extensions = computed(() => [
|
||||||
|
styleTheme,
|
||||||
|
bgTheme.value,
|
||||||
|
...(isDark.value ? [syntaxHighlighting(oneDarkHighlightStyle)] : []),
|
||||||
|
lang.value,
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Codemirror
|
<Codemirror
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import Editor from "./Editor.vue"
|
|||||||
import Toolbar from "./Toolbar.vue"
|
import Toolbar from "./Toolbar.vue"
|
||||||
import { html, css, js, tab, size, reset } from "../../store/editors"
|
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||||
import { taskId } from "../../store/task"
|
import { taskId } from "../../store/task"
|
||||||
|
import { formatCode } from "../../utils/code"
|
||||||
import { Submission } from "../../api"
|
import { Submission } from "../../api"
|
||||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||||
import { h, ref } from "vue"
|
import { h, ref } from "vue"
|
||||||
@@ -86,43 +87,16 @@ function changeSize(num: number) {
|
|||||||
size.value = num
|
size.value = num
|
||||||
}
|
}
|
||||||
|
|
||||||
async function formatCode() {
|
async function formatEditors() {
|
||||||
// prettier 加上各语言插件近 1MB,只在点“格式化”时才加载
|
const out = await formatCode({ html: html.value, css: css.value, js: js.value })
|
||||||
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
|
html.value = out.html
|
||||||
await Promise.all([
|
css.value = out.css
|
||||||
import("prettier/standalone"),
|
js.value = out.js
|
||||||
import("prettier/parser-html"),
|
|
||||||
import("prettier/parser-postcss"),
|
|
||||||
import("prettier/parser-babel"),
|
|
||||||
import("prettier/plugins/estree"),
|
|
||||||
])
|
|
||||||
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
|
|
||||||
prettier.format(html.value, {
|
|
||||||
parser: "html",
|
|
||||||
//@ts-ignore
|
|
||||||
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
|
||||||
tabWidth: 4,
|
|
||||||
}),
|
|
||||||
prettier.format(css.value, {
|
|
||||||
parser: "css",
|
|
||||||
plugins: [cssParser],
|
|
||||||
tabWidth: 4,
|
|
||||||
}),
|
|
||||||
prettier.format(js.value, {
|
|
||||||
parser: "babel",
|
|
||||||
//@ts-ignore
|
|
||||||
plugins: [babelParser, estreeParser],
|
|
||||||
tabWidth: 2,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
html.value = htmlFormatted
|
|
||||||
css.value = cssFormatted
|
|
||||||
js.value = jsFormatted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function format() {
|
async function format() {
|
||||||
try {
|
try {
|
||||||
await formatCode()
|
await formatEditors()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
dialog.error({
|
dialog.error({
|
||||||
title: "格式化失败",
|
title: "格式化失败",
|
||||||
@@ -148,7 +122,7 @@ async function doSubmit() {
|
|||||||
async function formatAndSubmit() {
|
async function formatAndSubmit() {
|
||||||
submitLoading.value = true
|
submitLoading.value = true
|
||||||
try {
|
try {
|
||||||
await formatCode()
|
await formatEditors()
|
||||||
await doSubmit()
|
await doSubmit()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
@@ -180,41 +154,6 @@ async function formatAndSubmit() {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
||||||
background-color: var(--wp-html-soft) !important;
|
background-color: var(--wp-html-soft) !important;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex align="center" justify="space-between" class="title">
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
<div>
|
<div>
|
||||||
<n-text class="titleText">预览</n-text>
|
<slot name="title"><n-text class="titleText">预览</n-text></slot>
|
||||||
<n-text v-if="!!submission.id" depth="3">
|
<n-text v-if="!!submission.id" depth="3">
|
||||||
({{ submission.view_count || 0 }})
|
({{ submission.view_count || 0 }})
|
||||||
</n-text>
|
</n-text>
|
||||||
@@ -214,9 +214,11 @@ onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.title {
|
.title {
|
||||||
height: 43px;
|
height: 43px;
|
||||||
padding: 0 20px;
|
padding: 0 12px 0 16px;
|
||||||
border-bottom: 1px solid rgb(239, 239, 245);
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
background: var(--wp-surface);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleText {
|
.titleText {
|
||||||
@@ -225,7 +227,8 @@ onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
|||||||
|
|
||||||
.iframe-wrapper {
|
.iframe-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
/* 减去上面标题栏的高度,否则底部 43px 会被裁掉 */
|
||||||
|
height: calc(100% - 43px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
transition: max-width 0.3s ease;
|
transition: max-width 0.3s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<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">
|
<template v-if="user.loaded">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="roleStaff"
|
v-if="roleStaff"
|
||||||
@@ -55,6 +63,19 @@ import { authed, roleStaff, user } from "../../store/user"
|
|||||||
import { loginModal } from "../../store/modal"
|
import { loginModal } from "../../store/modal"
|
||||||
import { step } from "../../store/tutorial"
|
import { step } from "../../store/tutorial"
|
||||||
import { logout } from "../../store/session"
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<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">
|
||||||
|
<p class="hint">
|
||||||
|
本班同学在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。
|
||||||
|
</p>
|
||||||
|
<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 }">{{ 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 :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 { 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 docOf = (w: PeerWork) =>
|
||||||
|
buildPreviewDocument({ html: w.html, css: w.css, js: w.js, assetBaseUrl: props.assetBaseUrl })
|
||||||
|
|
||||||
|
watch(show, async (open) => {
|
||||||
|
if (!open || !props.taskId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
data.value = await Review.peers(props.taskId)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,393 @@
|
|||||||
|
<template>
|
||||||
|
<div class="panel">
|
||||||
|
<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>
|
||||||
|
上一个学生(K)
|
||||||
|
</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>
|
||||||
|
下一个学生(J)
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
|
<n-button size="small" quaternary @click="emit('close')">
|
||||||
|
<Icon icon="lucide:x" />
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-spin :show="loading" class="body">
|
||||||
|
<template v-if="sub">
|
||||||
|
<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">
|
||||||
|
<iframe :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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
|
||||||
|
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
|
||||||
|
<ol class="chain">
|
||||||
|
<li v-for="(r, i) in chain" :key="i">
|
||||||
|
<n-flex :size="6" align="center">
|
||||||
|
<span class="src">{{ sourceLabel(r.source) }}</span>
|
||||||
|
<span v-if="r.prompt_level" class="level">L{{ r.prompt_level }}</span>
|
||||||
|
</n-flex>
|
||||||
|
<div class="q">{{ r.question }}</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</n-spin>
|
||||||
|
</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 { parseTime, sourceLabel } from "../../utils/helper"
|
||||||
|
import type {
|
||||||
|
AwardManageOut,
|
||||||
|
FlagType,
|
||||||
|
PromptRound,
|
||||||
|
SubmissionAll,
|
||||||
|
SubmissionOut,
|
||||||
|
} from "../../utils/type"
|
||||||
|
|
||||||
|
// 老师看一份提交:预览、交过的各个版本、打分、课上展示、加入作品展、提示词链
|
||||||
|
const props = defineProps<{
|
||||||
|
submissionId: string
|
||||||
|
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||||
|
nav?: { prev: boolean; next: boolean }
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
prev: []
|
||||||
|
next: []
|
||||||
|
close: []
|
||||||
|
// 让作品墙同步更新这张卡片
|
||||||
|
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 ?? "加入失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.src {
|
||||||
|
font-size: 11px;
|
||||||
|
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>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<span>提示词</span>
|
<span>提示词</span>
|
||||||
<n-tooltip placement="bottom-start">
|
<n-tooltip placement="bottom-start">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Icon icon="lucide:info" :width="20" style="color: #aaa" />
|
<Icon icon="lucide:info" :width="20" style="color: var(--wp-faint)" />
|
||||||
</template>
|
</template>
|
||||||
<div style="line-height: 2">
|
<div style="line-height: 2">
|
||||||
<div>
|
<div>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
style="
|
style="
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
border-right: 1px solid #e0e0e0;
|
border-right: 1px solid var(--wp-line);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -94,8 +94,8 @@
|
|||||||
width: '22px',
|
width: '22px',
|
||||||
height: '22px',
|
height: '22px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: selectedRound === index ? '#2080f0' : '#c2d5fb',
|
background: selectedRound === index ? 'var(--wp-css)' : 'var(--wp-css-soft)',
|
||||||
color: '#fff',
|
color: 'var(--wp-surface)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -112,11 +112,11 @@
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
|
background: selectedRound === index ? 'var(--wp-css-soft)' : 'var(--wp-paper)',
|
||||||
border:
|
border:
|
||||||
selectedRound === index
|
selectedRound === index
|
||||||
? '1px solid #2080f0'
|
? '1px solid var(--wp-css)'
|
||||||
: '1px solid #e0e0e0',
|
: '1px solid var(--wp-line)',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
@@ -154,15 +154,13 @@
|
|||||||
padding: '1px 5px',
|
padding: '1px 5px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
background:
|
background:
|
||||||
round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
|
round.source === 'conversation' ? 'var(--wp-css-soft)' : 'var(--wp-line)',
|
||||||
color:
|
color:
|
||||||
round.source === 'conversation' ? '#2060c0' : '#888',
|
round.source === 'conversation' ? 'var(--wp-css)' : 'var(--wp-muted)',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}"
|
}"
|
||||||
>{{
|
>{{ sourceLabel(round.source) }}</span
|
||||||
round.source === "conversation" ? "对话" : "手动"
|
|
||||||
}}</span
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="round.prompt_level"
|
v-if="round.prompt_level"
|
||||||
@@ -214,7 +212,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||||
<div style="font-weight: bold; font-size: 13px; color: #555">
|
<div style="font-weight: bold; font-size: 13px; color: var(--wp-ink)">
|
||||||
第 {{ selectedRound + 1 }} 轮网页
|
第 {{ selectedRound + 1 }} 轮网页
|
||||||
</div>
|
</div>
|
||||||
<iframe
|
<iframe
|
||||||
@@ -224,9 +222,9 @@
|
|||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts"
|
||||||
style="
|
style="
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--wp-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fff;
|
background: var(--wp-surface);
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
|
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
|
||||||
@@ -241,6 +239,7 @@ import { computed, ref, watch } from "vue"
|
|||||||
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
||||||
import { Icon } from "../../icons"
|
import { Icon } from "../../icons"
|
||||||
import { renderMarkdown } from "../../utils/markdown"
|
import { renderMarkdown } from "../../utils/markdown"
|
||||||
|
import { sourceLabel } from "../../utils/helper"
|
||||||
import { storageShim } from "../../utils/previewStorage"
|
import { storageShim } from "../../utils/previewStorage"
|
||||||
import { Prompt, Submission } from "../../api"
|
import { Prompt, Submission } from "../../api"
|
||||||
import type { PromptRound } from "../../utils/type"
|
import type { PromptRound } from "../../utils/type"
|
||||||
@@ -286,12 +285,12 @@ async function deleteRound(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const levelColors: Record<number, string> = {
|
const levelColors: Record<number, string> = {
|
||||||
1: "#aaa",
|
1: "var(--wp-faint)",
|
||||||
2: "#6aab9c",
|
2: "var(--wp-brand)",
|
||||||
3: "#4a9ade",
|
3: "var(--wp-css)",
|
||||||
4: "#c47ab8",
|
4: "#c47ab8",
|
||||||
5: "#e0a040",
|
5: "var(--wp-warn)",
|
||||||
6: "#e05c5c",
|
6: "var(--wp-bad)",
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPageHtml = computed(() => {
|
const selectedPageHtml = computed(() => {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
|
|||||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
||||||
[
|
[
|
||||||
h("span", avgScore),
|
h("span", avgScore),
|
||||||
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
|
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-popover v-if="isAdmin" trigger="click">
|
|
||||||
<template #trigger>
|
|
||||||
<span :style="dotStyle" />
|
|
||||||
</template>
|
|
||||||
<n-space vertical size="small">
|
|
||||||
<n-button
|
|
||||||
v-for="opt in FLAG_OPTIONS"
|
|
||||||
:key="opt.value"
|
|
||||||
text
|
|
||||||
@click="$emit('update:flag', opt.value)"
|
|
||||||
>
|
|
||||||
<span style="display: flex; align-items: center; gap: 6px">
|
|
||||||
<span
|
|
||||||
:style="{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: opt.color,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
{{ opt.label }}
|
|
||||||
</span>
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
v-if="flag"
|
|
||||||
text
|
|
||||||
block
|
|
||||||
type="error"
|
|
||||||
@click="$emit('update:flag', null)"
|
|
||||||
>
|
|
||||||
清除
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-popover>
|
|
||||||
<span v-else :style="dotStyle" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue"
|
|
||||||
import type { FlagType } from "../../utils/type"
|
|
||||||
|
|
||||||
const FLAG_OPTIONS: {
|
|
||||||
value: NonNullable<FlagType>
|
|
||||||
color: string
|
|
||||||
label: string
|
|
||||||
}[] = [
|
|
||||||
{ value: "red", color: "#e03030", label: "值得展示" },
|
|
||||||
{ value: "blue", color: "#2080f0", label: "需要讲解" },
|
|
||||||
{ value: "green", color: "#18a058", label: "优秀作品" },
|
|
||||||
{ value: "yellow", color: "#f0a020", label: "需要改进" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
flag: FlagType
|
|
||||||
isAdmin: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{ "update:flag": [value: FlagType] }>()
|
|
||||||
|
|
||||||
const dotStyle = computed(() => {
|
|
||||||
const match = FLAG_OPTIONS.find((f) => f.value === props.flag)
|
|
||||||
return {
|
|
||||||
display: "inline-block",
|
|
||||||
width: "12px",
|
|
||||||
height: "12px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: match ? match.color : "transparent",
|
|
||||||
border: match ? "none" : "1px dashed #ccc",
|
|
||||||
cursor: props.isAdmin ? "pointer" : "default",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
|
|
||||||
<n-empty v-if="!challenges.length">暂无挑战,敬请期待</n-empty>
|
|
||||||
<n-flex v-else vertical :size="12">
|
|
||||||
<n-card
|
|
||||||
v-for="item in challenges"
|
|
||||||
:key="item.display"
|
|
||||||
hoverable
|
|
||||||
:class="['challenge-card', { submitted: item.submitted }]"
|
|
||||||
@click="select(item)"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<n-flex align="center" :size="6">
|
|
||||||
<span v-if="item.submitted" class="check-icon">✓</span>
|
|
||||||
<span :class="{ 'submitted-title': item.submitted }">{{
|
|
||||||
item.title
|
|
||||||
}}</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<template #header-extra>
|
|
||||||
<n-flex :size="6">
|
|
||||||
<n-tag type="warning" size="small">{{ item.score }} 分</n-tag>
|
|
||||||
<n-tag v-if="item.pass_score != null" size="small"
|
|
||||||
>及格 {{ item.pass_score }} 分</n-tag
|
|
||||||
>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<n-text depth="3" class="challenge-author">
|
|
||||||
出题人:{{ item.author_name || "未设置" }}
|
|
||||||
</n-text>
|
|
||||||
</n-card>
|
|
||||||
</n-flex>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from "vue"
|
|
||||||
import { useRouter } from "vue-router"
|
|
||||||
import { Challenge } from "../../api"
|
|
||||||
import { taskTab } from "../../store/task"
|
|
||||||
import { TASK_TYPE } from "../../utils/const"
|
|
||||||
import type { ChallengeSlim } from "../../utils/type"
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const challenges = ref<ChallengeSlim[]>([])
|
|
||||||
|
|
||||||
function select(item: ChallengeSlim) {
|
|
||||||
router.push({ name: "home-challenge", params: { display: item.display } })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
challenges.value = await Challenge.listDisplay()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.container {
|
|
||||||
padding: 16px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-card {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-card.submitted {
|
|
||||||
background-color: #f6ffed;
|
|
||||||
border-color: #b7eb8f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-icon {
|
|
||||||
color: #52c41a;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submitted-title {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-author {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<n-flex align="center" justify="space-between" class="title" :wrap="false">
|
<n-flex align="center" justify="space-between" class="title" :wrap="false">
|
||||||
<n-flex align="center" :wrap="false">
|
<n-flex align="center" :wrap="false">
|
||||||
<span class="panel-name">
|
<span class="panel-name">教程</span>
|
||||||
{{ taskTab === TASK_TYPE.Tutorial ? "教程" : "挑战" }}
|
|
||||||
</span>
|
|
||||||
<template v-if="!hideNav">
|
<template v-if="!hideNav">
|
||||||
<n-tooltip>
|
<n-tooltip>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -37,6 +35,14 @@
|
|||||||
<template #icon><Icon icon="lucide:image" /></template>
|
<template #icon><Icon icon="lucide:image" /></template>
|
||||||
素材
|
素材
|
||||||
</n-button>
|
</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">
|
<n-button v-if="roleSuper" size="small" quaternary @click="edit">
|
||||||
<template #icon><Icon icon="lucide:edit" /></template>
|
<template #icon><Icon icon="lucide:edit" /></template>
|
||||||
编辑
|
编辑
|
||||||
@@ -51,9 +57,13 @@
|
|||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
|
<TutorialContent />
|
||||||
<ChallengeList v-else />
|
|
||||||
</div>
|
</div>
|
||||||
|
<PeersDrawer
|
||||||
|
v-model:show="showPeers"
|
||||||
|
:task-id="taskId"
|
||||||
|
:asset-base-url="`/media/tasks/tutorial/${step}/`"
|
||||||
|
/>
|
||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="showAssets"
|
v-model:show="showAssets"
|
||||||
preset="card"
|
preset="card"
|
||||||
@@ -89,17 +99,18 @@ import {
|
|||||||
prevDisabled,
|
prevDisabled,
|
||||||
nextDisabled,
|
nextDisabled,
|
||||||
} from "../../store/tutorial"
|
} from "../../store/tutorial"
|
||||||
import { roleSuper } from "../../store/user"
|
import { authed, roleSuper } from "../../store/user"
|
||||||
|
import PeersDrawer from "../review/PeersDrawer.vue"
|
||||||
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { TASK_TYPE } from "../../utils/const"
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
import { TaskAssets } from "../../api"
|
import { TaskAssets } from "../../api"
|
||||||
import type { TaskAsset } from "../../utils/type"
|
import type { TaskAsset } from "../../utils/type"
|
||||||
import ChallengeList from "./ChallengeList.vue"
|
|
||||||
import TutorialContent from "./TutorialContent.vue"
|
import TutorialContent from "./TutorialContent.vue"
|
||||||
|
|
||||||
const tutorialAssets = ref<TaskAsset[]>([])
|
const tutorialAssets = ref<TaskAsset[]>([])
|
||||||
const showAssets = ref(false)
|
const showAssets = ref(false)
|
||||||
|
const showPeers = ref(false)
|
||||||
|
|
||||||
async function loadTutorialAssets(display: number) {
|
async function loadTutorialAssets(display: number) {
|
||||||
if (!display) {
|
if (!display) {
|
||||||
@@ -119,16 +130,11 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 路由同步:初始执行 + watch 响应 SPA 内部导航
|
// 路由同步:初始执行 + watch 响应 SPA 内部导航
|
||||||
|
// 这个面板只用在教程页(挑战有自己的页面)
|
||||||
function syncRoute(routeName: string) {
|
function syncRoute(routeName: string) {
|
||||||
if (routeName.startsWith("home-tutorial")) {
|
|
||||||
taskTab.value = TASK_TYPE.Tutorial
|
taskTab.value = TASK_TYPE.Tutorial
|
||||||
if (route.params.display) step.value = Number(route.params.display)
|
if (routeName.startsWith("home-tutorial") && route.params.display) {
|
||||||
} else if (routeName.startsWith("home-challenge")) {
|
step.value = Number(route.params.display)
|
||||||
taskTab.value = TASK_TYPE.Challenge
|
|
||||||
if (route.params.display)
|
|
||||||
challengeDisplay.value = Number(route.params.display)
|
|
||||||
// 挑战列表里没有选中的任务,“提交”不可用
|
|
||||||
else taskId.value = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
syncRoute(route.name as string)
|
syncRoute(route.name as string)
|
||||||
@@ -140,9 +146,7 @@ watch(
|
|||||||
|
|
||||||
defineEmits(["hide"])
|
defineEmits(["hide"])
|
||||||
|
|
||||||
const hideNav = computed(
|
const hideNav = computed(() => tutorialIds.value.length <= 1)
|
||||||
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
const progressText = computed(() => {
|
const progressText = computed(() => {
|
||||||
const ids = tutorialIds.value
|
const ids = tutorialIds.value
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
align="center"
|
align="center"
|
||||||
style="margin-bottom: 16px; flex-wrap: wrap; gap: 6px"
|
style="margin-bottom: 16px; flex-wrap: wrap; gap: 6px"
|
||||||
>
|
>
|
||||||
<span style="color: #666; font-size: 12px">班级筛选:</span>
|
<span style="color: var(--wp-muted); font-size: 12px">班级筛选:</span>
|
||||||
<n-button
|
<n-button
|
||||||
size="small"
|
size="small"
|
||||||
:type="selectedClass === null ? 'primary' : 'default'"
|
:type="selectedClass === null ? 'primary' : 'default'"
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
style="
|
style="
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
border: 1px solid #eee;
|
border: 1px solid var(--wp-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
:style="{
|
:style="{
|
||||||
padding: '12px 8px',
|
padding: '12px 8px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
borderRight: i < metrics.length - 1 ? '1px solid #eee' : 'none',
|
borderRight: i < metrics.length - 1 ? '1px solid var(--wp-line)' : 'none',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
>
|
>
|
||||||
{{ metric.value }}
|
{{ metric.value }}
|
||||||
</div>
|
</div>
|
||||||
<div style="color: #888; font-size: 11px; margin-top: 2px">
|
<div style="color: var(--wp-muted); font-size: 11px; margin-top: 2px">
|
||||||
{{ metric.label }}
|
{{ metric.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<!-- 未提交名单(可折叠) -->
|
<!-- 未提交名单(可折叠) -->
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
border: 1px solid #eee;
|
border: 1px solid var(--wp-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #fff8f8;
|
background: var(--wp-bad-soft);
|
||||||
"
|
"
|
||||||
@click="showUnsubmitted = !showUnsubmitted"
|
@click="showUnsubmitted = !showUnsubmitted"
|
||||||
>
|
>
|
||||||
@@ -99,12 +99,12 @@
|
|||||||
style="
|
style="
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background: #d03050;
|
background: var(--wp-bad);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
"
|
"
|
||||||
></span>
|
></span>
|
||||||
<span style="font-weight: 600; color: #d03050; font-size: 12px"
|
<span style="font-weight: 600; color: var(--wp-bad); font-size: 12px"
|
||||||
>未提交({{ stats.unsubmitted_count }}人)</span
|
>未提交({{ stats.unsubmitted_count }}人)</span
|
||||||
>
|
>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -115,14 +115,14 @@
|
|||||||
: 'lucide:chevron-right'
|
: 'lucide:chevron-right'
|
||||||
"
|
"
|
||||||
:width="14"
|
:width="14"
|
||||||
style="color: #aaa"
|
style="color: var(--wp-faint)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showUnsubmitted"
|
v-if="showUnsubmitted"
|
||||||
style="
|
style="
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: #fff8f8;
|
background: var(--wp-bad-soft);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -133,12 +133,12 @@
|
|||||||
:key="u.username"
|
:key="u.username"
|
||||||
size="small"
|
size="small"
|
||||||
:bordered="true"
|
:bordered="true"
|
||||||
style="border-color: #ffd0d0; background: #fff"
|
style="border-color: var(--wp-bad-soft); background: var(--wp-surface)"
|
||||||
>{{ displayName(u.username, u.classname) }}</n-tag
|
>{{ displayName(u.username, u.classname) }}</n-tag
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="!stats.unsubmitted_users.length"
|
v-if="!stats.unsubmitted_users.length"
|
||||||
style="color: #aaa; font-size: 12px"
|
style="color: var(--wp-faint); font-size: 12px"
|
||||||
>暂无</span
|
>暂无</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<!-- 未打分名单(可折叠) -->
|
<!-- 未打分名单(可折叠) -->
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
border: 1px solid #eee;
|
border: 1px solid var(--wp-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #fffaf5;
|
background: var(--wp-warn-soft);
|
||||||
"
|
"
|
||||||
@click="showUnrated = !showUnrated"
|
@click="showUnrated = !showUnrated"
|
||||||
>
|
>
|
||||||
@@ -169,15 +169,15 @@
|
|||||||
style="
|
style="
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background: #e07800;
|
background: var(--wp-warn);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
"
|
"
|
||||||
></span>
|
></span>
|
||||||
<span style="font-weight: 600; color: #e07800; font-size: 12px"
|
<span style="font-weight: 600; color: var(--wp-warn); font-size: 12px"
|
||||||
>未打分({{ stats.unrated_count }}人)</span
|
>未打分({{ stats.unrated_count }}人)</span
|
||||||
>
|
>
|
||||||
<span style="font-size: 11px; color: #aaa"
|
<span style="font-size: 11px; color: var(--wp-faint)"
|
||||||
>— 还没给任何提交打过分</span
|
>— 还没给任何提交打过分</span
|
||||||
>
|
>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -186,14 +186,14 @@
|
|||||||
showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'
|
showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'
|
||||||
"
|
"
|
||||||
:width="14"
|
:width="14"
|
||||||
style="color: #aaa"
|
style="color: var(--wp-faint)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showUnrated"
|
v-if="showUnrated"
|
||||||
style="
|
style="
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: #fffaf5;
|
background: var(--wp-warn-soft);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -204,12 +204,12 @@
|
|||||||
:key="u.username"
|
:key="u.username"
|
||||||
size="small"
|
size="small"
|
||||||
:bordered="true"
|
:bordered="true"
|
||||||
style="border-color: #ffd0a0; background: #fff"
|
style="border-color: var(--wp-warn-soft); background: var(--wp-surface)"
|
||||||
>{{ displayName(u.username, u.classname) }}</n-tag
|
>{{ displayName(u.username, u.classname) }}</n-tag
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="!stats.unrated_users.length"
|
v-if="!stats.unrated_users.length"
|
||||||
style="color: #aaa; font-size: 12px"
|
style="color: var(--wp-faint); font-size: 12px"
|
||||||
>暂无</span
|
>暂无</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,11 +222,11 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
提交次数分布
|
提交次数分布
|
||||||
<span style="font-size: 11px; color: #aaa; font-weight: 400"
|
<span style="font-size: 11px; color: var(--wp-faint); font-weight: 400"
|
||||||
>(已提交的 {{ stats.submitted_count }} 人)</span
|
>(已提交的 {{ stats.submitted_count }} 人)</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,13 +252,13 @@
|
|||||||
>
|
>
|
||||||
{{ bucket.value }}
|
{{ bucket.value }}
|
||||||
</div>
|
</div>
|
||||||
<div style="color: #aaa; font-size: 11px; margin-top: 2px">
|
<div style="color: var(--wp-faint); font-size: 11px; margin-top: 2px">
|
||||||
{{ bucket.label }}
|
{{ bucket.label }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background: #e8e8e8;
|
background: var(--wp-line);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
"
|
"
|
||||||
@@ -272,73 +272,28 @@
|
|||||||
}"
|
}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="color: #bbb; font-size: 10px; margin-top: 3px">
|
<div style="color: var(--wp-faint); font-size: 10px; margin-top: 3px">
|
||||||
{{ bucketPct(bucket.value) }}
|
{{ bucketPct(bucket.value) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标记统计 -->
|
<!-- 人气前五 -->
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
"
|
|
||||||
>
|
|
||||||
标记统计
|
|
||||||
</div>
|
|
||||||
<n-flex :size="8" style="flex-wrap: wrap">
|
|
||||||
<div
|
|
||||||
v-for="flag in flagBadges"
|
|
||||||
:key="flag.label"
|
|
||||||
:style="{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
padding: '4px 10px',
|
|
||||||
background: flag.bg,
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: `1px solid ${flag.border}`,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:style="{
|
|
||||||
width: '7px',
|
|
||||||
height: '7px',
|
|
||||||
background: flag.color,
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'inline-block',
|
|
||||||
}"
|
|
||||||
></span>
|
|
||||||
<span :style="{ color: flag.color, fontSize: '12px' }">{{
|
|
||||||
flag.label
|
|
||||||
}}</span>
|
|
||||||
<span style="font-weight: 700; font-size: 13px; color: #333">{{
|
|
||||||
flag.value
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</n-flex>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 人气前五 -->
|
|
||||||
<div style="margin-top: 12px">
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #333;
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
人气前五
|
人气前五
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="stats.top_viewed.length === 0"
|
v-if="stats.top_viewed.length === 0"
|
||||||
style="color: #aaa; font-size: 12px"
|
style="color: var(--wp-faint); font-size: 12px"
|
||||||
>
|
>
|
||||||
暂无
|
暂无
|
||||||
</div>
|
</div>
|
||||||
@@ -351,10 +306,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: 1px solid #eee;
|
border: 1px solid var(--wp-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #fafafa;
|
background: var(--wp-paper);
|
||||||
"
|
"
|
||||||
@click="viewSubmission(item.submission_id)"
|
@click="viewSubmission(item.submission_id)"
|
||||||
>
|
>
|
||||||
@@ -364,8 +319,8 @@
|
|||||||
height: '20px',
|
height: '20px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background:
|
background:
|
||||||
i < 3 ? ['#f0a020', '#888', '#a07040'][i] : '#ddd',
|
i < 3 ? ['var(--wp-warn)', 'var(--wp-muted)', 'var(--wp-warn)'][i] : 'var(--wp-line)',
|
||||||
color: '#fff',
|
color: 'var(--wp-surface)',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -375,10 +330,10 @@
|
|||||||
}"
|
}"
|
||||||
>{{ i + 1 }}</span
|
>{{ i + 1 }}</span
|
||||||
>
|
>
|
||||||
<span style="flex: 1; font-size: 13px; color: #333">
|
<span style="flex: 1; font-size: 13px; color: var(--wp-ink)">
|
||||||
{{ displayName(item.username, item.classname) }}
|
{{ displayName(item.username, item.classname) }}
|
||||||
</span>
|
</span>
|
||||||
<span style="font-size: 12px; color: #2080f0; font-weight: 600">
|
<span style="font-size: 12px; color: var(--wp-css); font-weight: 600">
|
||||||
{{ item.view_count }} 次
|
{{ item.view_count }} 次
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,14 +395,14 @@ function bucketPct(value: number) {
|
|||||||
const metrics = computed(() => {
|
const metrics = computed(() => {
|
||||||
if (!stats.value) return []
|
if (!stats.value) return []
|
||||||
return [
|
return [
|
||||||
{ label: "已提交", value: stats.value.submitted_count, color: "#18a058" },
|
{ label: "已提交", value: stats.value.submitted_count, color: "var(--wp-brand)" },
|
||||||
{ label: "未提交", value: stats.value.unsubmitted_count, color: "#d03050" },
|
{ label: "未提交", value: stats.value.unsubmitted_count, color: "var(--wp-bad)" },
|
||||||
{
|
{
|
||||||
label: "平均分",
|
label: "平均分",
|
||||||
value: stats.value.average_score?.toFixed(1) ?? "—",
|
value: stats.value.average_score?.toFixed(1) ?? "—",
|
||||||
color: "#2080f0",
|
color: "var(--wp-css)",
|
||||||
},
|
},
|
||||||
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
|
{ label: "未打分", value: stats.value.unrated_count, color: "var(--wp-bad)" },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -458,65 +413,30 @@ const countBuckets = computed(() => {
|
|||||||
{
|
{
|
||||||
label: "4 次+",
|
label: "4 次+",
|
||||||
value: d.count_4_plus,
|
value: d.count_4_plus,
|
||||||
color: "#f0a020",
|
color: "var(--wp-warn)",
|
||||||
bg: "#fffbf0",
|
bg: "var(--wp-warn-soft)",
|
||||||
borderColor: "#ffe0a0",
|
borderColor: "var(--wp-warn-soft)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "3 次",
|
label: "3 次",
|
||||||
value: d.count_3,
|
value: d.count_3,
|
||||||
color: "#18a058",
|
color: "var(--wp-brand)",
|
||||||
bg: "#f0fff4",
|
bg: "var(--wp-brand-soft)",
|
||||||
borderColor: "#c8e8d0",
|
borderColor: "var(--wp-brand-soft)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "2 次",
|
label: "2 次",
|
||||||
value: d.count_2,
|
value: d.count_2,
|
||||||
color: "#2080f0",
|
color: "var(--wp-css)",
|
||||||
bg: "#f0f7ff",
|
bg: "var(--wp-css-soft)",
|
||||||
borderColor: "#d0e8ff",
|
borderColor: "var(--wp-css-soft)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "仅 1 次",
|
label: "仅 1 次",
|
||||||
value: d.count_1,
|
value: d.count_1,
|
||||||
color: "#888",
|
color: "var(--wp-muted)",
|
||||||
bg: "#fafafa",
|
bg: "var(--wp-paper)",
|
||||||
borderColor: "#e0e0e0",
|
borderColor: "var(--wp-line)",
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const flagBadges = computed(() => {
|
|
||||||
if (!stats.value) return []
|
|
||||||
const f = stats.value.flag_stats
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "值得展示",
|
|
||||||
value: f.red,
|
|
||||||
color: "#d03050",
|
|
||||||
bg: "#fff0f0",
|
|
||||||
border: "#ffd0d0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "需要讲解",
|
|
||||||
value: f.blue,
|
|
||||||
color: "#2080f0",
|
|
||||||
bg: "#f0f7ff",
|
|
||||||
border: "#c8deff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "优秀作品",
|
|
||||||
value: f.green,
|
|
||||||
color: "#18a058",
|
|
||||||
bg: "#f0fff4",
|
|
||||||
border: "#b8e8c8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "需要改进",
|
|
||||||
value: f.yellow,
|
|
||||||
color: "#f0a020",
|
|
||||||
bg: "#fffbf0",
|
|
||||||
border: "#ffe8a0",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { trustedExtensions } from "../../utils/markdown"
|
|||||||
import copyFn from "copy-text-to-clipboard"
|
import copyFn from "copy-text-to-clipboard"
|
||||||
import { css, html, js, tab } from "../../store/editors"
|
import { css, html, js, tab } from "../../store/editors"
|
||||||
import { Tutorial } from "../../api"
|
import { Tutorial } from "../../api"
|
||||||
|
import { readTaskCode } from "../../composables/useTaskCode"
|
||||||
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
||||||
import { taskId, assetBaseUrl } from "../../store/task"
|
import { taskId, assetBaseUrl } from "../../store/task"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
@@ -45,11 +46,13 @@ async function render() {
|
|||||||
const seq = ++renderSeq
|
const seq = ++renderSeq
|
||||||
const data = await Tutorial.get(step.value)
|
const data = await Tutorial.get(step.value)
|
||||||
if (seq !== renderSeq) return
|
if (seq !== renderSeq) return
|
||||||
|
// 这一节写过代码就接着写,没写过才放示例代码(以前每次切换都会被示例覆盖)
|
||||||
|
const saved = readTaskCode(data.task_ptr)
|
||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||||
html.value = data.example_html ?? ""
|
html.value = saved?.html ?? data.example_html ?? ""
|
||||||
css.value = data.example_css ?? ""
|
css.value = saved?.css ?? data.example_css ?? ""
|
||||||
js.value = data.example_js ?? ""
|
js.value = saved?.js ?? data.example_js ?? ""
|
||||||
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||||
const rendered = await tutorialMarked.parse(merged, { async: true })
|
const rendered = await tutorialMarked.parse(merged, { async: true })
|
||||||
if (seq !== renderSeq) return
|
if (seq !== renderSeq) return
|
||||||
@@ -124,7 +127,7 @@ watch(step, (v) => {
|
|||||||
|
|
||||||
.markdown-body .codeblock-wrapper {
|
.markdown-body .codeblock-wrapper {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: #f6f8fa;
|
background-color: var(--wp-paper);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -170,9 +173,9 @@ watch(step, (v) => {
|
|||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid var(--wp-line);
|
||||||
background-color: #fff;
|
background-color: var(--wp-surface);
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,79 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="show"
|
||||||
|
preset="card"
|
||||||
|
title="粘贴外部 AI 生成的代码"
|
||||||
|
style="width: 640px; max-width: 94vw"
|
||||||
|
>
|
||||||
|
<n-form-item label="你在外部 AI 里用的提示词" :show-feedback="false">
|
||||||
|
<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() || !raw.trim()"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
存为新版本
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { 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("")
|
||||||
|
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,257 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="current || revealed" class="rating-card">
|
||||||
|
<div class="head">
|
||||||
|
<span>等 AI 的时候,给同学的作品打个分</span>
|
||||||
|
<n-button v-if="current" text size="tiny" @click="next(true)">跳过</n-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="revealed" class="revealed">
|
||||||
|
刚才那份是 <b>{{ revealed }}</b> 的作品,谢谢你的评分
|
||||||
|
</div>
|
||||||
|
<div v-else-if="current" class="body">
|
||||||
|
<button class="thumb" title="点击放大看" @click="showLarge = true">
|
||||||
|
<iframe
|
||||||
|
:sandbox="PREVIEW_SANDBOX"
|
||||||
|
:srcdoc="doc"
|
||||||
|
tabindex="-1"
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>
|
||||||
|
</button>
|
||||||
|
<div class="info">
|
||||||
|
<div class="task">{{ current.task_title }}</div>
|
||||||
|
<div class="rates">
|
||||||
|
<n-tooltip v-for="s in scores" :key="s.value">
|
||||||
|
<template #trigger>
|
||||||
|
<button class="rate" :disabled="rating" @click="rate(s.value)">
|
||||||
|
{{ s.value }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ s.value }} 分:{{ s.label }}
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="legend">1 没完成 · 3 基本完成 · 5 完成且有自己的想法</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showLarge"
|
||||||
|
preset="card"
|
||||||
|
:title="current ? `同学的作品 · ${current.task_title}` : '同学的作品'"
|
||||||
|
style="width: 90vw; max-width: 1000px"
|
||||||
|
>
|
||||||
|
<div class="large">
|
||||||
|
<iframe :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="large-rates">
|
||||||
|
<button
|
||||||
|
v-for="s in scores"
|
||||||
|
:key="s.value"
|
||||||
|
class="rate wide"
|
||||||
|
:disabled="rating"
|
||||||
|
@click="rate(s.value)"
|
||||||
|
>
|
||||||
|
<b>{{ s.value }}</b> {{ s.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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 生成期间在对话里显示一份同学的作品让学生打分。打分前不显示作者,减少给熟人打高分
|
||||||
|
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 showLarge = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
showLarge.value = false
|
||||||
|
setTimeout(() => {
|
||||||
|
revealed.value = ""
|
||||||
|
if (props.active) next()
|
||||||
|
}, 1800)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||||
|
} finally {
|
||||||
|
rating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(active) => {
|
||||||
|
if (active) next()
|
||||||
|
else {
|
||||||
|
seq++
|
||||||
|
current.value = null
|
||||||
|
revealed.value = ""
|
||||||
|
showLarge.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.rating-card {
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 132px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 132px;
|
||||||
|
height: 84px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按 4 倍大小渲染再缩小,看起来像网页缩略图 */
|
||||||
|
.thumb iframe {
|
||||||
|
width: 528px;
|
||||||
|
height: 336px;
|
||||||
|
border: 0;
|
||||||
|
transform: scale(0.25);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rates {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate {
|
||||||
|
min-width: 32px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revealed {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
height: 65vh;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-rates {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate.wide {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
font: 13px var(--wp-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate.wide b {
|
||||||
|
font-family: var(--wp-font-mono);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</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,474 @@
|
|||||||
|
<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" />
|
||||||
|
<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 { 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 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() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
if (props.kind === "creation") {
|
||||||
|
await props.saveWork?.({ ...code })
|
||||||
|
message.success("作品已保存")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id =
|
||||||
|
modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value
|
||||||
|
await Submission.create(props.taskId, { ...code }, id)
|
||||||
|
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 ?? "提交失败,请重试")
|
||||||
|
} 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 }
|
||||||
|
}
|
||||||
@@ -46,3 +46,45 @@ samp {
|
|||||||
iframe {
|
iframe {
|
||||||
background: #fff;
|
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);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
Moon,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -28,6 +29,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
SunMoon,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Star,
|
Star,
|
||||||
Swords,
|
Swords,
|
||||||
@@ -72,6 +75,9 @@ const registry: Record<string, Component> = {
|
|||||||
users: Users,
|
users: Users,
|
||||||
"graduation-cap": GraduationCap,
|
"graduation-cap": GraduationCap,
|
||||||
x: X,
|
x: X,
|
||||||
|
sun: Sun,
|
||||||
|
moon: Moon,
|
||||||
|
"sun-moon": SunMoon,
|
||||||
// 原 material-symbols / pepicons
|
// 原 material-symbols / pepicons
|
||||||
"close-rounded": X,
|
"close-rounded": X,
|
||||||
"desktop-windows-outline": Monitor,
|
"desktop-windows-outline": Monitor,
|
||||||
|
|||||||
+3
-2
@@ -4,7 +4,7 @@ import App from "./App.vue"
|
|||||||
import "@fontsource/jetbrains-mono/400.css"
|
import "@fontsource/jetbrains-mono/400.css"
|
||||||
import "@fontsource/jetbrains-mono/600.css"
|
import "@fontsource/jetbrains-mono/600.css"
|
||||||
import "./global.css"
|
import "./global.css"
|
||||||
import { applyCssVars, light } from "./theme"
|
import "./store/theme"
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import "github-markdown-css/github-markdown-light.css"
|
import "github-markdown-css/github-markdown-light.css"
|
||||||
@@ -13,10 +13,11 @@ import { marked } from "marked"
|
|||||||
import { trustedExtensions } from "./utils/markdown"
|
import { trustedExtensions } from "./utils/markdown"
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import "highlight.js/styles/github.min.css"
|
import "highlight.js/styles/github.min.css"
|
||||||
|
// 暗色时覆盖上面两份亮色样式
|
||||||
|
import "./styles/dark-content.css"
|
||||||
import { router } from "./router"
|
import { router } from "./router"
|
||||||
|
|
||||||
marked.use(...trustedExtensions)
|
marked.use(...trustedExtensions)
|
||||||
applyCssVars(light)
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const naive = create()
|
const naive = create()
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="challenge-layout">
|
<Workbench
|
||||||
<div class="challenge-sider">
|
ref="wb"
|
||||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
kind="challenge"
|
||||||
<template #prefix>
|
:task-id="taskId"
|
||||||
<n-tooltip>
|
:title="title"
|
||||||
<template #trigger>
|
back-label="返回挑战列表"
|
||||||
<n-button text @click="back" style="margin: 0 8px">
|
:back-to="{ name: 'home-challenge-list' }"
|
||||||
<Icon :width="18" icon="lucide:arrow-left" />
|
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-button>
|
||||||
</template>
|
<n-tooltip v-if="roleStaff">
|
||||||
返回挑战列表
|
|
||||||
</n-tooltip>
|
|
||||||
</template>
|
|
||||||
<template #suffix>
|
|
||||||
<n-flex style="margin: 0 8px">
|
|
||||||
<n-tooltip v-if="roleAdmin || roleSuper">
|
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button text @click="showStats = true">
|
<n-button text @click="showStats = true">
|
||||||
<Icon :width="16" icon="lucide:bar-chart-2" />
|
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||||
@@ -30,78 +35,26 @@
|
|||||||
</template>
|
</template>
|
||||||
编辑这道题
|
编辑这道题
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
<template #brief>
|
||||||
<div class="desc-pane">
|
<div class="brief">
|
||||||
<div class="challenge-meta">
|
<n-flex align="center" justify="space-between" class="meta">
|
||||||
<n-flex align="center" justify="space-between">
|
<n-text depth="3">出题人:{{ author || "未设置" }}</n-text>
|
||||||
<n-text depth="3">
|
<n-button v-if="exampleCode" size="tiny" @click="useExample">
|
||||||
出题人:{{ challengeAuthor || "未设置" }}
|
放入示例代码
|
||||||
</n-text>
|
|
||||||
<n-flex align="center" size="small">
|
|
||||||
<n-button
|
|
||||||
v-if="assets.length"
|
|
||||||
size="small"
|
|
||||||
@click="showAssets = true"
|
|
||||||
>
|
|
||||||
看素材
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
v-if="exampleCode"
|
|
||||||
size="small"
|
|
||||||
@click="previewExample"
|
|
||||||
>
|
|
||||||
看示例
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="markdown-body content no-select"
|
class="markdown-body challenge-desc no-select"
|
||||||
v-html="challengeContent"
|
v-html="content"
|
||||||
ref="$desc"
|
|
||||||
@copy.prevent
|
@copy.prevent
|
||||||
|
@click="onCopyClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</template>
|
||||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
</Workbench>
|
||||||
<PromptPanel
|
|
||||||
@deleted="historyRefreshKey++"
|
|
||||||
@submitted="historyRefreshKey++"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="external" tab="手动提交" display-directive="show">
|
|
||||||
<ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
|
||||||
<PromptHistoryPanel
|
|
||||||
:task-id="taskId"
|
|
||||||
:active="activeTab === 'history'"
|
|
||||||
:asset-base-url="assetBaseUrl"
|
|
||||||
:refresh-key="historyRefreshKey"
|
|
||||||
@select="previewHistoryItem"
|
|
||||||
@deleted="removeMessagePair"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</div>
|
|
||||||
<div class="challenge-content">
|
|
||||||
<Preview
|
|
||||||
:html="html"
|
|
||||||
:css="css"
|
|
||||||
:js="js"
|
|
||||||
:asset-base-url="assetBaseUrl"
|
|
||||||
:storage-task-id="taskId"
|
|
||||||
show-code-button
|
|
||||||
clearable
|
|
||||||
@showCode="showCode = true"
|
|
||||||
@clear="clearAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
||||||
<RandomRatingModal v-if="authed" />
|
<PeersDrawer v-model:show="showPeers" :task-id="taskId" :asset-base-url="assetBaseUrl" />
|
||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="showAssets"
|
v-model:show="showAssets"
|
||||||
preset="card"
|
preset="card"
|
||||||
@@ -119,61 +72,32 @@
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
<n-modal
|
|
||||||
v-model:show="showCode"
|
|
||||||
preset="card"
|
|
||||||
title="代码"
|
|
||||||
style="width: 700px"
|
|
||||||
>
|
|
||||||
<n-tabs type="line">
|
|
||||||
<n-tab-pane name="html" tab="HTML">
|
|
||||||
<n-code :code="html" language="html" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="css" tab="CSS">
|
|
||||||
<n-code :code="css" language="css" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="js" tab="JS">
|
|
||||||
<n-code :code="js" language="javascript" />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { useMessage } from "naive-ui"
|
import { Marked } from "marked"
|
||||||
import { Icon } from "../icons"
|
|
||||||
import { marked, type MarkedOptions } from "marked"
|
|
||||||
import copyFn from "copy-text-to-clipboard"
|
import copyFn from "copy-text-to-clipboard"
|
||||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
import { Icon } from "../icons"
|
||||||
import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
|
import Workbench from "../components/workbench/Workbench.vue"
|
||||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
|
||||||
import Preview from "../components/editor/Preview.vue"
|
|
||||||
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||||
import RandomRatingModal from "../components/ai/RandomRatingModal.vue"
|
import PeersDrawer from "../components/review/PeersDrawer.vue"
|
||||||
import { Challenge, Submission, TaskAssets } from "../api"
|
import { Challenge, TaskAssets } from "../api"
|
||||||
import type { TaskAsset } from "../utils/type"
|
import type { TaskAsset } from "../utils/type"
|
||||||
import { html, css, js } from "../store/editors"
|
|
||||||
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import { authed, roleAdmin, roleSuper } from "../store/user"
|
import { roleStaff, roleSuper } from "../store/user"
|
||||||
import {
|
import { trustedExtensions } from "../utils/markdown"
|
||||||
connectPrompt,
|
import type { Code } from "../utils/code"
|
||||||
disconnectPrompt,
|
|
||||||
streaming,
|
|
||||||
setOnCodeComplete,
|
|
||||||
removeMessagePair,
|
|
||||||
markMessageSubmitted,
|
|
||||||
} from "../store/prompt"
|
|
||||||
import { closeGuidance } from "../store/guidance"
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const challengeRenderer = new marked.Renderer()
|
// 题目里的代码块带“复制”按钮;题目正文本身不让复制
|
||||||
challengeRenderer.code = ({ text, lang }) => {
|
const challengeMarked = new Marked(...trustedExtensions, {
|
||||||
|
renderer: {
|
||||||
|
code({ text, lang }) {
|
||||||
const language = lang?.toLowerCase() ?? "text"
|
const language = lang?.toLowerCase() ?? "text"
|
||||||
return `<div class="codeblock-wrapper" data-lang="${language}">
|
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||||
<div class="codeblock-action">
|
<div class="codeblock-action">
|
||||||
@@ -182,42 +106,32 @@ challengeRenderer.code = ({ text, lang }) => {
|
|||||||
</div>
|
</div>
|
||||||
<pre><code class="language-${language}">${text}</code></pre>
|
<pre><code class="language-${language}">${text}</code></pre>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
},
|
||||||
challengeRenderer.link = ({ href, text }) =>
|
link({ href, text }) {
|
||||||
`<a href="${href}" target="_blank">${text}</a>`
|
return `<a href="${href}" target="_blank">${text}</a>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function setupCodeCopy() {
|
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||||
$desc.value?.addEventListener("click", (e: MouseEvent) => {
|
const title = ref("")
|
||||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
const score = ref(0)
|
||||||
if (!btn) return
|
const author = ref("")
|
||||||
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
const content = ref("")
|
||||||
const code = wrapper.querySelector("code")?.textContent ?? ""
|
const assets = ref<TaskAsset[]>([])
|
||||||
copyFn(code)
|
const exampleCode = ref<Code | null>(null)
|
||||||
btn.textContent = "已复制"
|
|
||||||
setTimeout(() => { btn.textContent = "复制" }, 1000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTab = ref("desc")
|
|
||||||
const challengeContent = ref("")
|
|
||||||
const challengeAuthor = ref("")
|
|
||||||
const $desc = useTemplateRef<HTMLElement>("$desc")
|
|
||||||
const showCode = ref(false)
|
|
||||||
const showStats = ref(false)
|
const showStats = ref(false)
|
||||||
const showAssets = ref(false)
|
const showAssets = ref(false)
|
||||||
const assets = ref<TaskAsset[]>([])
|
const showPeers = ref(false)
|
||||||
const historyRefreshKey = ref(0)
|
|
||||||
const exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
|
|
||||||
|
|
||||||
const assetBaseUrl = computed(
|
const assetBaseUrl = computed(
|
||||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(streaming, (val) => {
|
// 加载还没完成就离开页面时,不要再改全局状态
|
||||||
if (val) activeTab.value = "chat"
|
let disposed = false
|
||||||
})
|
|
||||||
|
|
||||||
async function loadChallenge() {
|
async function load() {
|
||||||
const display = Number(route.params.display)
|
const display = Number(route.params.display)
|
||||||
taskTab.value = TASK_TYPE.Challenge
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
challengeDisplay.value = display
|
challengeDisplay.value = display
|
||||||
@@ -226,151 +140,57 @@ async function loadChallenge() {
|
|||||||
TaskAssets.listChallenge(display),
|
TaskAssets.listChallenge(display),
|
||||||
])
|
])
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
taskId.value = data.task_ptr
|
title.value = `${data.display}. ${data.title}`
|
||||||
challengeAuthor.value = data.author_name ?? ""
|
score.value = data.score
|
||||||
if (data.example_html || data.example_css || data.example_js) {
|
author.value = data.author_name ?? ""
|
||||||
exampleCode.value = {
|
assets.value = fetchedAssets
|
||||||
|
exampleCode.value =
|
||||||
|
data.example_html || data.example_css || data.example_js
|
||||||
|
? {
|
||||||
html: data.example_html ?? "",
|
html: data.example_html ?? "",
|
||||||
css: data.example_css ?? "",
|
css: data.example_css ?? "",
|
||||||
js: data.example_js ?? "",
|
js: data.example_js ?? "",
|
||||||
}
|
}
|
||||||
} else {
|
: null
|
||||||
exampleCode.value = null
|
content.value = await challengeMarked.parse(data.content, { async: true })
|
||||||
}
|
|
||||||
challengeContent.value = await marked.parse(data.content, {
|
|
||||||
async: true,
|
|
||||||
renderer: challengeRenderer,
|
|
||||||
} as MarkedOptions)
|
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
assets.value = fetchedAssets
|
taskId.value = data.task_ptr
|
||||||
if (!authed.value) return
|
}
|
||||||
connectPrompt(data.task_ptr)
|
|
||||||
setOnCodeComplete(async (code, messageId) => {
|
function useExample() {
|
||||||
try {
|
if (exampleCode.value) wb.value?.setCode({ ...exampleCode.value })
|
||||||
await Submission.create(
|
}
|
||||||
taskId.value,
|
|
||||||
{
|
function onCopyClick(e: MouseEvent) {
|
||||||
html: code.html ?? "",
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||||
css: code.css ?? "",
|
if (!btn) return
|
||||||
js: code.js ?? "",
|
const code = btn.closest<HTMLElement>("[data-lang]")?.querySelector("code")?.textContent ?? ""
|
||||||
},
|
copyFn(code)
|
||||||
messageId,
|
btn.textContent = "已复制"
|
||||||
)
|
setTimeout(() => {
|
||||||
markMessageSubmitted(messageId)
|
btn.textContent = "复制"
|
||||||
historyRefreshKey.value++
|
}, 1000)
|
||||||
message.success("已自动提交本次对话生成的代码")
|
|
||||||
} catch {
|
|
||||||
message.error("自动提交失败,请稍后重试")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit() {
|
function edit() {
|
||||||
router.push({
|
router.push({ name: "challenge-editor", params: { display: route.params.display } })
|
||||||
name: "challenge-editor",
|
|
||||||
params: { display: route.params.display },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewExample() {
|
onMounted(load)
|
||||||
if (!exampleCode.value) return
|
|
||||||
html.value = exampleCode.value.html
|
|
||||||
css.value = exampleCode.value.css
|
|
||||||
js.value = exampleCode.value.js
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAll() {
|
|
||||||
html.value = ""
|
|
||||||
css.value = ""
|
|
||||||
js.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
|
||||||
html.value = code.html
|
|
||||||
css.value = code.css
|
|
||||||
js.value = code.js
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() {
|
|
||||||
disconnectPrompt()
|
|
||||||
taskId.value = 0
|
|
||||||
router.push({ name: "home-challenge-list" })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载还没完成就离开页面时,不要再建立连接
|
|
||||||
let disposed = false
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setupCodeCopy()
|
|
||||||
loadChallenge()
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
disconnectPrompt()
|
taskId.value = 0
|
||||||
closeGuidance()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.challenge-layout {
|
.brief {
|
||||||
display: flex;
|
padding: 10px 12px 12px;
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider {
|
.meta {
|
||||||
width: 40%;
|
margin-bottom: 8px;
|
||||||
height: 100%;
|
font-size: 12px;
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid var(--n-border-color, #efeff5);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-content {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs :deep(.n-tab-pane) {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 12px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc-pane {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-meta {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-select {
|
.no-select {
|
||||||
@@ -379,15 +199,20 @@ onUnmounted(() => {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.challenge-sider .codeblock-wrapper {
|
.challenge-desc {
|
||||||
padding: 1rem;
|
font-size: 14px;
|
||||||
background-color: #f6f8fa;
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-desc .codeblock-wrapper {
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--wp-paper);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 12px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-wrapper pre {
|
.challenge-desc .codeblock-wrapper pre {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -395,39 +220,38 @@ onUnmounted(() => {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-wrapper pre code {
|
.challenge-desc .codeblock-wrapper pre code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1rem;
|
font-size: 13px;
|
||||||
font-family: var(--wp-font-mono);
|
font-family: var(--wp-font-mono);
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-action {
|
.challenge-desc .codeblock-action {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .codeblock-action .lang {
|
.challenge-desc .codeblock-action .lang {
|
||||||
font-size: 0.9rem;
|
font: 600 12px var(--wp-font-mono);
|
||||||
font-weight: bold;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .action-btn {
|
.challenge-desc .action-btn {
|
||||||
height: 28px;
|
height: 24px;
|
||||||
padding: 0 14px;
|
padding: 0 10px;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid var(--wp-line);
|
||||||
background-color: #fff;
|
background-color: var(--wp-surface);
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.challenge-sider .action-btn:hover {
|
.challenge-desc .action-btn:hover {
|
||||||
border-color: var(--wp-brand);
|
border-color: var(--wp-brand);
|
||||||
color: var(--wp-brand);
|
color: var(--wp-brand);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-grid x-gap="10" :cols="8">
|
|
||||||
<n-gi :span="2" class="col">
|
|
||||||
<n-flex vertical class="list">
|
|
||||||
<n-card
|
|
||||||
v-for="item in list"
|
|
||||||
:key="item.display"
|
|
||||||
@click="show(item.display)"
|
|
||||||
class="card"
|
|
||||||
:header-style="{
|
|
||||||
backgroundColor:
|
|
||||||
item.display === challenge.display
|
|
||||||
? 'rgba(24, 160, 80, 0.1)'
|
|
||||||
: '',
|
|
||||||
}"
|
|
||||||
:embedded="!item.is_public"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<n-flex align="center">
|
|
||||||
<n-button
|
|
||||||
text
|
|
||||||
:disabled="!roleSuper"
|
|
||||||
@click.stop="togglePublic(item.display)"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
width="24"
|
|
||||||
:icon="
|
|
||||||
item.is_public
|
|
||||||
? 'lucide:globe'
|
|
||||||
: 'lucide:lock'
|
|
||||||
"
|
|
||||||
></Icon>
|
|
||||||
</n-button>
|
|
||||||
<span>【{{ item.display }}】{{ item.title }}</span>
|
|
||||||
<n-tag size="small" type="warning">{{ item.score }}分</n-tag>
|
|
||||||
<n-tag size="small"
|
|
||||||
>出题人 {{ item.author_name || "未设置" }}</n-tag
|
|
||||||
>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button text type="default" @click="remove(item.display)">
|
|
||||||
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
</n-card>
|
|
||||||
<n-button @click="createNew">新建</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<n-gi :span="6" class="col">
|
|
||||||
<n-flex vertical>
|
|
||||||
<n-form inline :show-feedback="false">
|
|
||||||
<n-form-item label="序号" label-placement="left">
|
|
||||||
<n-input-number
|
|
||||||
style="width: 100px"
|
|
||||||
v-model:value="challenge.display"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="标题" label-placement="left">
|
|
||||||
<n-input v-model:value="challenge.title" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="分数" label-placement="left">
|
|
||||||
<n-input-number
|
|
||||||
style="width: 100px"
|
|
||||||
v-model:value="challenge.score"
|
|
||||||
:min="0"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="公开" label-placement="left">
|
|
||||||
<n-switch v-model:value="challenge.is_public" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="出题人" label-placement="left">
|
|
||||||
<n-text depth="3">{{ challenge.author_name || "未设置" }}</n-text>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label-placement="left">
|
|
||||||
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
|
||||||
提交
|
|
||||||
</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<n-flex>
|
|
||||||
<TaskAssetManager
|
|
||||||
v-if="challenge.display"
|
|
||||||
task-type="challenge"
|
|
||||||
:display="challenge.display"
|
|
||||||
/>
|
|
||||||
<n-button
|
|
||||||
v-if="challenge.display"
|
|
||||||
size="small"
|
|
||||||
@click="showExampleModal = true"
|
|
||||||
>
|
|
||||||
示例代码
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
<MarkdownEditor
|
|
||||||
style="height: calc(var(--wp-main-h) - 100px)"
|
|
||||||
v-model="challenge.content"
|
|
||||||
/>
|
|
||||||
</n-flex>
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showExampleModal"
|
|
||||||
preset="card"
|
|
||||||
title="示例代码(点击「看示例」时显示效果)"
|
|
||||||
style="width: 640px"
|
|
||||||
>
|
|
||||||
<n-input
|
|
||||||
type="textarea"
|
|
||||||
v-model:value="rawCode"
|
|
||||||
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
|
||||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
|
||||||
/>
|
|
||||||
<n-flex v-if="splitResult" style="margin-top: 8px">
|
|
||||||
<n-tag size="small" type="success"
|
|
||||||
>HTML · {{ splitResult.html.length }} 字符</n-tag
|
|
||||||
>
|
|
||||||
<n-tag size="small" type="info"
|
|
||||||
>CSS · {{ splitResult.css.length }} 字符</n-tag
|
|
||||||
>
|
|
||||||
<n-tag size="small" type="warning"
|
|
||||||
>JS · {{ splitResult.js.length }} 字符</n-tag
|
|
||||||
>
|
|
||||||
</n-flex>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
|
||||||
import { useRoute, useRouter } from "vue-router"
|
|
||||||
import { Icon } from "../icons"
|
|
||||||
import { Challenge } from "../api"
|
|
||||||
import { roleSuper } from "../store/user"
|
|
||||||
import type { ChallengeSlim } from "../utils/type"
|
|
||||||
import { useDialog, useMessage } from "naive-ui"
|
|
||||||
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
|
||||||
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const confirm = useDialog()
|
|
||||||
|
|
||||||
const list = ref<ChallengeSlim[]>([])
|
|
||||||
const showExampleModal = ref(false)
|
|
||||||
const rawCode = ref("")
|
|
||||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
|
||||||
|
|
||||||
function splitHtml(raw: string) {
|
|
||||||
let result = raw
|
|
||||||
const cssBlocks: string[] = []
|
|
||||||
const jsBlocks: string[] = []
|
|
||||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
|
||||||
cssBlocks.push(c.trim())
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
result = result.replace(
|
|
||||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
|
||||||
(_, c) => {
|
|
||||||
jsBlocks.push(c.trim())
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
html: result.trim(),
|
|
||||||
css: cssBlocks.join("\n\n"),
|
|
||||||
js: jsBlocks.join("\n\n"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(rawCode, (val) => {
|
|
||||||
if (!val.trim()) {
|
|
||||||
splitResult.value = null
|
|
||||||
challenge.example_html = null
|
|
||||||
challenge.example_css = null
|
|
||||||
challenge.example_js = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const split = splitHtml(val)
|
|
||||||
splitResult.value = split
|
|
||||||
challenge.example_html = split.html || null
|
|
||||||
challenge.example_css = split.css || null
|
|
||||||
challenge.example_js = split.js || null
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(showExampleModal, (visible) => {
|
|
||||||
if (!visible) return
|
|
||||||
const parts: string[] = []
|
|
||||||
if (challenge.example_css)
|
|
||||||
parts.push(`<style>\n${challenge.example_css}\n</style>`)
|
|
||||||
if (challenge.example_html) parts.push(challenge.example_html)
|
|
||||||
if (challenge.example_js)
|
|
||||||
parts.push(`<script>\n${challenge.example_js}\n<\/script>`)
|
|
||||||
rawCode.value = parts.join("\n\n")
|
|
||||||
})
|
|
||||||
const challenge = reactive({
|
|
||||||
display: 0,
|
|
||||||
title: "",
|
|
||||||
content: "",
|
|
||||||
score: 0,
|
|
||||||
is_public: false,
|
|
||||||
author_name: "",
|
|
||||||
example_html: null as string | null,
|
|
||||||
example_css: null as string | null,
|
|
||||||
example_js: null as string | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSubmit = computed(
|
|
||||||
() => challenge.display && challenge.title && challenge.content,
|
|
||||||
)
|
|
||||||
async function getContent() {
|
|
||||||
list.value = await Challenge.list()
|
|
||||||
const display = Number(route.params.display)
|
|
||||||
const target = list.value.find((item) => item.display === display)
|
|
||||||
if (target) {
|
|
||||||
show(display)
|
|
||||||
} else if (list.value.length > 0) {
|
|
||||||
show(list.value[0].display)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNew() {
|
|
||||||
challenge.display = (list.value[list.value.length - 1]?.display ?? 0) + 1
|
|
||||||
challenge.title = ""
|
|
||||||
challenge.content = ""
|
|
||||||
challenge.score = 0
|
|
||||||
challenge.is_public = false
|
|
||||||
challenge.author_name = ""
|
|
||||||
challenge.example_html = null
|
|
||||||
challenge.example_css = null
|
|
||||||
challenge.example_js = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
try {
|
|
||||||
await Challenge.createOrUpdate(challenge)
|
|
||||||
message.success("提交成功")
|
|
||||||
challenge.display = 0
|
|
||||||
challenge.title = ""
|
|
||||||
challenge.content = ""
|
|
||||||
challenge.score = 0
|
|
||||||
challenge.is_public = false
|
|
||||||
challenge.author_name = ""
|
|
||||||
challenge.example_html = null
|
|
||||||
challenge.example_css = null
|
|
||||||
challenge.example_js = null
|
|
||||||
await getContent()
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.response.data.detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(display: number) {
|
|
||||||
confirm.warning({
|
|
||||||
title: "警告",
|
|
||||||
content: "你确定要删除吗?",
|
|
||||||
positiveText: "确定",
|
|
||||||
negativeText: "取消",
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
await Challenge.remove(display)
|
|
||||||
message.success("删除成功")
|
|
||||||
getContent()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function show(display: number) {
|
|
||||||
router.push({ name: "challenge-editor", params: { display } })
|
|
||||||
const item = await Challenge.get(display)
|
|
||||||
challenge.display = item.display
|
|
||||||
challenge.title = item.title
|
|
||||||
challenge.content = item.content
|
|
||||||
challenge.score = item.score
|
|
||||||
challenge.is_public = item.is_public
|
|
||||||
challenge.author_name = item.author_name ?? ""
|
|
||||||
challenge.example_html = item.example_html ?? null
|
|
||||||
challenge.example_css = item.example_css ?? null
|
|
||||||
challenge.example_js = item.example_js ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePublic(display: number) {
|
|
||||||
const data = await Challenge.togglePublic(display)
|
|
||||||
message.success(data.message)
|
|
||||||
list.value = list.value.map((item) => {
|
|
||||||
if (item.display === display) item.is_public = !item.is_public
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(getContent)
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.list {
|
|
||||||
overflow: auto;
|
|
||||||
height: calc(var(--wp-main-h) - 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.col {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
|
||||||
height: calc(var(--wp-main-h) - 200px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<main class="challenge-home">
|
||||||
|
<header class="head">
|
||||||
|
<div>
|
||||||
|
<h2>挑战</h2>
|
||||||
|
<p class="sub">
|
||||||
|
用提示词让 AI 做出题目要求的网页。已完成 {{ doneCount }} / {{ challenges.length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<n-radio-group v-model:value="filter" size="small">
|
||||||
|
<n-radio-button value="all">全部</n-radio-button>
|
||||||
|
<n-radio-button value="todo">未做</n-radio-button>
|
||||||
|
<n-radio-button value="done">已交</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!loading && !visible.length"
|
||||||
|
:description="challenges.length ? '没有符合条件的挑战' : '暂无挑战,敬请期待'"
|
||||||
|
style="margin-top: 72px"
|
||||||
|
/>
|
||||||
|
<ol v-else class="list">
|
||||||
|
<li v-for="item in visible" :key="item.display">
|
||||||
|
<router-link
|
||||||
|
class="row"
|
||||||
|
:to="{ name: 'home-challenge', params: { display: item.display } }"
|
||||||
|
>
|
||||||
|
<span class="num">{{ item.display }}</span>
|
||||||
|
<span class="main">
|
||||||
|
<span class="title">{{ item.title }}</span>
|
||||||
|
<span class="author">出题人:{{ item.author_name || "未设置" }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="score">{{ item.score }} 分</span>
|
||||||
|
<span :class="['state', { done: item.submitted }]">
|
||||||
|
<template v-if="item.submitted">
|
||||||
|
<Icon icon="lucide:check" :width="14" /> 已交
|
||||||
|
</template>
|
||||||
|
<template v-else>未做</template>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</n-spin>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import { Challenge } from "../api"
|
||||||
|
import { taskId, taskTab } from "../store/task"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
|
|
||||||
|
const challenges = ref<ChallengeSlim[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const filter = ref<"all" | "todo" | "done">("all")
|
||||||
|
|
||||||
|
const doneCount = computed(() => challenges.value.filter((c) => c.submitted).length)
|
||||||
|
const visible = computed(() =>
|
||||||
|
challenges.value.filter((c) =>
|
||||||
|
filter.value === "todo" ? !c.submitted : filter.value === "done" ? c.submitted : true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
|
taskId.value = 0
|
||||||
|
try {
|
||||||
|
challenges.value = await Challenge.listDisplay()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.challenge-home {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 20px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--wp-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--wp-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li + li {
|
||||||
|
border-top: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 1fr auto 72px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus-visible {
|
||||||
|
outline: 2px solid var(--wp-brand);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font: 600 15px var(--wp-font-mono);
|
||||||
|
color: var(--wp-faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font: 13px var(--wp-font-mono);
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wp-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.done {
|
||||||
|
color: var(--wp-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,31 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="creation-layout">
|
<Workbench
|
||||||
<div class="creation-sider">
|
ref="wb"
|
||||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
kind="creation"
|
||||||
<template #prefix>
|
:task-id="taskId"
|
||||||
<n-tooltip>
|
:title="title || '自由创作'"
|
||||||
<template #trigger>
|
back-label="返回我的创作"
|
||||||
<n-button text @click="back" style="margin: 0 8px">
|
:back-to="{ name: 'creation-list' }"
|
||||||
<Icon :width="18" icon="lucide:arrow-left" />
|
brief-label="我的主题"
|
||||||
</n-button>
|
:save-work="saveWork"
|
||||||
</template>
|
>
|
||||||
返回我的创作
|
<template #brief>
|
||||||
</n-tooltip>
|
<div class="topic">
|
||||||
</template>
|
|
||||||
<template #suffix>
|
|
||||||
<n-flex style="margin: 0 8px">
|
|
||||||
<n-tooltip trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text :loading="savingCode" @click="saveCode(true)">
|
|
||||||
<Icon :width="16" icon="lucide:save" />
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
保存当前作品
|
|
||||||
</n-tooltip>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<n-tab-pane name="topic" tab="我的主题" display-directive="show">
|
|
||||||
<div class="topic-pane">
|
|
||||||
<n-form-item label="主题名称" :show-feedback="false">
|
<n-form-item label="主题名称" :show-feedback="false">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="title"
|
v-model:value="title"
|
||||||
@@ -33,20 +18,17 @@
|
|||||||
maxlength="100"
|
maxlength="100"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item
|
<n-form-item label="主题描述" :show-feedback="false" style="margin-top: 10px">
|
||||||
label="主题描述"
|
|
||||||
:show-feedback="false"
|
|
||||||
style="margin-top: 12px"
|
|
||||||
>
|
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="content"
|
v-model:value="content"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 8 }"
|
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-flex style="margin-top: 16px">
|
<n-flex style="margin-top: 12px">
|
||||||
<n-button
|
<n-button
|
||||||
|
size="small"
|
||||||
:loading="savingTopic"
|
:loading="savingTopic"
|
||||||
:disabled="!title.trim()"
|
:disabled="!title.trim()"
|
||||||
@click="saveTopic"
|
@click="saveTopic"
|
||||||
@@ -54,6 +36,7 @@
|
|||||||
保存主题
|
保存主题
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!content.trim() || streaming"
|
:disabled="!content.trim() || streaming"
|
||||||
@click="generate"
|
@click="generate"
|
||||||
@@ -61,107 +44,50 @@
|
|||||||
用这个主题生成
|
用这个主题生成
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-text depth="3" class="topic-hint">
|
<n-text depth="3" class="hint">
|
||||||
自由创作只属于你自己,不会进入提交记录和评分。
|
自由创作只属于你自己,不会进入提交记录和评分。
|
||||||
</n-text>
|
</n-text>
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</template>
|
||||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
</Workbench>
|
||||||
<PromptPanel :allow-submit="false" @deleted="historyRefreshKey++" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
|
||||||
<PromptHistoryPanel
|
|
||||||
:task-id="taskId"
|
|
||||||
:active="activeTab === 'history'"
|
|
||||||
:refresh-key="historyRefreshKey"
|
|
||||||
@select="previewHistoryItem"
|
|
||||||
@deleted="removeMessagePair"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</div>
|
|
||||||
<div class="creation-content">
|
|
||||||
<Preview
|
|
||||||
:html="html"
|
|
||||||
:css="css"
|
|
||||||
:js="js"
|
|
||||||
:storage-task-id="taskId"
|
|
||||||
show-code-button
|
|
||||||
clearable
|
|
||||||
@showCode="showCode = true"
|
|
||||||
@clear="clearAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showCode"
|
|
||||||
preset="card"
|
|
||||||
title="代码"
|
|
||||||
style="width: 700px"
|
|
||||||
>
|
|
||||||
<n-tabs type="line">
|
|
||||||
<n-tab-pane name="html" tab="HTML">
|
|
||||||
<n-code :code="html" language="html" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="css" tab="CSS">
|
|
||||||
<n-code :code="css" language="css" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="js" tab="JS">
|
|
||||||
<n-code :code="js" language="javascript" />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, watch } from "vue"
|
import { onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "../icons"
|
import Workbench from "../components/workbench/Workbench.vue"
|
||||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
|
||||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
|
||||||
import Preview from "../components/editor/Preview.vue"
|
|
||||||
import { Creation } from "../api"
|
import { Creation } from "../api"
|
||||||
import { html, css, js } from "../store/editors"
|
import { streaming } from "../store/prompt"
|
||||||
import {
|
import type { Code } from "../utils/code"
|
||||||
connectPrompt,
|
|
||||||
disconnectPrompt,
|
|
||||||
removeMessagePair,
|
|
||||||
sendPrompt,
|
|
||||||
setOnCodeComplete,
|
|
||||||
streaming,
|
|
||||||
} from "../store/prompt"
|
|
||||||
import { closeGuidance } from "../store/guidance"
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||||
|
|
||||||
const display = Number(route.params.display)
|
const display = Number(route.params.display)
|
||||||
const activeTab = ref("topic")
|
|
||||||
const taskId = ref(0)
|
const taskId = ref(0)
|
||||||
const title = ref("")
|
const title = ref("")
|
||||||
const content = ref("")
|
const content = ref("")
|
||||||
const showCode = ref(false)
|
|
||||||
const savingTopic = ref(false)
|
const savingTopic = ref(false)
|
||||||
const savingCode = ref(false)
|
|
||||||
const historyRefreshKey = ref(0)
|
|
||||||
|
|
||||||
watch(streaming, (val) => {
|
// 加载还没完成就离开页面时,不要再改状态
|
||||||
if (val) activeTab.value = "chat"
|
let disposed = false
|
||||||
})
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await Creation.get(display)
|
const data = await Creation.get(display)
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
taskId.value = data.task_id
|
|
||||||
title.value = data.title
|
title.value = data.title
|
||||||
content.value = data.content
|
content.value = data.content
|
||||||
html.value = data.html
|
taskId.value = data.task_id
|
||||||
css.value = data.css
|
// 本地没有存着修改到一半的代码,就用服务器上保存的作品
|
||||||
js.value = data.js
|
if (!wb.value?.restored) {
|
||||||
connectPrompt(data.task_id)
|
wb.value?.setCode({ html: data.html, css: data.css, js: data.js })
|
||||||
setOnCodeComplete(() => saveCode())
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWork(code: Code) {
|
||||||
|
await Creation.saveCode(display, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTopic() {
|
async function saveTopic() {
|
||||||
@@ -180,110 +106,26 @@ async function saveTopic() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCode(notify = false) {
|
|
||||||
savingCode.value = true
|
|
||||||
try {
|
|
||||||
await Creation.saveCode(display, {
|
|
||||||
html: html.value,
|
|
||||||
css: css.value,
|
|
||||||
js: js.value,
|
|
||||||
})
|
|
||||||
if (notify) message.success("作品已保存")
|
|
||||||
} catch {
|
|
||||||
if (notify) message.error("保存失败,请重试")
|
|
||||||
} finally {
|
|
||||||
savingCode.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generate() {
|
function generate() {
|
||||||
const text = content.value.trim()
|
const text = content.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value) return
|
||||||
activeTab.value = "chat"
|
wb.value?.send(text)
|
||||||
sendPrompt(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAll() {
|
|
||||||
html.value = ""
|
|
||||||
css.value = ""
|
|
||||||
js.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
|
||||||
html.value = code.html
|
|
||||||
css.value = code.css
|
|
||||||
js.value = code.js
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() {
|
|
||||||
disconnectPrompt()
|
|
||||||
router.push({ name: "creation-list" })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载还没完成就离开页面时,不要再建立连接
|
|
||||||
let disposed = false
|
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
disconnectPrompt()
|
|
||||||
closeGuidance()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.creation-layout {
|
.topic {
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.creation-sider {
|
|
||||||
width: 40%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid var(--n-border-color, #efeff5);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.creation-content {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs :deep(.n-tab-pane) {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic-pane {
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-hint {
|
.hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 12px;
|
margin-top: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -197,6 +197,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.creation-desc {
|
.creation-desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #666;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -50,6 +50,13 @@ const groups = computed(() =>
|
|||||||
{
|
{
|
||||||
label: "班级",
|
label: "班级",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: "批改",
|
||||||
|
icon: "lucide:check-circle",
|
||||||
|
route: { name: "review" },
|
||||||
|
names: ["review"],
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "成绩册",
|
label: "成绩册",
|
||||||
icon: "lucide:graduation-cap",
|
icon: "lucide:graduation-cap",
|
||||||
@@ -58,11 +65,12 @@ const groups = computed(() =>
|
|||||||
show: roleSuper.value,
|
show: roleSuper.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 全站提交列表留给超管排查问题;平时批改用上面的“批改”
|
||||||
label: "全部提交",
|
label: "全部提交",
|
||||||
icon: "lucide:list",
|
icon: "lucide:list",
|
||||||
route: { name: "teach-submissions", params: { page: 1 } },
|
route: { name: "teach-submissions", params: { page: 1 } },
|
||||||
names: ["teach-submissions"],
|
names: ["teach-submissions"],
|
||||||
show: true,
|
show: roleSuper.value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -83,9 +83,18 @@
|
|||||||
style="flex: 1; min-height: 0;"
|
style="flex: 1; min-height: 0;"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
<n-drawer
|
||||||
|
:show="!!reviewId"
|
||||||
|
:width="400"
|
||||||
|
placement="right"
|
||||||
|
@update:show="(v: boolean) => !v && (reviewId = null)"
|
||||||
|
>
|
||||||
|
<ReviewPanel v-if="reviewId" :submission-id="reviewId" @close="reviewId = null" />
|
||||||
|
</n-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||||
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||||
import { Icon } from "../icons"
|
import { Icon } from "../icons"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
@@ -96,7 +105,6 @@ import {
|
|||||||
useMessage,
|
useMessage,
|
||||||
type DataTableColumn,
|
type DataTableColumn,
|
||||||
} from "naive-ui"
|
} from "naive-ui"
|
||||||
import { useRouter } from "vue-router"
|
|
||||||
import { Account, Gradebook } from "../api"
|
import { Account, Gradebook } from "../api"
|
||||||
import { displayGradebookStudentName } from "../utils/gradebook"
|
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||||
import type {
|
import type {
|
||||||
@@ -108,7 +116,6 @@ import type {
|
|||||||
GradebookTaskType,
|
GradebookTaskType,
|
||||||
} from "../utils/type"
|
} from "../utils/type"
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const classesLoading = ref(false)
|
const classesLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -146,13 +153,11 @@ function taskTitle(task: GradebookTask) {
|
|||||||
return `${typeLabel}${task.display}`
|
return `${typeLabel}${task.display}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
|
||||||
|
const reviewId = ref<string | null>(null)
|
||||||
function openSubmission(cell: GradebookCell) {
|
function openSubmission(cell: GradebookCell) {
|
||||||
if (!cell.submitted || !cell.submission_id) return
|
if (!cell.submitted || !cell.submission_id) return
|
||||||
const { href } = router.resolve({
|
reviewId.value = cell.submission_id
|
||||||
name: "submission",
|
|
||||||
params: { id: cell.submission_id },
|
|
||||||
})
|
|
||||||
window.open(href, "_blank")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gradeTagType(grade: string) {
|
function gradeTagType(grade: string) {
|
||||||
@@ -392,7 +397,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-header.muted {
|
.task-header.muted {
|
||||||
color: #888;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-title {
|
.task-title {
|
||||||
@@ -409,21 +414,21 @@ onMounted(async () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #999;
|
color: var(--wp-faint);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.missing-cell {
|
.missing-cell {
|
||||||
color: #d03050;
|
color: var(--wp-bad);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-link.muted {
|
.score-link.muted {
|
||||||
color: #777;
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.excluded-task-column) {
|
:deep(.excluded-task-column) {
|
||||||
background: #fafafa;
|
background: var(--wp-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
<template>
|
||||||
|
<div class="review">
|
||||||
|
<aside 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 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-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">
|
||||||
|
<div class="grid">
|
||||||
|
<button
|
||||||
|
v-for="s in visible"
|
||||||
|
:key="s.user_id"
|
||||||
|
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
|
||||||
|
:disabled="!s.submission"
|
||||||
|
@click="s.submission && (selectedId = s.submission.id)"
|
||||||
|
>
|
||||||
|
<div class="thumb">
|
||||||
|
<iframe
|
||||||
|
v-if="s.submission"
|
||||||
|
: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>
|
||||||
|
<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">
|
||||||
|
<ReviewPanel
|
||||||
|
:submission-id="selectedId"
|
||||||
|
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
|
||||||
|
@prev="move(-1)"
|
||||||
|
@next="move(1)"
|
||||||
|
@close="selectedId = null"
|
||||||
|
@changed="applyPatch"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
:srcdoc="docOf(presentList[presentIndex]!)"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import ReviewPanel from "../components/review/ReviewPanel.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")
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
const presenting = ref(false)
|
||||||
|
const presentIndex = ref(0)
|
||||||
|
|
||||||
|
const classOptions = computed(() => options.value.classes.map((c) => ({ label: `${c} 班`, value: c })))
|
||||||
|
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 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(classname.value, 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(classname.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } })
|
||||||
|
selectedId.value = null
|
||||||
|
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) selectedId.value = 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 === "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 && 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -53,20 +53,20 @@
|
|||||||
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
|
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
|
||||||
<n-flex align="center" :size="3">
|
<n-flex align="center" :size="3">
|
||||||
<Icon icon="lucide:star" :width="13" />
|
<Icon icon="lucide:star" :width="13" />
|
||||||
<n-text style="font-size: 12px; color: #666;">
|
<n-text style="font-size: 12px; color: var(--wp-muted);">
|
||||||
{{ item.score.toFixed(1) }}
|
{{ item.score.toFixed(1) }}
|
||||||
</n-text>
|
</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex align="center" :size="3">
|
<n-flex align="center" :size="3">
|
||||||
<Icon icon="lucide:eye" :width="13" />
|
<Icon icon="lucide:eye" :width="13" />
|
||||||
<n-text style="font-size: 12px; color: #666;">
|
<n-text style="font-size: 12px; color: var(--wp-muted);">
|
||||||
{{ item.view_count }}
|
{{ item.view_count }}
|
||||||
</n-text>
|
</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-ellipsis
|
<n-ellipsis
|
||||||
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: #888;"
|
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: var(--wp-muted);"
|
||||||
>
|
>
|
||||||
{{ item.task_title }}
|
{{ item.task_title }}
|
||||||
</n-ellipsis>
|
</n-ellipsis>
|
||||||
@@ -142,7 +142,7 @@ onMounted(init)
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f7f8fa;
|
background: var(--wp-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-iframe {
|
.preview-iframe {
|
||||||
@@ -162,6 +162,6 @@ onMounted(init)
|
|||||||
.card-info {
|
.card-info {
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid var(--wp-line);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
:width="260"
|
:width="260"
|
||||||
bordered
|
bordered
|
||||||
content-style="overflow: auto; height: 100%;"
|
content-style="overflow: auto; height: 100%;"
|
||||||
style="background: #fafafa;"
|
style="background: var(--wp-paper);"
|
||||||
>
|
>
|
||||||
<n-flex class="panel-header" justify="space-between" align="center">
|
<n-flex class="panel-header" justify="space-between" align="center">
|
||||||
<n-text strong>奖项</n-text>
|
<n-text strong>奖项</n-text>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<n-ellipsis style="flex: 1; min-width: 0; font-size: 14px; font-weight: 500;">
|
<n-ellipsis style="flex: 1; min-width: 0; font-size: 14px; font-weight: 500;">
|
||||||
{{ award.name }}
|
{{ award.name }}
|
||||||
</n-ellipsis>
|
</n-ellipsis>
|
||||||
<n-flex align="center" :size="6" style="flex-shrink: 0; color: #777; font-size: 12px;">
|
<n-flex align="center" :size="6" style="flex-shrink: 0; color: var(--wp-muted); font-size: 12px;">
|
||||||
<n-tag v-if="!award.is_active" size="small">停用</n-tag>
|
<n-tag v-if="!award.is_active" size="small">停用</n-tag>
|
||||||
<span>{{ award.item_count }} 件</span>
|
<span>{{ award.item_count }} 件</span>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -615,8 +615,8 @@ onMounted(async () => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid #efeff5;
|
border-bottom: 1px solid var(--wp-line);
|
||||||
background: #fafafa;
|
background: var(--wp-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
.award-row {
|
.award-row {
|
||||||
@@ -628,15 +628,15 @@ onMounted(async () => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid var(--wp-line);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #333;
|
color: var(--wp-ink);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.award-row:hover {
|
.award-row:hover {
|
||||||
background: #f2f5f8;
|
background: var(--wp-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
.award-row.active {
|
.award-row.active {
|
||||||
|
|||||||
@@ -12,28 +12,18 @@
|
|||||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
style="height: 100%; padding-right: 10px; overflow: hidden"
|
||||||
>
|
>
|
||||||
<n-flex align="center" justify="end">
|
<n-flex align="center" justify="end">
|
||||||
<n-select
|
|
||||||
:value="query.flag"
|
|
||||||
style="width: 100px"
|
|
||||||
clearable
|
|
||||||
placeholder="标记"
|
|
||||||
:options="flagFilterOptions"
|
|
||||||
@update:value="handleFlagSelect"
|
|
||||||
/>
|
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="query.zone"
|
v-model:value="query.zone"
|
||||||
style="width: 100px"
|
style="width: 110px"
|
||||||
clearable
|
clearable
|
||||||
placeholder="从夯到拉"
|
placeholder="得分分区"
|
||||||
:options="[
|
:options="zoneOptions"
|
||||||
{ label: '夯爆了', value: 'featured' },
|
|
||||||
{ label: 'NPC', value: 'pending' },
|
|
||||||
{ label: '拉完了', value: 'low' },
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
<n-input
|
<n-input
|
||||||
|
v-if="isAdmin"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
v-model:value="query.username"
|
v-model:value="query.username"
|
||||||
|
placeholder="搜索学生"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
<n-pagination
|
<n-pagination
|
||||||
@@ -100,9 +90,10 @@ import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
|||||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
||||||
import { Icon } from "../icons"
|
import { Icon } from "../icons"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionOut, FlagType } from "../utils/type"
|
import type { SubmissionOut } from "../utils/type"
|
||||||
import { parseTime } from "../utils/helper"
|
import { parseTime } from "../utils/helper"
|
||||||
import { goHome } from "../utils/helper"
|
import { goHome } from "../utils/helper"
|
||||||
|
import { saveTaskCode } from "../composables/useTaskCode"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import { useRouter, useRoute } from "vue-router"
|
import { useRouter, useRoute } from "vue-router"
|
||||||
@@ -111,11 +102,9 @@ import Preview from "../components/editor/Preview.vue"
|
|||||||
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
||||||
import CodeModal from "../components/submissions/CodeModal.vue"
|
import CodeModal from "../components/submissions/CodeModal.vue"
|
||||||
import ChainModal from "../components/submissions/ChainModal.vue"
|
import ChainModal from "../components/submissions/ChainModal.vue"
|
||||||
import FlagCell from "../components/submissions/FlagCell.vue"
|
|
||||||
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
||||||
|
|
||||||
import { submission } from "../store/submission"
|
import { submission } from "../store/submission"
|
||||||
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
|
||||||
import { roleAdmin, roleSuper } from "../store/user"
|
import { roleAdmin, roleSuper } from "../store/user"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -130,7 +119,6 @@ const query = reactive({
|
|||||||
username: (Array.isArray(route.query.username)
|
username: (Array.isArray(route.query.username)
|
||||||
? ""
|
? ""
|
||||||
: (route.query.username ?? "")) as string,
|
: (route.query.username ?? "")) as string,
|
||||||
flag: null as string | null,
|
|
||||||
zone: null as string | null,
|
zone: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -153,37 +141,12 @@ const expandedLoading = reactive(new Set<string>())
|
|||||||
// 管理员判断
|
// 管理员判断
|
||||||
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
|
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
|
||||||
|
|
||||||
// Flag 过滤选项
|
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
|
||||||
const flagFilterOptions = computed(() => {
|
const zoneOptions = [
|
||||||
const opts = [
|
{ label: "优秀", value: "featured" },
|
||||||
{ label: "红旗", value: "red" },
|
{ label: "待定", value: "pending" },
|
||||||
{ label: "蓝旗", value: "blue" },
|
{ label: "待提高", value: "low" },
|
||||||
{ label: "绿旗", value: "green" },
|
]
|
||||||
{ label: "黄旗", value: "yellow" },
|
|
||||||
{ label: "全部", value: "any" },
|
|
||||||
]
|
|
||||||
if (isAdmin.value) opts.push({ label: "清除", value: "_clear_all" })
|
|
||||||
return opts
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleFlagSelect(value: string | null) {
|
|
||||||
if (value === "_clear_all") {
|
|
||||||
clearAllFlags()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
query.flag = value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateFlag(row: SubmissionOut, flag: FlagType) {
|
|
||||||
await Submission.updateFlag(row.id, flag)
|
|
||||||
row.flag = flag
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearAllFlags() {
|
|
||||||
await Submission.clearAllFlags()
|
|
||||||
data.value = data.value.map((d) => ({ ...d, flag: null }))
|
|
||||||
query.flag = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function showChain(submissionId: string, username: string) {
|
function showChain(submissionId: string, username: string) {
|
||||||
chainSubmissionId.value = submissionId
|
chainSubmissionId.value = submissionId
|
||||||
@@ -208,28 +171,17 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "",
|
title: "分区",
|
||||||
key: "flag",
|
|
||||||
width: 50,
|
|
||||||
render: (row) =>
|
|
||||||
h(FlagCell, {
|
|
||||||
flag: row.flag ?? null,
|
|
||||||
isAdmin: isAdmin.value,
|
|
||||||
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "",
|
|
||||||
key: "zone",
|
key: "zone",
|
||||||
width: 42,
|
width: 64,
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const map: Record<
|
const map: Record<
|
||||||
string,
|
string,
|
||||||
{ label: string; type: "success" | "default" | "warning" }
|
{ label: string; type: "success" | "default" | "warning" }
|
||||||
> = {
|
> = {
|
||||||
featured: { label: "夯", type: "success" },
|
featured: { label: "优秀", type: "success" },
|
||||||
pending: { label: "N", type: "default" },
|
pending: { label: "待定", type: "default" },
|
||||||
low: { label: "拉", type: "warning" },
|
low: { label: "待提高", type: "warning" },
|
||||||
}
|
}
|
||||||
if (!row.zone || !map[row.zone]) return null
|
if (!row.zone || !map[row.zone]) return null
|
||||||
const { label, type } = map[row.zone]
|
const { label, type } = map[row.zone]
|
||||||
@@ -265,7 +217,7 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
|||||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
||||||
[
|
[
|
||||||
h("span", avgScore),
|
h("span", avgScore),
|
||||||
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
|
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -368,9 +320,8 @@ function afterScore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copyToEditor() {
|
function copyToEditor() {
|
||||||
eHtml.value = html.value
|
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
|
||||||
eCss.value = css.value
|
saveTaskCode(submission.value.task_id, { html: html.value, css: css.value, js: js.value })
|
||||||
eJs.value = js.value
|
|
||||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
goHome(router, submission.value.task_type, submission.value.task_display)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,13 +348,6 @@ watchDebounced(
|
|||||||
},
|
},
|
||||||
{ debounce: 500, maxWait: 1000 },
|
{ debounce: 500, maxWait: 1000 },
|
||||||
)
|
)
|
||||||
watch(
|
|
||||||
() => query.flag,
|
|
||||||
() => {
|
|
||||||
if (query.page !== 1) query.page = 1
|
|
||||||
else init()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
watch(
|
watch(
|
||||||
() => query.zone,
|
() => query.zone,
|
||||||
() => {
|
() => {
|
||||||
@@ -443,6 +387,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.row-active td) {
|
:deep(.row-active td) {
|
||||||
background-color: rgba(24, 160, 80, 0.1) !important;
|
background-color: var(--wp-brand-soft) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<template>
|
||||||
|
<div class="editor-page">
|
||||||
|
<aside class="list">
|
||||||
|
<div class="list-head">
|
||||||
|
<b>{{ kindLabel }}</b>
|
||||||
|
<n-button size="small" @click="createNew">
|
||||||
|
<template #icon><Icon icon="lucide:plus" /></template>
|
||||||
|
新建
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="items">
|
||||||
|
<div
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.display"
|
||||||
|
:class="['item', { on: item.display === form.display, draft: !item.is_public }]"
|
||||||
|
@click="show(item.display)"
|
||||||
|
>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click.stop="togglePublic(item.display)">
|
||||||
|
<Icon :icon="item.is_public ? 'lucide:globe' : 'lucide:lock'" :width="16" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ item.is_public ? "已公开,点击设为不公开" : "未公开,点击公开" }}
|
||||||
|
</n-tooltip>
|
||||||
|
<span class="item-title">{{ item.display }}. {{ item.title }}</span>
|
||||||
|
<span v-if="kind === 'challenge'" class="item-score">{{ item.score }}分</span>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text class="item-del" @click.stop="remove(item.display)">
|
||||||
|
<Icon icon="lucide:trash-2" :width="14" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
删除
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="form">
|
||||||
|
<n-form inline :show-feedback="false" class="fields">
|
||||||
|
<n-form-item label="序号" label-placement="left">
|
||||||
|
<n-input-number style="width: 96px" v-model:value="form.display" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="标题" label-placement="left">
|
||||||
|
<n-input v-model:value="form.title" style="width: 220px" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="kind === 'challenge'" label="分数" label-placement="left">
|
||||||
|
<n-input-number style="width: 96px" v-model:value="form.score" :min="0" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="公开" label-placement="left">
|
||||||
|
<n-switch v-model:value="form.is_public" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="kind === 'challenge'" label="出题人" label-placement="left">
|
||||||
|
<n-text depth="3">{{ form.author_name || "未设置" }}</n-text>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-flex align="center" class="actions">
|
||||||
|
<n-button type="primary" :disabled="!canSave" :loading="saving" @click="save">
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
<TaskAssetManager v-if="form.display" :task-type="kind" :display="form.display" />
|
||||||
|
<n-button v-if="form.display" @click="showExampleModal = true">示例代码</n-button>
|
||||||
|
<n-text depth="3" class="hint">{{ exampleHint }}</n-text>
|
||||||
|
</n-flex>
|
||||||
|
<MarkdownEditor class="md" v-model="form.content" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showExampleModal"
|
||||||
|
preset="card"
|
||||||
|
:title="`示例代码(${exampleHint})`"
|
||||||
|
style="width: 640px"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
v-model:value="rawCode"
|
||||||
|
placeholder="粘贴完整的网页代码,会自动拆成 HTML / CSS / JS"
|
||||||
|
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||||
|
/>
|
||||||
|
<n-flex v-if="splitResult" style="margin-top: 8px">
|
||||||
|
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
|
||||||
|
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
|
||||||
|
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
<template #footer>
|
||||||
|
<n-text depth="3">关掉窗口后点“保存”才会存下来。</n-text>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "../icons"
|
||||||
|
import { Challenge, Tutorial } from "../api"
|
||||||
|
import { splitHtml, type Code } from "../utils/code"
|
||||||
|
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||||
|
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||||
|
|
||||||
|
// 教程和挑战的出题页(以前是两个几乎一样的文件)
|
||||||
|
const props = defineProps<{ kind: "tutorial" | "challenge" }>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const api = props.kind === "tutorial" ? Tutorial : Challenge
|
||||||
|
const kindLabel = props.kind === "tutorial" ? "教程" : "挑战"
|
||||||
|
const routeName = props.kind === "tutorial" ? "tutorial-editor" : "challenge-editor"
|
||||||
|
const exampleHint =
|
||||||
|
props.kind === "tutorial" ? "打开这一节教程时自动放进编辑器" : "学生点“放入示例代码”时使用"
|
||||||
|
|
||||||
|
interface ListItem {
|
||||||
|
display: number
|
||||||
|
title: string
|
||||||
|
is_public: boolean
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = ref<ListItem[]>([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const showExampleModal = ref(false)
|
||||||
|
const rawCode = ref("")
|
||||||
|
const splitResult = ref<Code | null>(null)
|
||||||
|
|
||||||
|
const blank = () => ({
|
||||||
|
display: 0,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
score: 0,
|
||||||
|
is_public: false,
|
||||||
|
author_name: "",
|
||||||
|
example_html: null as string | null,
|
||||||
|
example_css: null as string | null,
|
||||||
|
example_js: null as string | null,
|
||||||
|
})
|
||||||
|
const form = reactive(blank())
|
||||||
|
|
||||||
|
const canSave = computed(() => !!(form.display && form.title && form.content))
|
||||||
|
|
||||||
|
watch(rawCode, (val) => {
|
||||||
|
if (!val.trim()) {
|
||||||
|
splitResult.value = null
|
||||||
|
Object.assign(form, { example_html: null, example_css: null, example_js: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const split = splitHtml(val)
|
||||||
|
splitResult.value = split
|
||||||
|
form.example_html = split.html || null
|
||||||
|
form.example_css = split.css || null
|
||||||
|
form.example_js = split.js || null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showExampleModal, (visible) => {
|
||||||
|
if (!visible) return
|
||||||
|
const parts: string[] = []
|
||||||
|
if (form.example_css) parts.push(`<style>\n${form.example_css}\n</style>`)
|
||||||
|
if (form.example_html) parts.push(form.example_html)
|
||||||
|
if (form.example_js) parts.push(`<script>\n${form.example_js}\n<\/script>`)
|
||||||
|
rawCode.value = parts.join("\n\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
list.value = await api.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(display: number) {
|
||||||
|
router.replace({ name: routeName, params: { display } })
|
||||||
|
const item: any = await api.get(display)
|
||||||
|
Object.assign(form, blank(), {
|
||||||
|
display: item.display,
|
||||||
|
title: item.title,
|
||||||
|
content: item.content,
|
||||||
|
score: item.score ?? 0,
|
||||||
|
is_public: item.is_public,
|
||||||
|
author_name: item.author_name ?? "",
|
||||||
|
example_html: item.example_html ?? null,
|
||||||
|
example_css: item.example_css ?? null,
|
||||||
|
example_js: item.example_js ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
// 列表按序号升序,新的接在最后
|
||||||
|
Object.assign(form, blank(), {
|
||||||
|
display: (list.value[list.value.length - 1]?.display ?? 0) + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.createOrUpdate(form as any)
|
||||||
|
message.success(res?.message ?? "已保存")
|
||||||
|
await loadList()
|
||||||
|
// 保存后留在这一项上,方便接着改
|
||||||
|
await show(form.display)
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.detail ?? "保存失败")
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(display: number) {
|
||||||
|
const item = list.value.find((i) => i.display === display)
|
||||||
|
dialog.warning({
|
||||||
|
title: `删除${kindLabel}「${item?.title ?? display}」?`,
|
||||||
|
content: "学生在这个任务上的提交和对话也会一起删除,不能恢复。",
|
||||||
|
positiveText: "删除",
|
||||||
|
negativeText: "取消",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await api.remove(display)
|
||||||
|
message.success("已删除")
|
||||||
|
await loadList()
|
||||||
|
if (form.display === display) {
|
||||||
|
if (list.value.length) show(list.value[0]!.display)
|
||||||
|
else createNew()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublic(display: number) {
|
||||||
|
const data = await api.togglePublic(display)
|
||||||
|
message.success(data.message)
|
||||||
|
const item = list.value.find((i) => i.display === display)
|
||||||
|
if (item) item.is_public = !item.is_public
|
||||||
|
if (form.display === display) form.is_public = !form.is_public
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadList()
|
||||||
|
const display = Number(route.params.display)
|
||||||
|
if (list.value.some((item) => item.display === display)) show(display)
|
||||||
|
else if (list.value.length) show(list.value[0]!.display)
|
||||||
|
else createNew()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.editor-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
width: 280px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--wp-line);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--wp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
background: var(--wp-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.on {
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
color: var(--wp-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.draft .item-title {
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-score {
|
||||||
|
font: 12px var(--wp-font-mono);
|
||||||
|
color: var(--wp-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-del {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover .item-del,
|
||||||
|
.item-del:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-grid x-gap="10" :cols="8">
|
|
||||||
<n-gi :span="2" class="col">
|
|
||||||
<n-flex vertical class="list">
|
|
||||||
<n-card
|
|
||||||
v-for="item in list"
|
|
||||||
:key="item.display"
|
|
||||||
@click="show(item.display)"
|
|
||||||
class="card"
|
|
||||||
:header-style="{
|
|
||||||
backgroundColor:
|
|
||||||
item.display === tutorial.display ? 'rgba(24, 160, 80, 0.1)' : '',
|
|
||||||
}"
|
|
||||||
:embedded="!item.is_public"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<n-flex align="center">
|
|
||||||
<n-button text @click.stop="togglePublic(item.display)">
|
|
||||||
<Icon
|
|
||||||
width="24"
|
|
||||||
:icon="
|
|
||||||
item.is_public
|
|
||||||
? 'lucide:globe'
|
|
||||||
: 'lucide:lock'
|
|
||||||
"
|
|
||||||
></Icon>
|
|
||||||
</n-button>
|
|
||||||
<span>【{{ item.display }}】{{ item.title }}</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button text type="default" @click="remove(item.display)">
|
|
||||||
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
</n-card>
|
|
||||||
<n-button @click="createNew">新建</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<n-gi :span="6" class="col">
|
|
||||||
<n-flex vertical>
|
|
||||||
<n-form inline :show-feedback="false">
|
|
||||||
<n-form-item label="序号" label-placement="left">
|
|
||||||
<n-input-number
|
|
||||||
style="width: 100px"
|
|
||||||
v-model:value="tutorial.display"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="标题" label-placement="left">
|
|
||||||
<n-input v-model:value="tutorial.title" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="公开" label-placement="left">
|
|
||||||
<n-switch v-model:value="tutorial.is_public" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label-placement="left">
|
|
||||||
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
|
||||||
提交
|
|
||||||
</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<n-flex>
|
|
||||||
<TaskAssetManager
|
|
||||||
v-if="tutorial.display"
|
|
||||||
task-type="tutorial"
|
|
||||||
:display="tutorial.display"
|
|
||||||
/>
|
|
||||||
<n-button
|
|
||||||
v-if="tutorial.display"
|
|
||||||
size="small"
|
|
||||||
@click="showExampleModal = true"
|
|
||||||
>
|
|
||||||
示例代码
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
<MarkdownEditor
|
|
||||||
style="height: calc(var(--wp-main-h) - 100px)"
|
|
||||||
v-model="tutorial.content"
|
|
||||||
/>
|
|
||||||
</n-flex>
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showExampleModal"
|
|
||||||
preset="card"
|
|
||||||
title="示例代码(加载教程时自动填入编辑器)"
|
|
||||||
style="width: 640px"
|
|
||||||
>
|
|
||||||
<n-input
|
|
||||||
type="textarea"
|
|
||||||
v-model:value="rawCode"
|
|
||||||
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
|
||||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
|
||||||
/>
|
|
||||||
<n-flex v-if="splitResult" style="margin-top: 8px">
|
|
||||||
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
|
|
||||||
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
|
|
||||||
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
|
||||||
import { useRoute, useRouter } from "vue-router"
|
|
||||||
import { Icon } from "../icons"
|
|
||||||
import { Tutorial } from "../api"
|
|
||||||
import type { TutorialSlim } from "../utils/type"
|
|
||||||
import { useDialog, useMessage } from "naive-ui"
|
|
||||||
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
|
||||||
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const confirm = useDialog()
|
|
||||||
|
|
||||||
const list = ref<TutorialSlim[]>([])
|
|
||||||
const showExampleModal = ref(false)
|
|
||||||
const rawCode = ref("")
|
|
||||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
|
||||||
|
|
||||||
function splitHtml(raw: string) {
|
|
||||||
let result = raw
|
|
||||||
const cssBlocks: string[] = []
|
|
||||||
const jsBlocks: string[] = []
|
|
||||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
|
||||||
cssBlocks.push(c.trim())
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
result = result.replace(
|
|
||||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
|
||||||
(_, c) => {
|
|
||||||
jsBlocks.push(c.trim())
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
html: result.trim(),
|
|
||||||
css: cssBlocks.join("\n\n"),
|
|
||||||
js: jsBlocks.join("\n\n"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(rawCode, (val) => {
|
|
||||||
if (!val.trim()) {
|
|
||||||
splitResult.value = null
|
|
||||||
tutorial.example_html = null
|
|
||||||
tutorial.example_css = null
|
|
||||||
tutorial.example_js = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const split = splitHtml(val)
|
|
||||||
splitResult.value = split
|
|
||||||
tutorial.example_html = split.html || null
|
|
||||||
tutorial.example_css = split.css || null
|
|
||||||
tutorial.example_js = split.js || null
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(showExampleModal, (visible) => {
|
|
||||||
if (!visible) return
|
|
||||||
const parts: string[] = []
|
|
||||||
if (tutorial.example_css) parts.push(`<style>\n${tutorial.example_css}\n</style>`)
|
|
||||||
if (tutorial.example_html) parts.push(tutorial.example_html)
|
|
||||||
if (tutorial.example_js) parts.push(`<script>\n${tutorial.example_js}\n<\/script>`)
|
|
||||||
rawCode.value = parts.join("\n\n")
|
|
||||||
})
|
|
||||||
const tutorial = reactive({
|
|
||||||
display: 0,
|
|
||||||
title: "",
|
|
||||||
content: "",
|
|
||||||
is_public: false,
|
|
||||||
example_html: null as string | null,
|
|
||||||
example_css: null as string | null,
|
|
||||||
example_js: null as string | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSubmit = computed(
|
|
||||||
() => tutorial.display && tutorial.title && tutorial.content,
|
|
||||||
)
|
|
||||||
async function getContent() {
|
|
||||||
list.value = await Tutorial.list()
|
|
||||||
const display = Number(route.params.display)
|
|
||||||
const target = list.value.find((item) => item.display === display)
|
|
||||||
if (target) {
|
|
||||||
show(display)
|
|
||||||
} else if (list.value.length > 0) {
|
|
||||||
show(list.value[0]!.display)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNew() {
|
|
||||||
tutorial.display = (list.value[list.value.length - 1]?.display ?? 0) + 1
|
|
||||||
tutorial.title = ""
|
|
||||||
tutorial.content = ""
|
|
||||||
tutorial.is_public = false
|
|
||||||
tutorial.example_html = null
|
|
||||||
tutorial.example_css = null
|
|
||||||
tutorial.example_js = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
try {
|
|
||||||
await Tutorial.createOrUpdate(tutorial)
|
|
||||||
message.success("提交成功")
|
|
||||||
tutorial.display = 0
|
|
||||||
tutorial.title = ""
|
|
||||||
tutorial.content = ""
|
|
||||||
tutorial.is_public = false
|
|
||||||
tutorial.example_html = null
|
|
||||||
tutorial.example_css = null
|
|
||||||
tutorial.example_js = null
|
|
||||||
await getContent()
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.response.data.detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(display: number) {
|
|
||||||
confirm.warning({
|
|
||||||
title: "警告",
|
|
||||||
content: "你确定要删除吗?",
|
|
||||||
positiveText: "确定",
|
|
||||||
negativeText: "取消",
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
await Tutorial.remove(display)
|
|
||||||
message.success("删除成功")
|
|
||||||
getContent()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function show(display: number) {
|
|
||||||
router.push({ name: "tutorial-editor", params: { display } })
|
|
||||||
const item = await Tutorial.get(display)
|
|
||||||
tutorial.display = item.display
|
|
||||||
tutorial.title = item.title
|
|
||||||
tutorial.content = item.content
|
|
||||||
tutorial.is_public = item.is_public
|
|
||||||
tutorial.example_html = item.example_html ?? null
|
|
||||||
tutorial.example_css = item.example_css ?? null
|
|
||||||
tutorial.example_js = item.example_js ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePublic(display: number) {
|
|
||||||
const data = await Tutorial.togglePublic(display)
|
|
||||||
message.success(data.message)
|
|
||||||
list.value = list.value.map((item) => {
|
|
||||||
if (item.display === display) item.is_public = !item.is_public
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(getContent)
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.list {
|
|
||||||
overflow: auto;
|
|
||||||
height: calc(var(--wp-main-h) - 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.col {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
|
||||||
height: calc(var(--wp-main-h) - 200px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<n-input
|
<n-input
|
||||||
style="width: 200px"
|
style="width: 200px"
|
||||||
v-model:value="query.username"
|
v-model:value="query.username"
|
||||||
|
placeholder="搜索用户名"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,21 +28,26 @@
|
|||||||
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
||||||
<UserFormModal v-model:show="showCreate" @saved="init" />
|
<UserFormModal v-model:show="showCreate" @saved="init" />
|
||||||
<n-modal
|
<n-modal
|
||||||
style="width: 300px"
|
style="width: 440px"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="批量新建 (前缀 web)"
|
title="批量新建学生"
|
||||||
v-model:show="showBatch"
|
v-model:show="showBatch"
|
||||||
>
|
>
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
|
<n-text depth="3" style="font-size: 13px">
|
||||||
|
每行一个名字。用户名是 web + 班级 + 名字(比如
|
||||||
|
web{{ classname || "241" }}{{ names[0] || "张三" }}),密码随机生成,建好后在列表里点“显示”查看。
|
||||||
|
</n-text>
|
||||||
<n-input
|
<n-input
|
||||||
placeholder="班级"
|
placeholder="班级,比如 241"
|
||||||
v-model:value="classname"
|
v-model:value="classname"
|
||||||
:disabled="batchLoading"
|
:disabled="batchLoading"
|
||||||
/>
|
/>
|
||||||
<n-input
|
<n-input
|
||||||
rows="20"
|
rows="16"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
|
placeholder="张三 李四 王五"
|
||||||
v-model:value="namesStr"
|
v-model:value="namesStr"
|
||||||
:disabled="batchLoading"
|
:disabled="batchLoading"
|
||||||
/>
|
/>
|
||||||
@@ -51,7 +57,7 @@
|
|||||||
:loading="batchLoading"
|
:loading="batchLoading"
|
||||||
@click="batchCreateUsers"
|
@click="batchCreateUsers"
|
||||||
>
|
>
|
||||||
提交
|
创建 {{ names.length }} 个账号
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
@@ -61,7 +67,7 @@
|
|||||||
import { onMounted, reactive, ref, h, watch, computed } from "vue"
|
import { onMounted, reactive, ref, h, watch, computed } from "vue"
|
||||||
import { Account } from "../api"
|
import { Account } from "../api"
|
||||||
import { parseTime } from "../utils/helper"
|
import { parseTime } from "../utils/helper"
|
||||||
import { useMessage, type DataTableColumn } from "naive-ui"
|
import { NButton, useMessage, type DataTableColumn } from "naive-ui"
|
||||||
import { getRole, Role, type User } from "../utils/type"
|
import { getRole, Role, type User } from "../utils/type"
|
||||||
import UserActions from "../components/dashboard/UserActions.vue"
|
import UserActions from "../components/dashboard/UserActions.vue"
|
||||||
import UserFormModal from "../components/dashboard/UserFormModal.vue"
|
import UserFormModal from "../components/dashboard/UserFormModal.vue"
|
||||||
@@ -82,7 +88,13 @@ const showBatch = ref(false)
|
|||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const classname = ref("")
|
const classname = ref("")
|
||||||
const namesStr = ref("")
|
const namesStr = ref("")
|
||||||
const names = computed(() => namesStr.value.split("\n").filter((it) => !!it))
|
const names = computed(() =>
|
||||||
|
namesStr.value
|
||||||
|
.split("\n")
|
||||||
|
.map((it) => it.trim())
|
||||||
|
.filter((it) => !!it),
|
||||||
|
)
|
||||||
|
const revealed = reactive(new Set<number>())
|
||||||
|
|
||||||
const batchLoading = ref(false)
|
const batchLoading = ref(false)
|
||||||
|
|
||||||
@@ -99,8 +111,19 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
key: "username",
|
key: "username",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "密码",
|
// 密码默认不显示,投屏时不会被学生看到
|
||||||
|
title: "初始密码",
|
||||||
key: "raw_password",
|
key: "raw_password",
|
||||||
|
render: (row) =>
|
||||||
|
!row.raw_password
|
||||||
|
? "—"
|
||||||
|
: revealed.has(row.id)
|
||||||
|
? h("span", { style: "font-family: var(--wp-font-mono)" }, row.raw_password)
|
||||||
|
: h(
|
||||||
|
NButton,
|
||||||
|
{ text: true, size: "small", onClick: () => revealed.add(row.id) },
|
||||||
|
() => "显示",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
</n-split>
|
</n-split>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { watch } from "vue"
|
||||||
import { useMagicKeys, whenever } from "@vueuse/core"
|
import { useMagicKeys, whenever } from "@vueuse/core"
|
||||||
import Editors from "../components/editor/Editors.vue"
|
import Editors from "../components/editor/Editors.vue"
|
||||||
import Preview from "../components/editor/Preview.vue"
|
import Preview from "../components/editor/Preview.vue"
|
||||||
@@ -29,6 +30,12 @@ import TaskPanel from "../components/task/TaskPanel.vue"
|
|||||||
import { show, panelSize } from "../store/panel"
|
import { show, panelSize } from "../store/panel"
|
||||||
import { html, css, js } from "../store/editors"
|
import { html, css, js } from "../store/editors"
|
||||||
import { assetBaseUrl, taskId } from "../store/task"
|
import { assetBaseUrl, taskId } from "../store/task"
|
||||||
|
import { saveTaskCode } from "../composables/useTaskCode"
|
||||||
|
|
||||||
|
// 每一节教程的代码各存一份;任务 id 和代码一起看,切换小节时不会把新一节的代码存到上一节下面
|
||||||
|
watch([taskId, html, css, js], ([id]) => {
|
||||||
|
if (id) saveTaskCode(id, { html: html.value, css: css.value, js: js.value })
|
||||||
|
})
|
||||||
|
|
||||||
const { ctrl_s } = useMagicKeys({
|
const { ctrl_s } = useMagicKeys({
|
||||||
passive: false,
|
passive: false,
|
||||||
|
|||||||
+13
-10
@@ -34,7 +34,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "/challenge",
|
path: "/challenge",
|
||||||
name: "home-challenge-list",
|
name: "home-challenge-list",
|
||||||
component: Workspace,
|
component: () => import("./pages/ChallengeHome.vue"),
|
||||||
meta: { auth: true },
|
meta: { auth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,13 +83,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "tutorial/:display",
|
path: "tutorial/:display",
|
||||||
name: "tutorial-editor",
|
name: "tutorial-editor",
|
||||||
component: () => import("./pages/TutorialEditor.vue"),
|
component: () => import("./pages/TaskEditor.vue"),
|
||||||
|
props: { kind: "tutorial" },
|
||||||
meta: { role: "super" },
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "challenge/:display",
|
path: "challenge/:display",
|
||||||
name: "challenge-editor",
|
name: "challenge-editor",
|
||||||
component: () => import("./pages/ChallengeEditor.vue"),
|
component: () => import("./pages/TaskEditor.vue"),
|
||||||
|
props: { kind: "challenge" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "user-manage/:page",
|
path: "user-manage/:page",
|
||||||
@@ -108,11 +110,17 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("./pages/Gradebook.vue"),
|
component: () => import("./pages/Gradebook.vue"),
|
||||||
meta: { role: "super" },
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "review",
|
||||||
|
name: "review",
|
||||||
|
component: () => import("./pages/Review.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
|
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
|
||||||
path: "submissions/:page",
|
path: "submissions/:page",
|
||||||
name: "teach-submissions",
|
name: "teach-submissions",
|
||||||
component: () => import("./pages/Submissions.vue"),
|
component: () => import("./pages/Submissions.vue"),
|
||||||
|
meta: { role: "super" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -134,11 +142,6 @@ router.beforeEach(async (to) => {
|
|||||||
const allowed = to.meta.role === "super" ? roleSuper.value : roleStaff.value
|
const allowed = to.meta.role === "super" ? roleSuper.value : roleStaff.value
|
||||||
if (!allowed) return { name: "home" }
|
if (!allowed) return { name: "home" }
|
||||||
}
|
}
|
||||||
// 教学的默认页:超管从教程开始,管理员只能出挑战
|
// 教学的默认页是批改
|
||||||
if (to.name === "dashboard") {
|
if (to.name === "dashboard") return { name: "review" }
|
||||||
return {
|
|
||||||
name: roleSuper.value ? "tutorial-editor" : "challenge-editor",
|
|
||||||
params: { display: 0 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
+27
-27
@@ -1,6 +1,5 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { ReconnectingSocket } from "../utils/socket"
|
import { ReconnectingSocket } from "../utils/socket"
|
||||||
import { html, css, js } from "./editors"
|
|
||||||
|
|
||||||
export interface PromptMessage {
|
export interface PromptMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
@@ -9,23 +8,25 @@ export interface PromptMessage {
|
|||||||
code?: { html: string | null; css: string | null; js: string | null }
|
code?: { html: string | null; css: string | null; js: string | null }
|
||||||
submitted?: boolean // whether this assistant message's code has been submitted
|
submitted?: boolean // whether this assistant message's code has been submitted
|
||||||
created?: string
|
created?: string
|
||||||
|
// conversation:AI 对话;edit:学生手改;manual:粘贴外部 AI 的代码
|
||||||
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CodeOut = { html: string | null; css: string | null; js: string | null }
|
||||||
|
|
||||||
export const messages = ref<PromptMessage[]>([])
|
export const messages = ref<PromptMessage[]>([])
|
||||||
export const conversationId = ref<string>("")
|
export const conversationId = ref<string>("")
|
||||||
export const connected = ref(false)
|
export const connected = ref(false)
|
||||||
export const streaming = ref(false)
|
export const streaming = ref(false)
|
||||||
export const streamingContent = ref("")
|
export const streamingContent = ref("")
|
||||||
export const currentTaskId = ref(0)
|
export const currentTaskId = ref(0)
|
||||||
let _onCodeComplete:
|
// 新代码交给工作台处理:init 是连上时对话里最新的那一版,complete 是 AI 刚生成的
|
||||||
| ((
|
let _codeSink:
|
||||||
code: { html: string | null; css: string | null; js: string | null },
|
| ((code: CodeOut, messageId: number, reason: "init" | "complete") => void)
|
||||||
messageId: number
|
|
||||||
) => void)
|
|
||||||
| null = null
|
| null = null
|
||||||
|
|
||||||
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
export function setCodeSink(fn: typeof _codeSink) {
|
||||||
_onCodeComplete = fn
|
_codeSink = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMessage(data: any) {
|
function onMessage(data: any) {
|
||||||
@@ -39,8 +40,8 @@ function onMessage(data: any) {
|
|||||||
const lastAssistant = [...messages.value]
|
const lastAssistant = [...messages.value]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((m) => m.role === "assistant" && m.code)
|
.find((m) => m.role === "assistant" && m.code)
|
||||||
if (lastAssistant?.code) {
|
if (lastAssistant?.code && lastAssistant.id) {
|
||||||
applyCode(lastAssistant.code)
|
_codeSink?.(lastAssistant.code, lastAssistant.id, "init")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (data.type === "stream") {
|
} else if (data.type === "stream") {
|
||||||
@@ -54,14 +55,10 @@ function onMessage(data: any) {
|
|||||||
id: data.message_id,
|
id: data.message_id,
|
||||||
code: data.code,
|
code: data.code,
|
||||||
submitted: false,
|
submitted: false,
|
||||||
|
source: "conversation",
|
||||||
})
|
})
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
if (data.code) {
|
if (data.code) _codeSink?.(data.code, data.message_id, "complete")
|
||||||
applyCode(data.code)
|
|
||||||
if (_onCodeComplete) {
|
|
||||||
_onCodeComplete(data.code, data.message_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (data.type === "error") {
|
} else if (data.type === "error") {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
@@ -103,7 +100,7 @@ export function disconnectPrompt() {
|
|||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
currentTaskId.value = 0
|
currentTaskId.value = 0
|
||||||
_onCodeComplete = null
|
_codeSink = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendPrompt(content: string, model: string = "") {
|
export function sendPrompt(content: string, model: string = "") {
|
||||||
@@ -130,6 +127,19 @@ export function stopPrompt() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手改或粘贴外部代码存成版本后,把这一问一答加到当前对话里显示
|
||||||
|
export function addVersionPair(
|
||||||
|
prompt: string,
|
||||||
|
source: string,
|
||||||
|
messageId: number,
|
||||||
|
code: CodeOut,
|
||||||
|
) {
|
||||||
|
messages.value.push(
|
||||||
|
{ role: "user", content: prompt, source },
|
||||||
|
{ role: "assistant", content: "", id: messageId, code, submitted: false, source },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function markMessageSubmitted(assistantMsgId: number) {
|
export function markMessageSubmitted(assistantMsgId: number) {
|
||||||
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
||||||
if (msg) msg.submitted = true
|
if (msg) msg.submitted = true
|
||||||
@@ -141,13 +151,3 @@ export function removeMessagePair(assistantMsgId: number) {
|
|||||||
messages.value.splice(idx - 1, 2)
|
messages.value.splice(idx - 1, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCode(code: {
|
|
||||||
html: string | null
|
|
||||||
css: string | null
|
|
||||||
js: string | null
|
|
||||||
}) {
|
|
||||||
if (code.html !== null) html.value = code.html
|
|
||||||
if (code.css !== null) css.value = code.css
|
|
||||||
if (code.js !== null) js.value = code.js
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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,384 @@
|
|||||||
|
/* 由 github-markdown-css 的 github-markdown-dark.css 和 highlight.js 的 github-dark.css 生成:
|
||||||
|
每个选择器前加 :root[data-theme="dark"],只保留颜色相关的声明。暗色模式下覆盖亮色样式 */
|
||||||
|
:root[data-theme="dark"] .markdown-body {
|
||||||
|
color-scheme: dark;
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #0d1117;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .octicon {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h1:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h2:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h3:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h4:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h5:hover .anchor .octicon-link:before,
|
||||||
|
:root[data-theme="dark"] .markdown-body h6:hover .anchor .octicon-link:before {
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
color: #4493f8;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body abbr[title] {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h1 {
|
||||||
|
border-bottom: 1px solid #3d444db3;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body mark {
|
||||||
|
background-color: #bb800926;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body hr {
|
||||||
|
background: rgba(0,0,0,0);
|
||||||
|
border-bottom: 1px solid #3d444db3;
|
||||||
|
background-color: #3d444d;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body ::-webkit-input-placeholder {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body ::placeholder {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus,
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=radio]:focus,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus {
|
||||||
|
outline: 2px solid var(--borderColor-accent-emphasis);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=radio]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus:not(:focus-visible) {
|
||||||
|
outline: solid 1px rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus-visible,
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus-visible,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=radio]:focus-visible,
|
||||||
|
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus-visible {
|
||||||
|
outline: 2px solid var(--borderColor-accent-emphasis);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body kbd {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #151b23;
|
||||||
|
border: solid 1px var(--borderColor-muted);
|
||||||
|
border-bottom-color: var(--borderColor-muted);
|
||||||
|
box-shadow: inset 0 -1px 0 var(--borderColor-muted);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h2 {
|
||||||
|
border-bottom: 1px solid #3d444db3;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h6 {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body blockquote {
|
||||||
|
color: #9198a1;
|
||||||
|
border-left: .25em solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .octicon {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:not([href]) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .absent {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .anchor:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body h1 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h2 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h3 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h4 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h5 .octicon-link,
|
||||||
|
:root[data-theme="dark"] .markdown-body h6 .octicon-link {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body summary h1,
|
||||||
|
:root[data-theme="dark"] .markdown-body summary h2 {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table th,
|
||||||
|
:root[data-theme="dark"] .markdown-body table td {
|
||||||
|
border: 1px solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table tr {
|
||||||
|
background-color: #0d1117;
|
||||||
|
border-top: 1px solid #3d444db3;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table tr:nth-child(2n) {
|
||||||
|
background-color: #151b23;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body table img {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .emoji {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body span.frame>span {
|
||||||
|
border: 1px solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body span.frame span span {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body code,
|
||||||
|
:root[data-theme="dark"] .markdown-body tt {
|
||||||
|
background-color: #656c7633;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body pre>code {
|
||||||
|
background: rgba(0,0,0,0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .highlight pre,
|
||||||
|
:root[data-theme="dark"] .markdown-body pre {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #151b23;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body pre code,
|
||||||
|
:root[data-theme="dark"] .markdown-body pre tt {
|
||||||
|
background-color: rgba(0,0,0,0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .csv-data .blob-num {
|
||||||
|
background: #0d1117;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .csv-data tr {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .csv-data th {
|
||||||
|
background: #151b23;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .footnotes {
|
||||||
|
color: #9198a1;
|
||||||
|
border-top: 1px solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .footnotes li:target::before {
|
||||||
|
border: 2px solid #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .footnotes li:target {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-c {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-c1,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s .pl-v {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-e,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-en {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-smi,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s .pl-s1 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ent {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-k {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-pds,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-s .pl-pse .pl-s1,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sre,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sra {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-v,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-smw {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-bu {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ii {
|
||||||
|
color: var(--fgColor-danger);
|
||||||
|
background-color: var(--bgColor-danger-muted);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-c2 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #b62324;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ml {
|
||||||
|
color: #f2cc60;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mh,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mh .pl-en,
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ms {
|
||||||
|
color: #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mi {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mb {
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-md {
|
||||||
|
color: #ffdcd7;
|
||||||
|
background-color: #67060c;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mi1 {
|
||||||
|
color: #aff5b4;
|
||||||
|
background-color: #033a16;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mc {
|
||||||
|
color: #ffdfb6;
|
||||||
|
background-color: #5a1e02;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mi2 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #1158c7;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-mdr {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-ba {
|
||||||
|
color: #9198a1;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-sg {
|
||||||
|
color: #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .pl-corl {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body button:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body summary:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body [tabindex="0"]:focus:not(:focus-visible),
|
||||||
|
:root[data-theme="dark"] .markdown-body details-dialog:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body a:has(>p,
|
||||||
|
:root[data-theme="dark"] >div,
|
||||||
|
:root[data-theme="dark"] >pre,
|
||||||
|
:root[data-theme="dark"] >blockquote):has(.snippet-clipboard-content,
|
||||||
|
:root[data-theme="dark"] >pre):focus-visible {
|
||||||
|
outline: 2px solid var(--borderColor-accent-emphasis);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert {
|
||||||
|
color: inherit;
|
||||||
|
border-left: .25em solid #3d444d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note {
|
||||||
|
border-left-color: #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {
|
||||||
|
color: #4493f8;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important {
|
||||||
|
border-left-color: #8957e5;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {
|
||||||
|
color: #ab7df8;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning {
|
||||||
|
border-left-color: #9e6a03;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {
|
||||||
|
color: #d29922;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip {
|
||||||
|
border-left-color: #238636;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution {
|
||||||
|
border-left-color: #da3633;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs {
|
||||||
|
color: #c9d1d9;
|
||||||
|
background: #0d1117;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-doctag,
|
||||||
|
:root[data-theme="dark"] .hljs-keyword,
|
||||||
|
:root[data-theme="dark"] .hljs-meta .hljs-keyword,
|
||||||
|
:root[data-theme="dark"] .hljs-template-tag,
|
||||||
|
:root[data-theme="dark"] .hljs-template-variable,
|
||||||
|
:root[data-theme="dark"] .hljs-type,
|
||||||
|
:root[data-theme="dark"] .hljs-variable.language_ {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-title,
|
||||||
|
:root[data-theme="dark"] .hljs-title.class_,
|
||||||
|
:root[data-theme="dark"] .hljs-title.class_.inherited__,
|
||||||
|
:root[data-theme="dark"] .hljs-title.function_ {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-attr,
|
||||||
|
:root[data-theme="dark"] .hljs-attribute,
|
||||||
|
:root[data-theme="dark"] .hljs-literal,
|
||||||
|
:root[data-theme="dark"] .hljs-meta,
|
||||||
|
:root[data-theme="dark"] .hljs-number,
|
||||||
|
:root[data-theme="dark"] .hljs-operator,
|
||||||
|
:root[data-theme="dark"] .hljs-variable,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-attr,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-class,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-id {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-regexp,
|
||||||
|
:root[data-theme="dark"] .hljs-string,
|
||||||
|
:root[data-theme="dark"] .hljs-meta .hljs-string {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-built_in,
|
||||||
|
:root[data-theme="dark"] .hljs-symbol {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-comment,
|
||||||
|
:root[data-theme="dark"] .hljs-code,
|
||||||
|
:root[data-theme="dark"] .hljs-formula {
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-name,
|
||||||
|
:root[data-theme="dark"] .hljs-quote,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-tag,
|
||||||
|
:root[data-theme="dark"] .hljs-selector-pseudo {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-subst {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-section {
|
||||||
|
color: #1f6feb;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-bullet {
|
||||||
|
color: #f2cc60;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-emphasis {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-strong {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-addition {
|
||||||
|
color: #aff5b4;
|
||||||
|
background-color: #033a16;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hljs-deletion {
|
||||||
|
color: #ffdcd7;
|
||||||
|
background-color: #67060c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface Code {
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyCode = (): Code => ({ html: "", css: "", js: "" })
|
||||||
|
|
||||||
|
export const sameCode = (a: Code, b: Code) =>
|
||||||
|
a.html === b.html && a.css === b.css && a.js === b.js
|
||||||
|
|
||||||
|
export const isEmptyCode = (c: Code) => !c.html.trim() && !c.css.trim() && !c.js.trim()
|
||||||
|
|
||||||
|
// 把一整页 HTML(外部 AI 常给的格式)拆成 html / css / js 三段
|
||||||
|
export function splitHtml(raw: string): Code {
|
||||||
|
const cssBlocks: string[] = []
|
||||||
|
const jsBlocks: string[] = []
|
||||||
|
let result = raw.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
|
||||||
|
cssBlocks.push(content.trim())
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
result = result.replace(
|
||||||
|
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||||
|
(_, content) => {
|
||||||
|
jsBlocks.push(content.trim())
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
html: result.trim(),
|
||||||
|
css: cssBlocks.join("\n\n"),
|
||||||
|
js: jsBlocks.join("\n\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prettier 加上各语言插件近 1MB,只在点“整理代码”时才加载
|
||||||
|
export async function formatCode(code: Code): Promise<Code> {
|
||||||
|
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
|
||||||
|
await Promise.all([
|
||||||
|
import("prettier/standalone"),
|
||||||
|
import("prettier/parser-html"),
|
||||||
|
import("prettier/parser-postcss"),
|
||||||
|
import("prettier/parser-babel"),
|
||||||
|
import("prettier/plugins/estree"),
|
||||||
|
])
|
||||||
|
const [html, css, js] = await Promise.all([
|
||||||
|
prettier.format(code.html, {
|
||||||
|
parser: "html",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(code.css, {
|
||||||
|
parser: "css",
|
||||||
|
plugins: [cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(code.js, {
|
||||||
|
parser: "babel",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [babelParser, estreeParser],
|
||||||
|
tabWidth: 2,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
return { html, css, js }
|
||||||
|
}
|
||||||
@@ -16,3 +16,10 @@ export function goHome(router: any, type: TASK_TYPE, display: number) {
|
|||||||
router.push({ name: "home" })
|
router.push({ name: "home" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 一轮对话 / 一个版本是怎么来的
|
||||||
|
export function sourceLabel(source: string | null | undefined) {
|
||||||
|
if (source === "edit") return "手改"
|
||||||
|
if (source === "manual") return "外部 AI"
|
||||||
|
return "AI 对话"
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ export function getRole(role: Role) {
|
|||||||
}[role]
|
}[role]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlagType = "red" | "blue" | "green" | "yellow" | null
|
// 只用 present(课上展示);以前的四色标记数据里还可能有
|
||||||
|
export type FlagType = "present" | "red" | "blue" | "green" | "yellow" | null
|
||||||
|
|
||||||
export interface TaskAsset {
|
export interface TaskAsset {
|
||||||
name: string
|
name: string
|
||||||
@@ -126,6 +127,8 @@ export interface User {
|
|||||||
role: Role
|
role: Role
|
||||||
classname: string
|
classname: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
// 批量创建时生成的初始密码;超管和改过密码的为空
|
||||||
|
raw_password?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmissionOut {
|
export interface SubmissionOut {
|
||||||
@@ -362,9 +365,9 @@ export interface PromptRound {
|
|||||||
js: string | null
|
js: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打分前不带作者(打完分 updateScore 返回)
|
||||||
export interface RandomRatingItem {
|
export interface RandomRatingItem {
|
||||||
submission_id: string
|
submission_id: string
|
||||||
username: string
|
|
||||||
task_title: string
|
task_title: string
|
||||||
task_display: number
|
task_display: number
|
||||||
task_type: TASK_TYPE
|
task_type: TASK_TYPE
|
||||||
@@ -372,3 +375,58 @@ export interface RandomRatingItem {
|
|||||||
css: string | null
|
css: string | null
|
||||||
js: string | null
|
js: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewOptions {
|
||||||
|
classes: string[]
|
||||||
|
tasks: {
|
||||||
|
id: number
|
||||||
|
task_type: "tutorial" | "challenge"
|
||||||
|
display: number
|
||||||
|
title: string
|
||||||
|
is_public: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewProgress {
|
||||||
|
student_count: number
|
||||||
|
// task_id → 交了的人数
|
||||||
|
submitted: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSubmission {
|
||||||
|
id: string
|
||||||
|
score: number
|
||||||
|
my_score: number
|
||||||
|
rating_count: number
|
||||||
|
flag: FlagType
|
||||||
|
view_count: number
|
||||||
|
submit_count: number
|
||||||
|
created: string
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewStudent {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
submission: ReviewSubmission | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerWork {
|
||||||
|
id: string
|
||||||
|
// 自己打分之前是 null
|
||||||
|
username: string | null
|
||||||
|
score: number | null
|
||||||
|
my_score: number
|
||||||
|
created: string
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerWorks {
|
||||||
|
// 自己还没交这道题
|
||||||
|
locked: boolean
|
||||||
|
items: PeerWork[]
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ const { values } = parseArgs({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const where = ["m.role = 'user'"]
|
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||||
const params: Record<string, unknown> = {}
|
const params: Record<string, unknown> = {}
|
||||||
if (values["task-id"]) {
|
if (values["task-id"]) {
|
||||||
where.push("c.task_id = $task_id")
|
where.push("c.task_id = $task_id")
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ import { Hono } from "hono"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { all, get, tx, iso, uuidOut } from "../db"
|
import { all, get, tx, iso, uuidOut } from "../db"
|
||||||
import { Role, isStaff, requireUser } from "../auth"
|
import { Role, isStaff, requireUser } from "../auth"
|
||||||
import { HttpError, ValidationError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
import { HttpError, ValidationError, body, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
||||||
import { deleteRound, hasRatingsOrAwards } from "../services/cascade"
|
import { deleteRound, hasRatingsOrAwards } from "../services/cascade"
|
||||||
import {
|
import {
|
||||||
classifyConversationMessages,
|
classifyConversationMessages,
|
||||||
|
classifyMessage,
|
||||||
classifyMessagesBatch,
|
classifyMessagesBatch,
|
||||||
inBackground,
|
inBackground,
|
||||||
} from "../services/classifier"
|
} from "../services/classifier"
|
||||||
import type { MessageRow } from "../services/prompt"
|
import {
|
||||||
|
EDIT_PROMPT,
|
||||||
|
canAccessTask,
|
||||||
|
codeAsMarkdown,
|
||||||
|
getOrCreateActiveConversation,
|
||||||
|
insertMessage,
|
||||||
|
type MessageRow,
|
||||||
|
} from "../services/prompt"
|
||||||
|
|
||||||
const router = new Hono<Env>()
|
const router = new Hono<Env>()
|
||||||
|
|
||||||
@@ -133,6 +141,45 @@ router.get("/history/:task_id", (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
|
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
|
||||||
|
// 保存一个不是 AI 生成的版本:学生手改的代码(source=edit),或者粘贴外部 AI 的代码(source=manual,带提示词)。
|
||||||
|
// 存成一问一答放进当前对话,版本条和提示词链里都能看到;之后接着和 AI 对话,AI 也是在这份代码上改
|
||||||
|
router.post("/versions", async (c) => {
|
||||||
|
const user = requireUser(c)
|
||||||
|
const p = await body(
|
||||||
|
c,
|
||||||
|
z.object({
|
||||||
|
task_id: z.number().int(),
|
||||||
|
html: z.string(),
|
||||||
|
css: z.string(),
|
||||||
|
js: z.string(),
|
||||||
|
prompt: z.string().trim().max(5000).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: p.task_id }), "任务不存在")
|
||||||
|
if (!canAccessTask(user, p.task_id)) throw new HttpError(403, "没有权限")
|
||||||
|
const source = p.prompt ? "manual" : "edit"
|
||||||
|
const code = { code_html: p.html, code_css: p.css, code_js: p.js }
|
||||||
|
const ids = tx(() => {
|
||||||
|
const convId = getOrCreateActiveConversation(user.id, p.task_id)
|
||||||
|
const userMessageId = insertMessage({
|
||||||
|
conversation_id: convId,
|
||||||
|
role: "user",
|
||||||
|
content: p.prompt || EDIT_PROMPT,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
const messageId = insertMessage({
|
||||||
|
conversation_id: convId,
|
||||||
|
role: "assistant",
|
||||||
|
content: codeAsMarkdown(code),
|
||||||
|
source,
|
||||||
|
...code,
|
||||||
|
})
|
||||||
|
return { userMessageId, messageId }
|
||||||
|
})
|
||||||
|
if (p.prompt) inBackground(classifyMessage(ids.userMessageId))
|
||||||
|
return c.json({ message_id: ids.messageId, user_message_id: ids.userMessageId, source })
|
||||||
|
})
|
||||||
|
|
||||||
router.post("/conversations/:conversation_id/classify", (c) => {
|
router.post("/conversations/:conversation_id/classify", (c) => {
|
||||||
const user = requireUser(c)
|
const user = requireUser(c)
|
||||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||||
@@ -151,7 +198,7 @@ router.post("/classify-batch", (c) => {
|
|||||||
c,
|
c,
|
||||||
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
|
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
|
||||||
)
|
)
|
||||||
const where = ["m.role = 'user'"]
|
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||||
const params: Record<string, unknown> = {}
|
const params: Record<string, unknown> = {}
|
||||||
if (q.task_id) {
|
if (q.task_id) {
|
||||||
where.push("c.task_id = $task_id")
|
where.push("c.task_id = $task_id")
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Hono } from "hono"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { all, get, iso, uuidOut } from "../db"
|
||||||
|
import { isStaff, requireUser, type User } from "../auth"
|
||||||
|
import { HttpError, intParam, orNotFound, query, type Env } from "../http"
|
||||||
|
|
||||||
|
// 老师按“班级 × 任务”批改,学生看本班同学的作品。挂在 /submission/review 下
|
||||||
|
const router = new Hono<Env>()
|
||||||
|
|
||||||
|
function requireStaff(user: User) {
|
||||||
|
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 和成绩册一致:班里的学生包括当助教的 admin
|
||||||
|
const CLASS_MEMBERS = `SELECT id FROM account_user
|
||||||
|
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1`
|
||||||
|
|
||||||
|
// 每个人在这个任务上最新的一次提交,以及提交了几次。
|
||||||
|
// 先只在 slim 覆盖索引上找,再按 rowid 读这几行的完整内容(代码是宽列,见 CLAUDE.md)
|
||||||
|
function latestByUser(taskId: number, members: string, params: Record<string, unknown>) {
|
||||||
|
const rows = all<{ rid: number; user_id: number; created: string }>(
|
||||||
|
`SELECT s.rowid AS rid, s.user_id, s.created FROM submission_submission s
|
||||||
|
WHERE s.task_id = $task_id AND s.user_id IN (${members})`,
|
||||||
|
{ task_id: taskId, ...params },
|
||||||
|
)
|
||||||
|
const latest = new Map<number, { rid: number; created: string; count: number }>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const cur = latest.get(r.user_id)
|
||||||
|
if (!cur) latest.set(r.user_id, { rid: r.rid, created: r.created, count: 1 })
|
||||||
|
else {
|
||||||
|
cur.count++
|
||||||
|
if (r.created > cur.created) Object.assign(cur, { rid: r.rid, created: r.created })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRows(rids: number[], me: number) {
|
||||||
|
if (!rids.length) return new Map<number, any>()
|
||||||
|
const rows = all(
|
||||||
|
`SELECT s.rowid AS rid, s.id, s.user_id, s.score, s.html, s.css, s.js, s.flag,
|
||||||
|
s.view_count, s.created,
|
||||||
|
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id) AS my_score,
|
||||||
|
(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) AS rating_count
|
||||||
|
FROM submission_submission s WHERE s.rowid IN (SELECT value FROM json_each($rids))`,
|
||||||
|
{ rids: JSON.stringify(rids), me },
|
||||||
|
)
|
||||||
|
return new Map(rows.map((r) => [r.user_id as number, r]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级列表和可批改的任务(教程、挑战)
|
||||||
|
router.get("/options", (c) => {
|
||||||
|
requireStaff(requireUser(c))
|
||||||
|
const classes = all<{ classname: string }>(
|
||||||
|
`SELECT DISTINCT classname FROM account_user
|
||||||
|
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
|
||||||
|
).map((r) => r.classname)
|
||||||
|
const tasks = all(
|
||||||
|
`SELECT id, task_type, display, title, is_public FROM task_task
|
||||||
|
WHERE task_type IN ('tutorial', 'challenge')
|
||||||
|
ORDER BY task_type = 'challenge', display`,
|
||||||
|
).map((t) => ({ ...t, is_public: !!t.is_public }))
|
||||||
|
return c.json({ classes, tasks })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 一个班每个任务交了几个人
|
||||||
|
router.get("/progress", (c) => {
|
||||||
|
requireStaff(requireUser(c))
|
||||||
|
const { classname } = query(c, z.object({ classname: z.string().min(1) }))
|
||||||
|
const studentCount = get<{ n: number }>(
|
||||||
|
`SELECT COUNT(*) AS n FROM (${CLASS_MEMBERS})`,
|
||||||
|
{ classname },
|
||||||
|
)!.n
|
||||||
|
const rows = all<{ task_id: number; n: number }>(
|
||||||
|
`SELECT s.task_id, COUNT(DISTINCT s.user_id) AS n FROM submission_submission s
|
||||||
|
WHERE s.user_id IN (${CLASS_MEMBERS}) GROUP BY s.task_id`,
|
||||||
|
{ classname },
|
||||||
|
)
|
||||||
|
return c.json({
|
||||||
|
student_count: studentCount,
|
||||||
|
submitted: Object.fromEntries(rows.map((r) => [r.task_id, r.n])),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 作品墙:班里每个学生在这个任务上最新的一次提交(没交的 submission 为 null)
|
||||||
|
router.get("/class", (c) => {
|
||||||
|
const user = requireUser(c)
|
||||||
|
requireStaff(user)
|
||||||
|
const q = query(c, z.object({ classname: z.string().min(1), task_id: z.coerce.number().int() }))
|
||||||
|
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: q.task_id }), "任务不存在")
|
||||||
|
const students = all<{ id: number; username: string }>(
|
||||||
|
`SELECT id, username FROM account_user
|
||||||
|
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1
|
||||||
|
ORDER BY username, id`,
|
||||||
|
{ classname: q.classname },
|
||||||
|
)
|
||||||
|
const latest = latestByUser(q.task_id, CLASS_MEMBERS, { classname: q.classname })
|
||||||
|
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||||
|
return c.json(
|
||||||
|
students.map((s) => {
|
||||||
|
const r = rows.get(s.id)
|
||||||
|
return {
|
||||||
|
user_id: s.id,
|
||||||
|
username: s.username,
|
||||||
|
submission: r
|
||||||
|
? {
|
||||||
|
id: uuidOut(r.id),
|
||||||
|
score: r.score,
|
||||||
|
my_score: r.my_score || 0,
|
||||||
|
rating_count: r.rating_count,
|
||||||
|
flag: r.flag,
|
||||||
|
view_count: r.view_count,
|
||||||
|
submit_count: latest.get(s.id)!.count,
|
||||||
|
created: iso(r.created),
|
||||||
|
html: r.html,
|
||||||
|
css: r.css,
|
||||||
|
js: r.js,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 学生看本班同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分
|
||||||
|
router.get("/peers/:task_id", (c) => {
|
||||||
|
const user = requireUser(c)
|
||||||
|
const taskId = intParam(c, "task_id")
|
||||||
|
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }), "任务不存在")
|
||||||
|
const submitted = get(
|
||||||
|
`SELECT 1 FROM submission_submission WHERE user_id = $me AND task_id = $task_id LIMIT 1`,
|
||||||
|
{ me: user.id, task_id: taskId },
|
||||||
|
)
|
||||||
|
if (!submitted || !user.classname) return c.json({ locked: true, items: [] })
|
||||||
|
const members = `SELECT id FROM account_user
|
||||||
|
WHERE classname = $classname AND role = 'normal' AND is_active = 1 AND id != $me`
|
||||||
|
const params = { classname: user.classname, me: user.id }
|
||||||
|
const latest = latestByUser(taskId, members, params)
|
||||||
|
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||||
|
const names = new Map(
|
||||||
|
all<{ id: number; username: string }>(`SELECT id, username FROM account_user WHERE id IN (${members})`, params).map(
|
||||||
|
(u) => [u.id, u.username],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const items = [...rows.values()]
|
||||||
|
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
||||||
|
.map((r) => {
|
||||||
|
const rated = (r.my_score || 0) > 0
|
||||||
|
return {
|
||||||
|
id: uuidOut(r.id),
|
||||||
|
username: rated ? names.get(r.user_id) : null,
|
||||||
|
score: rated ? r.score : null,
|
||||||
|
my_score: r.my_score || 0,
|
||||||
|
created: iso(r.created),
|
||||||
|
html: r.html,
|
||||||
|
css: r.css,
|
||||||
|
js: r.js,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return c.json({ locked: false, items })
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
|
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
|
||||||
import { Role, isStaff, requireSuper, requireUser } from "../auth"
|
import { Role, isStaff, requireSuper, requireUser, type User } from "../auth"
|
||||||
import {
|
import {
|
||||||
HttpError,
|
HttpError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -30,10 +30,35 @@ import {
|
|||||||
} from "../services/prompt"
|
} from "../services/prompt"
|
||||||
import { pyRound } from "../services/pyround"
|
import { pyRound } from "../services/pyround"
|
||||||
import showcase from "./showcase"
|
import showcase from "./showcase"
|
||||||
|
import review from "./review"
|
||||||
|
|
||||||
const router = new Hono<Env>()
|
const router = new Hono<Env>()
|
||||||
|
|
||||||
router.route("/showcase", showcase)
|
router.route("/showcase", showcase)
|
||||||
|
router.route("/review", review)
|
||||||
|
|
||||||
|
// 学生能看的提交:自己的、进了作品展的、本班同学在自己也交过的题上的。老师都能看
|
||||||
|
function canView(user: User, sub: { id: string; user_id: number; task_id: number }) {
|
||||||
|
if (isStaff(user) || sub.user_id === user.id) return true
|
||||||
|
if (get(`SELECT 1 FROM submission_submissionaward WHERE submission_id = $id`, { id: sub.id })) return true
|
||||||
|
if (!user.classname) return false
|
||||||
|
return !!get(
|
||||||
|
`SELECT 1 FROM account_user u
|
||||||
|
WHERE u.id = $owner AND u.classname = $classname
|
||||||
|
AND EXISTS (SELECT 1 FROM submission_submission s WHERE s.user_id = $me AND s.task_id = $task_id)`,
|
||||||
|
{ owner: sub.user_id, classname: user.classname, me: user.id, task_id: sub.task_id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireView(user: User, id: string) {
|
||||||
|
const sub = orNotFound(
|
||||||
|
get<{ id: string; user_id: number; task_id: number }>(
|
||||||
|
`SELECT id, user_id, task_id FROM submission_submission WHERE id = $id`,
|
||||||
|
{ id },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!canView(user, sub)) throw new HttpError(403, "交过这道题以后才能看同学的作品")
|
||||||
|
}
|
||||||
|
|
||||||
// 当天(UTC 零点起)的评分次数,与原实现保持一致
|
// 当天(UTC 零点起)的评分次数,与原实现保持一致
|
||||||
function todayRatingCount(userId: number) {
|
function todayRatingCount(userId: number) {
|
||||||
@@ -221,6 +246,11 @@ router.get("/", (c) => {
|
|||||||
where.push("t.task_type = $task_type")
|
where.push("t.task_type = $task_type")
|
||||||
params.task_type = f.task_type
|
params.task_type = f.task_type
|
||||||
}
|
}
|
||||||
|
// 学生只能看自己的提交(同学的作品走 /review/peers)
|
||||||
|
if (user.role === Role.NORMAL) {
|
||||||
|
where.push("s.user_id = $user_id")
|
||||||
|
params.user_id = user.id
|
||||||
|
} else {
|
||||||
if (f.username) {
|
if (f.username) {
|
||||||
where.push(`u.username LIKE $username ESCAPE '\\'`)
|
where.push(`u.username LIKE $username ESCAPE '\\'`)
|
||||||
params.username = likeEscape(f.username)
|
params.username = likeEscape(f.username)
|
||||||
@@ -229,6 +259,7 @@ router.get("/", (c) => {
|
|||||||
where.push("s.user_id = $user_id")
|
where.push("s.user_id = $user_id")
|
||||||
params.user_id = f.user_id
|
params.user_id = f.user_id
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
|
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
|
||||||
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
|
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
|
||||||
else if (f.flag) {
|
else if (f.flag) {
|
||||||
@@ -288,6 +319,7 @@ router.get("/", (c) => {
|
|||||||
router.get("/by-user-task", (c) => {
|
router.get("/by-user-task", (c) => {
|
||||||
const me = requireUser(c)
|
const me = requireUser(c)
|
||||||
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
|
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
|
||||||
|
if (me.role === Role.NORMAL && q.user_id !== me.id) throw new HttpError(403, "没有权限")
|
||||||
const rows = all(
|
const rows = all(
|
||||||
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
|
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
|
||||||
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
|
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
|
||||||
@@ -333,14 +365,14 @@ router.get("/random-for-rating", (c) => {
|
|||||||
get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
|
get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
|
||||||
if (!picked) return c.json(null)
|
if (!picked) return c.json(null)
|
||||||
const pick = get(
|
const pick = get(
|
||||||
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
|
`SELECT s.id, t.title AS task_title, t.display AS task_display,
|
||||||
t.task_type, s.html, s.css, s.js
|
t.task_type, s.html, s.css, s.js
|
||||||
${SUBMISSION_FROM} WHERE s.rowid = $rid`,
|
${SUBMISSION_FROM} WHERE s.rowid = $rid`,
|
||||||
{ rid: picked.rid },
|
{ rid: picked.rid },
|
||||||
)!
|
)!
|
||||||
|
// 打分前不告诉作者是谁(打完分 PUT /:id/score 会返回)
|
||||||
return c.json({
|
return c.json({
|
||||||
submission_id: uuidOut(pick.id),
|
submission_id: uuidOut(pick.id),
|
||||||
username: pick.username,
|
|
||||||
task_title: pick.task_title,
|
task_title: pick.task_title,
|
||||||
task_display: pick.task_display,
|
task_display: pick.task_display,
|
||||||
task_type: pick.task_type,
|
task_type: pick.task_type,
|
||||||
@@ -350,13 +382,6 @@ router.get("/random-for-rating", (c) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete("/flags", (c) => {
|
|
||||||
const user = requireUser(c)
|
|
||||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
|
||||||
const res = run(`UPDATE submission_submission SET flag = NULL WHERE flag IS NOT NULL`)
|
|
||||||
return c.json({ cleared: res.changes })
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 统计 ──────────────────────────────────────────────────────────────────────
|
// ── 统计 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/stats/:task_id", (c) => {
|
router.get("/stats/:task_id", (c) => {
|
||||||
@@ -510,9 +535,9 @@ router.delete("/:id", (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.get("/:id/prompt-chain", (c) => {
|
router.get("/:id/prompt-chain", (c) => {
|
||||||
requireUser(c)
|
const user = requireUser(c)
|
||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
requireView(user, id)
|
||||||
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
|
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
|
||||||
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
|
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
|
||||||
return c.json(buildPromptRounds(msg))
|
return c.json(buildPromptRounds(msg))
|
||||||
@@ -521,6 +546,7 @@ router.get("/:id/prompt-chain", (c) => {
|
|||||||
router.get("/:id", (c) => {
|
router.get("/:id", (c) => {
|
||||||
const user = requireUser(c)
|
const user = requireUser(c)
|
||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
|
requireView(user, id)
|
||||||
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
|
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
|
||||||
return c.json(submissionOut(s))
|
return c.json(submissionOut(s))
|
||||||
})
|
})
|
||||||
@@ -539,7 +565,13 @@ router.put("/:id/score", async (c) => {
|
|||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
|
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
|
||||||
if (score <= 0) throw new HttpError(400, "分数不能为零")
|
if (score <= 0) throw new HttpError(400, "分数不能为零")
|
||||||
const sub = orNotFound(get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }))
|
const sub = orNotFound(
|
||||||
|
get<{ user_id: number; username: string }>(
|
||||||
|
`SELECT s.user_id, u.username FROM submission_submission s
|
||||||
|
JOIN account_user u ON u.id = s.user_id WHERE s.id = $id`,
|
||||||
|
{ id },
|
||||||
|
),
|
||||||
|
)
|
||||||
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
|
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
|
||||||
|
|
||||||
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
|
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
|
||||||
@@ -558,7 +590,8 @@ router.put("/:id/score", async (c) => {
|
|||||||
)
|
)
|
||||||
updateSubmissionScore(id)
|
updateSubmissionScore(id)
|
||||||
})
|
})
|
||||||
return c.json({ message: "打分成功" })
|
// 互评时打分前是匿名的,打完分再告诉作者是谁
|
||||||
|
return c.json({ message: "打分成功", username: sub.username })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.put("/:id/flag", async (c) => {
|
router.put("/:id/flag", async (c) => {
|
||||||
@@ -567,7 +600,9 @@ router.put("/:id/flag", async (c) => {
|
|||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
const { flag } = await body(
|
const { flag } = await body(
|
||||||
c,
|
c,
|
||||||
z.object({ flag: z.enum(["red", "blue", "green", "yellow"]).nullable().default(null) }),
|
z.object({
|
||||||
|
flag: z.enum(["present", "red", "blue", "green", "yellow"]).nullable().default(null),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
||||||
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
|
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
|
||||||
|
|||||||
@@ -274,8 +274,9 @@ challenge.post("/", async (c) => {
|
|||||||
return c.json({ message: "创建成功" })
|
return c.json({ message: "创建成功" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 管理员保存挑战时本来就能设“公开”,这里和它保持一致
|
||||||
challenge.put("/public/:display", (c) => {
|
challenge.put("/public/:display", (c) => {
|
||||||
requireSuper(c)
|
requireAdmin(c)
|
||||||
const found = orNotFound(findChallenge(intParam(c, "display")))
|
const found = orNotFound(findChallenge(intParam(c, "display")))
|
||||||
return c.json(togglePublic(found.id))
|
return c.json(togglePublic(found.id))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function callLlm(content: string): Promise<number | null> {
|
|||||||
|
|
||||||
export async function classifyMessage(messageId: number): Promise<number | null> {
|
export async function classifyMessage(messageId: number): Promise<number | null> {
|
||||||
const msg = get<{ content: string }>(
|
const msg = get<{ content: string }>(
|
||||||
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user'`,
|
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user' AND source != 'edit'`,
|
||||||
{ id: messageId },
|
{ id: messageId },
|
||||||
)
|
)
|
||||||
if (!msg) return null
|
if (!msg) return null
|
||||||
@@ -56,7 +56,7 @@ export async function classifyMessage(messageId: number): Promise<number | null>
|
|||||||
export async function classifyConversationMessages(conversationId: string, force = false) {
|
export async function classifyConversationMessages(conversationId: string, force = false) {
|
||||||
const rows = all<{ id: number }>(
|
const rows = all<{ id: number }>(
|
||||||
`SELECT id FROM prompt_message
|
`SELECT id FROM prompt_message
|
||||||
WHERE conversation_id = $id AND role = 'user' ${force ? "" : "AND prompt_level IS NULL"}
|
WHERE conversation_id = $id AND role = 'user' AND source != 'edit' ${force ? "" : "AND prompt_level IS NULL"}
|
||||||
ORDER BY created, id`,
|
ORDER BY created, id`,
|
||||||
{ id: conversationId },
|
{ id: conversationId },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,6 +81,23 @@ export function insertMessage(m: {
|
|||||||
return Number(res.lastInsertRowid)
|
return Number(res.lastInsertRowid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不是 AI 生成的版本(手改 / 粘贴外部 AI 的代码),AI 回复那一条的正文写成和 AI 输出一样的代码块,
|
||||||
|
// 后面接着和 AI 对话时,它在上下文里看到的就是这份代码
|
||||||
|
export const EDIT_PROMPT = "(手动修改了代码)"
|
||||||
|
|
||||||
|
export function codeAsMarkdown(code: {
|
||||||
|
code_html: string | null
|
||||||
|
code_css: string | null
|
||||||
|
code_js: string | null
|
||||||
|
}) {
|
||||||
|
const parts = [
|
||||||
|
code.code_css ? `<style>\n${code.code_css}\n</style>` : "",
|
||||||
|
code.code_html ?? "",
|
||||||
|
code.code_js ? `<script>\n${code.code_js}\n</script>` : "",
|
||||||
|
].filter(Boolean)
|
||||||
|
return "```html\n" + parts.join("\n\n") + "\n```"
|
||||||
|
}
|
||||||
|
|
||||||
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
||||||
return get<MessageRow>(
|
return get<MessageRow>(
|
||||||
`SELECT * FROM prompt_message
|
`SELECT * FROM prompt_message
|
||||||
|
|||||||
+13
-4
@@ -1,7 +1,12 @@
|
|||||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||||
import { all, get, run, iso, uuidOut } from "./db"
|
import { all, get, run, iso, uuidOut } from "./db"
|
||||||
import { userFromAccessToken, type User } from "./auth"
|
import { userFromAccessToken, type User } from "./auth"
|
||||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
import {
|
||||||
|
canAccessTask,
|
||||||
|
codeAsMarkdown,
|
||||||
|
getOrCreateActiveConversation,
|
||||||
|
insertMessage,
|
||||||
|
} from "./services/prompt"
|
||||||
import {
|
import {
|
||||||
extractCode,
|
extractCode,
|
||||||
parseGuidanceResponse,
|
parseGuidanceResponse,
|
||||||
@@ -80,6 +85,7 @@ function conversationHistory(conversationId: string) {
|
|||||||
return all<{
|
return all<{
|
||||||
id: number
|
id: number
|
||||||
role: string
|
role: string
|
||||||
|
source: string
|
||||||
content: string
|
content: string
|
||||||
code_html: string | null
|
code_html: string | null
|
||||||
code_css: string | null
|
code_css: string | null
|
||||||
@@ -87,8 +93,9 @@ function conversationHistory(conversationId: string) {
|
|||||||
submission_id: string | null
|
submission_id: string | null
|
||||||
created: string
|
created: string
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, role, content, code_html, code_css, code_js, submission_id, created
|
// 除了 AI 对话,还有学生手改(edit)和粘贴外部 AI 代码(manual)的版本;引导对话(guidance)不算
|
||||||
FROM prompt_message WHERE conversation_id = $id AND source = 'conversation'
|
`SELECT id, role, source, content, code_html, code_css, code_js, submission_id, created
|
||||||
|
FROM prompt_message WHERE conversation_id = $id AND source IN ('conversation', 'edit', 'manual')
|
||||||
ORDER BY created, id`,
|
ORDER BY created, id`,
|
||||||
{ id: conversationId },
|
{ id: conversationId },
|
||||||
)
|
)
|
||||||
@@ -117,7 +124,8 @@ async function handlePrompt(ws: WS, data: any) {
|
|||||||
try {
|
try {
|
||||||
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
||||||
role: m.role as ChatMessage["role"],
|
role: m.role as ChatMessage["role"],
|
||||||
content: m.content,
|
// 以前“手动提交”存的 AI 回复正文是空的,代码只在 code_* 里
|
||||||
|
content: m.role === "assistant" && !m.content ? codeAsMarkdown(m) : m.content,
|
||||||
}))
|
}))
|
||||||
let full: string
|
let full: string
|
||||||
try {
|
try {
|
||||||
@@ -211,6 +219,7 @@ export const websocket: WebSocketHandler<WsData> = {
|
|||||||
messages: conversationHistory(d.conversationId).map((m) => ({
|
messages: conversationHistory(d.conversationId).map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
|
source: m.source,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
code:
|
code:
|
||||||
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
|
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
|
||||||
|
|||||||
@@ -226,6 +226,34 @@ describe("提交与删除", () => {
|
|||||||
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("保存手改 / 外部 AI 版本,交这一版时关联到它", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
const task = makeTask("T")
|
||||||
|
const stu = await client("stu")
|
||||||
|
const edit = await stu("POST", "/api/prompt/versions", { task_id: task, html: "<p>1", css: "", js: "" })
|
||||||
|
expect(edit.json.source).toBe("edit")
|
||||||
|
const ext = await stu("POST", "/api/prompt/versions", {
|
||||||
|
task_id: task,
|
||||||
|
html: "<p>2",
|
||||||
|
css: "",
|
||||||
|
js: "",
|
||||||
|
prompt: "用外部 AI 做的",
|
||||||
|
})
|
||||||
|
expect(ext.json.source).toBe("manual")
|
||||||
|
const history = await stu("GET", `/api/prompt/history/${task}`)
|
||||||
|
expect(history.json.map((h: any) => [h.source, h.prompt])).toEqual([
|
||||||
|
["edit", "(手动修改了代码)"],
|
||||||
|
["manual", "用外部 AI 做的"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const sub = await stu("POST", "/api/submission/", { task_id: task, html: "<p>1", message_id: edit.json.message_id })
|
||||||
|
const linked = get(`SELECT submission_id FROM prompt_message WHERE id = $id`, { id: edit.json.message_id })
|
||||||
|
expect(linked.submission_id).toBe(sub.json.id.replaceAll("-", ""))
|
||||||
|
// 同一个版本再交一次,更新原来的提交,不新建
|
||||||
|
await stu("POST", "/api/submission/", { task_id: task, html: "<p>1!", message_id: edit.json.message_id })
|
||||||
|
expect(all(`SELECT html FROM submission_submission`)).toEqual([{ html: "<p>1!" }])
|
||||||
|
})
|
||||||
|
|
||||||
test("删除一轮对话:连带提问和关联提交;被评过分的不能删", async () => {
|
test("删除一轮对话:连带提问和关联提交;被评过分的不能删", async () => {
|
||||||
makeUser("stu")
|
makeUser("stu")
|
||||||
makeUser("other")
|
makeUser("other")
|
||||||
@@ -465,3 +493,74 @@ describe("任务素材", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("同学作品与批改", () => {
|
||||||
|
test("学生只能看自己的、作品展里的、本班同学在自己也交过的题上的提交", async () => {
|
||||||
|
const me = makeUser("me", "normal", "c1")
|
||||||
|
const mate = makeUser("mate", "normal", "c1")
|
||||||
|
const other = makeUser("other", "normal", "c2")
|
||||||
|
const task = makeTask("T")
|
||||||
|
const mateSub = makeSubmission(mate, task)
|
||||||
|
const otherSub = makeSubmission(other, task)
|
||||||
|
const stu = await client("me")
|
||||||
|
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(mateSub)}`)).status).toBe(403)
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(mateSub)}/prompt-chain`)).status).toBe(403)
|
||||||
|
const locked = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(locked.json).toEqual({ locked: true, items: [] })
|
||||||
|
|
||||||
|
makeSubmission(me, task)
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(mateSub)}`)).status).toBe(200)
|
||||||
|
// 外班的仍然看不到
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(otherSub)}`)).status).toBe(403)
|
||||||
|
// 列表里只有自己的
|
||||||
|
const list = await stu("GET", "/api/submission/")
|
||||||
|
expect(list.json.items.map((s: any) => s.username)).toEqual(["me"])
|
||||||
|
expect((await stu("GET", `/api/submission/by-user-task?user_id=${mate}&task_id=${task}`)).status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("同学作品打分前匿名,打完分才显示作者", async () => {
|
||||||
|
const me = makeUser("me", "normal", "c1")
|
||||||
|
const mate = makeUser("mate", "normal", "c1")
|
||||||
|
makeUser("other", "normal", "c2")
|
||||||
|
const task = makeTask("T")
|
||||||
|
makeSubmission(me, task)
|
||||||
|
const mateSub = makeSubmission(mate, task)
|
||||||
|
const stu = await client("me")
|
||||||
|
|
||||||
|
let peers = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(peers.json.items).toHaveLength(1)
|
||||||
|
expect(peers.json.items[0]).toMatchObject({ id: uuid(mateSub), username: null, score: null, my_score: 0 })
|
||||||
|
|
||||||
|
const rated = await stu("PUT", `/api/submission/${uuid(mateSub)}/score`, { score: 4 })
|
||||||
|
expect(rated.json.username).toBe("mate")
|
||||||
|
peers = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
|
expect(peers.json.items[0]).toMatchObject({ username: "mate", my_score: 4 })
|
||||||
|
|
||||||
|
const random = await (await client("other"))("GET", "/api/submission/random-for-rating")
|
||||||
|
expect(random.json.username).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("老师按班级 × 任务看作品墙;课上展示用 flag", async () => {
|
||||||
|
makeUser("t", "admin")
|
||||||
|
const a = makeUser("a", "normal", "c1")
|
||||||
|
makeUser("b", "normal", "c1")
|
||||||
|
const task = makeTask("T")
|
||||||
|
makeSubmission(a, task, 0, "2026-01-01 00:00:00.000000")
|
||||||
|
const latest = makeSubmission(a, task, 0, "2026-01-02 00:00:00.000000")
|
||||||
|
const teacher = await client("t")
|
||||||
|
|
||||||
|
expect((await (await client("a"))("GET", `/api/submission/review/class?classname=c1&task_id=${task}`)).status).toBe(403)
|
||||||
|
const wall = await teacher("GET", `/api/submission/review/class?classname=c1&task_id=${task}`)
|
||||||
|
expect(wall.json.map((s: any) => [s.username, s.submission?.id ?? null, s.submission?.submit_count])).toEqual([
|
||||||
|
["a", uuid(latest), 2],
|
||||||
|
["b", null, undefined],
|
||||||
|
])
|
||||||
|
const progress = await teacher("GET", "/api/submission/review/progress?classname=c1")
|
||||||
|
expect(progress.json).toEqual({ student_count: 2, submitted: { [task]: 1 } })
|
||||||
|
const options = await teacher("GET", "/api/submission/review/options")
|
||||||
|
expect(options.json.classes).toEqual(["c1"])
|
||||||
|
|
||||||
|
expect((await teacher("PUT", `/api/submission/${uuid(latest)}/flag`, { flag: "present" })).json.flag).toBe("present")
|
||||||
|
})
|
||||||
|
})
|
||||||
+32
-1
@@ -8,9 +8,12 @@ import { client, freshDb, makeTask, makeUser } from "./helpers"
|
|||||||
|
|
||||||
// 假的模型接口:每段之间停一下,方便测试中途断开
|
// 假的模型接口:每段之间停一下,方便测试中途断开
|
||||||
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
|
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
|
||||||
|
// 最近一次发给模型的请求
|
||||||
|
let lastRequest: any = null
|
||||||
const llm = Bun.serve({
|
const llm = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch() {
|
async fetch(req) {
|
||||||
|
lastRequest = await req.json()
|
||||||
const enc = new TextEncoder()
|
const enc = new TextEncoder()
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(ctrl) {
|
async start(ctrl) {
|
||||||
@@ -126,6 +129,34 @@ describe("WebSocket 代码生成", () => {
|
|||||||
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("手改的版本进入对话,AI 下一轮在它上面改", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
const task = makeTask("挑战")
|
||||||
|
const stu = await client("stu")
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${stu.tokens.access_token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await c.next("complete")
|
||||||
|
|
||||||
|
const saved = await stu("POST", "/api/prompt/versions", {
|
||||||
|
task_id: task,
|
||||||
|
html: "<p>我改过的</p>",
|
||||||
|
css: "p { color: red }",
|
||||||
|
js: "",
|
||||||
|
})
|
||||||
|
expect(saved.json.source).toBe("edit")
|
||||||
|
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "再加个按钮" }))
|
||||||
|
await c.next("complete")
|
||||||
|
const sent = lastRequest.messages.map((m: any) => `${m.role}:${m.content}`)
|
||||||
|
// system、第一轮、手改那一轮、这一轮的提问
|
||||||
|
expect(sent.at(-3)).toBe("user:(手动修改了代码)")
|
||||||
|
expect(sent.at(-2)).toContain("<p>我改过的</p>")
|
||||||
|
expect(sent.at(-2)).toContain("p { color: red }")
|
||||||
|
expect(sent.at(-1)).toBe("user:再加个按钮")
|
||||||
|
c.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
test("token 无效或任务不存在时握手失败", async () => {
|
test("token 无效或任务不存在时握手失败", async () => {
|
||||||
const { task, token } = await setup()
|
const { task, token } = await setup()
|
||||||
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
||||||
|
|||||||
Reference in new issue
Block a user