618 lines
22 KiB
Vue
618 lines
22 KiB
Vue
<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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||
}
|
||
|
||
/* ---------- 套题:新增 / 重命名 / 删除 ---------- */
|
||
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;代码里的 < > 会<b>自动转义</b>,不用手写 &lt;)</label>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||
<button class="btn btn-outline btn-sm" @click="insertCode('q')"><code> 插入代码块</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')"><code> 插入代码块</button>
|
||
</div>
|
||
<textarea class="textarea" ref="expRef" v-model="qForm.exp" placeholder="例如:入口是 <b>main()</b>。"></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>
|