Compare commits
76 Commits
83537face1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 983e87403c | |||
| 5aaba42068 | |||
| 5ecf8caf83 | |||
| 2b216878ca | |||
| 77aca640ac | |||
| 9a8e5ad48e | |||
| 4af9ae90c9 | |||
| f63f7cbbce | |||
| 21e3a7f39b | |||
| f255367b08 | |||
| edbf66874b | |||
| e8992edabc | |||
| f3eed84f7c | |||
| b5e0421fd4 | |||
| c0084462eb | |||
| 5fced6b4c2 | |||
| 2e6e3aacec | |||
| 64dc1c9234 | |||
| 2abf95888b | |||
| eff635fb49 | |||
| 3136be2df7 | |||
| efecef4e98 | |||
| f025ebfa2e | |||
| 375a78b852 | |||
| dd249c8753 | |||
| 7af5e3117d | |||
| 45b40f13ad | |||
| 567da331f4 | |||
| e9781fdada | |||
| 266f042fb2 | |||
| 6591ca6574 | |||
| ed8e016e30 | |||
| b7f3c9c96d | |||
| 2fa90738e7 | |||
| 35ee7d69dd | |||
| b22eb85f3a | |||
| 1d01415771 | |||
| 36fcf8427f | |||
| 754eea951c | |||
| 58f462bb3b | |||
| 3697078fc3 | |||
| 17e816761f | |||
| 93c816f3ab | |||
| 8a594446b6 | |||
| 134e2e8713 | |||
| d628e94519 | |||
| 96e5bfd959 | |||
| 74bdef2236 | |||
| b4c73411d6 | |||
| eda968c250 | |||
| 7f0bfd67fa | |||
| 501f314aff | |||
| 6fb3bc0198 | |||
| e4359e8093 | |||
| 40f361cf91 | |||
| f2ddd4bc93 | |||
| 2179ff1daa | |||
| a7b1a58449 | |||
| 8d510e19bb | |||
| e539f9450a | |||
| 91e1b2b48b | |||
| f16104b011 | |||
| 85e1681017 | |||
| 3c721224c8 | |||
| 67b1baede8 | |||
| a97a40475a | |||
| 1df211c760 | |||
| e9596e28a3 | |||
| 8db17aba12 | |||
| 9ff2edeecf | |||
| 35dbfda52c | |||
| ecce21aaaf | |||
| 951e53c1dd | |||
| f26b06877c | |||
| 1744c405a5 | |||
| 5f95b88914 |
2
.github/workflows/deploy.yaml
vendored
2
.github/workflows/deploy.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm install
|
||||||
- run: npm run ${{ matrix.build_command }}
|
- run: npm run ${{ matrix.build_command }}
|
||||||
env:
|
env:
|
||||||
CI: false
|
CI: false
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
.worktrees/
|
||||||
*.local
|
*.local
|
||||||
components.d.ts
|
components.d.ts
|
||||||
|
|
||||||
|
|||||||
2342
package-lock.json
generated
2342
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -12,30 +12,31 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"axios": "^1.13.3",
|
"axios": "^1.16.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.2",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.9.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^17.0.1",
|
"marked": "^18.0.0",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
"marked-code-preview": "^1.3.7",
|
"marked-code-preview": "^1.3.7",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.4",
|
||||||
"md-editor-v3": "^6.3.1",
|
"md-editor-v3": "^6.5.0",
|
||||||
"naive-ui": "^2.43.2",
|
"naive-ui": "^2.44.1",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.33",
|
||||||
"vue-codemirror": "^6.1.1",
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "^5.0.0",
|
||||||
"@rsbuild/core": "^1.7.2",
|
"@rsbuild/core": "^2.0.3",
|
||||||
"@rsbuild/plugin-vue": "^1.2.3",
|
"@rsbuild/plugin-vue": "^1.2.7",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"prettier": "^3.8.1",
|
"core-js": "^3.49.0",
|
||||||
"typescript": "^5.9.3",
|
"prettier": "^3.8.3",
|
||||||
"unplugin-vue-components": "^31.0.0"
|
"typescript": "^6.0.3",
|
||||||
|
"unplugin-vue-components": "^32.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/App.vue
12
src/App.vue
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { dateZhCN, zhCN } from "naive-ui"
|
import { dateZhCN, zhCN } from "naive-ui"
|
||||||
|
import type { GlobalThemeOverrides } from "naive-ui"
|
||||||
import Login from "./components/Login.vue"
|
import Login from "./components/Login.vue"
|
||||||
import { onMounted, watch } from "vue"
|
import { onMounted, watch } from "vue"
|
||||||
import { Account } from "./api"
|
import { Account } from "./api"
|
||||||
@@ -7,6 +8,16 @@ import { authed, user } from "./store/user"
|
|||||||
import { STORAGE_KEY } from "./utils/const"
|
import { STORAGE_KEY } from "./utils/const"
|
||||||
import hljs from "highlight.js/lib/core"
|
import hljs from "highlight.js/lib/core"
|
||||||
|
|
||||||
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
|
common: {
|
||||||
|
borderRadius: "6px",
|
||||||
|
borderRadiusSmall: "4px",
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await Account.getMyProfile()
|
const data = await Account.getMyProfile()
|
||||||
@@ -33,6 +44,7 @@ watch(authed, (v) => {
|
|||||||
:locale="zhCN"
|
:locale="zhCN"
|
||||||
:date-locale="dateZhCN"
|
:date-locale="dateZhCN"
|
||||||
:hljs="hljs"
|
:hljs="hljs"
|
||||||
|
:theme-overrides="themeOverrides"
|
||||||
>
|
>
|
||||||
<n-modal-provider>
|
<n-modal-provider>
|
||||||
<n-message-provider :max="1">
|
<n-message-provider :max="1">
|
||||||
|
|||||||
231
src/api.ts
231
src/api.ts
@@ -6,7 +6,20 @@ import type {
|
|||||||
FlagType,
|
FlagType,
|
||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PromptMessage,
|
PromptMessage,
|
||||||
|
PromptHistoryItem,
|
||||||
TaskStatsOut,
|
TaskStatsOut,
|
||||||
|
TaskAsset,
|
||||||
|
AwardSection,
|
||||||
|
AwardManageIn,
|
||||||
|
AwardManageOut,
|
||||||
|
AwardItemIn,
|
||||||
|
AwardItemUpdateIn,
|
||||||
|
AwardItemManageOut,
|
||||||
|
GradebookOut,
|
||||||
|
GradebookQuery,
|
||||||
|
ShowcaseSubmissionLookupOut,
|
||||||
|
ShowcaseDetail,
|
||||||
|
PromptRound,
|
||||||
} from "./utils/type"
|
} from "./utils/type"
|
||||||
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
||||||
|
|
||||||
@@ -157,14 +170,16 @@ export const Submission = {
|
|||||||
html?: string
|
html?: string
|
||||||
css?: string
|
css?: string
|
||||||
js?: string
|
js?: string
|
||||||
conversationId?: string
|
prompt?: string
|
||||||
},
|
},
|
||||||
|
messageId?: number,
|
||||||
) {
|
) {
|
||||||
const { conversationId, ...rest } = code
|
const { prompt, ...rest } = code
|
||||||
const data = {
|
const data = {
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
...rest,
|
...rest,
|
||||||
conversation_id: conversationId || null,
|
prompt: prompt || null,
|
||||||
|
message_id: messageId ?? null,
|
||||||
}
|
}
|
||||||
const res = await http.post("/submission/", data)
|
const res = await http.post("/submission/", data)
|
||||||
return res.data
|
return res.data
|
||||||
@@ -176,12 +191,12 @@ export const Submission = {
|
|||||||
username?: string
|
username?: string
|
||||||
user_id?: number
|
user_id?: number
|
||||||
flag?: string | null
|
flag?: string | null
|
||||||
|
zone?: string
|
||||||
task_id?: number
|
task_id?: number
|
||||||
task_type?: string
|
task_type?: string
|
||||||
score_min?: number
|
score_min?: number
|
||||||
score_max_exclusive?: number
|
score_max_exclusive?: number
|
||||||
score_lt_threshold?: number
|
score_lt_threshold?: number
|
||||||
nominated?: boolean
|
|
||||||
ordering?: string
|
ordering?: string
|
||||||
grouped?: boolean
|
grouped?: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -203,11 +218,20 @@ export const Submission = {
|
|||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getPromptChain(id: string): Promise<PromptRound[]> {
|
||||||
|
const res = await http.get(`/submission/${id}/prompt-chain`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
const res = await http.delete("/submission/" + id)
|
const res = await http.delete("/submission/" + id)
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async incrementView(id: string) {
|
||||||
|
await http.post(`/submission/${id}/view`)
|
||||||
|
},
|
||||||
|
|
||||||
async updateScore(id: string, score: number) {
|
async updateScore(id: string, score: number) {
|
||||||
const res = await http.put(`/submission/${id}/score`, { score })
|
const res = await http.put(`/submission/${id}/score`, { score })
|
||||||
return res.data
|
return res.data
|
||||||
@@ -223,11 +247,6 @@ export const Submission = {
|
|||||||
return res.data as { cleared: number }
|
return res.data as { cleared: number }
|
||||||
},
|
},
|
||||||
|
|
||||||
async nominate(id: string) {
|
|
||||||
const res = await http.put(`/submission/${id}/nominate`)
|
|
||||||
return res.data as { nominated: boolean }
|
|
||||||
},
|
|
||||||
|
|
||||||
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
|
||||||
@@ -236,6 +255,50 @@ export const Submission = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gradebookParams(query: GradebookQuery) {
|
||||||
|
const params: Record<string, string | boolean> = {
|
||||||
|
classname: query.classname,
|
||||||
|
}
|
||||||
|
if (query.task_type) params.task_type = query.task_type
|
||||||
|
if (query.username) params.username = query.username
|
||||||
|
if (query.include_all_tasks) params.include_all_tasks = true
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromDisposition(
|
||||||
|
disposition: string | undefined,
|
||||||
|
fallback: string,
|
||||||
|
) {
|
||||||
|
const match = disposition?.match(/filename\*=UTF-8''([^;]+)/)
|
||||||
|
return match ? decodeURIComponent(match[1]) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Gradebook = {
|
||||||
|
async get(query: GradebookQuery): Promise<GradebookOut> {
|
||||||
|
const res = await http.get("/submission/gradebook/", {
|
||||||
|
params: gradebookParams(query),
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadCsv(query: GradebookQuery) {
|
||||||
|
const res = await http.get("/submission/gradebook/export/", {
|
||||||
|
params: gradebookParams(query),
|
||||||
|
responseType: "blob",
|
||||||
|
})
|
||||||
|
const blob = new Blob([res.data], { type: "text/csv;charset=utf-8" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filenameFromDisposition(
|
||||||
|
res.headers["content-disposition"],
|
||||||
|
`gradebook-${query.classname}.csv`,
|
||||||
|
)
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const Prompt = {
|
export const Prompt = {
|
||||||
async listConversations(taskId?: number, userId?: number) {
|
async listConversations(taskId?: number, userId?: number) {
|
||||||
const params: Record<string, number> = {}
|
const params: Record<string, number> = {}
|
||||||
@@ -244,6 +307,11 @@ export const Prompt = {
|
|||||||
return (await http.get("/prompt/conversations/", { params })).data
|
return (await http.get("/prompt/conversations/", { params })).data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listHistory(taskId: number): Promise<PromptHistoryItem[]> {
|
||||||
|
const res = await http.get(`/prompt/history/${taskId}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
async getMessages(conversationId: string): Promise<PromptMessage[]> {
|
async getMessages(conversationId: string): Promise<PromptMessage[]> {
|
||||||
return (
|
return (
|
||||||
await http.get<PromptMessage[]>(
|
await http.get<PromptMessage[]>(
|
||||||
@@ -251,6 +319,22 @@ export const Prompt = {
|
|||||||
)
|
)
|
||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMessagesByUserTask(
|
||||||
|
taskId: number,
|
||||||
|
userId: number,
|
||||||
|
): Promise<PromptMessage[]> {
|
||||||
|
const convs = await this.listConversations(taskId, userId)
|
||||||
|
if (!convs.length) return []
|
||||||
|
return this.getMessages(convs[0].id)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMessagePair(
|
||||||
|
messageId: number,
|
||||||
|
): Promise<{ deleted: boolean; submission_deleted: boolean }> {
|
||||||
|
const res = await http.delete(`/prompt/messages/${messageId}/pair`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Helper = {
|
export const Helper = {
|
||||||
@@ -263,3 +347,132 @@ export const Helper = {
|
|||||||
return !!res.data.url ? res.data.url : ""
|
return !!res.data.url ? res.data.url : ""
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Showcase = {
|
||||||
|
async list(): Promise<AwardSection[]> {
|
||||||
|
const res = await http.get("/submission/showcase/")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listManageAwards(): Promise<AwardManageOut[]> {
|
||||||
|
const res = await http.get("/submission/showcase/manage/awards")
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAward(payload: AwardManageIn): Promise<AwardManageOut> {
|
||||||
|
const res = await http.post("/submission/showcase/manage/awards", payload)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAward(
|
||||||
|
id: number,
|
||||||
|
payload: AwardManageIn,
|
||||||
|
): Promise<AwardManageOut> {
|
||||||
|
const res = await http.put(
|
||||||
|
`/submission/showcase/manage/awards/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAward(id: number) {
|
||||||
|
const res = await http.delete(`/submission/showcase/manage/awards/${id}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAwardItems(id: number): Promise<AwardItemManageOut[]> {
|
||||||
|
const res = await http.get(`/submission/showcase/manage/awards/${id}/items`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async findSubmissionForAward(
|
||||||
|
submissionId: string,
|
||||||
|
): Promise<ShowcaseSubmissionLookupOut> {
|
||||||
|
const res = await http.get(
|
||||||
|
`/submission/showcase/manage/submissions/${submissionId}`,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async addAwardItem(
|
||||||
|
id: number,
|
||||||
|
payload: AwardItemIn,
|
||||||
|
): Promise<AwardItemManageOut> {
|
||||||
|
const res = await http.post(
|
||||||
|
`/submission/showcase/manage/awards/${id}/items`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAwardItem(
|
||||||
|
itemId: number,
|
||||||
|
payload: AwardItemUpdateIn,
|
||||||
|
): Promise<AwardItemManageOut> {
|
||||||
|
const res = await http.put(
|
||||||
|
`/submission/showcase/manage/items/${itemId}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAwardItem(itemId: number) {
|
||||||
|
const res = await http.delete(`/submission/showcase/manage/items/${itemId}`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDetail(submissionId: string): Promise<ShowcaseDetail> {
|
||||||
|
const res = await http.get(`/submission/showcase/${submissionId}/`)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPromptChain(submissionId: string): Promise<PromptRound[]> {
|
||||||
|
const res = await http.get(
|
||||||
|
`/submission/showcase/${submissionId}/prompt-chain/`,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskAssets = {
|
||||||
|
async listChallenge(display: number): Promise<TaskAsset[]> {
|
||||||
|
return (await http.get<TaskAsset[]>(`/assets/challenge/${display}`)).data
|
||||||
|
},
|
||||||
|
async uploadChallenge(
|
||||||
|
display: number,
|
||||||
|
name: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<TaskAsset> {
|
||||||
|
const form = new window.FormData()
|
||||||
|
form.append("name", name)
|
||||||
|
form.append("file", file)
|
||||||
|
return (
|
||||||
|
await http.post<TaskAsset>(`/assets/challenge/${display}`, form, {
|
||||||
|
headers: { "content-type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
},
|
||||||
|
async deleteChallenge(display: number, name: string) {
|
||||||
|
return (await http.delete(`/assets/challenge/${display}/${name}`)).data
|
||||||
|
},
|
||||||
|
async listTutorial(display: number): Promise<TaskAsset[]> {
|
||||||
|
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
|
||||||
|
},
|
||||||
|
async uploadTutorial(
|
||||||
|
display: number,
|
||||||
|
name: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<TaskAsset> {
|
||||||
|
const form = new window.FormData()
|
||||||
|
form.append("name", name)
|
||||||
|
form.append("file", file)
|
||||||
|
return (
|
||||||
|
await http.post<TaskAsset>(`/assets/tutorial/${display}`, form, {
|
||||||
|
headers: { "content-type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
},
|
||||||
|
async deleteTutorial(display: number, name: string) {
|
||||||
|
return (await http.delete(`/assets/tutorial/${display}/${name}`)).data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,50 +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"
|
|
||||||
@click="select(item)"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
{{ item.title }}
|
|
||||||
</template>
|
|
||||||
<template #header-extra>
|
|
||||||
<n-tag type="warning" size="small">{{ item.score }}分</n-tag>
|
|
||||||
</template>
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-tabs
|
|
||||||
:value="tab"
|
|
||||||
pane-class="pane"
|
|
||||||
style="height: 100%"
|
|
||||||
type="card"
|
|
||||||
@update:value="changeTab"
|
|
||||||
>
|
|
||||||
<n-tab-pane name="html" tab="HTML">
|
|
||||||
<template #tab>
|
|
||||||
<n-flex align="center">
|
|
||||||
<Icon :width="20" icon="skill-icons:html"></Icon>
|
|
||||||
<span>HTML</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<Editor v-model:value="html" :font-size="size" language="html" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="css" tab="CSS">
|
|
||||||
<template #tab>
|
|
||||||
<n-flex align="center">
|
|
||||||
<Icon :width="20" icon="skill-icons:css"></Icon>
|
|
||||||
<span>CSS</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<Editor v-model:value="css" :font-size="size" language="css" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="js" tab="JS">
|
|
||||||
<template #tab>
|
|
||||||
<n-flex align="center">
|
|
||||||
<Icon :width="20" icon="skill-icons:javascript"></Icon>
|
|
||||||
<span>JS</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<Editor v-model:value="js" :font-size="size" language="js" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="actions" tab="选项">
|
|
||||||
<template #tab>
|
|
||||||
<n-flex align="center">
|
|
||||||
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
|
|
||||||
<span>选项</span>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<n-flex class="wrapper" vertical>
|
|
||||||
<n-flex align="center">
|
|
||||||
<span class="label">重置</span>
|
|
||||||
<n-button @click="reset('html')">HTML</n-button>
|
|
||||||
<n-button @click="reset('css')">CSS</n-button>
|
|
||||||
<n-button @click="reset('js')">JS</n-button>
|
|
||||||
</n-flex>
|
|
||||||
<n-flex align="center">
|
|
||||||
<span class="label">字号</span>
|
|
||||||
<n-flex align="center">
|
|
||||||
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
|
||||||
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
|
|
||||||
调小
|
|
||||||
</n-button>
|
|
||||||
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
|
||||||
调大
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
<n-flex align="center">
|
|
||||||
<span class="label">预加载</span>
|
|
||||||
<n-tag type="success">Normalize.css</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
</n-tab-pane>
|
|
||||||
<template #suffix>
|
|
||||||
<Toolbar
|
|
||||||
:submit-loading="submitLoading"
|
|
||||||
@format="format"
|
|
||||||
@submit="formatAndSubmit"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</n-tabs>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import prettier from "prettier/standalone"
|
|
||||||
import * as htmlParser from "prettier/parser-html"
|
|
||||||
import * as cssParser from "prettier/parser-postcss"
|
|
||||||
import * as babelParser from "prettier/parser-babel"
|
|
||||||
import * as estreeParser from "prettier/plugins/estree"
|
|
||||||
import Editor from "./Editor.vue"
|
|
||||||
import Toolbar from "./Toolbar.vue"
|
|
||||||
import { html, css, js, tab, size, reset } from "../store/editors"
|
|
||||||
import { taskId } from "../store/task"
|
|
||||||
import { conversationId } from "../store/prompt"
|
|
||||||
import { Submission } from "../api"
|
|
||||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
|
||||||
import { h, ref } from "vue"
|
|
||||||
|
|
||||||
const dialog = useDialog()
|
|
||||||
const message = useMessage()
|
|
||||||
const submitLoading = ref(false)
|
|
||||||
|
|
||||||
function changeTab(name: string) {
|
|
||||||
tab.value = name
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeSize(num: number) {
|
|
||||||
size.value = num
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formatCode() {
|
|
||||||
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
|
|
||||||
prettier.format(html.value, {
|
|
||||||
parser: "html",
|
|
||||||
//@ts-ignore
|
|
||||||
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
|
||||||
tabWidth: 4,
|
|
||||||
}),
|
|
||||||
prettier.format(css.value, {
|
|
||||||
parser: "css",
|
|
||||||
plugins: [cssParser],
|
|
||||||
tabWidth: 4,
|
|
||||||
}),
|
|
||||||
prettier.format(js.value, {
|
|
||||||
parser: "babel",
|
|
||||||
//@ts-ignore
|
|
||||||
plugins: [babelParser, estreeParser],
|
|
||||||
tabWidth: 2,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
html.value = htmlFormatted
|
|
||||||
css.value = cssFormatted
|
|
||||||
js.value = jsFormatted
|
|
||||||
}
|
|
||||||
|
|
||||||
async function format() {
|
|
||||||
try {
|
|
||||||
await formatCode()
|
|
||||||
} catch (err: any) {
|
|
||||||
dialog.error({
|
|
||||||
title: "格式化失败",
|
|
||||||
content: () => h(NCode, { code: err.message }),
|
|
||||||
style: { width: "auto" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doSubmit() {
|
|
||||||
try {
|
|
||||||
await Submission.create(taskId.value, {
|
|
||||||
html: html.value,
|
|
||||||
css: css.value,
|
|
||||||
js: js.value,
|
|
||||||
conversationId: conversationId.value || undefined,
|
|
||||||
})
|
|
||||||
message.success("提交成功")
|
|
||||||
} catch (err) {
|
|
||||||
message.error("提交失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formatAndSubmit() {
|
|
||||||
submitLoading.value = true
|
|
||||||
try {
|
|
||||||
await formatCode()
|
|
||||||
await doSubmit()
|
|
||||||
} catch (err: any) {
|
|
||||||
dialog.warning({
|
|
||||||
title: "代码整理失败",
|
|
||||||
content: () => h(NCode, { code: err.message }),
|
|
||||||
positiveText: "忽略并提交",
|
|
||||||
negativeText: "取消",
|
|
||||||
style: { width: "auto" },
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
await doSubmit()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
submitLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.pane {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container">
|
|
||||||
<n-flex align="center" justify="space-between" class="title">
|
|
||||||
<n-flex align="center">
|
|
||||||
<Icon
|
|
||||||
:icon="
|
|
||||||
taskTab === TASK_TYPE.Tutorial
|
|
||||||
? 'twemoji:books'
|
|
||||||
: 'twemoji:crossed-swords'
|
|
||||||
"
|
|
||||||
:width="20"
|
|
||||||
></Icon>
|
|
||||||
<n-tabs
|
|
||||||
style="width: 150px"
|
|
||||||
type="segment"
|
|
||||||
animated
|
|
||||||
:value="taskTab"
|
|
||||||
@update:value="changeTab"
|
|
||||||
>
|
|
||||||
<n-tab name="tutorial" tab="教程"></n-tab>
|
|
||||||
<n-tab name="challenge" tab="挑战"></n-tab>
|
|
||||||
</n-tabs>
|
|
||||||
<template v-if="!hideNav">
|
|
||||||
<n-button
|
|
||||||
text
|
|
||||||
@click="tutorialRef?.prev()"
|
|
||||||
:disabled="tutorialRef?.prevDisabled()"
|
|
||||||
>
|
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
text
|
|
||||||
@click="tutorialRef?.next()"
|
|
||||||
:disabled="tutorialRef?.nextDisabled()"
|
|
||||||
>
|
|
||||||
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
</n-flex>
|
|
||||||
<n-flex>
|
|
||||||
<n-button v-if="roleSuper" text @click="statsModal = true">
|
|
||||||
<Icon :width="16" icon="lucide:bar-chart-2"></Icon>
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
v-if="authed"
|
|
||||||
text
|
|
||||||
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
|
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:list"></Icon>
|
|
||||||
</n-button>
|
|
||||||
<n-button text v-if="roleSuper" @click="edit">
|
|
||||||
<Icon :width="16" icon="lucide:edit"></Icon>
|
|
||||||
</n-button>
|
|
||||||
<n-button text @click="$emit('hide')">
|
|
||||||
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
<Tutorial v-if="taskTab === TASK_TYPE.Tutorial" ref="tutorialRef" />
|
|
||||||
<Challenge v-else />
|
|
||||||
</div>
|
|
||||||
<TaskStatsModal v-model:show="statsModal" :task-id="taskId" />
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { computed, onMounted, ref } from "vue"
|
|
||||||
import { step } from "../store/tutorial"
|
|
||||||
import { authed, roleAdmin, roleSuper } from "../store/user"
|
|
||||||
import { taskTab, taskId, challengeDisplay } from "../store/task"
|
|
||||||
import { useRoute, useRouter } from "vue-router"
|
|
||||||
import { TASK_TYPE } from "../utils/const"
|
|
||||||
import Challenge from "./Challenge.vue"
|
|
||||||
import Tutorial from "./Tutorial.vue"
|
|
||||||
import TaskStatsModal from "./TaskStatsModal.vue"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const tutorialRef = ref<InstanceType<typeof Tutorial>>()
|
|
||||||
const statsModal = ref(false)
|
|
||||||
|
|
||||||
defineEmits(["hide"])
|
|
||||||
|
|
||||||
const hideNav = computed(
|
|
||||||
() =>
|
|
||||||
taskTab.value !== TASK_TYPE.Tutorial ||
|
|
||||||
(tutorialRef.value?.tutorialIds?.length ?? 0) <= 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
function changeTab(v: TASK_TYPE) {
|
|
||||||
taskTab.value = v
|
|
||||||
if (v === TASK_TYPE.Tutorial) {
|
|
||||||
router.push(
|
|
||||||
step.value
|
|
||||||
? { name: "home-tutorial", params: { display: step.value } }
|
|
||||||
: { name: "home-tutorial-list" },
|
|
||||||
)
|
|
||||||
} else if (v === TASK_TYPE.Challenge) {
|
|
||||||
challengeDisplay.value = 0
|
|
||||||
router.push({ name: "home-challenge-list" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function edit() {
|
|
||||||
const name =
|
|
||||||
taskTab.value === TASK_TYPE.Tutorial
|
|
||||||
? "tutorial-editor"
|
|
||||||
: "challenge-editor"
|
|
||||||
const display =
|
|
||||||
taskTab.value === TASK_TYPE.Tutorial ? step.value : challengeDisplay.value
|
|
||||||
router.push({ name, params: { display } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
const name = route.name as string
|
|
||||||
if (name.startsWith("home-tutorial")) {
|
|
||||||
taskTab.value = TASK_TYPE.Tutorial
|
|
||||||
if (route.params.display) step.value = Number(route.params.display)
|
|
||||||
} else if (name.startsWith("home-challenge")) {
|
|
||||||
taskTab.value = TASK_TYPE.Challenge
|
|
||||||
if (route.params.display)
|
|
||||||
challengeDisplay.value = Number(route.params.display)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(init)
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
height: 43px;
|
|
||||||
padding: 0 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid rgb(239, 239, 245);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
182
src/components/ai/ExternalAIPanel.vue
Normal file
182
src/components/ai/ExternalAIPanel.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="external-panel">
|
||||||
|
<div class="content-area">
|
||||||
|
<div class="field-label">提示词</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="promptText"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||||
|
placeholder="粘贴你发给外部 AI 的提示词..."
|
||||||
|
/>
|
||||||
|
<div class="code-field">
|
||||||
|
<div class="field-label" style="margin-top: 12px">完整的代码</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="rawCode"
|
||||||
|
type="textarea"
|
||||||
|
class="code-input"
|
||||||
|
placeholder="粘贴完整的前端代码..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="splitResult" class="split-result">
|
||||||
|
<n-tag size="small" type="success"
|
||||||
|
>HTML · {{ splitResult.html.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
<n-tag size="small" type="info"
|
||||||
|
>CSS · {{ splitResult.css.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
<n-tag size="small" type="warning"
|
||||||
|
>JS · {{ splitResult.js.length }} 字符</n-tag
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<n-button :disabled="!rawCode.trim()" @click="applyPreview"
|
||||||
|
>应用预览</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!splitResult || !promptText.trim()"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { html, css, js } from "../../store/editors"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
|
||||||
|
const props = defineProps<{ taskId: number }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: []
|
||||||
|
}>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const promptText = ref("")
|
||||||
|
const rawCode = ref("")
|
||||||
|
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
watch(rawCode, () => {
|
||||||
|
splitResult.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
function splitHtml(raw: string): { html: string; css: string; js: string } {
|
||||||
|
let result = raw
|
||||||
|
const cssBlocks: string[] = []
|
||||||
|
const jsBlocks: string[] = []
|
||||||
|
|
||||||
|
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
|
||||||
|
cssBlocks.push(content.trim())
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
result = result.replace(
|
||||||
|
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||||
|
(_, content) => {
|
||||||
|
jsBlocks.push(content.trim())
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: result.trim(),
|
||||||
|
css: cssBlocks.join("\n\n"),
|
||||||
|
js: jsBlocks.join("\n\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreview() {
|
||||||
|
const result = splitHtml(rawCode.value)
|
||||||
|
splitResult.value = result
|
||||||
|
html.value = result.html
|
||||||
|
css.value = result.css
|
||||||
|
js.value = result.js
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!splitResult.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await Submission.create(props.taskId, {
|
||||||
|
html: splitResult.value.html,
|
||||||
|
css: splitResult.value.css,
|
||||||
|
js: splitResult.value.js,
|
||||||
|
prompt: promptText.value.trim(),
|
||||||
|
})
|
||||||
|
emit("submitted")
|
||||||
|
message.success("提交成功")
|
||||||
|
promptText.value = ""
|
||||||
|
rawCode.value = ""
|
||||||
|
splitResult.value = null
|
||||||
|
} catch {
|
||||||
|
message.error("提交失败,请重试")
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.external-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-field {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input :deep(.n-input__textarea) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input :deep(.n-input__textarea-el) {
|
||||||
|
height: 100%;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-result {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
316
src/components/ai/PromptHistoryPanel.vue
Normal file
316
src/components/ai/PromptHistoryPanel.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<template>
|
||||||
|
<div class="history-panel">
|
||||||
|
<n-flex
|
||||||
|
class="history-toolbar"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
:wrap="false"
|
||||||
|
>
|
||||||
|
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
quaternary
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:loading="loading"
|
||||||
|
aria-label="刷新历史对话"
|
||||||
|
@click="load(true)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:refresh-cw" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
刷新历史对话
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin v-if="loading" class="state" />
|
||||||
|
<n-empty
|
||||||
|
v-else-if="!items.length"
|
||||||
|
class="state"
|
||||||
|
description="暂无历史对话"
|
||||||
|
/>
|
||||||
|
<n-scrollbar v-else class="history-scrollbar">
|
||||||
|
<n-flex vertical :size="12" class="history-list">
|
||||||
|
<n-card
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.assistant_message_id"
|
||||||
|
class="history-card"
|
||||||
|
:class="{
|
||||||
|
'is-selected':
|
||||||
|
selectedAssistantMessageId === item.assistant_message_id,
|
||||||
|
}"
|
||||||
|
size="small"
|
||||||
|
:bordered="true"
|
||||||
|
hoverable
|
||||||
|
:embedded="selectedAssistantMessageId === item.assistant_message_id"
|
||||||
|
:content-style="{ padding: 0 }"
|
||||||
|
@click="selectItem(item)"
|
||||||
|
>
|
||||||
|
<n-flex
|
||||||
|
class="history-main"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
:wrap="false"
|
||||||
|
>
|
||||||
|
<n-flex align="center" :wrap="false" :size="6">
|
||||||
|
<n-tag
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
:bordered="false"
|
||||||
|
:type="
|
||||||
|
selectedAssistantMessageId === item.assistant_message_id
|
||||||
|
? 'success'
|
||||||
|
: 'default'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
#{{ index + 1 }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:type="item.source === 'manual' ? 'info' : 'success'"
|
||||||
|
>
|
||||||
|
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
<n-tag
|
||||||
|
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
正在预览
|
||||||
|
</n-tag>
|
||||||
|
<n-text depth="3">
|
||||||
|
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
<div
|
||||||
|
class="prompt-markdown markdown-body"
|
||||||
|
v-html="renderMarkdown(item.prompt)"
|
||||||
|
/>
|
||||||
|
<div class="thumbnail" aria-label="页面缩略图">
|
||||||
|
<iframe
|
||||||
|
v-if="item.hasPage"
|
||||||
|
title="页面缩略图"
|
||||||
|
loading="lazy"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
tabindex="-1"
|
||||||
|
:srcdoc="item.previewDoc"
|
||||||
|
/>
|
||||||
|
<n-empty v-else size="small" description="未生成页面" />
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
</n-scrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { marked } from "marked"
|
||||||
|
import { Prompt } from "../../api"
|
||||||
|
import type { PromptHistoryItem } from "../../utils/type"
|
||||||
|
import { parseTime } from "../../utils/helper"
|
||||||
|
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: number
|
||||||
|
active: boolean
|
||||||
|
assetBaseUrl?: string
|
||||||
|
refreshKey?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [code: { html: string; css: string; js: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type HistoryViewItem = PromptHistoryItem & {
|
||||||
|
hasPage: boolean
|
||||||
|
previewDoc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = ref<HistoryViewItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedAssistantMessageId = ref<number | null>(null)
|
||||||
|
let loadedTaskId = 0
|
||||||
|
|
||||||
|
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
||||||
|
const html = item.code_html ?? ""
|
||||||
|
const css = item.code_css ?? ""
|
||||||
|
const js = item.code_js ?? ""
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
hasPage: !!(html.trim() || css.trim() || js.trim()),
|
||||||
|
previewDoc: buildPreviewDocument({
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
js,
|
||||||
|
assetBaseUrl: props.assetBaseUrl,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text: string): string {
|
||||||
|
return marked.parse(text) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: HistoryViewItem) {
|
||||||
|
selectedAssistantMessageId.value = item.assistant_message_id
|
||||||
|
emit("select", {
|
||||||
|
html: item.code_html ?? "",
|
||||||
|
css: item.code_css ?? "",
|
||||||
|
js: item.code_js ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(force = true) {
|
||||||
|
if (!props.taskId || loading.value) return
|
||||||
|
if (!force && loadedTaskId === props.taskId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const taskChanged = loadedTaskId !== props.taskId
|
||||||
|
const data = await Prompt.listHistory(props.taskId)
|
||||||
|
items.value = data.map(toViewItem)
|
||||||
|
if (
|
||||||
|
taskChanged ||
|
||||||
|
!items.value.some(
|
||||||
|
(item) =>
|
||||||
|
item.assistant_message_id === selectedAssistantMessageId.value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
selectedAssistantMessageId.value = null
|
||||||
|
}
|
||||||
|
loadedTaskId = props.taskId
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.active, props.taskId] as const,
|
||||||
|
([active]) => {
|
||||||
|
if (active) load(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.refreshKey,
|
||||||
|
() => {
|
||||||
|
if (props.active) load(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.active) load(false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.history-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-toolbar {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card.is-selected {
|
||||||
|
--n-color: #f7fffa;
|
||||||
|
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card.is-selected .history-main {
|
||||||
|
background: #eefaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-main {
|
||||||
|
padding: 10px 12px;
|
||||||
|
transition: background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(p),
|
||||||
|
.prompt-markdown :deep(ul),
|
||||||
|
.prompt-markdown :deep(ol),
|
||||||
|
.prompt-markdown :deep(blockquote),
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
padding: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(code) {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
height: 160px;
|
||||||
|
border-top: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
background: #f7f7f9;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card.is-selected .thumbnail {
|
||||||
|
border-top-color: #d8f1e2;
|
||||||
|
background: #f4fbf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="prompt-panel">
|
<div class="prompt-panel">
|
||||||
<div class="messages" ref="messagesRef">
|
<div class="messages" ref="messagesRef">
|
||||||
<div v-if="historyLoading" class="history-loading">
|
<div
|
||||||
<n-spin size="small" />
|
v-for="(pair, pi) in pairs"
|
||||||
<span>加载历史记录…</span>
|
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
||||||
</div>
|
class="message-pair"
|
||||||
<div v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]">
|
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
||||||
<div class="message-role">{{ msg.role === "user" ? "我" : "AI" }}</div>
|
>
|
||||||
<div class="message-content" v-html="renderContent(msg)"></div>
|
<!-- Delete button: only shown on hover when pair has assistant id and not streaming -->
|
||||||
|
<button
|
||||||
|
v-if="pair.assistantMsg?.id && !streaming"
|
||||||
|
class="pair-delete-btn"
|
||||||
|
@click="deletePair(pair.assistantMsg!.id!)"
|
||||||
|
title="删除这轮对话及关联提交"
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:trash-2" :width="14" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- User message -->
|
||||||
|
<div class="message user">
|
||||||
|
<div class="message-role">我</div>
|
||||||
|
<div class="message-content" v-html="renderContent(pair.userMsg)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assistant message -->
|
||||||
|
<div v-if="pair.assistantMsg" class="message assistant">
|
||||||
|
<div class="message-role">AI</div>
|
||||||
|
<div class="message-content" v-html="renderContent(pair.assistantMsg)"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Streaming indicator -->
|
||||||
<div v-if="streaming" class="message assistant">
|
<div v-if="streaming" class="message assistant">
|
||||||
<div class="message-role">AI</div>
|
<div class="message-role">AI</div>
|
||||||
<div v-if="!streamingContent" class="typing-indicator">
|
<div v-if="!streamingContent" class="typing-indicator">
|
||||||
@@ -31,19 +53,20 @@
|
|||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
@keydown.enter.exact.prevent="send"
|
@keydown.enter.exact.prevent="send"
|
||||||
/>
|
/>
|
||||||
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
||||||
<n-button
|
<n-select
|
||||||
text
|
v-model:value="selectedModel"
|
||||||
size="small"
|
:options="modelOptions"
|
||||||
@click="newConversation"
|
style="width: 120px"
|
||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
>
|
/>
|
||||||
新对话
|
<n-button v-if="streaming" type="error" @click="stopPrompt">
|
||||||
|
停止
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
|
v-else
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="streaming"
|
:disabled="!input.trim() || !connected"
|
||||||
:disabled="!input.trim() || streaming"
|
|
||||||
@click="send"
|
@click="send"
|
||||||
>
|
>
|
||||||
发送
|
发送
|
||||||
@@ -54,24 +77,69 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from "vue"
|
import { ref, watch, nextTick, computed } from "vue"
|
||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
import { marked, Renderer } from "marked"
|
import { marked, Renderer } from "marked"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
import {
|
import {
|
||||||
messages,
|
messages,
|
||||||
streaming,
|
streaming,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
|
connected,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
newConversation,
|
stopPrompt,
|
||||||
historyLoading,
|
} from "../../store/prompt"
|
||||||
} from "../store/prompt"
|
import { Prompt } from "../../api"
|
||||||
|
|
||||||
const input = ref("")
|
const input = ref("")
|
||||||
const messagesRef = ref<HTMLElement>()
|
const messagesRef = ref<HTMLElement>()
|
||||||
|
const naiveMessage = useMessage()
|
||||||
|
|
||||||
|
const modelOptions = [
|
||||||
|
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" },
|
||||||
|
{ label: "DeepSeek", value: "deepseek-v4-flash" },
|
||||||
|
]
|
||||||
|
const selectedModel = useStorage("prompt-model", "deepseek-v4-flash")
|
||||||
|
|
||||||
|
// Group messages into user+assistant pairs
|
||||||
|
const pairs = computed(() => {
|
||||||
|
const result: Array<{
|
||||||
|
userMsg: { role: string; content: string; id?: number }
|
||||||
|
assistantMsg: { role: string; content: string; id?: number; code?: any } | null
|
||||||
|
index: number
|
||||||
|
}> = []
|
||||||
|
const msgs = messages.value
|
||||||
|
let i = 0
|
||||||
|
while (i < msgs.length) {
|
||||||
|
if (msgs[i].role === "user") {
|
||||||
|
const assistantMsg = msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
|
||||||
|
result.push({ userMsg: msgs[i], assistantMsg, index: i })
|
||||||
|
i += assistantMsg ? 2 : 1
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
async function deletePair(assistantMsgId: number) {
|
||||||
|
try {
|
||||||
|
await Prompt.deleteMessagePair(assistantMsgId)
|
||||||
|
const msgIdx = messages.value.findIndex((m) => m.id === assistantMsgId)
|
||||||
|
if (msgIdx >= 1) {
|
||||||
|
messages.value.splice(msgIdx - 1, 2)
|
||||||
|
}
|
||||||
|
naiveMessage.success("已删除")
|
||||||
|
} catch {
|
||||||
|
naiveMessage.error("删除失败,请重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function send() {
|
function send() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value) return
|
||||||
sendPrompt(text)
|
sendPrompt(text, selectedModel.value)
|
||||||
input.value = ""
|
input.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +197,6 @@ function renderContent(msg: { role: string; content: string }): string {
|
|||||||
return renderMarkdown(msg.content)
|
return renderMarkdown(msg.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
|
||||||
watch([() => messages.value.length, streamingContent], () => {
|
watch([() => messages.value.length, streamingContent], () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (messagesRef.value) {
|
if (messagesRef.value) {
|
||||||
@@ -281,17 +348,42 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-area {
|
.input-area {
|
||||||
|
flex-shrink: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-loading {
|
.message-pair {
|
||||||
display: flex;
|
position: relative;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-pair .message {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-delete-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #bbb;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-delete-btn:hover {
|
||||||
|
color: #e03e3e;
|
||||||
|
border-color: #e03e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-pair.has-delete:hover .pair-delete-btn {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 13px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -18,6 +18,12 @@ const styleTheme = EditorView.baseTheme({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bgColors: Record<string, string> = {
|
||||||
|
html: "#fff6f3",
|
||||||
|
css: "#f3f6ff",
|
||||||
|
js: "#fffdf0",
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
language?: "html" | "css" | "js"
|
language?: "html" | "css" | "js"
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
@@ -35,12 +41,22 @@ const lang = computed(() => {
|
|||||||
if (props.language === "css") return css()
|
if (props.language === "css") return css()
|
||||||
return javascript()
|
return javascript()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bgTheme = computed(() => {
|
||||||
|
const bg = bgColors[props.language] ?? "#ffffff"
|
||||||
|
return EditorView.theme({
|
||||||
|
"&": { backgroundColor: bg },
|
||||||
|
".cm-gutters": { backgroundColor: bg },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const extensions = computed(() => [styleTheme, bgTheme.value, lang.value])
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Codemirror
|
<Codemirror
|
||||||
v-model="code"
|
v-model="code"
|
||||||
indentWithTab
|
indentWithTab
|
||||||
:extensions="[styleTheme, lang]"
|
:extensions="extensions"
|
||||||
:tabSize="4"
|
:tabSize="4"
|
||||||
:style="{ height: '100%', fontSize: props.fontSize + 'px' }"
|
:style="{ height: '100%', fontSize: props.fontSize + 'px' }"
|
||||||
/>
|
/>
|
||||||
215
src/components/editor/Editors.vue
Normal file
215
src/components/editor/Editors.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="editors-root">
|
||||||
|
<n-tabs
|
||||||
|
:value="tab"
|
||||||
|
:class="`tab-active-${tab}`"
|
||||||
|
pane-class="pane"
|
||||||
|
style="height: 100%"
|
||||||
|
type="card"
|
||||||
|
@update:value="changeTab"
|
||||||
|
>
|
||||||
|
<n-tab-pane name="html" tab="HTML">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:html"></Icon>
|
||||||
|
<span>HTML</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<Editor v-model:value="html" :font-size="size" language="html" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="css" tab="CSS">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:css"></Icon>
|
||||||
|
<span>CSS</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<Editor v-model:value="css" :font-size="size" language="css" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="js" tab="JS">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:javascript"></Icon>
|
||||||
|
<span>JS</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<Editor v-model:value="js" :font-size="size" language="js" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="actions" tab="选项">
|
||||||
|
<template #tab>
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
|
||||||
|
<span>选项</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-flex class="wrapper" vertical>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span class="label">重置</span>
|
||||||
|
<n-button @click="reset('html')">HTML</n-button>
|
||||||
|
<n-button @click="reset('css')">CSS</n-button>
|
||||||
|
<n-button @click="reset('js')">JS</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span class="label">字号</span>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
|
||||||
|
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
|
||||||
|
调小
|
||||||
|
</n-button>
|
||||||
|
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
|
||||||
|
调大
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span class="label">预加载</span>
|
||||||
|
<n-tag type="success">Normalize.css</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-tab-pane>
|
||||||
|
<template #suffix>
|
||||||
|
<Toolbar
|
||||||
|
:submit-loading="submitLoading"
|
||||||
|
@format="format"
|
||||||
|
@submit="formatAndSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import prettier from "prettier/standalone"
|
||||||
|
import * as htmlParser from "prettier/parser-html"
|
||||||
|
import * as cssParser from "prettier/parser-postcss"
|
||||||
|
import * as babelParser from "prettier/parser-babel"
|
||||||
|
import * as estreeParser from "prettier/plugins/estree"
|
||||||
|
import Editor from "./Editor.vue"
|
||||||
|
import Toolbar from "./Toolbar.vue"
|
||||||
|
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||||
|
import { taskId } from "../../store/task"
|
||||||
|
import { Submission } from "../../api"
|
||||||
|
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||||
|
import { h, ref } from "vue"
|
||||||
|
|
||||||
|
const dialog = useDialog()
|
||||||
|
const message = useMessage()
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
|
||||||
|
function changeTab(name: string) {
|
||||||
|
tab.value = name
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(num: number) {
|
||||||
|
size.value = num
|
||||||
|
}
|
||||||
|
|
||||||
|
async function formatCode() {
|
||||||
|
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
|
||||||
|
prettier.format(html.value, {
|
||||||
|
parser: "html",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(css.value, {
|
||||||
|
parser: "css",
|
||||||
|
plugins: [cssParser],
|
||||||
|
tabWidth: 4,
|
||||||
|
}),
|
||||||
|
prettier.format(js.value, {
|
||||||
|
parser: "babel",
|
||||||
|
//@ts-ignore
|
||||||
|
plugins: [babelParser, estreeParser],
|
||||||
|
tabWidth: 2,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
html.value = htmlFormatted
|
||||||
|
css.value = cssFormatted
|
||||||
|
js.value = jsFormatted
|
||||||
|
}
|
||||||
|
|
||||||
|
async function format() {
|
||||||
|
try {
|
||||||
|
await formatCode()
|
||||||
|
} catch (err: any) {
|
||||||
|
dialog.error({
|
||||||
|
title: "格式化失败",
|
||||||
|
content: () => h(NCode, { code: err.message }),
|
||||||
|
style: { width: "auto" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSubmit() {
|
||||||
|
try {
|
||||||
|
await Submission.create(taskId.value, {
|
||||||
|
html: html.value,
|
||||||
|
css: css.value,
|
||||||
|
js: js.value,
|
||||||
|
})
|
||||||
|
message.success("提交成功")
|
||||||
|
} catch (err) {
|
||||||
|
message.error("提交失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function formatAndSubmit() {
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
await formatCode()
|
||||||
|
await doSubmit()
|
||||||
|
} catch (err: any) {
|
||||||
|
dialog.warning({
|
||||||
|
title: "代码整理失败",
|
||||||
|
content: () => h(NCode, { code: err.message }),
|
||||||
|
positiveText: "忽略并提交",
|
||||||
|
negativeText: "取消",
|
||||||
|
style: { width: "auto" },
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await doSubmit()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.pane {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
||||||
|
background-color: #fff6f3 !important;
|
||||||
|
}
|
||||||
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
|
||||||
|
background-color: #f3f6ff !important;
|
||||||
|
}
|
||||||
|
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
|
||||||
|
background-color: #fffdf0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editors-root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tab-active-html .n-tab-pane) {
|
||||||
|
background-color: #fff6f3;
|
||||||
|
}
|
||||||
|
:deep(.tab-active-css .n-tab-pane) {
|
||||||
|
background-color: #f3f6ff;
|
||||||
|
}
|
||||||
|
:deep(.tab-active-js .n-tab-pane) {
|
||||||
|
background-color: #fffdf0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex align="center" justify="space-between" class="title">
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
<n-text class="titleText">预览</n-text>
|
<div>
|
||||||
|
<n-text class="titleText">预览</n-text>
|
||||||
|
<n-text v-if="!!submission.id" depth="3">
|
||||||
|
({{ submission.view_count || 0 }})
|
||||||
|
</n-text>
|
||||||
|
</div>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary @click="cycleLayout">
|
||||||
|
<template #icon>
|
||||||
|
<Icon :icon="layoutIcon" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ layoutLabel }}
|
||||||
|
</n-tooltip>
|
||||||
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||||
<n-button quaternary @click="open">全屏</n-button>
|
<n-button quaternary @click="open">全屏</n-button>
|
||||||
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
|
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
|
||||||
@@ -9,12 +24,13 @@
|
|||||||
quaternary
|
quaternary
|
||||||
v-if="props.showCodeButton"
|
v-if="props.showCodeButton"
|
||||||
@click="emits('showCode')"
|
@click="emits('showCode')"
|
||||||
>代码</n-button
|
|
||||||
>
|
>
|
||||||
|
代码
|
||||||
|
</n-button>
|
||||||
<n-button quaternary v-if="props.submissionId" @click="copyLink">
|
<n-button quaternary v-if="props.submissionId" @click="copyLink">
|
||||||
链接
|
链接
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-flex v-if="!!submission.id">
|
<n-flex v-if="!!submission.id" align="center">
|
||||||
<n-button quaternary @click="emits('showCode')">代码</n-button>
|
<n-button quaternary @click="emits('showCode')">代码</n-button>
|
||||||
<n-popover v-if="submission.my_score === 0">
|
<n-popover v-if="submission.my_score === 0">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -22,25 +38,29 @@
|
|||||||
</template>
|
</template>
|
||||||
<n-rate :size="30" @update:value="updateScore" />
|
<n-rate :size="30" @update:value="updateScore" />
|
||||||
</n-popover>
|
</n-popover>
|
||||||
<!-- <n-button secondary type="info">智能打分</n-button> -->
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<iframe class="iframe" ref="iframe"></iframe>
|
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||||
|
<iframe class="iframe" :srcdoc="previewContent"></iframe>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import { computed, onMounted, useTemplateRef } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../../api"
|
||||||
import { submission } from "../store/submission"
|
import { submission } from "../../store/submission"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
import copy from "copy-text-to-clipboard"
|
import copy from "copy-text-to-clipboard"
|
||||||
|
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
html: string
|
html: string
|
||||||
css: string
|
css: string
|
||||||
js: string
|
js: string
|
||||||
|
assetBaseUrl?: string
|
||||||
submissionId?: string
|
submissionId?: string
|
||||||
showCodeButton?: boolean
|
showCodeButton?: boolean
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
@@ -49,37 +69,59 @@ interface Props {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
||||||
|
|
||||||
|
type Layout = "desktop" | "mobile" | "tablet"
|
||||||
|
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
|
||||||
|
const layoutConfig: Record<
|
||||||
|
Layout,
|
||||||
|
{ icon: string; label: string; width: string }
|
||||||
|
> = {
|
||||||
|
desktop: {
|
||||||
|
icon: "material-symbols:desktop-windows-outline",
|
||||||
|
label: "桌面",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
icon: "material-symbols:smartphone-outline",
|
||||||
|
label: "移动端 (375px)",
|
||||||
|
width: "375px",
|
||||||
|
},
|
||||||
|
tablet: {
|
||||||
|
icon: "material-symbols:tablet-outline",
|
||||||
|
label: "平板 (768px)",
|
||||||
|
width: "768px",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const layoutIndex = ref(0)
|
||||||
|
const layoutIcon = computed(
|
||||||
|
() => layoutConfig[layouts[layoutIndex.value]!].icon,
|
||||||
|
)
|
||||||
|
const layoutLabel = computed(
|
||||||
|
() => layoutConfig[layouts[layoutIndex.value]!].label,
|
||||||
|
)
|
||||||
|
const iframeWrapperStyle = computed(() => ({
|
||||||
|
maxWidth: layoutConfig[layouts[layoutIndex.value]!].width,
|
||||||
|
}))
|
||||||
|
function cycleLayout() {
|
||||||
|
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
|
||||||
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
|
|
||||||
const showDL = computed(() => props.html || props.css || props.js)
|
const showDL = computed(() => props.html || props.css || props.js)
|
||||||
|
const previewContent = ref("")
|
||||||
|
|
||||||
function getContent() {
|
function getContent() {
|
||||||
return `<!DOCTYPE html>
|
return buildPreviewDocument({
|
||||||
<html lang="zh-Hans-CN">
|
html: props.html,
|
||||||
<head>
|
css: props.css,
|
||||||
<meta charset="UTF-8" />
|
js: props.js,
|
||||||
<title>预览</title>
|
assetBaseUrl: props.assetBaseUrl,
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
})
|
||||||
<style>${props.css}</style>
|
|
||||||
<link rel="stylesheet" href="/normalize.min.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${props.html}
|
|
||||||
<script>${props.js}<\/script>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function preview() {
|
function preview() {
|
||||||
if (!iframe.value) return
|
previewContent.value = getContent()
|
||||||
const doc = iframe.value.contentDocument
|
|
||||||
if (doc) {
|
|
||||||
doc.open()
|
|
||||||
doc.write(getContent())
|
|
||||||
doc.close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
@@ -101,7 +143,7 @@ function open() {
|
|||||||
})
|
})
|
||||||
window.open(data.href, "_blank")
|
window.open(data.href, "_blank")
|
||||||
} else {
|
} else {
|
||||||
const newTab = window.open("/usercontent.html")
|
const newTab = window.open("about:blank", "_blank")
|
||||||
if (!newTab) return
|
if (!newTab) return
|
||||||
newTab.document.open()
|
newTab.document.open()
|
||||||
newTab.document.write(getContent())
|
newTab.document.write(getContent())
|
||||||
@@ -129,10 +171,14 @@ async function updateScore(score: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watchDebounced(() => [props.html, props.css, props.js], preview, {
|
watchDebounced(
|
||||||
debounce: 500,
|
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||||
maxWait: 1000,
|
preview,
|
||||||
})
|
{
|
||||||
|
debounce: 500,
|
||||||
|
maxWait: 1000,
|
||||||
|
},
|
||||||
|
)
|
||||||
onMounted(preview)
|
onMounted(preview)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -147,6 +193,14 @@ onMounted(preview)
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iframe-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
transition: max-width 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.iframe {
|
.iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -29,14 +29,20 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, h } from "vue"
|
import { computed, h } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { authed, roleNormal, roleSuper, user } from "../store/user"
|
import {
|
||||||
import { loginModal } from "../store/modal"
|
authed,
|
||||||
import { show, tutorialSize, step } from "../store/tutorial"
|
roleAdmin,
|
||||||
import { taskId, taskTab } from "../store/task"
|
roleNormal,
|
||||||
import { Account } from "../api"
|
roleSuper,
|
||||||
import { Role } from "../utils/type"
|
user,
|
||||||
import { router } from "../router"
|
} from "../../store/user"
|
||||||
import { ADMIN_URL } from "../utils/const"
|
import { loginModal } from "../../store/modal"
|
||||||
|
import { show, panelSize } from "../../store/panel"
|
||||||
|
import { taskId } from "../../store/task"
|
||||||
|
import { Account } from "../../api"
|
||||||
|
import { Role } from "../../utils/type"
|
||||||
|
import { router } from "../../router"
|
||||||
|
import { ADMIN_URL } from "../../utils/const"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
submitLoading: boolean
|
submitLoading: boolean
|
||||||
@@ -77,15 +83,6 @@ const menu = computed(() => [
|
|||||||
width: 20,
|
width: 20,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "排名榜",
|
|
||||||
key: "ranking",
|
|
||||||
icon: () =>
|
|
||||||
h(Icon, {
|
|
||||||
icon: "streamline-emojis:sunglasses",
|
|
||||||
width: 20,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "退出账号",
|
label: "退出账号",
|
||||||
key: "logout",
|
key: "logout",
|
||||||
@@ -99,14 +96,16 @@ const menu = computed(() => [
|
|||||||
|
|
||||||
function showTutorial() {
|
function showTutorial() {
|
||||||
show.value = true
|
show.value = true
|
||||||
tutorialSize.value = 2 / 5
|
panelSize.value = 2 / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickMenu(name: string) {
|
function clickMenu(name: string) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "dashboard":
|
case "dashboard": {
|
||||||
router.push({ name: "tutorial-editor", params: { display: step.value } })
|
const route = roleAdmin.value ? "challenge-editor" : "tutorial-editor"
|
||||||
|
router.push({ name: route, params: { display: 0 } })
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case "admin":
|
case "admin":
|
||||||
window.open(ADMIN_URL)
|
window.open(ADMIN_URL)
|
||||||
break
|
break
|
||||||
@@ -117,9 +116,6 @@ function clickMenu(name: string) {
|
|||||||
query: { username: user.username },
|
query: { username: user.username },
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "ranking":
|
|
||||||
router.push({ name: "ranking" })
|
|
||||||
break
|
|
||||||
case "logout":
|
case "logout":
|
||||||
handleLogout()
|
handleLogout()
|
||||||
break
|
break
|
||||||
@@ -2,10 +2,57 @@
|
|||||||
<n-modal
|
<n-modal
|
||||||
:show="show"
|
:show="show"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="提示词"
|
|
||||||
style="width: 90vw; max-width: 1400px"
|
style="width: 90vw; max-width: 1400px"
|
||||||
@update:show="$emit('update:show', $event)"
|
@update:show="$emit('update:show', $event)"
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex justify="start" align="center">
|
||||||
|
<span>提示词</span>
|
||||||
|
<n-tooltip placement="bottom-start">
|
||||||
|
<template #trigger>
|
||||||
|
<Icon icon="lucide:info" :width="20" style="color: #aaa" />
|
||||||
|
</template>
|
||||||
|
<div style="line-height: 2">
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
|
||||||
|
L1
|
||||||
|
</span>
|
||||||
|
— 记忆:复述或识别知识点
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[2], fontWeight: 'bold' }">
|
||||||
|
L2
|
||||||
|
</span>
|
||||||
|
— 理解:用自己的话解释概念
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[3], fontWeight: 'bold' }">
|
||||||
|
L3
|
||||||
|
</span>
|
||||||
|
— 应用:将知识用于新情境
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[4], fontWeight: 'bold' }">
|
||||||
|
L4
|
||||||
|
</span>
|
||||||
|
— 分析:拆解结构、找出规律
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[5], fontWeight: 'bold' }">
|
||||||
|
L5
|
||||||
|
</span>
|
||||||
|
— 评价:基于标准作出判断
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span :style="{ color: levelColors[6], fontWeight: 'bold' }">
|
||||||
|
L6
|
||||||
|
</span>
|
||||||
|
— 创造:整合信息产出新成果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
<n-spin :show="loading">
|
<n-spin :show="loading">
|
||||||
<n-empty
|
<n-empty
|
||||||
v-if="!loading && rounds.length === 0"
|
v-if="!loading && rounds.length === 0"
|
||||||
@@ -75,7 +122,94 @@
|
|||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ round.question }}
|
<div
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="prompt-markdown markdown-body"
|
||||||
|
:class="{
|
||||||
|
'is-collapsed':
|
||||||
|
isPromptLong(round.question) && !isExpanded(index),
|
||||||
|
}"
|
||||||
|
v-html="renderMarkdown(round.question)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="round.source"
|
||||||
|
:style="{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '1px 5px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background:
|
||||||
|
round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
|
||||||
|
color:
|
||||||
|
round.source === 'conversation' ? '#2060c0' : '#888',
|
||||||
|
fontWeight: 500,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}"
|
||||||
|
>{{
|
||||||
|
round.source === "conversation" ? "对话" : "手动"
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="round.prompt_level"
|
||||||
|
:style="{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: levelColors[round.prompt_level],
|
||||||
|
}"
|
||||||
|
>L{{ round.prompt_level }}</span
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
v-if="isPromptLong(round.question)"
|
||||||
|
text
|
||||||
|
size="tiny"
|
||||||
|
type="primary"
|
||||||
|
@click.stop="toggleExpanded(index)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
isExpanded(index)
|
||||||
|
? 'lucide:chevron-up'
|
||||||
|
: 'lucide:chevron-down'
|
||||||
|
"
|
||||||
|
:width="12"
|
||||||
|
/>
|
||||||
|
{{ isExpanded(index) ? "收起" : "展开" }}
|
||||||
|
</n-button>
|
||||||
|
<n-popconfirm
|
||||||
|
v-if="round.assistantMsgId && canDelete"
|
||||||
|
:show-icon="false"
|
||||||
|
@positive-click="deleteRound(index)"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
size="tiny"
|
||||||
|
type="error"
|
||||||
|
style="margin-left: 2px"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:trash-2" :width="12" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
确定删除这一轮?
|
||||||
|
</n-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,45 +238,53 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { Prompt } from "../../api"
|
import { NPopconfirm, NButton } from "naive-ui"
|
||||||
import type { PromptMessage } from "../../utils/type"
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { marked } from "marked"
|
||||||
|
import { Prompt, Submission } from "../../api"
|
||||||
|
import type { PromptRound } from "../../utils/type"
|
||||||
|
import { user, roleSuper } from "../../store/user"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
conversationId?: string
|
submissionId: string
|
||||||
|
username?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const canDelete = computed(
|
||||||
|
() =>
|
||||||
|
roleSuper.value || (!!props.username && props.username === user.username),
|
||||||
|
)
|
||||||
|
|
||||||
defineEmits<{ "update:show": [value: boolean] }>()
|
defineEmits<{ "update:show": [value: boolean] }>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const messages = ref<PromptMessage[]>([])
|
|
||||||
const selectedRound = ref(0)
|
const selectedRound = ref(0)
|
||||||
|
const expandedRounds = ref<Set<number>>(new Set())
|
||||||
|
type ChainRound = Omit<PromptRound, "source"> & {
|
||||||
|
source: string | null
|
||||||
|
assistantMsgId: number | null
|
||||||
|
}
|
||||||
|
const rounds = ref<ChainRound[]>([])
|
||||||
|
|
||||||
const rounds = computed(() => {
|
async function deleteRound(index: number) {
|
||||||
const result: {
|
const round = rounds.value[index]
|
||||||
question: string
|
if (!round.assistantMsgId) return
|
||||||
html: string | null
|
await Prompt.deleteMessagePair(round.assistantMsgId)
|
||||||
css: string | null
|
await loadMessages()
|
||||||
js: string | null
|
if (selectedRound.value >= rounds.value.length) {
|
||||||
}[] = []
|
selectedRound.value = Math.max(0, rounds.value.length - 1)
|
||||||
for (const [i, msg] of messages.value.entries()) {
|
|
||||||
if (msg.role !== "user") continue
|
|
||||||
let html: string | null = null,
|
|
||||||
css: string | null = null,
|
|
||||||
js: string | null = null
|
|
||||||
for (const reply of messages.value.slice(i + 1)) {
|
|
||||||
if (reply.role === "user") break
|
|
||||||
if (reply.role === "assistant" && reply.code_html) {
|
|
||||||
html = reply.code_html
|
|
||||||
css = reply.code_css
|
|
||||||
js = reply.code_js
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.push({ question: msg.content, html, css, js })
|
|
||||||
}
|
}
|
||||||
return result
|
}
|
||||||
})
|
|
||||||
|
const levelColors: Record<number, string> = {
|
||||||
|
1: "#aaa",
|
||||||
|
2: "#6aab9c",
|
||||||
|
3: "#4a9ade",
|
||||||
|
4: "#c47ab8",
|
||||||
|
5: "#e0a040",
|
||||||
|
6: "#e05c5c",
|
||||||
|
}
|
||||||
|
|
||||||
const selectedPageHtml = computed(() => {
|
const selectedPageHtml = computed(() => {
|
||||||
const round = rounds.value[selectedRound.value]
|
const round = rounds.value[selectedRound.value]
|
||||||
@@ -152,37 +294,95 @@ const selectedPageHtml = computed(() => {
|
|||||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
|
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
function renderMarkdown(text: string): string {
|
||||||
() => props.conversationId,
|
return marked.parse(text) as string
|
||||||
async (id) => {
|
}
|
||||||
if (!id || !props.show) return
|
|
||||||
loading.value = true
|
function isPromptLong(text: string): boolean {
|
||||||
messages.value = []
|
return text.length > 220 || text.split(/\r?\n/).length > 4
|
||||||
selectedRound.value = 0
|
}
|
||||||
try {
|
|
||||||
messages.value = await Prompt.getMessages(id)
|
function isExpanded(index: number): boolean {
|
||||||
const last = rounds.value.length - 1
|
return expandedRounds.value.has(index)
|
||||||
if (last >= 0) selectedRound.value = last
|
}
|
||||||
} finally {
|
|
||||||
loading.value = false
|
function toggleExpanded(index: number) {
|
||||||
}
|
const next = new Set(expandedRounds.value)
|
||||||
},
|
if (next.has(index)) {
|
||||||
)
|
next.delete(index)
|
||||||
|
} else {
|
||||||
|
next.add(index)
|
||||||
|
}
|
||||||
|
expandedRounds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
if (!props.submissionId) return
|
||||||
|
loading.value = true
|
||||||
|
rounds.value = []
|
||||||
|
selectedRound.value = 0
|
||||||
|
expandedRounds.value = new Set()
|
||||||
|
try {
|
||||||
|
const data = await Submission.getPromptChain(props.submissionId)
|
||||||
|
rounds.value = data.map((round) => ({
|
||||||
|
...round,
|
||||||
|
source: round.source ?? null,
|
||||||
|
assistantMsgId: round.assistant_msg_id ?? null,
|
||||||
|
}))
|
||||||
|
const last = rounds.value.length - 1
|
||||||
|
if (last >= 0) selectedRound.value = last
|
||||||
|
} catch {
|
||||||
|
rounds.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => [props.show, props.submissionId] as const,
|
||||||
async (visible) => {
|
([visible]) => {
|
||||||
if (!visible || !props.conversationId) return
|
if (visible) loadMessages()
|
||||||
loading.value = true
|
|
||||||
messages.value = []
|
|
||||||
selectedRound.value = 0
|
|
||||||
try {
|
|
||||||
messages.value = await Prompt.getMessages(props.conversationId)
|
|
||||||
const last = rounds.value.length - 1
|
|
||||||
if (last >= 0) selectedRound.value = last
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prompt-markdown {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown.is-collapsed {
|
||||||
|
position: relative;
|
||||||
|
max-height: 126px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(p),
|
||||||
|
.prompt-markdown :deep(ul),
|
||||||
|
.prompt-markdown :deep(ol),
|
||||||
|
.prompt-markdown :deep(blockquote),
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(pre) {
|
||||||
|
padding: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-markdown :deep(code) {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import type { SubmissionOut } from "../../utils/type"
|
import type { SubmissionOut } from "../../utils/type"
|
||||||
import { TASK_TYPE } from "../../utils/const"
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
import { parseTime } from "../../utils/helper"
|
import { parseTime } from "../../utils/helper"
|
||||||
import { user } from "../../store/user"
|
import { user, roleSuper } from "../../store/user"
|
||||||
import { submission } from "../../store/submission"
|
import { submission } from "../../store/submission"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -36,8 +36,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [id: string]
|
select: [id: string]
|
||||||
delete: [row: SubmissionOut, parentId: string]
|
delete: [row: SubmissionOut, parentId: string]
|
||||||
"show-chain": [conversationId: string]
|
"show-chain": [submissionId: string, username: string]
|
||||||
nominate: [row: SubmissionOut]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
|
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
|
||||||
@@ -78,82 +77,50 @@ const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "排名",
|
title: "操作",
|
||||||
key: "nominated",
|
key: "actions",
|
||||||
width: 60,
|
width: isChallenge.value ? 110 : 60,
|
||||||
render: (r: SubmissionOut) => {
|
render: (r: SubmissionOut) =>
|
||||||
if (r.username !== user.username) {
|
h("div", { style: { display: "flex", gap: "8px" } }, [
|
||||||
return r.nominated
|
...(isChallenge.value
|
||||||
? h("span", { style: { color: "#f0a020" } }, "🏅")
|
? [
|
||||||
: null
|
h(
|
||||||
}
|
NButton,
|
||||||
return h(
|
{
|
||||||
NButton,
|
text: true,
|
||||||
{
|
type: "primary",
|
||||||
text: true,
|
onClick: (e: Event) => {
|
||||||
title: r.nominated ? "已参与排名(点击可重新提名)" : "参与排名",
|
e.stopPropagation()
|
||||||
onClick: (e: Event) => {
|
emit("show-chain", r.id, r.username)
|
||||||
e.stopPropagation()
|
},
|
||||||
emit("nominate", r)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
() => (r.nominated ? "🏅" : "☆"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(isChallenge.value
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: "提示词",
|
|
||||||
key: "conversation_id",
|
|
||||||
width: 70,
|
|
||||||
render: (r: SubmissionOut) => {
|
|
||||||
if (!r.conversation_id) return "-"
|
|
||||||
return h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
text: true,
|
|
||||||
type: "primary",
|
|
||||||
onClick: (e: Event) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
emit("show-chain", r.conversation_id!)
|
|
||||||
},
|
},
|
||||||
},
|
() => "查看",
|
||||||
() => "查看",
|
),
|
||||||
)
|
]
|
||||||
},
|
: []),
|
||||||
} as DataTableColumn<SubmissionOut>,
|
...(r.username === user.username || roleSuper.value
|
||||||
]
|
? [
|
||||||
: []),
|
h(
|
||||||
...(!isChallenge.value
|
NPopconfirm,
|
||||||
? [
|
{ onPositiveClick: () => emit("delete", r, props.row.id) },
|
||||||
{
|
{
|
||||||
title: "操作",
|
trigger: () =>
|
||||||
key: "actions",
|
h(
|
||||||
width: 60,
|
NButton,
|
||||||
render: (r: SubmissionOut) => {
|
{
|
||||||
if (r.username !== user.username) return null
|
text: true,
|
||||||
return h(
|
type: "error",
|
||||||
NPopconfirm,
|
size: "small",
|
||||||
{ onPositiveClick: () => emit("delete", r, props.row.id) },
|
onClick: (e: Event) => e.stopPropagation(),
|
||||||
{
|
},
|
||||||
trigger: () =>
|
() => "删除",
|
||||||
h(
|
),
|
||||||
NButton,
|
default: () => "确定删除这次提交?",
|
||||||
{
|
},
|
||||||
text: true,
|
),
|
||||||
type: "error",
|
]
|
||||||
size: "small",
|
: []),
|
||||||
onClick: (e: Event) => e.stopPropagation(),
|
]),
|
||||||
},
|
} as DataTableColumn<SubmissionOut>,
|
||||||
() => "删除",
|
|
||||||
),
|
|
||||||
default: () => "确定删除这次提交?",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
} as DataTableColumn<SubmissionOut>,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
83
src/components/task/ChallengeList.vue
Normal file
83
src/components/task/ChallengeList.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
|
||||||
|
<n-empty v-if="!challenges.length">暂无挑战,敬请期待</n-empty>
|
||||||
|
<n-flex v-else vertical :size="12">
|
||||||
|
<n-card
|
||||||
|
v-for="item in challenges"
|
||||||
|
:key="item.display"
|
||||||
|
hoverable
|
||||||
|
:class="['challenge-card', { submitted: item.submitted }]"
|
||||||
|
@click="select(item)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<n-flex align="center" :size="6">
|
||||||
|
<span v-if="item.submitted" class="check-icon">✓</span>
|
||||||
|
<span :class="{ 'submitted-title': item.submitted }">{{
|
||||||
|
item.title
|
||||||
|
}}</span>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-flex :size="6">
|
||||||
|
<n-tag type="warning" size="small">{{ item.score }} 分</n-tag>
|
||||||
|
<n-tag v-if="item.pass_score != null" size="small"
|
||||||
|
>及格 {{ item.pass_score }} 分</n-tag
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-text depth="3" class="challenge-author">
|
||||||
|
出题人:{{ item.author_name || "未设置" }}
|
||||||
|
</n-text>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Challenge } from "../../api"
|
||||||
|
import { taskTab } from "../../store/task"
|
||||||
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
|
import type { ChallengeSlim } from "../../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const challenges = ref<ChallengeSlim[]>([])
|
||||||
|
|
||||||
|
function select(item: ChallengeSlim) {
|
||||||
|
router.push({ name: "home-challenge", params: { display: item.display } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
challenges.value = await Challenge.listDisplay()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-card.submitted {
|
||||||
|
background-color: #f6ffed;
|
||||||
|
border-color: #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: #52c41a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-title {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-author {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
152
src/components/task/TaskAssetManager.vue
Normal file
152
src/components/task/TaskAssetManager.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex vertical>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-text strong>图片素材</n-text>
|
||||||
|
<n-button size="small" @click="showUpload = true">上传</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex v-if="assets.length" wrap>
|
||||||
|
<n-card
|
||||||
|
v-for="asset in assets"
|
||||||
|
:key="asset.name"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<template #cover>
|
||||||
|
<n-image
|
||||||
|
:src="asset.url"
|
||||||
|
style="width: 100%; height: 100px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<n-flex align="center" justify="space-between">
|
||||||
|
<n-text style="font-size: 12px; word-break: break-all">
|
||||||
|
{{ asset.name }}
|
||||||
|
</n-text>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
quaternary
|
||||||
|
type="error"
|
||||||
|
@click="remove(asset.name)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="material-symbols:close-rounded" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showUpload"
|
||||||
|
preset="card"
|
||||||
|
title="上传素材"
|
||||||
|
style="width: 400px"
|
||||||
|
>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="文件名(如 1.png)">
|
||||||
|
<n-input v-model:value="uploadName" placeholder="1.png" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="选择文件">
|
||||||
|
<n-upload :max="1" @change="onFileChange" :default-upload="false">
|
||||||
|
<n-button>选择文件</n-button>
|
||||||
|
</n-upload>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="showUpload = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!uploadName || !uploadFile"
|
||||||
|
:loading="uploading"
|
||||||
|
@click="upload"
|
||||||
|
>
|
||||||
|
上传
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import type { UploadFileInfo } from "naive-ui"
|
||||||
|
import { TaskAssets } from "../../api"
|
||||||
|
import type { TaskAsset } from "../../utils/type"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskType: "challenge" | "tutorial"
|
||||||
|
display: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const assets = ref<TaskAsset[]>([])
|
||||||
|
const showUpload = ref(false)
|
||||||
|
const uploadName = ref("")
|
||||||
|
const uploadFile = ref<File | null>(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.display) return
|
||||||
|
try {
|
||||||
|
assets.value =
|
||||||
|
props.taskType === "challenge"
|
||||||
|
? await TaskAssets.listChallenge(props.display)
|
||||||
|
: await TaskAssets.listTutorial(props.display)
|
||||||
|
} catch {
|
||||||
|
assets.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange({ file }: { file: UploadFileInfo }) {
|
||||||
|
if (file.status === "pending") {
|
||||||
|
uploadFile.value = file.file ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!uploadName.value || !uploadFile.value) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const asset =
|
||||||
|
props.taskType === "challenge"
|
||||||
|
? await TaskAssets.uploadChallenge(
|
||||||
|
props.display,
|
||||||
|
uploadName.value,
|
||||||
|
uploadFile.value,
|
||||||
|
)
|
||||||
|
: await TaskAssets.uploadTutorial(
|
||||||
|
props.display,
|
||||||
|
uploadName.value,
|
||||||
|
uploadFile.value,
|
||||||
|
)
|
||||||
|
const idx = assets.value.findIndex((a) => a.name === asset.name)
|
||||||
|
if (idx >= 0) assets.value[idx] = asset
|
||||||
|
else assets.value.push(asset)
|
||||||
|
message.success(`${asset.name} 上传成功`)
|
||||||
|
showUpload.value = false
|
||||||
|
uploadName.value = ""
|
||||||
|
uploadFile.value = null
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.detail ?? "上传失败")
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(name: string) {
|
||||||
|
try {
|
||||||
|
props.taskType === "challenge"
|
||||||
|
? await TaskAssets.deleteChallenge(props.display, name)
|
||||||
|
: await TaskAssets.deleteTutorial(props.display, name)
|
||||||
|
assets.value = assets.value.filter((a) => a.name !== name)
|
||||||
|
message.success("删除成功")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.detail ?? "删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.display, load, { immediate: true })
|
||||||
|
</script>
|
||||||
226
src/components/task/TaskPanel.vue
Normal file
226
src/components/task/TaskPanel.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<n-flex align="center" justify="space-between" class="title">
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
taskTab === TASK_TYPE.Tutorial
|
||||||
|
? 'twemoji:books'
|
||||||
|
: 'twemoji:crossed-swords'
|
||||||
|
"
|
||||||
|
:width="20"
|
||||||
|
></Icon>
|
||||||
|
<n-tabs
|
||||||
|
style="width: 150px"
|
||||||
|
type="segment"
|
||||||
|
animated
|
||||||
|
:value="taskTab"
|
||||||
|
@update:value="changeTab"
|
||||||
|
>
|
||||||
|
<n-tab name="tutorial" tab="教程"></n-tab>
|
||||||
|
<n-tab name="challenge" tab="挑战"></n-tab>
|
||||||
|
</n-tabs>
|
||||||
|
<template v-if="!hideNav">
|
||||||
|
<n-button text @click="prev()" :disabled="prevDisabled()">
|
||||||
|
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
|
||||||
|
</n-button>
|
||||||
|
<span v-if="progressText" class="progress-text">
|
||||||
|
{{ progressText }}
|
||||||
|
</span>
|
||||||
|
<n-button text @click="next()" :disabled="nextDisabled()">
|
||||||
|
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex>
|
||||||
|
<n-tooltip
|
||||||
|
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="showAssets = true">
|
||||||
|
<Icon :width="16" icon="lucide:image"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
素材
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="authed" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
@click="
|
||||||
|
$router.push({ name: 'submissions', params: { page: 1 } })
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:list"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
提交记录
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="authed" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="$router.push({ name: 'showcase' })">
|
||||||
|
<Icon :width="16" icon="lucide:award"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
创意工坊
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="roleSuper" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="edit">
|
||||||
|
<Icon :width="16" icon="lucide:edit"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
编辑
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="$emit('hide')">
|
||||||
|
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
关闭
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
|
||||||
|
<ChallengeList v-else />
|
||||||
|
</div>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showAssets"
|
||||||
|
preset="card"
|
||||||
|
title="图片素材"
|
||||||
|
style="width: 570px"
|
||||||
|
>
|
||||||
|
<n-flex wrap>
|
||||||
|
<n-card
|
||||||
|
v-for="asset in tutorialAssets"
|
||||||
|
:key="asset.name"
|
||||||
|
:title="asset.name"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
>
|
||||||
|
<template #cover>
|
||||||
|
<n-image
|
||||||
|
:src="asset.url"
|
||||||
|
style="width: 100%; height: 100px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
import {
|
||||||
|
step,
|
||||||
|
tutorialIds,
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
prevDisabled,
|
||||||
|
nextDisabled,
|
||||||
|
} from "../../store/tutorial"
|
||||||
|
import { authed, roleSuper } from "../../store/user"
|
||||||
|
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { TASK_TYPE } from "../../utils/const"
|
||||||
|
import { TaskAssets } from "../../api"
|
||||||
|
import type { TaskAsset } from "../../utils/type"
|
||||||
|
import ChallengeList from "./ChallengeList.vue"
|
||||||
|
import TutorialContent from "./TutorialContent.vue"
|
||||||
|
|
||||||
|
const tutorialAssets = ref<TaskAsset[]>([])
|
||||||
|
const showAssets = ref(false)
|
||||||
|
|
||||||
|
async function loadTutorialAssets(display: number) {
|
||||||
|
if (!display) {
|
||||||
|
tutorialAssets.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
tutorialAssets.value = await TaskAssets.listTutorial(display)
|
||||||
|
} catch {
|
||||||
|
tutorialAssets.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(step, loadTutorialAssets, { immediate: true })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 路由同步:初始执行 + watch 响应 SPA 内部导航
|
||||||
|
function syncRoute(routeName: string) {
|
||||||
|
if (routeName.startsWith("home-tutorial")) {
|
||||||
|
taskTab.value = TASK_TYPE.Tutorial
|
||||||
|
if (route.params.display) step.value = Number(route.params.display)
|
||||||
|
} else if (routeName.startsWith("home-challenge")) {
|
||||||
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
|
if (route.params.display)
|
||||||
|
challengeDisplay.value = Number(route.params.display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncRoute(route.name as string)
|
||||||
|
watch(() => route.name as string, syncRoute)
|
||||||
|
|
||||||
|
defineEmits(["hide"])
|
||||||
|
|
||||||
|
const hideNav = computed(
|
||||||
|
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
const progressText = computed(() => {
|
||||||
|
const ids = tutorialIds.value
|
||||||
|
if (!ids.length) return ""
|
||||||
|
const i = ids.indexOf(step.value)
|
||||||
|
return i === -1 ? "" : `${i + 1} / ${ids.length}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function changeTab(v: TASK_TYPE) {
|
||||||
|
taskId.value = 0
|
||||||
|
taskTab.value = v
|
||||||
|
if (v === TASK_TYPE.Tutorial) {
|
||||||
|
router.push(
|
||||||
|
step.value
|
||||||
|
? { name: "home-tutorial", params: { display: step.value } }
|
||||||
|
: { name: "home-tutorial-list" },
|
||||||
|
)
|
||||||
|
} else if (v === TASK_TYPE.Challenge) {
|
||||||
|
router.push({ name: "home-challenge-list" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit() {
|
||||||
|
const name =
|
||||||
|
taskTab.value === TASK_TYPE.Tutorial
|
||||||
|
? "tutorial-editor"
|
||||||
|
: "challenge-editor"
|
||||||
|
const display =
|
||||||
|
taskTab.value === TASK_TYPE.Tutorial ? step.value : challengeDisplay.value
|
||||||
|
router.push({ name, params: { display } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 43px;
|
||||||
|
padding: 0 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgb(239, 239, 245);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -279,72 +279,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 人气提交 Top 5 -->
|
|
||||||
<div style="margin-bottom: 12px">
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #333;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
人气提交 Top 5
|
|
||||||
<span style="font-size: 11px; color: #aaa; font-weight: 400"
|
|
||||||
>(按打分人数)</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 5px">
|
|
||||||
<div
|
|
||||||
v-for="(sub, i) in stats.top_submissions"
|
|
||||||
:key="sub.submission_id"
|
|
||||||
:style="{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '6px 10px',
|
|
||||||
background: rankBg(i),
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}"
|
|
||||||
@click="viewSubmission(sub.submission_id)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
:style="{
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
background: rankColor(i),
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: '11px',
|
|
||||||
flexShrink: 0,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ i + 1 }}
|
|
||||||
</div>
|
|
||||||
<div style="flex: 1">
|
|
||||||
<div style="font-weight: 500; font-size: 13px">
|
|
||||||
{{ displayName(sub.username, sub.classname) }}
|
|
||||||
</div>
|
|
||||||
<div style="color: #aaa; font-size: 11px">
|
|
||||||
{{ sub.score.toFixed(1) }} 分 ·
|
|
||||||
{{ sub.rating_count }} 人打分
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="color: #2080f0; font-size: 12px">查看 →</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-if="!stats.top_submissions.length"
|
|
||||||
style="color: #aaa; font-size: 12px"
|
|
||||||
>暂无打分记录</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标记统计 -->
|
<!-- 标记统计 -->
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -389,6 +323,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 人气前五 -->
|
||||||
|
<div style="margin-top: 12px">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
人气前五
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="stats.top_viewed.length === 0"
|
||||||
|
style="color: #aaa; font-size: 12px"
|
||||||
|
>
|
||||||
|
暂无
|
||||||
|
</div>
|
||||||
|
<div v-else style="display: flex; flex-direction: column; gap: 6px">
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in stats.top_viewed"
|
||||||
|
:key="item.submission_id"
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fafafa;
|
||||||
|
"
|
||||||
|
@click="viewSubmission(item.submission_id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
i < 3 ? ['#f0a020', '#888', '#a07040'][i] : '#ddd',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '700',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}"
|
||||||
|
>{{ i + 1 }}</span
|
||||||
|
>
|
||||||
|
<span style="flex: 1; font-size: 13px; color: #333">
|
||||||
|
{{ displayName(item.username, item.classname) }}
|
||||||
|
</span>
|
||||||
|
<span style="font-size: 12px; color: #2080f0; font-weight: 600">
|
||||||
|
{{ item.view_count }} 次
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,8 +394,8 @@
|
|||||||
import { ref, computed, watch } from "vue"
|
import { ref, computed, watch } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../../api"
|
||||||
import type { TaskStatsOut } from "../utils/type"
|
import type { TaskStatsOut } from "../../utils/type"
|
||||||
|
|
||||||
const props = defineProps<{ taskId: number; show: boolean }>()
|
const props = defineProps<{ taskId: number; show: boolean }>()
|
||||||
const emit = defineEmits<{ (e: "update:show", v: boolean): void }>()
|
const emit = defineEmits<{ (e: "update:show", v: boolean): void }>()
|
||||||
@@ -436,20 +431,6 @@ function viewSubmission(id: string) {
|
|||||||
window.open(href, "_blank")
|
window.open(href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankColor(i: number) {
|
|
||||||
return (
|
|
||||||
(["#f0a020", "#909090", "#cd7f32", "#8899aa", "#7a8fa0"] as const)[i] ??
|
|
||||||
"#aaa"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function rankBg(i: number) {
|
|
||||||
return (
|
|
||||||
(["#fffbef", "#f8f8f8", "#fdf5ee", "#f2f5f8", "#eef2f5"] as const)[i] ??
|
|
||||||
"#f8f8f8"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bucketPct(value: number) {
|
function bucketPct(value: number) {
|
||||||
const total = stats.value?.submitted_count ?? 0
|
const total = stats.value?.submitted_count ?? 0
|
||||||
if (!total) return "0%"
|
if (!total) return "0%"
|
||||||
@@ -467,7 +448,6 @@ const metrics = computed(() => {
|
|||||||
color: "#2080f0",
|
color: "#2080f0",
|
||||||
},
|
},
|
||||||
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
|
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
|
||||||
{ label: "参与排名", value: stats.value.nominated_count, color: "#f0a020" },
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
import { onMounted, ref, useTemplateRef, watch } from "vue"
|
import { onMounted, ref, useTemplateRef, watch } from "vue"
|
||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
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 { step } from "../store/tutorial"
|
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
||||||
import { taskId } from "../store/task"
|
import { taskId, assetBaseUrl } from "../../store/task"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
marked.use({
|
marked.use({
|
||||||
@@ -33,47 +33,14 @@ marked.use({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tutorialIds = ref<number[]>([])
|
|
||||||
const content = ref("")
|
const content = ref("")
|
||||||
const $content = useTemplateRef<any>("$content")
|
const $content = useTemplateRef<any>("$content")
|
||||||
|
|
||||||
const prevDisabled = () => {
|
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
|
||||||
return i <= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDisabled = () => {
|
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
|
||||||
return i === tutorialIds.value.length - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function prev() {
|
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
|
||||||
step.value = tutorialIds.value[i - 1] as number
|
|
||||||
}
|
|
||||||
|
|
||||||
function next() {
|
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
|
||||||
step.value = tutorialIds.value[i + 1] as number
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ tutorialIds, prevDisabled, nextDisabled, prev, next })
|
|
||||||
|
|
||||||
async function prepare() {
|
|
||||||
tutorialIds.value = await Tutorial.listDisplay()
|
|
||||||
if (!tutorialIds.value.length) {
|
|
||||||
content.value = "暂无教程"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!tutorialIds.value.includes(step.value)) {
|
|
||||||
step.value = tutorialIds.value[0] as number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
const data = await Tutorial.get(step.value)
|
const data = await Tutorial.get(step.value)
|
||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
const merged = `# #${data.display} ${data.title}\n${data.content}`
|
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||||
|
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||||
content.value = await marked.parse(merged, { async: true })
|
content.value = await marked.parse(merged, { async: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +75,11 @@ function setupCodeActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await prepare()
|
await loadTutorials()
|
||||||
|
if (!tutorialIds.value.length) {
|
||||||
|
content.value = "暂无教程"
|
||||||
|
return
|
||||||
|
}
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
27
src/global.css
Normal file
27
src/global.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d0d0d0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #b0b0b0;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from "vue"
|
import { createApp } from "vue"
|
||||||
import { create } from "naive-ui"
|
import { create } from "naive-ui"
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
|
import "./global.css"
|
||||||
import { addAPIProvider } from "@iconify/vue"
|
import { addAPIProvider } from "@iconify/vue"
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
|
|||||||
377
src/pages/ChallengeDetail.vue
Normal file
377
src/pages/ChallengeDetail.vue
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
<template>
|
||||||
|
<div class="challenge-layout">
|
||||||
|
<div class="challenge-sider">
|
||||||
|
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||||
|
<template #prefix>
|
||||||
|
<n-button text @click="back" style="margin: 0 8px">
|
||||||
|
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<n-flex style="margin: 0 8px">
|
||||||
|
<n-button v-if="assets.length" text @click="showAssets = true">
|
||||||
|
<Icon :width="16" icon="lucide:image" />
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="roleAdmin || roleSuper"
|
||||||
|
text
|
||||||
|
@click="showStats = true"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="authed"
|
||||||
|
text
|
||||||
|
@click="
|
||||||
|
$router.push({ name: 'submissions', params: { page: 1 } })
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon :width="16" icon="lucide:list" />
|
||||||
|
</n-button>
|
||||||
|
<n-button v-if="roleSuper" text @click="edit">
|
||||||
|
<Icon :width="16" icon="lucide:edit" />
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||||
|
<div class="desc-pane">
|
||||||
|
<div class="challenge-meta">
|
||||||
|
<n-text depth="3">
|
||||||
|
出题人:{{ challengeAuthor || "未设置" }}
|
||||||
|
</n-text>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="markdown-body content no-select"
|
||||||
|
v-html="challengeContent"
|
||||||
|
ref="$desc"
|
||||||
|
@copy.prevent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||||
|
<PromptPanel />
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
|
<div class="challenge-content">
|
||||||
|
<Preview
|
||||||
|
:html="html"
|
||||||
|
:css="css"
|
||||||
|
:js="js"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
show-code-button
|
||||||
|
clearable
|
||||||
|
@showCode="showCode = true"
|
||||||
|
@clear="clearAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showAssets"
|
||||||
|
preset="card"
|
||||||
|
title="素材"
|
||||||
|
style="width: 500px"
|
||||||
|
>
|
||||||
|
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||||
|
<n-gi v-for="asset in assets" :key="asset.name">
|
||||||
|
<n-card size="small" :title="asset.name">
|
||||||
|
<n-image
|
||||||
|
:src="asset.url"
|
||||||
|
style="width: 100%; height: 100px; object-fit: contain"
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-modal>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showCode"
|
||||||
|
preset="card"
|
||||||
|
title="代码"
|
||||||
|
style="width: 700px"
|
||||||
|
>
|
||||||
|
<n-tabs type="line">
|
||||||
|
<n-tab-pane name="html" tab="HTML">
|
||||||
|
<n-code :code="html" language="html" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="css" tab="CSS">
|
||||||
|
<n-code :code="css" language="css" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="js" tab="JS">
|
||||||
|
<n-code :code="js" language="javascript" />
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useMessage } from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { marked, type MarkedOptions } from "marked"
|
||||||
|
import copyFn from "copy-text-to-clipboard"
|
||||||
|
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||||
|
import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
|
||||||
|
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||||
|
import Preview from "../components/editor/Preview.vue"
|
||||||
|
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||||
|
import { Challenge, Submission, TaskAssets } from "../api"
|
||||||
|
import type { TaskAsset } from "../utils/type"
|
||||||
|
import { html, css, js } from "../store/editors"
|
||||||
|
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||||
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
import { authed, roleAdmin, roleSuper } from "../store/user"
|
||||||
|
import {
|
||||||
|
connectPrompt,
|
||||||
|
disconnectPrompt,
|
||||||
|
streaming,
|
||||||
|
setOnCodeComplete,
|
||||||
|
} from "../store/prompt"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const challengeRenderer = new marked.Renderer()
|
||||||
|
challengeRenderer.code = ({ text, lang }) => {
|
||||||
|
const language = lang?.toLowerCase() ?? "text"
|
||||||
|
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||||
|
<div class="codeblock-action">
|
||||||
|
<span class="lang">${language.toUpperCase()}</span>
|
||||||
|
<button class="action-btn" data-action="copy">复制</button>
|
||||||
|
</div>
|
||||||
|
<pre><code class="language-${language}">${text}</code></pre>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
challengeRenderer.link = ({ href, text }) =>
|
||||||
|
`<a href="${href}" target="_blank">${text}</a>`
|
||||||
|
|
||||||
|
function setupCodeCopy() {
|
||||||
|
$desc.value?.addEventListener("click", (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||||
|
if (!btn) return
|
||||||
|
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
||||||
|
const code = wrapper.querySelector("code")?.textContent ?? ""
|
||||||
|
copyFn(code)
|
||||||
|
btn.textContent = "已复制"
|
||||||
|
setTimeout(() => { btn.textContent = "复制" }, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = ref("desc")
|
||||||
|
const challengeContent = ref("")
|
||||||
|
const challengeAuthor = ref("")
|
||||||
|
const $desc = useTemplateRef<HTMLElement>("$desc")
|
||||||
|
const showCode = ref(false)
|
||||||
|
const showStats = ref(false)
|
||||||
|
const showAssets = ref(false)
|
||||||
|
const assets = ref<TaskAsset[]>([])
|
||||||
|
const historyRefreshKey = ref(0)
|
||||||
|
|
||||||
|
const assetBaseUrl = computed(
|
||||||
|
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(streaming, (val) => {
|
||||||
|
if (val) activeTab.value = "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadChallenge() {
|
||||||
|
const display = Number(route.params.display)
|
||||||
|
taskTab.value = TASK_TYPE.Challenge
|
||||||
|
challengeDisplay.value = display
|
||||||
|
const data = await Challenge.get(display)
|
||||||
|
taskId.value = data.task_ptr
|
||||||
|
challengeAuthor.value = data.author_name ?? ""
|
||||||
|
challengeContent.value = await marked.parse(data.content, {
|
||||||
|
async: true,
|
||||||
|
renderer: challengeRenderer,
|
||||||
|
} as MarkedOptions)
|
||||||
|
assets.value = await TaskAssets.listChallenge(display)
|
||||||
|
if (!authed.value) return
|
||||||
|
connectPrompt(data.task_ptr)
|
||||||
|
setOnCodeComplete(async (code, messageId) => {
|
||||||
|
try {
|
||||||
|
await Submission.create(
|
||||||
|
taskId.value,
|
||||||
|
{
|
||||||
|
html: code.html ?? "",
|
||||||
|
css: code.css ?? "",
|
||||||
|
js: code.js ?? "",
|
||||||
|
},
|
||||||
|
messageId,
|
||||||
|
)
|
||||||
|
historyRefreshKey.value++
|
||||||
|
message.success("已自动提交本次对话生成的代码")
|
||||||
|
} catch {
|
||||||
|
// 静默失败,不打扰用户
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit() {
|
||||||
|
router.push({
|
||||||
|
name: "challenge-editor",
|
||||||
|
params: { display: route.params.display },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
html.value = ""
|
||||||
|
css.value = ""
|
||||||
|
js.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
||||||
|
html.value = code.html
|
||||||
|
css.value = code.css
|
||||||
|
js.value = code.js
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
disconnectPrompt()
|
||||||
|
taskId.value = 0
|
||||||
|
router.push({ name: "home-challenge-list" })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupCodeCopy()
|
||||||
|
loadChallenge()
|
||||||
|
})
|
||||||
|
onUnmounted(disconnectPrompt)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.challenge-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider {
|
||||||
|
width: 40%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-content {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-tabs :deep(.n-tab-pane) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-pane {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-meta {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-select {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.challenge-sider .codeblock-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-wrapper pre {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-wrapper pre code {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: Monaco, monospace;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-action {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .codeblock-action .lang {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .action-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge-sider .action-btn:hover {
|
||||||
|
border-color: #18a058;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
<span>【{{ item.display }}】{{ item.title }}</span>
|
<span>【{{ item.display }}】{{ item.title }}</span>
|
||||||
<n-tag size="small" type="warning">{{ item.score }}分</n-tag>
|
<n-tag size="small" type="warning">{{ item.score }}分</n-tag>
|
||||||
|
<n-tag size="small">出题人 {{ item.author_name || "未设置" }}</n-tag>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
@@ -43,9 +44,12 @@
|
|||||||
|
|
||||||
<n-gi :span="6" class="col">
|
<n-gi :span="6" class="col">
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<n-form inline>
|
<n-form inline :show-feedback="false">
|
||||||
<n-form-item label="序号" label-placement="left">
|
<n-form-item label="序号" label-placement="left">
|
||||||
<n-input-number v-model:value="challenge.display" />
|
<n-input-number
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="challenge.display"
|
||||||
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="标题" label-placement="left">
|
<n-form-item label="标题" label-placement="left">
|
||||||
@@ -53,20 +57,32 @@
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="分数" label-placement="left">
|
<n-form-item label="分数" label-placement="left">
|
||||||
<n-input-number v-model:value="challenge.score" :min="0" />
|
<n-input-number
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="challenge.score"
|
||||||
|
:min="0"
|
||||||
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="公开" label-placement="left">
|
<n-form-item label="公开" label-placement="left">
|
||||||
<n-switch v-model:value="challenge.is_public" />
|
<n-switch v-model:value="challenge.is_public" />
|
||||||
</n-form-item>
|
</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-form-item label-placement="left">
|
||||||
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
||||||
提交
|
提交
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
<TaskAssetManager
|
||||||
|
v-if="challenge.display"
|
||||||
|
task-type="challenge"
|
||||||
|
:display="challenge.display"
|
||||||
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
style="height: calc(100vh - 90px)"
|
style="height: calc(100vh - 100px)"
|
||||||
v-model="challenge.content"
|
v-model="challenge.content"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -81,6 +97,7 @@ import { Challenge } from "../api"
|
|||||||
import type { ChallengeSlim } from "../utils/type"
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
import { useDialog, useMessage } from "naive-ui"
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||||
|
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -94,6 +111,7 @@ const challenge = reactive({
|
|||||||
content: "",
|
content: "",
|
||||||
score: 0,
|
score: 0,
|
||||||
is_public: false,
|
is_public: false,
|
||||||
|
author_name: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(
|
||||||
@@ -116,6 +134,7 @@ function createNew() {
|
|||||||
challenge.content = ""
|
challenge.content = ""
|
||||||
challenge.score = 0
|
challenge.score = 0
|
||||||
challenge.is_public = false
|
challenge.is_public = false
|
||||||
|
challenge.author_name = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -127,6 +146,7 @@ async function submit() {
|
|||||||
challenge.content = ""
|
challenge.content = ""
|
||||||
challenge.score = 0
|
challenge.score = 0
|
||||||
challenge.is_public = false
|
challenge.is_public = false
|
||||||
|
challenge.author_name = ""
|
||||||
await getContent()
|
await getContent()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response.data.detail)
|
message.error(error.response.data.detail)
|
||||||
@@ -155,6 +175,7 @@ async function show(display: number) {
|
|||||||
challenge.content = item.content
|
challenge.content = item.content
|
||||||
challenge.score = item.score
|
challenge.score = item.score
|
||||||
challenge.is_public = item.is_public
|
challenge.is_public = item.is_public
|
||||||
|
challenge.author_name = item.author_name ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePublic(display: number) {
|
async function togglePublic(display: number) {
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="challenge-layout">
|
|
||||||
<div class="challenge-sider">
|
|
||||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
|
||||||
<template #prefix>
|
|
||||||
<n-button text @click="back" style="margin: 0 8px">
|
|
||||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
<template #suffix>
|
|
||||||
<n-flex style="margin: 0 8px">
|
|
||||||
<n-button
|
|
||||||
v-if="authed"
|
|
||||||
text
|
|
||||||
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
|
|
||||||
>
|
|
||||||
<Icon :width="16" icon="lucide:list" />
|
|
||||||
</n-button>
|
|
||||||
<n-button v-if="roleSuper" text @click="edit">
|
|
||||||
<Icon :width="16" icon="lucide:edit" />
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
|
||||||
<div
|
|
||||||
class="markdown-body"
|
|
||||||
style="padding: 12px; overflow-y: auto; height: 100%"
|
|
||||||
v-html="challengeContent"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
|
||||||
<PromptPanel />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</div>
|
|
||||||
<div class="challenge-content">
|
|
||||||
<Preview
|
|
||||||
:html="html"
|
|
||||||
:css="css"
|
|
||||||
:js="js"
|
|
||||||
show-code-button
|
|
||||||
clearable
|
|
||||||
@showCode="showCode = true"
|
|
||||||
@clear="clearAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showCode"
|
|
||||||
preset="card"
|
|
||||||
title="代码"
|
|
||||||
style="width: 700px"
|
|
||||||
>
|
|
||||||
<n-tabs type="line">
|
|
||||||
<n-tab-pane name="html" tab="HTML">
|
|
||||||
<n-code :code="html" language="html" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="css" tab="CSS">
|
|
||||||
<n-code :code="css" language="css" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="js" tab="JS">
|
|
||||||
<n-code :code="js" language="javascript" />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, onMounted, onUnmounted } from "vue"
|
|
||||||
import { useRoute, useRouter } from "vue-router"
|
|
||||||
import { useMessage } from "naive-ui"
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { marked } from "marked"
|
|
||||||
import PromptPanel from "../components/PromptPanel.vue"
|
|
||||||
import Preview from "../components/Preview.vue"
|
|
||||||
import { Challenge, Submission } from "../api"
|
|
||||||
import { html, css, js } from "../store/editors"
|
|
||||||
import { taskId } from "../store/task"
|
|
||||||
import { authed, roleSuper } from "../store/user"
|
|
||||||
import {
|
|
||||||
connectPrompt,
|
|
||||||
disconnectPrompt,
|
|
||||||
conversationId,
|
|
||||||
streaming,
|
|
||||||
setOnCodeComplete,
|
|
||||||
loadHistory,
|
|
||||||
} from "../store/prompt"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const activeTab = ref("desc")
|
|
||||||
const challengeContent = ref("")
|
|
||||||
const showCode = ref(false)
|
|
||||||
|
|
||||||
watch(streaming, (val) => {
|
|
||||||
if (val) activeTab.value = "chat"
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadChallenge() {
|
|
||||||
const display = Number(route.params.display)
|
|
||||||
const data = await Challenge.get(display)
|
|
||||||
taskId.value = data.task_ptr
|
|
||||||
challengeContent.value = await marked.parse(data.content, { async: true })
|
|
||||||
if (!authed.value) return
|
|
||||||
loadHistory(data.task_ptr) // HTTP preload — async, non-blocking
|
|
||||||
connectPrompt(data.task_ptr) // WebSocket — synchronous open
|
|
||||||
setOnCodeComplete(async (code) => {
|
|
||||||
if (!conversationId.value) return
|
|
||||||
try {
|
|
||||||
await Submission.create(taskId.value, {
|
|
||||||
html: code.html ?? "",
|
|
||||||
css: code.css ?? "",
|
|
||||||
js: code.js ?? "",
|
|
||||||
conversationId: conversationId.value,
|
|
||||||
})
|
|
||||||
message.success("已自动提交本次对话生成的代码")
|
|
||||||
} catch {
|
|
||||||
// 静默失败,不打扰用户
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function edit() {
|
|
||||||
router.push({
|
|
||||||
name: "challenge-editor",
|
|
||||||
params: { display: route.params.display },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAll() {
|
|
||||||
html.value = ""
|
|
||||||
css.value = ""
|
|
||||||
js.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() {
|
|
||||||
disconnectPrompt()
|
|
||||||
router.push({ name: "home-challenge-list" })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadChallenge)
|
|
||||||
onUnmounted(disconnectPrompt)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.challenge-layout {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-sider {
|
|
||||||
width: 40%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid var(--n-border-color, #efeff5);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-content {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-tabs :deep(.n-tab-pane) {
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,40 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-flex class="container" :wrap="false">
|
<n-flex class="container" :wrap="false">
|
||||||
<n-flex vertical class="menu">
|
<div class="sidebar">
|
||||||
<n-button secondary @click="() => goHome($router, taskTab, step)">
|
<div class="back-btn" @click="() => goHome($router, taskTab, step)">
|
||||||
返回
|
← 返回
|
||||||
</n-button>
|
</div>
|
||||||
<n-button
|
<n-divider style="margin: 8px 0" />
|
||||||
|
<div
|
||||||
v-for="item in menu"
|
v-for="item in menu"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
:type="$route.name === item.route.name ? 'primary' : 'default'"
|
:class="['nav-item', { active: $route.name === item.route.name }]"
|
||||||
@click="$router.push(item.route)"
|
@click="$router.push(item.route)"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</n-button>
|
</div>
|
||||||
</n-flex>
|
</div>
|
||||||
<n-flex class="content">
|
<div class="content">
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
import { taskTab } from "../store/task"
|
import { taskTab } from "../store/task"
|
||||||
import { step } from "../store/tutorial"
|
import { step } from "../store/tutorial"
|
||||||
|
import { roleAdmin, roleSuper } from "../store/user"
|
||||||
import { goHome } from "../utils/helper"
|
import { goHome } from "../utils/helper"
|
||||||
|
|
||||||
const menu = [
|
const menu = computed(() =>
|
||||||
{
|
[
|
||||||
label: "教程",
|
{
|
||||||
route: { name: "tutorial-editor", params: { display: step.value } },
|
label: "教程",
|
||||||
},
|
route: { name: "tutorial-editor", params: { display: step.value } },
|
||||||
{
|
show: roleSuper.value,
|
||||||
label: "挑战",
|
},
|
||||||
route: { name: "challenge-editor", params: { display: 0 } },
|
{
|
||||||
},
|
label: "挑战",
|
||||||
{ label: "用户", route: { name: "user-manage", params: { page: 1 } } },
|
route: { name: "challenge-editor", params: { display: 0 } },
|
||||||
{ label: "提交", route: { name: "submissions", params: { page: 1 } } },
|
show: roleAdmin.value || roleSuper.value,
|
||||||
]
|
},
|
||||||
|
{
|
||||||
|
label: "用户",
|
||||||
|
route: { name: "user-manage", params: { page: 1 } },
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "工坊",
|
||||||
|
route: { name: "showcase-manage" },
|
||||||
|
show: roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "成绩",
|
||||||
|
route: { name: "gradebook" },
|
||||||
|
show: roleAdmin.value || roleSuper.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "提交",
|
||||||
|
route: { name: "submissions", params: { page: 1 } },
|
||||||
|
show: roleAdmin.value || roleSuper.value,
|
||||||
|
},
|
||||||
|
].filter((item) => item.show),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
@@ -42,13 +67,60 @@ const menu = [
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.menu {
|
|
||||||
width: 100px;
|
.sidebar {
|
||||||
padding: 10px 0 10px 10px;
|
width: 110px;
|
||||||
|
min-width: 110px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-right: 1px solid #efeff5;
|
||||||
|
background: #fafafa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background-color: #e8f8f0;
|
||||||
|
color: #18a058;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
flex: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: calc(100vw - 100px);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
411
src/pages/Gradebook.vue
Normal file
411
src/pages/Gradebook.vue
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
<template>
|
||||||
|
<n-flex
|
||||||
|
vertical
|
||||||
|
:size="12"
|
||||||
|
style="height: 100%; min-width: 0; box-sizing: border-box; padding: 10px 10px 10px 0; overflow: hidden;"
|
||||||
|
>
|
||||||
|
<n-flex class="toolbar" align="center" justify="space-between" style="flex-shrink: 0;">
|
||||||
|
<n-flex align="center" :size="8" wrap style="min-width: 0;">
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.classname"
|
||||||
|
class="class-select"
|
||||||
|
:options="classOptions"
|
||||||
|
placeholder="班级"
|
||||||
|
:loading="classesLoading"
|
||||||
|
/>
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.task_type"
|
||||||
|
class="type-select"
|
||||||
|
:options="taskTypeOptions"
|
||||||
|
/>
|
||||||
|
<n-input
|
||||||
|
v-model:value="query.username"
|
||||||
|
class="search-input"
|
||||||
|
clearable
|
||||||
|
placeholder="学生搜索"
|
||||||
|
/>
|
||||||
|
<n-switch v-model:value="query.include_all_tasks">
|
||||||
|
<template #checked>全部有提交任务</template>
|
||||||
|
<template #unchecked>只看计入任务</template>
|
||||||
|
</n-switch>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center" :size="8">
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
title="刷新"
|
||||||
|
:disabled="!query.classname"
|
||||||
|
:loading="loading"
|
||||||
|
@click="loadGradebook"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:refresh-cw" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
:disabled="!query.classname || !gradebook"
|
||||||
|
:loading="exporting"
|
||||||
|
@click="exportCsv"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:download" :width="15" />
|
||||||
|
</template>
|
||||||
|
导出 CSV
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
||||||
|
{{ loadError }}
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-flex v-if="gradebook" align="center" :size="8" style="flex-shrink: 0;">
|
||||||
|
<n-tag size="small">学生 {{ gradebook.student_count }}</n-tag>
|
||||||
|
<n-tag size="small">任务 {{ gradebook.task_count }}</n-tag>
|
||||||
|
<n-tag size="small" type="success">
|
||||||
|
计入 {{ gradebook.included_task_count }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag size="small">
|
||||||
|
覆盖门槛 {{ gradebook.coverage_threshold_count }} 人
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-data-table
|
||||||
|
size="small"
|
||||||
|
striped
|
||||||
|
flex-height
|
||||||
|
:loading="loading"
|
||||||
|
:columns="columns"
|
||||||
|
:data="rows"
|
||||||
|
:row-key="(row: GradebookRow) => row.user_id"
|
||||||
|
:scroll-x="scrollX"
|
||||||
|
style="flex: 1; min-height: 0;"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { watchDebounced } from "@vueuse/core"
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NTag,
|
||||||
|
NText,
|
||||||
|
useMessage,
|
||||||
|
type DataTableColumn,
|
||||||
|
} from "naive-ui"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Account, Gradebook } from "../api"
|
||||||
|
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||||
|
import type {
|
||||||
|
GradebookCell,
|
||||||
|
GradebookOut,
|
||||||
|
GradebookQuery,
|
||||||
|
GradebookRow,
|
||||||
|
GradebookTask,
|
||||||
|
GradebookTaskType,
|
||||||
|
} from "../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const classesLoading = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const loadError = ref("")
|
||||||
|
const gradebook = ref<GradebookOut | null>(null)
|
||||||
|
const classes = ref<string[]>([])
|
||||||
|
|
||||||
|
const query = reactive<GradebookQuery>({
|
||||||
|
classname: "",
|
||||||
|
task_type: "",
|
||||||
|
username: "",
|
||||||
|
include_all_tasks: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "教程", value: "tutorial" },
|
||||||
|
{ label: "挑战", value: "challenge" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const classOptions = computed(() =>
|
||||||
|
classes.value.map((classname) => ({ label: classname, value: classname })),
|
||||||
|
)
|
||||||
|
const rows = computed(() => gradebook.value?.rows ?? [])
|
||||||
|
const scrollX = computed(() => 860 + (gradebook.value?.tasks.length ?? 0) * 96)
|
||||||
|
|
||||||
|
function formatScore(value: number | null) {
|
||||||
|
if (value === null) return "-"
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskTitle(task: GradebookTask) {
|
||||||
|
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战"
|
||||||
|
return `${typeLabel}${task.display}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubmission(cell: GradebookCell) {
|
||||||
|
if (!cell.submitted || !cell.submission_id) return
|
||||||
|
const { href } = router.resolve({
|
||||||
|
name: "submission",
|
||||||
|
params: { id: cell.submission_id },
|
||||||
|
})
|
||||||
|
window.open(href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradeTagType(grade: string) {
|
||||||
|
if (grade === "A") return "success"
|
||||||
|
if (grade === "B") return "info"
|
||||||
|
if (grade === "C") return "default"
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskHeader(task: GradebookTask) {
|
||||||
|
return h("div", { class: ["task-header", { muted: !task.included }] }, [
|
||||||
|
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
|
||||||
|
h("div", { class: "task-meta" }, [
|
||||||
|
h("span", `${Math.round(task.coverage * 100)}%`),
|
||||||
|
task.included
|
||||||
|
? null
|
||||||
|
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScore(row: GradebookRow, task: GradebookTask) {
|
||||||
|
const cell = row.scores[task.id]
|
||||||
|
if (!cell || !cell.submitted) {
|
||||||
|
return h(
|
||||||
|
NText,
|
||||||
|
{ class: "missing-cell", type: "error" },
|
||||||
|
{ default: () => "缺交" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
type: task.included ? "primary" : "default",
|
||||||
|
class: ["score-link", { muted: !task.included }],
|
||||||
|
onClick: (event: MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
openSubmission(cell)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ default: () => formatScore(cell.score) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMissingCount(value: number) {
|
||||||
|
if (value <= 0) return "0"
|
||||||
|
return h(NText, { type: "error" }, { default: () => String(value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
|
||||||
|
const tasks = gradebook.value?.tasks ?? []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "排名",
|
||||||
|
key: "rank",
|
||||||
|
width: 60,
|
||||||
|
fixed: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "等级",
|
||||||
|
key: "grade",
|
||||||
|
width: 60,
|
||||||
|
fixed: "left",
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: gradeTagType(row.grade) },
|
||||||
|
{ default: () => row.grade },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "学生",
|
||||||
|
key: "username",
|
||||||
|
width: 80,
|
||||||
|
fixed: "left",
|
||||||
|
render: (row) => {
|
||||||
|
const studentName = displayGradebookStudentName(row)
|
||||||
|
return h(NText, { title: studentName }, { default: () => studentName })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...tasks.map((task) => ({
|
||||||
|
title: () => renderTaskHeader(task),
|
||||||
|
key: `task-${task.id}`,
|
||||||
|
width: 96,
|
||||||
|
align: "center" as const,
|
||||||
|
className: task.included ? "" : "excluded-task-column",
|
||||||
|
render: (row: GradebookRow) => renderScore(row, task),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
title: "教程合计",
|
||||||
|
key: "tutorial_total",
|
||||||
|
width: 92,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.tutorial_total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "挑战合计",
|
||||||
|
key: "challenge_total",
|
||||||
|
width: 92,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.challenge_total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "总分",
|
||||||
|
key: "total_score",
|
||||||
|
width: 82,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.total_score),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "平均",
|
||||||
|
key: "average_score",
|
||||||
|
width: 82,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => formatScore(row.average_score),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "已交",
|
||||||
|
key: "submitted_task_count",
|
||||||
|
width: 70,
|
||||||
|
fixed: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "缺交",
|
||||||
|
key: "missing_task_count",
|
||||||
|
width: 70,
|
||||||
|
fixed: "right",
|
||||||
|
render: (row) => renderMissingCount(row.missing_task_count),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadClasses() {
|
||||||
|
classesLoading.value = true
|
||||||
|
try {
|
||||||
|
classes.value = await Account.listClasses()
|
||||||
|
if (!query.classname && classes.value.length > 0) {
|
||||||
|
query.classname = classes.value[0]
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
classesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGradebook() {
|
||||||
|
if (!query.classname) {
|
||||||
|
gradebook.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ""
|
||||||
|
try {
|
||||||
|
gradebook.value = await Gradebook.get(query)
|
||||||
|
classes.value = gradebook.value.classes
|
||||||
|
} catch (err: any) {
|
||||||
|
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!query.classname) return
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
await Gradebook.downloadCsv(query)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "导出失败")
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [query.classname, query.task_type, query.include_all_tasks],
|
||||||
|
() => loadGradebook(),
|
||||||
|
)
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => query.username,
|
||||||
|
() => loadGradebook(),
|
||||||
|
{ debounce: 400, maxWait: 1000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadClasses()
|
||||||
|
await loadGradebook()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.class-select {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-select {
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header.muted {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
display: flex;
|
||||||
|
min-height: 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-cell {
|
||||||
|
color: #d03050;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-link.muted {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.excluded-task-column) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-select,
|
||||||
|
.type-select,
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
161
src/pages/Showcase.vue
Normal file
161
src/pages/Showcase.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<main class="showcase">
|
||||||
|
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;">
|
||||||
|
<div>
|
||||||
|
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2>
|
||||||
|
<n-text depth="3">优秀作品展示</n-text>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!loading && awards.length === 0"
|
||||||
|
description="暂无展示作品"
|
||||||
|
style="margin-top: 72px;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="section in awards"
|
||||||
|
:key="section.id"
|
||||||
|
style="margin-bottom: 48px;"
|
||||||
|
>
|
||||||
|
<n-flex vertical :size="4" style="margin-bottom: 16px;">
|
||||||
|
<n-h3 style="margin: 0;">{{ section.name }}</n-h3>
|
||||||
|
<n-text v-if="section.description" depth="3" style="font-size: 13px;">
|
||||||
|
{{ section.description }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<n-card
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.submission_id"
|
||||||
|
class="work-card"
|
||||||
|
content-style="padding: 0;"
|
||||||
|
hoverable
|
||||||
|
@click="openDetail(item.submission_id)"
|
||||||
|
>
|
||||||
|
<div class="card-preview">
|
||||||
|
<iframe
|
||||||
|
:srcdoc="buildSrcdoc(item)"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
scrolling="no"
|
||||||
|
class="preview-iframe"
|
||||||
|
/>
|
||||||
|
<div class="preview-overlay" />
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<n-flex justify="space-between" align="center" :wrap="false">
|
||||||
|
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;">
|
||||||
|
{{ item.username }}
|
||||||
|
</n-ellipsis>
|
||||||
|
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
|
||||||
|
<n-flex align="center" :size="3">
|
||||||
|
<Icon icon="lucide:star" :width="13" />
|
||||||
|
<n-text style="font-size: 12px; color: #666;">
|
||||||
|
{{ item.score.toFixed(1) }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center" :size="3">
|
||||||
|
<Icon icon="lucide:eye" :width="13" />
|
||||||
|
<n-text style="font-size: 12px; color: #666;">
|
||||||
|
{{ item.view_count }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-ellipsis
|
||||||
|
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: #888;"
|
||||||
|
>
|
||||||
|
{{ item.task_title }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</n-spin>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Showcase } from "../api"
|
||||||
|
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
const awards = ref<AwardSection[]>([])
|
||||||
|
|
||||||
|
function buildSrcdoc(item: ShowcaseItem): string {
|
||||||
|
const css = item.css ? `<style>${item.css}</style>` : ""
|
||||||
|
const js = item.js ? `<script>${item.js}<\/script>` : ""
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(id: string) {
|
||||||
|
router.push({ name: "showcase-detail", params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
awards.value = await Showcase.list()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.showcase {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card {
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-preview {
|
||||||
|
position: relative;
|
||||||
|
height: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-iframe {
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
border: none;
|
||||||
|
transform: scale(0.5);
|
||||||
|
transform-origin: top left;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
294
src/pages/ShowcaseDetail.vue
Normal file
294
src/pages/ShowcaseDetail.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<template>
|
||||||
|
<main v-if="detail" class="detail-layout">
|
||||||
|
<section class="preview-panel">
|
||||||
|
<div class="back-bar">
|
||||||
|
<n-button text @click="router.back()">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:arrow-left" />
|
||||||
|
</template>
|
||||||
|
返回创意工坊
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-if="detailSrcdoc"
|
||||||
|
:srcdoc="detailSrcdoc"
|
||||||
|
class="preview-iframe"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="info-panel">
|
||||||
|
<n-flex vertical :size="0">
|
||||||
|
<n-h3 style="margin: 0 0 4px;">{{ detail.task_title }}</n-h3>
|
||||||
|
<n-text depth="3">{{ detail.username }}</n-text>
|
||||||
|
<n-flex wrap :size="8" style="margin-top: 12px;">
|
||||||
|
<n-tag
|
||||||
|
v-for="award in detail.awards"
|
||||||
|
:key="award"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ award }}
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex :size="18" style="margin-top: 14px;">
|
||||||
|
<n-flex align="center" :size="6">
|
||||||
|
<Icon icon="lucide:star" :width="16" />
|
||||||
|
<n-text strong style="font-size: 14px;">
|
||||||
|
{{ detail.score.toFixed(1) }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center" :size="6">
|
||||||
|
<Icon icon="lucide:eye" :width="16" />
|
||||||
|
<n-text strong style="font-size: 14px;">
|
||||||
|
{{ detail.view_count }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-divider v-if="detail.has_prompt_chain" />
|
||||||
|
|
||||||
|
<n-collapse
|
||||||
|
v-if="detail.has_prompt_chain"
|
||||||
|
@update:expanded-names="onCollapseChange"
|
||||||
|
>
|
||||||
|
<n-collapse-item title="创作过程" name="chain">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-text depth="3" style="font-size: 12px;">点击展开</n-text>
|
||||||
|
</template>
|
||||||
|
<n-spin :show="chainLoading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!chainLoading && rounds.length === 0"
|
||||||
|
description="暂无记录"
|
||||||
|
/>
|
||||||
|
<n-flex v-else vertical :size="12">
|
||||||
|
<n-scrollbar style="max-height: 260px;">
|
||||||
|
<n-flex vertical :size="8" style="padding-right: 4px;">
|
||||||
|
<n-card
|
||||||
|
v-for="(round, i) in rounds"
|
||||||
|
:key="i"
|
||||||
|
size="small"
|
||||||
|
content-style="padding: 8px;"
|
||||||
|
:style="{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderColor: selectedRound === i ? '#2080f0' : undefined,
|
||||||
|
background: selectedRound === i ? '#e8f0fe' : undefined,
|
||||||
|
}"
|
||||||
|
@click="selectedRound = i"
|
||||||
|
>
|
||||||
|
<n-flex align="flex-start" :size="8">
|
||||||
|
<n-avatar
|
||||||
|
round
|
||||||
|
:size="20"
|
||||||
|
:color="selectedRound === i ? '#2080f0' : '#9db7e8'"
|
||||||
|
style="font-size: 11px; font-weight: 700; flex-shrink: 0;"
|
||||||
|
>
|
||||||
|
{{ i + 1 }}
|
||||||
|
</n-avatar>
|
||||||
|
<n-flex vertical :size="4" style="min-width: 0; flex: 1;">
|
||||||
|
<n-text style="font-size: 12px; line-height: 1.5;">
|
||||||
|
{{ round.question }}
|
||||||
|
</n-text>
|
||||||
|
<n-flex :size="5">
|
||||||
|
<n-tag size="small" style="font-size: 10px;">
|
||||||
|
{{ round.source === "conversation" ? "对话" : "手动" }}
|
||||||
|
</n-tag>
|
||||||
|
<n-text
|
||||||
|
v-if="round.prompt_level"
|
||||||
|
:style="{
|
||||||
|
color: levelColors[round.prompt_level],
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
L{{ round.prompt_level }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-card>
|
||||||
|
</n-flex>
|
||||||
|
</n-scrollbar>
|
||||||
|
|
||||||
|
<n-flex vertical :size="8">
|
||||||
|
<n-text strong style="font-size: 12px; color: #555;">
|
||||||
|
第 {{ selectedRound + 1 }} 轮效果
|
||||||
|
</n-text>
|
||||||
|
<iframe
|
||||||
|
v-if="selectedRoundSrcdoc"
|
||||||
|
:key="selectedRound"
|
||||||
|
:srcdoc="selectedRoundSrcdoc"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
class="round-iframe"
|
||||||
|
/>
|
||||||
|
<n-flex
|
||||||
|
v-else
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
style="min-height: 240px;"
|
||||||
|
>
|
||||||
|
<n-empty description="该轮无网页代码" />
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-spin>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<n-flex
|
||||||
|
v-else-if="notFound"
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
style="min-height: 100vh; padding: 40px;"
|
||||||
|
>
|
||||||
|
<n-empty description="作品不存在" />
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-flex v-else justify="center" align="center" style="min-height: 100vh;">
|
||||||
|
<n-spin />
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Showcase, Submission } from "../api"
|
||||||
|
import type { PromptRound, ShowcaseDetail } from "../utils/type"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const detail = ref<ShowcaseDetail | null>(null)
|
||||||
|
const notFound = ref(false)
|
||||||
|
const rounds = ref<PromptRound[]>([])
|
||||||
|
const chainLoading = ref(false)
|
||||||
|
const selectedRound = ref(0)
|
||||||
|
const chainLoaded = ref(false)
|
||||||
|
|
||||||
|
const levelColors: Record<number, string> = {
|
||||||
|
1: "#888",
|
||||||
|
2: "#4f8f7f",
|
||||||
|
3: "#2f7bc1",
|
||||||
|
4: "#aa5f9f",
|
||||||
|
5: "#c48620",
|
||||||
|
6: "#c94f4f",
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailSrcdoc = computed(() => {
|
||||||
|
if (!detail.value) return null
|
||||||
|
return buildDetailHtml(detail.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedRoundSrcdoc = computed(() => {
|
||||||
|
const round = rounds.value[selectedRound.value]
|
||||||
|
if (!round?.html) return null
|
||||||
|
const style = round.css ? `<style>${round.css}</style>` : ""
|
||||||
|
const script = round.js ? `<script>${round.js}<\/script>` : ""
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${style}</head><body>${round.html}${script}</body></html>`
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildDetailHtml(d: ShowcaseDetail) {
|
||||||
|
const css = d.css ? `<style>${d.css}</style>` : ""
|
||||||
|
const js = d.js ? `<script>${d.js}<\/script>` : ""
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${d.html ?? ""}${js}</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChain() {
|
||||||
|
if (chainLoaded.value) return
|
||||||
|
chainLoading.value = true
|
||||||
|
try {
|
||||||
|
rounds.value = await Showcase.getPromptChain(props.id)
|
||||||
|
selectedRound.value = Math.max(0, rounds.value.length - 1)
|
||||||
|
chainLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
chainLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCollapseChange(
|
||||||
|
names: string | number | Array<string | number> | null,
|
||||||
|
) {
|
||||||
|
const expanded = Array.isArray(names) ? names : names == null ? [] : [names]
|
||||||
|
if (expanded.includes("chain")) void loadChain()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
detail.value = await Showcase.getDetail(props.id)
|
||||||
|
void Submission.incrementView(props.id)
|
||||||
|
} catch {
|
||||||
|
notFound.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-iframe {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
width: 360px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-iframe {
|
||||||
|
min-height: 240px;
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.detail-layout {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel {
|
||||||
|
min-height: 56vh;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
600
src/pages/ShowcaseManage.vue
Normal file
600
src/pages/ShowcaseManage.vue
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
<template>
|
||||||
|
<n-layout has-sider style="height: 100%;">
|
||||||
|
<n-layout-sider
|
||||||
|
:width="260"
|
||||||
|
bordered
|
||||||
|
content-style="overflow: auto; height: 100%;"
|
||||||
|
style="background: #fafafa;"
|
||||||
|
>
|
||||||
|
<n-flex class="panel-header" justify="space-between" align="center">
|
||||||
|
<n-text strong>奖项</n-text>
|
||||||
|
<n-button size="small" secondary title="新建奖项" @click="startCreate">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:plus" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-spin :show="awardsLoading">
|
||||||
|
<n-empty
|
||||||
|
v-if="!awardsLoading && awards.length === 0"
|
||||||
|
description="暂无奖项"
|
||||||
|
size="small"
|
||||||
|
style="margin-top: 40px;"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-for="award in awards"
|
||||||
|
:key="award.id"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'award-row',
|
||||||
|
{ active: currentAwardId === award.id && !creating },
|
||||||
|
]"
|
||||||
|
@click="selectAward(award)"
|
||||||
|
>
|
||||||
|
<n-ellipsis style="flex: 1; min-width: 0; font-size: 14px; font-weight: 500;">
|
||||||
|
{{ award.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
<n-flex align="center" :size="6" style="flex-shrink: 0; color: #777; font-size: 12px;">
|
||||||
|
<n-tag v-if="!award.is_active" size="small">停用</n-tag>
|
||||||
|
<span>{{ award.item_count }} 件</span>
|
||||||
|
</n-flex>
|
||||||
|
</button>
|
||||||
|
</n-spin>
|
||||||
|
</n-layout-sider>
|
||||||
|
|
||||||
|
<n-layout content-style="padding: 12px; overflow: auto; height: 100%; box-sizing: border-box;">
|
||||||
|
<n-form
|
||||||
|
:model="awardDraft"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="82"
|
||||||
|
style="max-width: 1100px;"
|
||||||
|
>
|
||||||
|
<n-grid :cols="4" :x-gap="12" :y-gap="8" responsive="screen">
|
||||||
|
<n-form-item-gi :span="2" label="名称">
|
||||||
|
<n-input v-model:value="awardDraft.name" placeholder="奖项名称" />
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi label="排序">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="awardDraft.sort_order"
|
||||||
|
:show-button="false"
|
||||||
|
style="width: 120px;"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi label="启用">
|
||||||
|
<n-switch v-model:value="awardDraft.is_active" />
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi :span="2" label="简介">
|
||||||
|
<n-input
|
||||||
|
v-model:value="awardDraft.description"
|
||||||
|
placeholder="可留空"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi label="作品排序">
|
||||||
|
<n-select
|
||||||
|
v-model:value="awardDraft.item_ordering"
|
||||||
|
:options="orderingOptions"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi>
|
||||||
|
<n-flex justify="end" style="width: 100%;">
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!canSaveAward"
|
||||||
|
:loading="savingAward"
|
||||||
|
@click="saveAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:save" :width="15" />
|
||||||
|
</template>
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="currentAwardId && !creating"
|
||||||
|
tertiary
|
||||||
|
type="error"
|
||||||
|
:loading="deletingAward"
|
||||||
|
@click="deleteCurrentAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:trash-2" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-form-item-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<n-divider />
|
||||||
|
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-bottom: 10px;">
|
||||||
|
<n-text strong>已授奖作品</n-text>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
:disabled="!currentAwardId || creating"
|
||||||
|
@click="openAddWorkModal"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:plus" :width="15" />
|
||||||
|
</template>
|
||||||
|
添加作品
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
secondary
|
||||||
|
title="刷新"
|
||||||
|
:disabled="!currentAwardId || creating"
|
||||||
|
:loading="itemsLoading"
|
||||||
|
@click="loadAwardItems"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:refresh-cw" :width="15" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table
|
||||||
|
size="small"
|
||||||
|
striped
|
||||||
|
:columns="itemColumns"
|
||||||
|
:data="awardItems"
|
||||||
|
:loading="itemsLoading"
|
||||||
|
:row-key="(row: AwardItemManageOut) => row.id"
|
||||||
|
style="max-width: 1100px;"
|
||||||
|
/>
|
||||||
|
</n-layout>
|
||||||
|
</n-layout>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="addWorkModalVisible"
|
||||||
|
preset="card"
|
||||||
|
title="添加作品"
|
||||||
|
style="width: min(640px, calc(100vw - 32px));"
|
||||||
|
>
|
||||||
|
<n-flex vertical :size="12">
|
||||||
|
<n-input-group>
|
||||||
|
<n-input
|
||||||
|
v-model:value="lookupSubmissionId"
|
||||||
|
clearable
|
||||||
|
placeholder="提交 ID"
|
||||||
|
@keyup.enter="findSubmissionForAward"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!lookupSubmissionId.trim()"
|
||||||
|
:loading="lookupLoading"
|
||||||
|
@click="findSubmissionForAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:search" :width="15" />
|
||||||
|
</template>
|
||||||
|
查找
|
||||||
|
</n-button>
|
||||||
|
</n-input-group>
|
||||||
|
|
||||||
|
<n-alert v-if="lookupError" type="error">
|
||||||
|
{{ lookupError }}
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-flex v-if="submissionCandidate" vertical :size="12">
|
||||||
|
<n-descriptions :column="2" size="small" bordered>
|
||||||
|
<n-descriptions-item label="提交者">
|
||||||
|
{{ submissionCandidate.username }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="任务">
|
||||||
|
{{ submissionCandidate.task_title }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="得分">
|
||||||
|
{{
|
||||||
|
submissionCandidate.score > 0
|
||||||
|
? submissionCandidate.score.toFixed(2)
|
||||||
|
: "-"
|
||||||
|
}}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="浏览">
|
||||||
|
{{ submissionCandidate.view_count }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="提示词">
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:type="
|
||||||
|
submissionCandidate.has_prompt_chain ? 'success' : 'default'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ submissionCandidate.has_prompt_chain ? "有" : "无" }}
|
||||||
|
</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="状态">
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:type="candidateAlreadyAwarded ? 'default' : 'info'"
|
||||||
|
>
|
||||||
|
{{ candidateAlreadyAwarded ? "已添加" : "可添加" }}
|
||||||
|
</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
<n-flex justify="end" style="width: 100%;">
|
||||||
|
<n-button secondary @click="clearSubmissionLookup">清空</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="candidateAlreadyAwarded"
|
||||||
|
:loading="addingCandidate"
|
||||||
|
@click="addCandidateToAward"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon
|
||||||
|
:icon="candidateAlreadyAwarded ? 'lucide:check' : 'lucide:plus'"
|
||||||
|
:width="15"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
{{ candidateAlreadyAwarded ? "已添加" : "添加到奖项" }}
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, reactive, ref } from "vue"
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NInputNumber,
|
||||||
|
NTag,
|
||||||
|
useMessage,
|
||||||
|
type DataTableColumn,
|
||||||
|
} from "naive-ui"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { Showcase } from "../api"
|
||||||
|
import type {
|
||||||
|
AwardItemManageOut,
|
||||||
|
AwardManageIn,
|
||||||
|
AwardManageOut,
|
||||||
|
ItemOrdering,
|
||||||
|
ShowcaseSubmissionLookupOut,
|
||||||
|
} from "../utils/type"
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const awards = ref<AwardManageOut[]>([])
|
||||||
|
const awardItems = ref<AwardItemManageOut[]>([])
|
||||||
|
const currentAwardId = ref<number | null>(null)
|
||||||
|
const creating = ref(false)
|
||||||
|
const awardsLoading = ref(false)
|
||||||
|
const itemsLoading = ref(false)
|
||||||
|
const savingAward = ref(false)
|
||||||
|
const deletingAward = ref(false)
|
||||||
|
const updatingItemIds = ref(new Set<number>())
|
||||||
|
const addWorkModalVisible = ref(false)
|
||||||
|
const lookupSubmissionId = ref("")
|
||||||
|
const lookupLoading = ref(false)
|
||||||
|
const lookupError = ref("")
|
||||||
|
const submissionCandidate = ref<ShowcaseSubmissionLookupOut | null>(null)
|
||||||
|
const addingCandidate = ref(false)
|
||||||
|
|
||||||
|
const awardDraft = reactive<AwardManageIn>(defaultAward())
|
||||||
|
|
||||||
|
const orderingOptions: { label: string; value: ItemOrdering }[] = [
|
||||||
|
{ label: "手动排序", value: "manual" },
|
||||||
|
{ label: "授奖时间", value: "awarded_at" },
|
||||||
|
{ label: "评分", value: "score" },
|
||||||
|
{ label: "浏览量", value: "view_count" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const canSaveAward = computed(() => awardDraft.name.trim().length > 0)
|
||||||
|
const awardedSubmissionIds = computed(
|
||||||
|
() => new Set(awardItems.value.map((item) => item.submission_id)),
|
||||||
|
)
|
||||||
|
const candidateAlreadyAwarded = computed(
|
||||||
|
() =>
|
||||||
|
!!submissionCandidate.value &&
|
||||||
|
awardedSubmissionIds.value.has(submissionCandidate.value.submission_id),
|
||||||
|
)
|
||||||
|
const nextSortOrder = computed(() => {
|
||||||
|
if (awardItems.value.length === 0) return 0
|
||||||
|
return Math.max(...awardItems.value.map((item) => item.sort_order)) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemColumns: DataTableColumn<AwardItemManageOut>[] = [
|
||||||
|
{
|
||||||
|
title: "排序",
|
||||||
|
key: "sort_order",
|
||||||
|
width: 92,
|
||||||
|
render: (row) =>
|
||||||
|
h(NInputNumber, {
|
||||||
|
value: row.sort_order,
|
||||||
|
min: 0,
|
||||||
|
size: "small",
|
||||||
|
showButton: false,
|
||||||
|
class: "table-number-input",
|
||||||
|
loading: updatingItemIds.value.has(row.id),
|
||||||
|
"onUpdate:value": (value: number | null) =>
|
||||||
|
updateItemOrder(row, value ?? 0),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "提交者",
|
||||||
|
key: "username",
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "任务",
|
||||||
|
key: "task_title",
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "得分",
|
||||||
|
key: "score",
|
||||||
|
width: 72,
|
||||||
|
render: (row) => (row.score > 0 ? row.score.toFixed(2) : "-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "浏览",
|
||||||
|
key: "view_count",
|
||||||
|
width: 72,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "提示词",
|
||||||
|
key: "has_prompt_chain",
|
||||||
|
width: 88,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ size: "small", type: row.has_prompt_chain ? "success" : "default" },
|
||||||
|
{ default: () => (row.has_prompt_chain ? "有" : "无") },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "actions",
|
||||||
|
width: 54,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
tertiary: true,
|
||||||
|
type: "error",
|
||||||
|
title: "移除",
|
||||||
|
onClick: () => removeAwardItem(row),
|
||||||
|
},
|
||||||
|
{ icon: () => h(Icon, { icon: "lucide:trash-2", width: 15 }) },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function defaultAward(): AwardManageIn {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort_order: 0,
|
||||||
|
is_active: true,
|
||||||
|
item_ordering: "manual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignDraft(award: AwardManageIn) {
|
||||||
|
Object.assign(awardDraft, {
|
||||||
|
name: award.name,
|
||||||
|
description: award.description,
|
||||||
|
sort_order: award.sort_order,
|
||||||
|
is_active: award.is_active,
|
||||||
|
item_ordering: award.item_ordering,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
currentAwardId.value = null
|
||||||
|
creating.value = true
|
||||||
|
awardItems.value = []
|
||||||
|
assignDraft(defaultAward())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAwards() {
|
||||||
|
awardsLoading.value = true
|
||||||
|
try {
|
||||||
|
awards.value = await Showcase.listManageAwards()
|
||||||
|
} finally {
|
||||||
|
awardsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAward(award: AwardManageOut) {
|
||||||
|
currentAwardId.value = award.id
|
||||||
|
creating.value = false
|
||||||
|
assignDraft(award)
|
||||||
|
await loadAwardItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAward() {
|
||||||
|
if (!canSaveAward.value) return
|
||||||
|
const payload: AwardManageIn = {
|
||||||
|
...awardDraft,
|
||||||
|
name: awardDraft.name.trim(),
|
||||||
|
description: awardDraft.description.trim(),
|
||||||
|
}
|
||||||
|
savingAward.value = true
|
||||||
|
try {
|
||||||
|
const saved =
|
||||||
|
currentAwardId.value && !creating.value
|
||||||
|
? await Showcase.updateAward(currentAwardId.value, payload)
|
||||||
|
: await Showcase.createAward(payload)
|
||||||
|
await loadAwards()
|
||||||
|
const next = awards.value.find((award) => award.id === saved.id)
|
||||||
|
if (next) await selectAward(next)
|
||||||
|
message.success("已保存")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "保存失败")
|
||||||
|
} finally {
|
||||||
|
savingAward.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentAward() {
|
||||||
|
if (!currentAwardId.value || creating.value) return
|
||||||
|
if (!window.confirm("确定删除这个奖项?")) return
|
||||||
|
deletingAward.value = true
|
||||||
|
try {
|
||||||
|
await Showcase.deleteAward(currentAwardId.value)
|
||||||
|
await loadAwards()
|
||||||
|
if (awards.value.length > 0) await selectAward(awards.value[0])
|
||||||
|
else startCreate()
|
||||||
|
message.success("已删除")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "删除失败")
|
||||||
|
} finally {
|
||||||
|
deletingAward.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAwardItems() {
|
||||||
|
if (!currentAwardId.value || creating.value) {
|
||||||
|
awardItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemsLoading.value = true
|
||||||
|
try {
|
||||||
|
awardItems.value = await Showcase.listAwardItems(currentAwardId.value)
|
||||||
|
} finally {
|
||||||
|
itemsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUpdatingItem(id: number, loading: boolean) {
|
||||||
|
const next = new Set(updatingItemIds.value)
|
||||||
|
if (loading) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
updatingItemIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateItemOrder(row: AwardItemManageOut, sortOrder: number) {
|
||||||
|
if (row.sort_order === sortOrder) return
|
||||||
|
row.sort_order = sortOrder
|
||||||
|
setUpdatingItem(row.id, true)
|
||||||
|
try {
|
||||||
|
await Showcase.updateAwardItem(row.id, { sort_order: sortOrder })
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "排序更新失败")
|
||||||
|
await loadAwardItems()
|
||||||
|
} finally {
|
||||||
|
setUpdatingItem(row.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAwardItem(row: AwardItemManageOut) {
|
||||||
|
if (!window.confirm("确定移除这个作品?")) return
|
||||||
|
try {
|
||||||
|
await Showcase.deleteAwardItem(row.id)
|
||||||
|
awardItems.value = awardItems.value.filter((item) => item.id !== row.id)
|
||||||
|
await loadAwards()
|
||||||
|
message.success("已移除")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "移除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSubmissionLookup() {
|
||||||
|
lookupSubmissionId.value = ""
|
||||||
|
lookupError.value = ""
|
||||||
|
submissionCandidate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddWorkModal() {
|
||||||
|
if (!currentAwardId.value || creating.value) return
|
||||||
|
clearSubmissionLookup()
|
||||||
|
addWorkModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSubmissionForAward() {
|
||||||
|
const submissionId = lookupSubmissionId.value.trim()
|
||||||
|
if (!submissionId) return
|
||||||
|
lookupLoading.value = true
|
||||||
|
lookupError.value = ""
|
||||||
|
submissionCandidate.value = null
|
||||||
|
try {
|
||||||
|
submissionCandidate.value =
|
||||||
|
await Showcase.findSubmissionForAward(submissionId)
|
||||||
|
} catch (err: any) {
|
||||||
|
lookupError.value = err.response?.data?.detail ?? "没有找到这个提交"
|
||||||
|
} finally {
|
||||||
|
lookupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCandidateToAward() {
|
||||||
|
const candidate = submissionCandidate.value
|
||||||
|
if (
|
||||||
|
!currentAwardId.value ||
|
||||||
|
creating.value ||
|
||||||
|
!candidate ||
|
||||||
|
candidateAlreadyAwarded.value
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addingCandidate.value = true
|
||||||
|
try {
|
||||||
|
const item = await Showcase.addAwardItem(currentAwardId.value, {
|
||||||
|
submission_id: candidate.submission_id,
|
||||||
|
sort_order: nextSortOrder.value,
|
||||||
|
})
|
||||||
|
awardItems.value = [...awardItems.value, item]
|
||||||
|
await loadAwards()
|
||||||
|
message.success("已添加作品")
|
||||||
|
addWorkModalVisible.value = false
|
||||||
|
clearSubmissionLookup()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "添加作品失败")
|
||||||
|
} finally {
|
||||||
|
addingCandidate.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAwards()
|
||||||
|
if (awards.value.length > 0) await selectAward(awards.value[0])
|
||||||
|
else startCreate()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #efeff5;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: transparent;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award-row:hover {
|
||||||
|
background: #f2f5f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award-row.active {
|
||||||
|
background: #e8f8f0;
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.table-number-input) {
|
||||||
|
width: 76px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMounted, useTemplateRef } from "vue"
|
import { onMounted, useTemplateRef } from "vue"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionAll } from "../utils/type"
|
import type { SubmissionAll } from "../utils/type"
|
||||||
|
import { buildPreviewDocument } from "../utils/previewDocument"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,25 +13,19 @@ const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const submission: SubmissionAll = await Submission.get(props.id)
|
const submission: SubmissionAll = await Submission.get(props.id)
|
||||||
|
Submission.incrementView(props.id)
|
||||||
|
|
||||||
if (!iframe.value) return
|
if (!iframe.value) return
|
||||||
const doc = iframe.value.contentDocument
|
const doc = iframe.value.contentDocument
|
||||||
if (doc) {
|
if (doc) {
|
||||||
doc.open()
|
doc.open()
|
||||||
doc.write(`<!DOCTYPE html>
|
doc.write(
|
||||||
<html lang="zh-Hans-CN">
|
buildPreviewDocument({
|
||||||
<head>
|
html: submission.html,
|
||||||
<meta charset="UTF-8" />
|
css: submission.css,
|
||||||
<title>预览</title>
|
js: submission.js,
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
}),
|
||||||
<style>${submission.css}</style>
|
)
|
||||||
<link rel="stylesheet" href="/normalize.min.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${submission.html}
|
|
||||||
<script>${submission.js}<\/script>
|
|
||||||
</body>
|
|
||||||
</html>`)
|
|
||||||
doc.close()
|
doc.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,18 @@
|
|||||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
style="height: 100%; padding-right: 10px; overflow: hidden"
|
||||||
>
|
>
|
||||||
<n-flex justify="space-between" style="flex-shrink: 0">
|
<n-flex justify="space-between" style="flex-shrink: 0">
|
||||||
<n-button secondary @click="() => goHome($router, taskTab, step)">
|
<n-button
|
||||||
返回首页
|
secondary
|
||||||
|
@click="
|
||||||
|
() =>
|
||||||
|
goHome(
|
||||||
|
$router,
|
||||||
|
taskTab,
|
||||||
|
taskTab === TASK_TYPE.Challenge ? challengeDisplay : step,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
首页
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-select
|
<n-select
|
||||||
@@ -24,6 +34,17 @@
|
|||||||
:options="flagFilterOptions"
|
:options="flagFilterOptions"
|
||||||
@update:value="handleFlagSelect"
|
@update:value="handleFlagSelect"
|
||||||
/>
|
/>
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.zone"
|
||||||
|
style="width: 100px"
|
||||||
|
clearable
|
||||||
|
placeholder="从夯到拉"
|
||||||
|
:options="[
|
||||||
|
{ label: '夯爆了', value: 'featured' },
|
||||||
|
{ label: 'NPC', value: 'pending' },
|
||||||
|
{ label: '拉完了', value: 'low' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
<n-input
|
<n-input
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
v-model:value="query.username"
|
v-model:value="query.username"
|
||||||
@@ -84,13 +105,14 @@
|
|||||||
|
|
||||||
<ChainModal
|
<ChainModal
|
||||||
v-model:show="chainModal"
|
v-model:show="chainModal"
|
||||||
:conversation-id="chainConversationId"
|
:submission-id="chainSubmissionId"
|
||||||
|
:username="chainUsername"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||||
import { NButton, NDataTable, type DataTableColumn } from "naive-ui"
|
import { NButton, NDataTable, NTag, type DataTableColumn } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import type { SubmissionOut, FlagType } from "../utils/type"
|
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||||
@@ -100,7 +122,7 @@ 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"
|
||||||
|
|
||||||
import Preview from "../components/Preview.vue"
|
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"
|
||||||
@@ -108,7 +130,7 @@ 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 { taskTab } from "../store/task"
|
import { taskTab, challengeDisplay } from "../store/task"
|
||||||
import { step } from "../store/tutorial"
|
import { step } from "../store/tutorial"
|
||||||
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
||||||
import { roleAdmin, roleSuper, user } from "../store/user"
|
import { roleAdmin, roleSuper, user } from "../store/user"
|
||||||
@@ -125,6 +147,7 @@ const query = reactive({
|
|||||||
? ""
|
? ""
|
||||||
: (route.query.username ?? "")) as string,
|
: (route.query.username ?? "")) as string,
|
||||||
flag: null as string | null,
|
flag: null as string | null,
|
||||||
|
zone: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当前选中提交的代码
|
// 当前选中提交的代码
|
||||||
@@ -135,7 +158,8 @@ const js = computed(() => submission.value.js)
|
|||||||
// Modal 状态
|
// Modal 状态
|
||||||
const codeModal = ref(false)
|
const codeModal = ref(false)
|
||||||
const chainModal = ref(false)
|
const chainModal = ref(false)
|
||||||
const chainConversationId = ref<string | undefined>()
|
const chainSubmissionId = ref<string>("")
|
||||||
|
const chainUsername = ref<string>("")
|
||||||
|
|
||||||
// 展开行
|
// 展开行
|
||||||
const expandedKeys = ref<string[]>([])
|
const expandedKeys = ref<string[]>([])
|
||||||
@@ -177,8 +201,9 @@ async function clearAllFlags() {
|
|||||||
query.flag = null
|
query.flag = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function showChain(conversationId: string) {
|
function showChain(submissionId: string, username: string) {
|
||||||
chainConversationId.value = conversationId
|
chainSubmissionId.value = submissionId
|
||||||
|
chainUsername.value = username
|
||||||
chainModal.value = true
|
chainModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +219,8 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
|||||||
loading: expandedLoading.has(row.id),
|
loading: expandedLoading.has(row.id),
|
||||||
onSelect: (id) => getSubmissionByID(id),
|
onSelect: (id) => getSubmissionByID(id),
|
||||||
onDelete: (r, parentId) => handleDelete(r, parentId),
|
onDelete: (r, parentId) => handleDelete(r, parentId),
|
||||||
"onShow-chain": (id) => showChain(id),
|
"onShow-chain": (submissionId, username) =>
|
||||||
onNominate: (r) => handleNominateChild(r, row.id),
|
showChain(submissionId, username),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -209,6 +234,24 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
|||||||
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
|
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "zone",
|
||||||
|
width: 42,
|
||||||
|
render: (row) => {
|
||||||
|
const map: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; type: "success" | "default" | "warning" }
|
||||||
|
> = {
|
||||||
|
featured: { label: "夯", type: "success" },
|
||||||
|
pending: { label: "N", type: "default" },
|
||||||
|
low: { label: "拉", type: "warning" },
|
||||||
|
}
|
||||||
|
if (!row.zone || !map[row.zone]) return null
|
||||||
|
const { label, type } = map[row.zone]
|
||||||
|
return h(NTag, { size: "small", round: true, type }, () => label)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "时间",
|
title: "时间",
|
||||||
key: "created",
|
key: "created",
|
||||||
@@ -249,6 +292,12 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
|||||||
width: 60,
|
width: 60,
|
||||||
render: (row) => row.submit_count || "-",
|
render: (row) => row.submit_count || "-",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "浏览",
|
||||||
|
key: "view_count",
|
||||||
|
width: 60,
|
||||||
|
render: (row) => row.view_count || "-",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function handleExpand(keys: (string | number)[]) {
|
async function handleExpand(keys: (string | number)[]) {
|
||||||
@@ -313,23 +362,6 @@ async function getSubmissionByID(id: string) {
|
|||||||
submission.value = await Submission.get(id)
|
submission.value = await Submission.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNominateChild(row: SubmissionOut, parentId: string) {
|
|
||||||
await Submission.nominate(row.id)
|
|
||||||
const items = expandedData.get(parentId)
|
|
||||||
if (items) {
|
|
||||||
expandedData.set(
|
|
||||||
parentId,
|
|
||||||
items.map((d) => ({ ...d, nominated: d.id === row.id })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data.value = data.value.map((d) => {
|
|
||||||
if (d.username === user.username && d.task_id === row.task_id) {
|
|
||||||
d.nominated = d.id === row.id
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function afterScore() {
|
function afterScore() {
|
||||||
data.value = data.value.map((d) => {
|
data.value = data.value.map((d) => {
|
||||||
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
||||||
@@ -366,6 +398,13 @@ watch(
|
|||||||
init()
|
init()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
watch(
|
||||||
|
() => query.zone,
|
||||||
|
() => {
|
||||||
|
query.page = 1
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -382,6 +421,8 @@ onUnmounted(() => {
|
|||||||
html: "",
|
html: "",
|
||||||
css: "",
|
css: "",
|
||||||
js: "",
|
js: "",
|
||||||
|
submit_count: 0,
|
||||||
|
view_count: 0,
|
||||||
created: new Date(),
|
created: new Date(),
|
||||||
modified: new Date(),
|
modified: new Date(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,12 @@
|
|||||||
|
|
||||||
<n-gi :span="6" class="col">
|
<n-gi :span="6" class="col">
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<n-form inline>
|
<n-form inline :show-feedback="false">
|
||||||
<n-form-item label="序号" label-placement="left">
|
<n-form-item label="序号" label-placement="left">
|
||||||
<n-input-number v-model:value="tutorial.display" />
|
<n-input-number
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="tutorial.display"
|
||||||
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="标题" label-placement="left">
|
<n-form-item label="标题" label-placement="left">
|
||||||
@@ -58,8 +61,13 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
<TaskAssetManager
|
||||||
|
v-if="tutorial.display"
|
||||||
|
task-type="tutorial"
|
||||||
|
:display="tutorial.display"
|
||||||
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
style="height: calc(100vh - 90px)"
|
style="height: calc(100vh - 100px)"
|
||||||
v-model="tutorial.content"
|
v-model="tutorial.content"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -74,6 +82,7 @@ import { Tutorial } from "../api"
|
|||||||
import type { TutorialSlim } from "../utils/type"
|
import type { TutorialSlim } from "../utils/type"
|
||||||
import { useDialog, useMessage } from "naive-ui"
|
import { useDialog, useMessage } from "naive-ui"
|
||||||
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||||
|
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -98,7 +107,7 @@ async function getContent() {
|
|||||||
if (target) {
|
if (target) {
|
||||||
show(display)
|
show(display)
|
||||||
} else if (list.value.length > 0) {
|
} else if (list.value.length > 0) {
|
||||||
show(list.value[0].display)
|
show(list.value[0]!.display)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-split
|
<n-split :size="panelSize" @update-size="changeSize" min="400px" max="900px">
|
||||||
:size="tutorialSize"
|
|
||||||
@update-size="changeSize"
|
|
||||||
min="400px"
|
|
||||||
max="900px"
|
|
||||||
>
|
|
||||||
<template #1>
|
<template #1>
|
||||||
<Task @hide="hide" />
|
<TaskPanel @hide="hide" />
|
||||||
</template>
|
</template>
|
||||||
<template #2>
|
<template #2>
|
||||||
<n-split direction="vertical" min="200px">
|
<n-split direction="vertical" min="200px">
|
||||||
@@ -14,7 +9,12 @@
|
|||||||
<Editors />
|
<Editors />
|
||||||
</template>
|
</template>
|
||||||
<template #2>
|
<template #2>
|
||||||
<Preview :html="html" :css="css" :js="js" />
|
<Preview
|
||||||
|
:html="html"
|
||||||
|
:css="css"
|
||||||
|
:js="js"
|
||||||
|
:asset-base-url="assetBaseUrl"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-split>
|
</n-split>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,11 +22,12 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useMagicKeys, whenever } from "@vueuse/core"
|
import { useMagicKeys, whenever } from "@vueuse/core"
|
||||||
import Editors from "../components/Editors.vue"
|
import Editors from "../components/editor/Editors.vue"
|
||||||
import Preview from "../components/Preview.vue"
|
import Preview from "../components/editor/Preview.vue"
|
||||||
import Task from "../components/Task.vue"
|
import TaskPanel from "../components/task/TaskPanel.vue"
|
||||||
import { show, tutorialSize } from "../store/tutorial"
|
import { show, panelSize } from "../store/panel"
|
||||||
import { html, css, js } from "../store/editors"
|
import { html, css, js } from "../store/editors"
|
||||||
|
import { assetBaseUrl } from "../store/task"
|
||||||
|
|
||||||
const { ctrl_s } = useMagicKeys({
|
const { ctrl_s } = useMagicKeys({
|
||||||
passive: false,
|
passive: false,
|
||||||
@@ -43,12 +44,12 @@ const { ctrl_r } = useMagicKeys({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function changeSize(n: number) {
|
function changeSize(n: number) {
|
||||||
tutorialSize.value = n
|
panelSize.value = n
|
||||||
if (n > 0) show.value = true
|
if (n > 0) show.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
tutorialSize.value = 0
|
panelSize.value = 0
|
||||||
show.value = false
|
show.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { createWebHistory, createRouter } from "vue-router"
|
import { createWebHistory, createRouter } from "vue-router"
|
||||||
import { loginModal } from "./store/modal"
|
import { loginModal } from "./store/modal"
|
||||||
|
|
||||||
import Home from "./pages/Home.vue"
|
import Workspace from "./pages/Workspace.vue"
|
||||||
import { STORAGE_KEY } from "./utils/const"
|
import { STORAGE_KEY } from "./utils/const"
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: Home },
|
{ path: "/", name: "home", component: Workspace },
|
||||||
{ path: "/tutorial", name: "home-tutorial-list", component: Home },
|
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace },
|
||||||
{ path: "/tutorial/:display", name: "home-tutorial", component: Home },
|
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace },
|
||||||
{ path: "/challenge", name: "home-challenge-list", component: Home },
|
{ path: "/challenge", name: "home-challenge-list", component: Workspace },
|
||||||
{
|
{
|
||||||
path: "/challenge/:display",
|
path: "/challenge/:display",
|
||||||
name: "home-challenge",
|
name: "home-challenge",
|
||||||
component: () => import("./pages/ChallengeHome.vue"),
|
component: () => import("./pages/ChallengeDetail.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/submissions/:page",
|
path: "/submissions/:page",
|
||||||
@@ -25,6 +25,19 @@ const routes = [
|
|||||||
component: () => import("./pages/Submission.vue"),
|
component: () => import("./pages/Submission.vue"),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/showcase",
|
||||||
|
name: "showcase",
|
||||||
|
component: () => import("./pages/Showcase.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/showcase/:id",
|
||||||
|
name: "showcase-detail",
|
||||||
|
component: () => import("./pages/ShowcaseDetail.vue"),
|
||||||
|
props: true,
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
@@ -46,6 +59,16 @@ const routes = [
|
|||||||
name: "user-manage",
|
name: "user-manage",
|
||||||
component: () => import("./pages/UserManage.vue"),
|
component: () => import("./pages/UserManage.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "showcase",
|
||||||
|
name: "showcase-manage",
|
||||||
|
component: () => import("./pages/ShowcaseManage.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "gradebook",
|
||||||
|
name: "gradebook",
|
||||||
|
component: () => import("./pages/Gradebook.vue"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -55,12 +78,10 @@ export const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to) => {
|
||||||
const isLoggedIn = localStorage.getItem(STORAGE_KEY.LOGIN) === "true"
|
const isLoggedIn = localStorage.getItem(STORAGE_KEY.LOGIN) === "true"
|
||||||
if (to.meta.auth && !isLoggedIn) {
|
if (to.meta.auth && !isLoggedIn) {
|
||||||
loginModal.value = true
|
loginModal.value = true
|
||||||
next(false)
|
return false
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
5
src/store/panel.ts
Normal file
5
src/store/panel.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// client/src/store/panel.ts
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
export const show = ref(true)
|
||||||
|
export const panelSize = ref(2 / 5)
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { WS_BASE_URL } from "../utils/const"
|
import { WS_BASE_URL } from "../utils/const"
|
||||||
import { html, css, js } from "./editors"
|
import { html, css, js } from "./editors"
|
||||||
import { Prompt } from "../api"
|
|
||||||
import type { PromptMessage as RawMessage } from "../utils/type"
|
|
||||||
import { user } from "./user"
|
|
||||||
|
|
||||||
export interface PromptMessage {
|
export interface PromptMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
|
id?: number // assistant message backend pk (for deletion)
|
||||||
code?: { html: string | null; css: string | null; js: string | null }
|
code?: { html: string | null; css: string | null; js: string | null }
|
||||||
created?: string
|
created?: string
|
||||||
}
|
}
|
||||||
@@ -16,15 +14,12 @@ 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 historyLoading = ref(false)
|
|
||||||
let _historyLoadId = 0
|
|
||||||
export const streamingContent = ref("")
|
export const streamingContent = ref("")
|
||||||
let _onCodeComplete:
|
let _onCodeComplete:
|
||||||
| ((code: {
|
| ((
|
||||||
html: string | null
|
code: { html: string | null; css: string | null; js: string | null },
|
||||||
css: string | null
|
messageId: number
|
||||||
js: string | null
|
) => void)
|
||||||
}) => void)
|
|
||||||
| null = null
|
| null = null
|
||||||
|
|
||||||
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
||||||
@@ -32,8 +27,10 @@ export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
|
let _currentTaskId = 0
|
||||||
|
|
||||||
export function connectPrompt(taskId: number) {
|
export function connectPrompt(taskId: number) {
|
||||||
|
_currentTaskId = taskId
|
||||||
if (ws) ws.close()
|
if (ws) ws.close()
|
||||||
|
|
||||||
ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
|
ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
|
||||||
@@ -46,14 +43,12 @@ export function connectPrompt(taskId: number) {
|
|||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
if (data.type === "init") {
|
if (data.type === "init") {
|
||||||
// Skip overwriting messages if HTTP preload already loaded this conversation.
|
streaming.value = false
|
||||||
// If conversation_id differs (e.g. after "新对话"), always overwrite.
|
streamingContent.value = ""
|
||||||
const alreadyLoaded = conversationId.value === data.conversation_id
|
const alreadyLoaded = conversationId.value === data.conversation_id
|
||||||
conversationId.value = data.conversation_id
|
conversationId.value = data.conversation_id
|
||||||
if (!alreadyLoaded) {
|
if (!alreadyLoaded) {
|
||||||
messages.value = data.messages || []
|
messages.value = data.messages || []
|
||||||
// Apply code from last assistant message if exists
|
|
||||||
// (skipped when HTTP preload already loaded and applied)
|
|
||||||
const lastAssistant = [...messages.value]
|
const lastAssistant = [...messages.value]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((m) => m.role === "assistant" && m.code)
|
.find((m) => m.role === "assistant" && m.code)
|
||||||
@@ -66,18 +61,17 @@ export function connectPrompt(taskId: number) {
|
|||||||
streamingContent.value += data.content
|
streamingContent.value += data.content
|
||||||
} else if (data.type === "complete") {
|
} else if (data.type === "complete") {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
// Push the full assistant message
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: streamingContent.value,
|
content: streamingContent.value,
|
||||||
|
id: data.message_id,
|
||||||
code: data.code,
|
code: data.code,
|
||||||
})
|
})
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
// Apply code to editors
|
|
||||||
if (data.code) {
|
if (data.code) {
|
||||||
applyCode(data.code)
|
applyCode(data.code)
|
||||||
if (_onCodeComplete) {
|
if (_onCodeComplete) {
|
||||||
_onCodeComplete(data.code)
|
_onCodeComplete(data.code, data.message_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (data.type === "error") {
|
} else if (data.type === "error") {
|
||||||
@@ -96,8 +90,6 @@ export function connectPrompt(taskId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectPrompt() {
|
export function disconnectPrompt() {
|
||||||
_historyLoadId++ // cancel any in-flight loadHistory
|
|
||||||
historyLoading.value = false // reset here; finally block won't (loadId mismatch)
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close()
|
ws.close()
|
||||||
ws = null
|
ws = null
|
||||||
@@ -110,67 +102,25 @@ export function disconnectPrompt() {
|
|||||||
_onCodeComplete = null
|
_onCodeComplete = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadHistory(taskId: number) {
|
export function sendPrompt(content: string, model: string = "") {
|
||||||
const loadId = ++_historyLoadId
|
|
||||||
historyLoading.value = true
|
|
||||||
try {
|
|
||||||
const convs = await Prompt.listConversations(taskId)
|
|
||||||
console.log(
|
|
||||||
"[loadHistory] convs:",
|
|
||||||
convs.map((c: any) => ({
|
|
||||||
id: c.id,
|
|
||||||
is_active: c.is_active,
|
|
||||||
message_count: c.message_count,
|
|
||||||
username: c.username,
|
|
||||||
})),
|
|
||||||
"user.username:",
|
|
||||||
user.username,
|
|
||||||
)
|
|
||||||
if (loadId !== _historyLoadId) return // navigated away, abort
|
|
||||||
const active = convs.find(
|
|
||||||
(c: { is_active: boolean; message_count: number; username: string }) =>
|
|
||||||
c.is_active && c.message_count > 0 && c.username === user.username,
|
|
||||||
)
|
|
||||||
console.log("[loadHistory] active:", active)
|
|
||||||
if (!active) return
|
|
||||||
const raw: RawMessage[] = await Prompt.getMessages(active.id)
|
|
||||||
console.log("[loadHistory] raw messages:", raw.length)
|
|
||||||
if (loadId !== _historyLoadId) return // navigated away, abort
|
|
||||||
// Only apply if nothing has arrived via WebSocket yet
|
|
||||||
if (messages.value.length > 0) return
|
|
||||||
conversationId.value = active.id
|
|
||||||
messages.value = raw.map((m) => ({
|
|
||||||
role: m.role as "user" | "assistant",
|
|
||||||
content: m.content,
|
|
||||||
code:
|
|
||||||
m.role === "assistant"
|
|
||||||
? { html: m.code_html, css: m.code_css, js: m.code_js }
|
|
||||||
: undefined,
|
|
||||||
created: m.created,
|
|
||||||
}))
|
|
||||||
// Apply code from last assistant message to editors
|
|
||||||
const lastAssistant = [...messages.value]
|
|
||||||
.reverse()
|
|
||||||
.find((m) => m.role === "assistant" && m.code)
|
|
||||||
if (lastAssistant?.code) {
|
|
||||||
applyCode(lastAssistant.code)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 静默失败,不影响 WebSocket 正常流程
|
|
||||||
} finally {
|
|
||||||
if (loadId === _historyLoadId) historyLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sendPrompt(content: string) {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
streaming.value = true
|
||||||
messages.value.push({ role: "user", content })
|
messages.value.push({ role: "user", content })
|
||||||
ws.send(JSON.stringify({ type: "message", content }))
|
ws.send(JSON.stringify({ type: "message", content, model }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newConversation() {
|
export function stopPrompt() {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
if (
|
||||||
ws.send(JSON.stringify({ type: "new_conversation" }))
|
messages.value.length > 0 &&
|
||||||
|
messages.value[messages.value.length - 1].role === "user"
|
||||||
|
) {
|
||||||
|
messages.value.pop()
|
||||||
|
}
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
if (_currentTaskId) {
|
||||||
|
connectPrompt(_currentTaskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCode(code: {
|
function applyCode(code: {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const currentTask = window.location.pathname.startsWith("/challenge")
|
||||||
const currentTask = (urlParams.get("task") as TASK_TYPE) ?? TASK_TYPE.Tutorial
|
? TASK_TYPE.Challenge
|
||||||
|
: TASK_TYPE.Tutorial
|
||||||
|
|
||||||
export const taskTab = ref(currentTask)
|
export const taskTab = ref(currentTask)
|
||||||
export const taskId = ref(0)
|
export const taskId = ref(0)
|
||||||
export const challengeDisplay = ref(0)
|
export const challengeDisplay = useStorage("challenge-display", 0)
|
||||||
|
export const assetBaseUrl = ref("")
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
|
import { Tutorial } from "../api"
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
export const step = useStorage("tutorial-step", 1)
|
||||||
const currentStep = urlParams.get("step") ?? "1"
|
export const tutorialIds = ref<number[]>([])
|
||||||
|
|
||||||
export const step = ref(Number(currentStep))
|
export async function loadTutorials(): Promise<void> {
|
||||||
|
tutorialIds.value = await Tutorial.listDisplay()
|
||||||
|
if (tutorialIds.value.length && !tutorialIds.value.includes(step.value)) {
|
||||||
|
step.value = tutorialIds.value[0] as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const show = ref(true)
|
export function prevDisabled(): boolean {
|
||||||
export const tutorialSize = ref(2 / 5)
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
return i <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextDisabled(): boolean {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
return i === -1 || i === tutorialIds.value.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prev(): void {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
if (i > 0) step.value = tutorialIds.value[i - 1] as number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function next(): void {
|
||||||
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
|
if (i !== -1 && i < tutorialIds.value.length - 1)
|
||||||
|
step.value = tutorialIds.value[i + 1] as number
|
||||||
|
}
|
||||||
|
|||||||
12
src/utils/gradebook.ts
Normal file
12
src/utils/gradebook.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export interface GradebookStudentIdentity {
|
||||||
|
username: string
|
||||||
|
classname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayGradebookStudentName(student: GradebookStudentIdentity) {
|
||||||
|
const generatedPrefix = `web${student.classname}`
|
||||||
|
if (!student.classname || !student.username.startsWith(generatedPrefix)) {
|
||||||
|
return student.username
|
||||||
|
}
|
||||||
|
return student.username.slice(generatedPrefix.length)
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ export function goHome(router: any, type: TASK_TYPE, display: number) {
|
|||||||
if (type === TASK_TYPE.Tutorial) {
|
if (type === TASK_TYPE.Tutorial) {
|
||||||
router.push({ name: "home-tutorial", params: { display } })
|
router.push({ name: "home-tutorial", params: { display } })
|
||||||
} else if (type === TASK_TYPE.Challenge) {
|
} else if (type === TASK_TYPE.Challenge) {
|
||||||
router.push({ name: "home-challenge", params: { display } })
|
if (display) router.push({ name: "home-challenge", params: { display } })
|
||||||
|
else router.push({ name: "home-challenge-list" })
|
||||||
} else {
|
} else {
|
||||||
router.push({ name: "home" })
|
router.push({ name: "home" })
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/utils/previewDocument.ts
Normal file
29
src/utils/previewDocument.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interface PreviewDocumentOptions {
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
assetBaseUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPreviewDocument({
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
js,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: PreviewDocumentOptions) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-Hans-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>预览</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
|
||||||
|
<style>${css}</style>
|
||||||
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${html}
|
||||||
|
<script>${js}<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
@@ -3,10 +3,25 @@ import type { TASK_TYPE } from "./const"
|
|||||||
export interface PromptMessage {
|
export interface PromptMessage {
|
||||||
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
|
||||||
code_js: string | null
|
code_js: string | null
|
||||||
|
prompt_level?: number | null
|
||||||
|
created: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptHistoryItem {
|
||||||
|
user_message_id: number
|
||||||
|
assistant_message_id: number
|
||||||
|
submission_id: string | null
|
||||||
|
source: string
|
||||||
|
prompt: string
|
||||||
|
prompt_level: number | null
|
||||||
|
code_html: string | null
|
||||||
|
code_css: string | null
|
||||||
|
code_js: string | null
|
||||||
created: string
|
created: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +41,11 @@ export function getRole(role: Role) {
|
|||||||
|
|
||||||
export type FlagType = "red" | "blue" | "green" | "yellow" | null
|
export type FlagType = "red" | "blue" | "green" | "yellow" | null
|
||||||
|
|
||||||
|
export interface TaskAsset {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TutorialSlim {
|
export interface TutorialSlim {
|
||||||
display: number
|
display: number
|
||||||
title: string
|
title: string
|
||||||
@@ -46,7 +66,10 @@ export interface ChallengeSlim {
|
|||||||
display: number
|
display: number
|
||||||
title: string
|
title: string
|
||||||
score: number
|
score: number
|
||||||
|
pass_score: number | null
|
||||||
|
submitted: boolean
|
||||||
is_public: boolean
|
is_public: boolean
|
||||||
|
author_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChallengeIn {
|
export interface ChallengeIn {
|
||||||
@@ -76,10 +99,10 @@ export interface SubmissionOut {
|
|||||||
task_title: string
|
task_title: string
|
||||||
score: number
|
score: number
|
||||||
my_score: number
|
my_score: number
|
||||||
conversation_id?: string
|
|
||||||
flag?: FlagType
|
flag?: FlagType
|
||||||
nominated: boolean
|
zone?: "featured" | "low" | "pending" | null
|
||||||
submit_count: number
|
submit_count: number
|
||||||
|
view_count: number
|
||||||
created: Date
|
created: Date
|
||||||
modified: Date
|
modified: Date
|
||||||
}
|
}
|
||||||
@@ -98,6 +121,8 @@ export interface SubmissionAll {
|
|||||||
html: ""
|
html: ""
|
||||||
css: ""
|
css: ""
|
||||||
js: ""
|
js: ""
|
||||||
|
submit_count: number
|
||||||
|
view_count: number
|
||||||
created: Date
|
created: Date
|
||||||
modified: Date
|
modified: Date
|
||||||
}
|
}
|
||||||
@@ -107,14 +132,6 @@ export interface UserTag {
|
|||||||
classname: string
|
classname: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopSubmission {
|
|
||||||
submission_id: string
|
|
||||||
username: string
|
|
||||||
classname: string
|
|
||||||
score: number
|
|
||||||
rating_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmissionCountBucket {
|
export interface SubmissionCountBucket {
|
||||||
count_1: number
|
count_1: number
|
||||||
count_2: number
|
count_2: number
|
||||||
@@ -137,17 +154,167 @@ export interface FlagStats {
|
|||||||
yellow: number
|
yellow: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopViewedItem {
|
||||||
|
username: string
|
||||||
|
classname: string
|
||||||
|
view_count: number
|
||||||
|
submission_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskStatsOut {
|
export interface TaskStatsOut {
|
||||||
submitted_count: number
|
submitted_count: number
|
||||||
unsubmitted_count: number
|
unsubmitted_count: number
|
||||||
average_score: number | null
|
average_score: number | null
|
||||||
unrated_count: number
|
unrated_count: number
|
||||||
nominated_count: number
|
|
||||||
unsubmitted_users: UserTag[]
|
unsubmitted_users: UserTag[]
|
||||||
unrated_users: UserTag[]
|
unrated_users: UserTag[]
|
||||||
submission_count_distribution: SubmissionCountBucket
|
submission_count_distribution: SubmissionCountBucket
|
||||||
score_distribution: ScoreBucket
|
score_distribution: ScoreBucket
|
||||||
top_submissions: TopSubmission[]
|
|
||||||
flag_stats: FlagStats
|
flag_stats: FlagStats
|
||||||
classes: string[]
|
classes: string[]
|
||||||
|
top_viewed: TopViewedItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GradebookTaskType = "tutorial" | "challenge"
|
||||||
|
export type GradebookGrade = "A" | "B" | "C" | "D" | "E"
|
||||||
|
|
||||||
|
export interface GradebookQuery {
|
||||||
|
classname: string
|
||||||
|
task_type?: GradebookTaskType | ""
|
||||||
|
username?: string
|
||||||
|
include_all_tasks?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradebookTask {
|
||||||
|
id: number
|
||||||
|
display: number
|
||||||
|
title: string
|
||||||
|
task_type: GradebookTaskType
|
||||||
|
submitted_count: number
|
||||||
|
coverage: number
|
||||||
|
included: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradebookCell {
|
||||||
|
score: number
|
||||||
|
submitted: boolean
|
||||||
|
submission_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradebookRow {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
classname: string
|
||||||
|
rank: number
|
||||||
|
grade: GradebookGrade
|
||||||
|
scores: Record<number, GradebookCell>
|
||||||
|
tutorial_total: number
|
||||||
|
challenge_total: number
|
||||||
|
total_score: number
|
||||||
|
average_score: number | null
|
||||||
|
submitted_task_count: number
|
||||||
|
missing_task_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradebookOut {
|
||||||
|
classname: string
|
||||||
|
classes: string[]
|
||||||
|
task_count: number
|
||||||
|
included_task_count: number
|
||||||
|
student_count: number
|
||||||
|
coverage_threshold_count: number
|
||||||
|
tasks: GradebookTask[]
|
||||||
|
rows: GradebookRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowcaseItem {
|
||||||
|
submission_id: string
|
||||||
|
username: string
|
||||||
|
task_title: string
|
||||||
|
task_display: number
|
||||||
|
score: number
|
||||||
|
view_count: number
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
|
has_prompt_chain: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwardSection {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
item_ordering: string
|
||||||
|
items: ShowcaseItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemOrdering = "manual" | "awarded_at" | "score" | "view_count"
|
||||||
|
|
||||||
|
export interface AwardManageIn {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
sort_order: number
|
||||||
|
is_active: boolean
|
||||||
|
item_ordering: ItemOrdering
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwardManageOut extends AwardManageIn {
|
||||||
|
id: number
|
||||||
|
item_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwardItemIn {
|
||||||
|
submission_id: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwardItemUpdateIn {
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowcaseSubmissionLookupOut {
|
||||||
|
submission_id: string
|
||||||
|
username: string
|
||||||
|
task_title: string
|
||||||
|
task_display: number
|
||||||
|
score: number
|
||||||
|
view_count: number
|
||||||
|
has_prompt_chain: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AwardItemManageOut {
|
||||||
|
id: number
|
||||||
|
submission_id: string
|
||||||
|
username: string
|
||||||
|
task_title: string
|
||||||
|
task_display: number
|
||||||
|
score: number
|
||||||
|
view_count: number
|
||||||
|
sort_order: number
|
||||||
|
awarded_at: string
|
||||||
|
has_prompt_chain: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowcaseDetail {
|
||||||
|
submission_id: string
|
||||||
|
username: string
|
||||||
|
task_title: string
|
||||||
|
task_display: number
|
||||||
|
score: number
|
||||||
|
view_count: number
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
|
awards: string[]
|
||||||
|
has_prompt_chain: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptRound {
|
||||||
|
question: string
|
||||||
|
source: string
|
||||||
|
prompt_level: number | null
|
||||||
|
assistant_msg_id?: number | null
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user