Files
flutter_learning/web/src/views/AdminView.vue
T
2026-08-04 18:07:40 +08:00

618 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { api, getToken, setToken, clearToken } from '../api.js'
import { escapeSmartHtml, decodeEntities, stripHtmlAndDecode } from '../htmlUtil.mjs'
const authed = ref(false)
const loginUser = ref('')
const loginPass = ref('')
const loginMsg = ref('')
const sets = ref([])
const view = ref('list')
const currentSetId = ref(null)
const qlist = ref([])
const busy = ref(false)
const keyOf = i => String.fromCharCode(65 + i)
const DEFAULT_LV_NAMES = ['基础语法', '控制流与函数', '集合', '面向对象', '空安全与异步', 'Flutter 入门']
const setForm = reactive({ show: false, editingId: null, title: '', name: '', lvNames: [...DEFAULT_LV_NAMES], msg: '' })
const importForm = reactive({ show: false, text: '', name: '', msg: '' })
const qForm = reactive({
show: false, editingId: null, title: '',
lv: '1', q: '', exp: '', opts: [], ans: '', msg: ''
})
const currentSet = computed(() => sets.value.find(s => s.id === currentSetId.value) || null)
const lvNameOptions = computed(() => {
const names = currentSet.value?.lvNames
return (Array.isArray(names) && names.length === 6) ? names : DEFAULT_LV_NAMES
})
async function checkAuth() {
if (!getToken()) return
try {
await api('/admin/sets')
authed.value = true
} catch (e) {
clearToken()
}
}
async function doLogin() {
loginMsg.value = ''
if (!loginUser.value.trim() || !loginPass.value) { loginMsg.value = '请输入用户名和密码'; return }
try {
const data = await api('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value })
})
setToken(data.token)
authed.value = true
} catch (e) {
loginMsg.value = e.message
}
}
function doLogout() {
clearToken()
authed.value = false
view.value = 'list'
}
async function loadSets() {
const data = await api('/admin/sets')
sets.value = data.sets
}
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
/* ---------- 套题:新增 / 重命名 / 删除 ---------- */
function showAddSet() {
setForm.show = true
setForm.editingId = null
setForm.title = '新增套题'
setForm.name = ''
setForm.lvNames = [...DEFAULT_LV_NAMES]
setForm.msg = ''
}
function showRenameSet(id) {
const s = sets.value.find(x => x.id === id)
if (!s) return
setForm.show = true
setForm.editingId = id
setForm.title = '重命名套题'
setForm.name = s.name
setForm.lvNames = s.lvNames ? [...s.lvNames] : [...DEFAULT_LV_NAMES]
setForm.msg = ''
}
async function saveSet() {
setForm.msg = ''
if (!setForm.name.trim()) { setForm.msg = '请输入套题名称'; return }
const body = {
name: setForm.name.trim(),
lvNames: setForm.lvNames.map(n => n.trim())
}
if (body.lvNames.some(n => !n)) { setForm.msg = '6 个难度名称都不能为空'; return }
try {
if (setForm.editingId === null) {
await api('/admin/sets', { method: 'POST', body: JSON.stringify(body) })
} else {
await api('/admin/sets/' + setForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
}
setForm.show = false
loadSets()
} catch (e) {
setForm.msg = e.message
}
}
async function deleteSet(id) {
const s = sets.value.find(x => x.id === id)
if (!confirm(`确定删除套题「${s.name}」吗?其下 ${s.questions.length} 道题将一并删除,不可恢复。`)) return
try {
await api('/admin/sets/' + id, { method: 'DELETE' })
if (currentSetId.value === id) backToList()
loadSets()
} catch (e) {
alert(e.message)
}
}
/* ---------- 套题:导出 / 导入 ---------- */
async function exportSet(id) {
try {
const data = await api('/admin/sets/' + id + '/export')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = data.name + '.json'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
alert(e.message)
}
}
function showImport() {
importForm.show = true
importForm.text = ''
importForm.name = ''
importForm.msg = ''
}
function onImportFile(e) {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
importForm.text = reader.result
try {
const parsed = JSON.parse(reader.result)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.name) {
importForm.name = parsed.name
}
} catch (err) { /* 稍后统一提示 */ }
importForm.msg = ''
}
reader.readAsText(file, 'utf-8')
}
async function doImport() {
importForm.msg = ''
const raw = importForm.text.trim()
if (!raw) { importForm.msg = '请粘贴 JSON 或选择文件'; return }
let parsed
try {
parsed = JSON.parse(raw)
} catch (e) {
importForm.msg = 'JSON 解析失败:' + e.message
return
}
let name, questions
if (Array.isArray(parsed)) {
questions = parsed
name = importForm.name.trim()
} else if (parsed && Array.isArray(parsed.questions)) {
questions = parsed.questions
name = importForm.name.trim() || parsed.name || ''
} else {
importForm.msg = '格式不支持:需要题目数组,或 {"name":..., "questions":[...]}'
return
}
if (!name) { importForm.msg = '请填写套题名称'; return }
try {
const res = await api('/admin/import', { method: 'POST', body: JSON.stringify({ name, questions }) })
importForm.show = false
loadSets()
alert('导入成功:共 ' + res.imported + ' 道题')
} catch (e) {
importForm.msg = e.message
}
}
/* ---------- 套题详情 ---------- */
async function openSet(id) {
currentSetId.value = id
view.value = 'detail'
await renderQuestions()
}
function backToList() {
view.value = 'list'
currentSetId.value = null
loadSets()
}
async function renderQuestions() {
if (currentSetId.value == null) return
const data = await api('/admin/sets')
const set = data.sets.find(s => s.id === currentSetId.value)
qlist.value = set ? set.questions : []
}
/* ---------- 题目 ---------- */
function resetOpts(opts) {
qForm.opts = (opts || []).map((o, i) => ({ key: keyOf(i), val: stripHtmlAndDecode(o) }))
qForm.ans = ''
}
function showQuestionForm() {
qForm.show = true
qForm.editingId = null
qForm.title = '新增题目'
qForm.lv = '1'
qForm.q = ''
qForm.exp = ''
qForm.msg = ''
resetOpts(['', '', '', ''])
syncAns()
scrollFormIntoView()
}
function addOpt() {
qForm.opts.push({ key: keyOf(qForm.opts.length), val: '' })
syncAns()
}
function removeOpt(i) {
qForm.opts.splice(i, 1)
qForm.opts.forEach((o, j) => { o.key = keyOf(j) })
syncAns()
}
function syncAns() {
const n = qForm.opts.length
if (Number(qForm.ans) >= n) qForm.ans = n > 0 ? String(n - 1) : ''
}
function editQuestion(id) {
const item = qlist.value.find(x => x.id === id)
if (!item) return
qForm.show = true
qForm.editingId = id
qForm.title = '编辑题目 #' + id
qForm.lv = String(item.lv || 1)
qForm.q = decodeEntities(item.q)
qForm.exp = decodeEntities(item.exp || '')
qForm.msg = ''
resetOpts(item.opts)
qForm.ans = String(item.ans)
scrollFormIntoView()
}
const qPreview = computed(() => escapeSmartHtml(decodeEntities(qForm.q)))
const expPreview = computed(() => escapeSmartHtml(decodeEntities(qForm.exp)))
const qRef = ref(null)
const expRef = ref(null)
const qFormRef = ref(null)
function scrollFormIntoView() {
nextTick(() => {
qFormRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function insertCode(target) {
const el = target === 'q' ? qRef.value : expRef.value
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const selected = qForm[target].slice(start, end)
qForm[target] = qForm[target].slice(0, start) + '<code>' + selected + '</code>' + qForm[target].slice(end)
el.focus()
const pos = start + '<code>'.length + selected.length + '</code>'.length - 2
el.setSelectionRange(pos, pos)
}
async function saveQuestion() {
qForm.msg = ''
const opts = qForm.opts.map(o => o.val.trim())
const body = {
lv: parseInt(qForm.lv, 10),
q: escapeSmartHtml(decodeEntities(qForm.q.trim())),
opts,
ans: parseInt(qForm.ans, 10),
exp: escapeSmartHtml(decodeEntities(qForm.exp.trim()))
}
if (!body.q) { qForm.msg = '请填写题干'; return }
if (opts.length < 2 || opts.some(o => !o)) { qForm.msg = '请填写所有选项(至少 2 个且不能为空)'; return }
try {
if (qForm.editingId === null) {
await api('/admin/sets/' + currentSetId.value + '/questions', { method: 'POST', body: JSON.stringify(body) })
} else {
await api('/admin/questions/' + qForm.editingId, { method: 'PUT', body: JSON.stringify(body) })
}
qForm.show = false
renderQuestions()
} catch (e) {
qForm.msg = e.message
}
}
async function deleteQuestion(id) {
if (!confirm('确定删除题目 #' + id + ' 吗?此操作不可恢复。')) return
try {
await api('/admin/questions/' + id, { method: 'DELETE' })
renderQuestions()
} catch (e) {
alert(e.message)
}
}
onMounted(async () => {
await checkAuth()
if (authed.value) loadSets()
})
</script>
<template>
<!-- 登录 -->
<div v-if="!authed" class="card login-card">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:6px;">
<div class="brand-seal small"></div>
<div>
<div style="font-family:var(--serif);font-weight:900;font-size:20px;letter-spacing:2px;">管理员登录</div>
<div class="sub">登录后可管理套题与题目</div>
</div>
</div>
<div class="field" style="margin-top:20px;">
<label>用户名</label>
<input class="input" v-model="loginUser" placeholder="admin" @keydown.enter="doLogin">
</div>
<div class="field">
<label>密码</label>
<input class="input" type="password" v-model="loginPass" placeholder="请输入密码" @keydown.enter="doLogin">
</div>
<div class="msg err">{{ loginMsg }}</div>
<button class="btn btn-filled" style="width:100%;justify-content:center;margin-top:10px;" @click="doLogin"> </button>
</div>
<!-- 管理 -->
<div v-else>
<div class="toolbar">
<div style="display:flex;align-items:center;gap:10px;">
<template v-if="view === 'detail'">
<button class="btn btn-outline btn-sm" @click="backToList"> 返回</button>
<span style="font-family:var(--serif);font-weight:700;font-size:17px;">{{ currentSet?.name }}</span>
<span style="font-size:12px;color:var(--ink-faint);">{{ qlist.length }} </span>
</template>
<span v-else style="font-family:var(--serif);font-weight:900;font-size:19px;letter-spacing:2px;">套题集</span>
</div>
<div style="display:flex;gap:8px;">
<template v-if="view === 'list'">
<button class="btn btn-tonal btn-sm" @click="showAddSet"> 新增套题</button>
<button class="btn btn-outline btn-sm" @click="showImport"> 导入套题</button>
</template>
<template v-else>
<button class="btn btn-tonal btn-sm" @click="showQuestionForm"> 新增题目</button>
</template>
<button class="btn btn-outline btn-sm" @click="doLogout">退出</button>
</div>
</div>
<!-- 套题表单新增/重命名 -->
<div v-if="setForm.show" class="card" style="padding:22px 24px;margin-bottom:20px;">
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ setForm.title }}</div>
<div class="field" style="margin-top:12px;">
<label>套题名称</label>
<input class="input" v-model="setForm.name" placeholder="例如:Dart 语法测验" @keydown.enter="saveSet">
</div>
<div class="field">
<label>6 个难度等级名称答题页徽章与题目表单使用</label>
<div v-for="(n, i) in setForm.lvNames" :key="i" class="lv-row">
<span class="lv-row-key">Lv{{ i + 1 }}</span>
<input class="input" v-model="setForm.lvNames[i]" :placeholder="'第 ' + (i + 1) + ' 级名称'">
</div>
</div>
<div class="msg err">{{ setForm.msg }}</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn btn-primary" @click="saveSet">保存</button>
<button class="btn btn-outline" @click="setForm.show = false">取消</button>
</div>
</div>
<!-- 导入表单 -->
<div v-if="importForm.show" class="card" style="padding:22px 24px;margin-bottom:20px;">
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">导入套题</div>
<div class="import-zone">
<div>
<input type="file" accept=".json,application/json" style="display:none;" :id="'importFile'" @change="onImportFile">
<button class="btn btn-outline btn-sm" @click="document.getElementById('importFile').click()">📁 选择 JSON 文件</button>
<span style="font-size:12px;color:var(--ink-faint);">或直接粘贴下方 JSON</span>
</div>
<div class="hint">
支持两种格式<br>
{"name":"套题名", "questions":[{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]}<br>
仅题目数组 [{"lv":1,"q":"题干","opts":["A","B"],"ans":0,"exp":"解析"}]名称在下方填写
</div>
<textarea class="textarea" style="margin-top:10px;min-height:110px;" v-model="importForm.text" placeholder="粘贴 JSON 内容,或选择文件后自动填入"></textarea>
<div class="field" style="margin-top:10px;">
<label>套题名称格式②或覆盖默认名时填写</label>
<input class="input" v-model="importForm.name" placeholder="留空则使用文件内的 name">
</div>
</div>
<div class="msg err">{{ importForm.msg }}</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn btn-primary" @click="doImport">开始导入</button>
<button class="btn btn-outline" @click="importForm.show = false">取消</button>
</div>
</div>
<!-- 套题列表 -->
<div v-if="view === 'list'" class="card">
<div v-if="!sets.length" class="empty-state">暂无套题点击新增套题创建第一套</div>
<div v-for="s in sets" :key="s.id" class="set-item">
<div class="set-mark">{{ s.questions.length }}</div>
<div style="flex:1;min-width:0;">
<div class="set-name">{{ s.name }}</div>
<div class="set-sub"> {{ s.questions.length }} </div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;">
<button class="btn btn-tonal btn-sm" @click="openSet(s.id)">编辑题目</button>
<button class="btn btn-outline btn-sm" @click="showRenameSet(s.id)">重命名</button>
<button class="btn btn-outline btn-sm" @click="exportSet(s.id)">导出</button>
<button class="btn btn-danger btn-sm" @click="deleteSet(s.id)">删除</button>
</div>
</div>
</div>
<!-- 套题详情题目管理 -->
<div v-else class="card">
<div class="table-wrap">
<table class="table">
<thead>
<tr><th style="width:50px;">ID</th><th style="width:70px;">难度</th><th>题干</th><th style="width:150px;">操作</th></tr>
</thead>
<tbody>
<tr v-for="q in qlist" :key="q.id">
<td style="color:var(--ink-faint);">{{ q.id }}</td>
<td><span class="seal" :class="'seal-lv' + (q.lv || 1)">Lv{{ q.lv || 1 }}</span></td>
<td style="color:var(--ink-secondary);line-height:1.6;">{{ stripHtmlAndDecode(q.q).slice(0, 60) }}</td>
<td style="white-space:nowrap;">
<button class="btn btn-outline btn-sm" @click="editQuestion(q.id)">编辑</button>
<button class="btn btn-danger btn-sm" @click="deleteQuestion(q.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!qlist.length" class="empty-state">暂无题目点击新增题目添加第一道题</div>
<!-- 题目表单 -->
<div v-if="qForm.show" class="qform" ref="qFormRef">
<div style="font-weight:800;color:var(--primary);font-family:var(--serif);letter-spacing:1px;">{{ qForm.title }}</div>
<div class="field">
<label>难度</label>
<select class="select" v-model="qForm.lv">
<option v-for="(n, i) in lvNameOptions" :key="i" :value="String(i + 1)">{{ i + 1 }} - {{ n }}</option>
</select>
</div>
<div class="field">
<label>题干支持 HTML代码里的 &lt; &gt; <b>自动转义</b>不用手写 &amp;lt;</label>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button class="btn btn-outline btn-sm" @click="insertCode('q')">&lt;code&gt; 插入代码块</button>
</div>
<textarea class="textarea" ref="qRef" v-model="qForm.q" placeholder="例如:Dart 程序的入口函数是?"></textarea>
<div v-if="qForm.q" class="preview">
<div class="preview-head">实时预览</div>
<div class="preview-body" v-html="qPreview"></div>
</div>
</div>
<div class="field">
<label>选项至少 2 可增减</label>
<div v-for="(o, i) in qForm.opts" :key="i" class="opt-row">
<span class="opt-row-key">{{ o.key }}</span>
<input class="input" v-model="o.val" :placeholder="'选项 ' + o.key">
<button class="btn btn-danger btn-sm" @click="removeOpt(i)"></button>
</div>
<button class="btn btn-outline btn-sm" @click="addOpt"> 添加选项</button>
</div>
<div class="field">
<label>正确答案</label>
<select class="select" v-model="qForm.ans">
<option v-for="(o, i) in qForm.opts" :key="i" :value="String(i)">{{ o.key }} - {{ stripHtmlAndDecode(o.val) || '' }}</option>
</select>
</div>
<div class="field">
<label>解析答题后展示支持 HTML< & > 自动转义</label>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button class="btn btn-outline btn-sm" @click="insertCode('exp')">&lt;code&gt; 插入代码块</button>
</div>
<textarea class="textarea" ref="expRef" v-model="qForm.exp" placeholder="例如:入口是 &lt;b&gt;main()&lt;/b&gt;。"></textarea>
<div v-if="qForm.exp" class="preview">
<div class="preview-head">实时预览</div>
<div class="preview-body" v-html="expPreview"></div>
</div>
</div>
<div class="msg err">{{ qForm.msg }}</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn btn-primary" @click="saveQuestion">保存</button>
<button class="btn btn-outline" @click="qForm.show = false">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.login-card { max-width: 400px; margin: 4vh auto 0; animation: fadeUp .4s ease; }
.brand-seal.small { width: 40px; height: 40px; font-size: 20px; border-radius: 11px; }
.sub { font-size: 12.5px; color: var(--ink-faint); margin-top: 2px; letter-spacing: 1px; }
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 10px;
}
.set-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 6px;
border-bottom: 1px solid var(--hairline);
}
.set-item:last-child { border-bottom: none; }
.set-mark {
width: 42px; height: 42px;
border-radius: 12px;
background: var(--primary-container);
color: var(--on-primary-container);
font-family: var(--serif);
font-weight: 900;
font-size: 16px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.set-name { font-weight: 800; font-size: 15px; color: var(--ink); }
.set-sub { font-size: 12px; color: var(--ink-faint); margin-top: 2px; }
.import-zone {
border: 1.5px dashed var(--hairline-strong);
border-radius: 14px;
padding: 16px;
margin-top: 12px;
background: var(--surface-alt);
}
.hint { font-size: 12px; color: var(--ink-faint); margin-top: 10px; line-height: 1.8; font-family: 'Consolas', monospace; }
.table-wrap { overflow-x: auto; }
.qform {
margin-top: 24px;
border-top: 1px solid var(--hairline);
padding-top: 20px;
animation: fadeUp .3s ease;
}
.opt-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.opt-row-key {
width: 26px; height: 26px;
border-radius: 8px;
background: var(--surface-high);
color: var(--ink-secondary);
font-weight: 800;
font-size: 13px;
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.lv-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.lv-row-key {
width: 42px;
font-size: 12px;
font-weight: 800;
color: var(--ink-faint);
flex-shrink: 0;
}
.preview {
margin-top: 10px;
border: 1px dashed var(--hairline-strong);
border-radius: 12px;
padding: 12px 14px;
background: var(--surface);
}
.preview-head {
font-size: 11px;
font-weight: 800;
color: var(--ink-faint);
letter-spacing: 1px;
margin-bottom: 6px;
}
.preview-body { font-size: 13.5px; line-height: 1.8; color: var(--ink); }
.preview-body :deep(code) { background: var(--surface-high); padding: 1px 6px; border-radius: 5px; font-family: 'Consolas', monospace; font-size: .92em; color: var(--primary); }
.preview-body :deep(b) { color: var(--primary); }
.preview-body :deep(pre) { background: var(--surface-high); border-radius: 10px; padding: 12px; overflow-x: auto; }
</style>