feat(admin): 更新题库管理和环境配置

- 禁用强制登录以便本地开发
- 更新开发环境API基础URL为本地服务
- 在代理配置中添加secure: false以支持HTTPS接口
- 修复请求响应中字符串类型数据的JSON解析问题
- 新增题目类型相关常量定义和类型声明
- 扩展transformRecordToOption函数支持数字键值
- 更新题库相关API接口和类型定义
- 在竞赛配置中添加题目分数字段
- 重构题库管理界面,实现分类和题目的完整CRUD操作
- 删除未使用的模板管理组件
This commit is contained in:
2026-01-28 08:33:36 +08:00
parent c3d23224ba
commit 133d9a903a
14 changed files with 484 additions and 454 deletions

View File

@ -25,7 +25,7 @@ VITE_ICON_LOCAL_PREFIX=icon-local
VITE_AUTH_ROUTE_MODE=static
# 是否强制登录: Y (开启) | N (关闭)
VITE_AUTH_ROUTE_FORCE_LOGIN=Y
VITE_AUTH_ROUTE_FORCE_LOGIN=N
# 静态认证路由的主页
VITE_ROUTE_HOME=home

View File

@ -3,9 +3,9 @@
# VITE_SERVICE_BASE_URL=http://192.168.5.140:9999
VITE_SERVICE_BASE_URL=https://api.qyzhjy.com
# VITE_SERVICE_BASE_URL=https://api.qyzhjy.com
# VITE_SERVICE_BASE_URL=http://172.16.10.130:5000
VITE_SERVICE_BASE_URL=https://172.16.10.130:5000
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"

View File

@ -40,6 +40,7 @@ function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean
proxy[item.proxyPattern] = {
target: item.baseURL,
changeOrigin: true,
secure: false, // 如果是https接口需要配置这个参数
configure: (_proxy, options) => {
_proxy.on('proxyReq', (_proxyReq, req, _res) => {
if (!enableLog)

View File

@ -63,6 +63,13 @@ export const timeRecord: Record<Api.Competition.QuestionTimeType, string> = {
90: '90s',
}
export const questionTypeRecord: Record<number, string> = {
0: '文字',
1: '图片',
}
export const questionTypeOptions: SelectOption[] = transformRecordToOption(questionTypeRecord, true) as unknown as SelectOption[]
export const timeOptions: SelectOption[] = transformRecordToOption(timeRecord) as unknown as SelectOption[]
export const uiTypeOptions: SelectOption[] = transformRecordToOption(uiTypeRecord) as unknown as SelectOption[]

View File

@ -1,16 +1,13 @@
import { request } from '../request'
/** 获取所有题目列表 */
export function fetchGetQuestionListAll(params?: any) {
return request<Api.Question.CommonRecord[]>({
export function fetchGetQuestionListAll() {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/GetQuestionListAll',
method: 'get',
params: params || {},
})
}
/** 新增题目 */
export function fetchAddQuestion(data?: Api.Question.AddParams) {
return request<Api.Question.CommonRecord>({
@ -19,3 +16,86 @@ export function fetchAddQuestion(data?: Api.Question.AddParams) {
data: data || {},
})
}
/** 更新题目 */
export function fetchUpdateQuestion(data?: Api.Question.AddParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionList',
method: 'post',
data: data || {},
})
}
/** 删除题目 */
export function fetchDeleteQuestion(id: number) {
return request<Api.Question.CommonRecord>({
url: `/Base/ActivityMain/DeleteQuestionList/?Id=${id}`,
method: 'post',
})
}
/** 获取所有问题库列表(分页) */
export function fetchGetQuestionLibraryListAll(params: Api.Question.GetQuestionLibraryListAllParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/GetQuestionListDetail',
method: 'get',
params: {
QuestionId: params.questionId,
PageIndex: params.pageIndex,
PageSizes: params.pageSizes,
keyWords: params.keyWords,
},
})
}
/** 新增一条问题库 */
export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/AddQuestionListDetail',
method: 'post',
params: {
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
},
data: formData,
})
}
/** 更新一条问题库 */
export function fetchUpdateQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionListDetail',
method: 'post',
params: {
Id: params.id,
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
},
data: formData,
})
}
/** 删除一条问题库 */
export function fetchDeleteQuestionLibrary(ids: number[]) {
return request({
url: `/Base/ActivityMain/DeleteQuestionListDetail`,
method: 'post',
data: ids,
})
}

View File

@ -28,7 +28,12 @@ export const request = createFlatRequest(
} as RequestInstanceState,
// 响应数据转换
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data
// 如果后端返回的data 是string 类型,需要转换为对象
if (typeof response.data.data === 'string') {
response.data.data = JSON.parse(response.data.data)
return response.data
}
return response.data
},
// 请求配置转换
async onRequest(config) {

View File

@ -7,29 +7,59 @@ declare namespace Api {
namespace Question {
/** common params of paginating */
interface PaginatingCommonParams {
/** current page number */
current: number
/** pageIndex page number */
pageIndex: number
/** page size */
size: number
pageSizes: number
/** total count */
total: number
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[]
data: T[]
}
/** get question library list all params */
interface GetQuestionLibraryListAllParams {
/** question id */
questionId: number
/** pageIndex page number */
pageIndex: number
/** page size */
pageSizes: number
/** search keyword */
keyWords?: string
}
/** add params */
interface AddParams {
/** question id */
id: string
id: number
/** question name */
name: string
/** question content */
questionContent: string
}
/** add question library params */
interface AddQuestionLibraryParams {
/** question library id */
id: number
/** question library name */
name: string
/** question library content */
answer: string
/** question id */
questionId: number
/** question library image url */
imageUrl: string
/** question library type */
type: QuestionScoreType
/** is priority 0: no 1: yes */
IsGood: number
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>

View File

@ -131,6 +131,7 @@ declare module 'vue' {
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSelectOption: typeof import('naive-ui')['NSelectOption']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
@ -290,6 +291,7 @@ declare global {
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
const NScrollbar: typeof import('naive-ui')['NScrollbar']
const NSelect: typeof import('naive-ui')['NSelect']
const NSelectOption: typeof import('naive-ui')['NSelectOption']
const NSpace: typeof import('naive-ui')['NSpace']
const NSpin: typeof import('naive-ui')['NSpin']
const NStatistic: typeof import('naive-ui')['NStatistic']

View File

@ -17,10 +17,11 @@ import { $t } from '@/locales'
* ```;
*
* @param record
* @param isNumberKey
*/
export function transformRecordToOption<T extends Record<string, string>>(record: T) {
export function transformRecordToOption<T extends Record<string, string>>(record: T, isNumberKey = false) {
return Object.entries(record).map(([value, label]) => ({
value,
value: isNumberKey ? Number(value) : value,
label,
})) as CommonType.Option<keyof T, T[keyof T]>[]
}
@ -67,8 +68,8 @@ export function browserPathJoin(...paths: string[]) {
/**
* 版本比较
* @param v1
* @param v2
* @param v1
* @param v2
* @param separator 分隔符 默认.
* @returns 1 v1>v2 | -1 v1<v2 | 0 v1==v2
*/
@ -76,12 +77,14 @@ export function compareVersion(v1: string, v2: string, separator = '.') {
const s1 = v1.split(separator)
const s2 = v2.split(separator)
const len = Math.max(s1.length, s2.length)
for (let i = 0; i < len; i++) {
const num1 = parseInt(s1[i] || '0')
const num2 = parseInt(s2[i] || '0')
if (num1 > num2) return 1
if (num1 < num2) return -1
const num1 = Number.parseInt(s1[i] || '0')
const num2 = Number.parseInt(s2[i] || '0')
if (num1 > num2)
return 1
if (num1 < num2)
return -1
}
return 0
}

View File

@ -17,6 +17,7 @@ const props = defineProps<{
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
score: number
uiType?: string
templateId?: string
}>
@ -292,7 +293,7 @@ defineExpose({ validate, reset })
</div>
<!-- 时间 -->
<div class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-timer class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间</span>
<NSelect
@ -303,13 +304,23 @@ defineExpose({ validate, reset })
</div>
<!-- 分数规则 -->
<div class="col-span-6 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则</span>
<NSelect
v-model:value="item.scoreType" :options="scoreTypeOptions as unknown as SelectOption[]"
class="flex-1 !bg-transparent" size="small" :bordered="false"
/>
</div>
<!-- 分数 -->
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-round-star-border class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数</span>
<NInputNumber
v-model:value="item.score" :min="0" :show-button="false" class="flex-1 !bg-transparent"
size="small" :bordered="false" placeholder="0"
/>
<span class="text-xs text-gray-400 font-bold"></span>
</div>
</div>

View File

@ -8,8 +8,9 @@ import {
useDialog,
useMessage,
} from 'naive-ui'
import { ref, watch } from 'vue'
import { onMounted, ref, watch } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { fetchAddQuestion, fetchDeleteQuestion, fetchGetQuestionListAll, fetchUpdateQuestion } from '@/service/api/question'
const emit = defineEmits<{
(e: 'update:category', node: any): void
@ -19,39 +20,19 @@ const message = useMessage()
const dialog = useDialog()
interface CategoryItem {
key: string
label: string
Id: number
Name: string
QuestionContent: string
}
// Mock Data - 模拟分类列表数据
const categoryList = ref<CategoryItem[]>([
{
key: 'stage-1',
label: '请根据提示书写正确的汉字。',
},
{
key: 'stage-2',
label: '请写出含有“车”的汉字。',
},
{
key: 'poem',
label: '请根据拼音书写正确的词语。',
},
{
key: 'stage-extra-1',
label: '请写出含有反义字的四字成语。',
},
{
key: 'stage-extra-2',
label: '请根据图片书写正确的成语。',
},
])
const categoryList = ref<CategoryItem[]>([])
const selectedKey = ref<string | null>(null)
const selectedKey = ref<number | null>(null)
// Modal State
const showCategoryModal = ref(false)
const categoryForm = ref({ name: '' })
const categoryForm = ref({ questionContent: '', name: '', Id: 0 })
const categoryOperation = ref<'add' | 'edit'>('add')
const currentOperationNode = ref<CategoryItem | null>(null)
@ -60,12 +41,10 @@ watch(selectedKey, (newKey) => {
emit('update:category', null)
return
}
const node = categoryList.value.find(item => item.key === newKey)
const node = categoryList.value.find(item => item.Id === newKey)
if (node) {
emit('update:category', {
...node,
level: 1, // Treat all as level 1
isLeaf: true, // Treat all as leaf (container for questions)
})
}
else {
@ -73,12 +52,13 @@ watch(selectedKey, (newKey) => {
}
})
function handleSelect(key: string) {
function handleSelect(key: number) {
selectedKey.value = key
}
function handleAddCategory() {
categoryOperation.value = 'add'
categoryForm.value.questionContent = ''
categoryForm.value.name = ''
currentOperationNode.value = null
showCategoryModal.value = true
@ -86,7 +66,8 @@ function handleAddCategory() {
function handleEditCategory(item: CategoryItem) {
categoryOperation.value = 'edit'
categoryForm.value.name = item.label
categoryForm.value.name = item.Name
categoryForm.value.questionContent = item.QuestionContent
currentOperationNode.value = item
showCategoryModal.value = true
}
@ -94,54 +75,98 @@ function handleEditCategory(item: CategoryItem) {
function handleDeleteCategory(item: CategoryItem) {
dialog.warning({
title: '警告',
content: `确定要删除分类 "${item.label}" 吗?此操作无法撤销。`,
content: `确定要删除分类 "${item.Name}" 吗?此操作无法撤销。`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
const index = categoryList.value.findIndex(n => n.key === item.key)
onPositiveClick: async () => {
const { error } = await fetchDeleteQuestion(item.Id)
if (error) {
message.error('删除分类失败')
return
}
const index = categoryList.value.findIndex(n => n.Id === item.Id)
if (index !== -1) {
categoryList.value.splice(index, 1)
if (selectedKey.value === item.key) {
if (selectedKey.value === item.Id) {
selectedKey.value = null
}
// 获取最新分类列表
await fetchCategoryList()
message.success('删除成功')
}
},
})
}
function submitCategory() {
/**
* 新增分类|更新分类
* @param data 新增分类参数|更新分类参数
*/
async function submitCategory() {
if (!categoryForm.value.name) {
message.error('请输入分类名称')
return
}
if (categoryOperation.value === 'add') {
const newKey = `cate-${Date.now()}`
categoryList.value.push({
key: newKey,
label: categoryForm.value.name,
const { error } = await fetchAddQuestion({
questionContent: categoryForm.value.questionContent,
name: categoryForm.value.name,
id: 0,
})
if (error) {
message.error('添加分类失败')
return
}
await fetchCategoryList()
message.success('分类添加成功')
// Automatically select the new category
selectedKey.value = newKey
}
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
currentOperationNode.value.label = categoryForm.value.name
const { error } = await fetchUpdateQuestion({
questionContent: categoryForm.value.questionContent,
name: categoryForm.value.name,
id: currentOperationNode.value.Id,
})
if (error) {
message.error('更新分类失败')
return
}
currentOperationNode.value.QuestionContent = categoryForm.value.questionContent
message.success('分类修改成功')
// Trigger update if currently selected
if (selectedKey.value === currentOperationNode.value.key) {
await fetchCategoryList()
// 更新选中分类的信息
if (selectedKey.value === currentOperationNode.value.Id) {
const node = currentOperationNode.value
emit('update:category', {
...node,
level: 1,
isLeaf: true,
Name: categoryForm.value.name,
QuestionContent: categoryForm.value.questionContent,
})
}
}
showCategoryModal.value = false
}
/**
* 获取分类列表
*/
async function fetchCategoryList() {
try {
const { data } = await fetchGetQuestionListAll()
// eslint-disable-next-line no-console
console.log('data', data)
categoryList.value = data?.data || []
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (error) {
message.error('获取分类列表失败')
}
}
onMounted(() => {
fetchCategoryList()
})
</script>
<template>
@ -161,17 +186,17 @@ function submitCategory() {
暂无分类请点击上方添加
</div>
<div
v-for="item in categoryList" :key="item.key"
v-for="item in categoryList" :key="item.Id"
class="group mb-1 flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 transition-all hover:bg-white hover:shadow-sm"
:class="selectedKey === item.key ? 'bg-white shadow-sm text-primary' : 'text-gray-600'"
@click="handleSelect(item.key)"
:class="selectedKey === item.Id ? 'bg-white shadow-sm text-primary' : 'text-gray-600'"
@click="handleSelect(item.Id)"
>
<div class="flex items-center gap-2 overflow-hidden">
<SvgIcon
icon="carbon:folder" class="flex-shrink-0 text-lg"
:class="selectedKey === item.key ? 'text-primary' : 'text-gray-400'"
:class="selectedKey === item.Id ? 'text-primary' : 'text-gray-400'"
/>
<span class="truncate font-medium">{{ item.label }}</span>
<span class="truncate font-medium">{{ item.Name }} - {{ item.QuestionContent }}</span>
</div>
<!-- Action Buttons -->
@ -205,6 +230,9 @@ function submitCategory() {
<NFormItem label="分类名称">
<NInput v-model:value="categoryForm.name" placeholder="请输入分类名称" @keyup.enter="submitCategory" />
</NFormItem>
<NFormItem label="分类描述">
<NInput v-model:value="categoryForm.questionContent" placeholder="请输入分类描述" @keyup.enter="submitCategory" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-3">

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import type { FormInst, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import {
NBreadcrumb,
NBreadcrumbItem,
@ -9,181 +10,241 @@ import {
NEmpty,
NForm,
NFormItem,
NImage,
NInput,
NInputNumber,
NPagination,
NRadio,
NRadioGroup,
NSelect,
NTag,
NUpload,
useDialog,
useMessage,
} from 'naive-ui'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { questionTypeOptions } from '@/constants/business'
import { fetchAddQuestionLibrary, fetchDeleteQuestionLibrary, fetchGetQuestionLibraryListAll, fetchUpdateQuestionLibrary } from '@/service/api/question'
const props = defineProps<{
currentCategory: any
}>()
// Mock Data - 模拟题目列表数据
const questionList = ref([
{
id: 'Q-0325',
categoryKey: 'stage-1',
content: 'jiū 表示小鸟的叫声',
answer: '碧',
score: 10,
time: 30,
const message = useMessage()
const dialog = useDialog()
// 题目列表数据
const questionList = ref<any[]>([])
const loading = ref(false)
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onChange: (page: number) => {
pagination.value.page = page
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
},
{
id: 'Q-0326',
categoryKey: 'stage-1',
content: '“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。',
answer: '杏',
score: 10,
time: 30,
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
},
{
id: 'Q-0327',
categoryKey: 'stage-1',
content: '“春色满园关不住,一枝红杏出墙来”。请书写“㽱”字。',
answer: '㽱',
score: 10,
time: 30,
},
{
id: 'Q-0328',
categoryKey: 'stage-1',
content: '“春色满园关不住,一枝红杏出墙来”。请书写“不”字。',
answer: '不',
score: 10,
time: 30,
},
{
id: 'Q-0329',
categoryKey: 'q-type-2',
content: 'táng',
answer: '糖',
score: 10,
time: 30,
},
{
id: 'Q-0330',
categoryKey: 'q-type-2',
content: 'lái',
answer: '莱',
score: 10,
time: 30,
},
{
id: 'Q-0331',
categoryKey: 'q-type-3',
content: '请写出含有“木”字的汉字。',
answer: '林',
score: 10,
time: 30,
},
])
})
const searchText = ref('')
const showQuestionModal = ref(false)
const questionOperation = ref<'add' | 'edit'>('add')
const currentQuestionId = ref<string | null>(null)
const currentQuestionId = ref<number | null>(null)
const questionForm = ref({
content: '',
name: '',
answer: '',
score: 10,
time: 30,
type: 0,
imageUrl: '',
IsGood: 0,
})
const isLeafSelected = computed(() => {
const fileList = ref<UploadFileInfo[]>([])
const questionFormRef = ref<FormInst | null>(null)
const rules = {
name: [{ required: true, message: '请输入题目正文内容', trigger: ['blur'] }],
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'], type: 'number' as const }],
}
const hasCategory = computed(() => {
return !!props.currentCategory
})
const filteredQuestions = computed(() => {
let list = questionList.value
// 1. Filter by Category Key
if (props.currentCategory && props.currentCategory.key) {
list = list.filter(q => q.categoryKey === props.currentCategory.key)
watch(() => props.currentCategory, (newVal) => {
if (newVal && newVal.Id) {
pagination.value.page = 1
searchText.value = ''
fetchData(newVal.Id)
}
// 2. Filter by Search Text
if (searchText.value) {
list = list.filter(q => q.content.includes(searchText.value))
else {
questionList.value = []
}
}, { immediate: true })
return list
})
function customRequest({ onFinish }: UploadCustomRequestOptions) {
onFinish()
}
function handleUploadChange(data: { fileList: UploadFileInfo[] }) {
fileList.value = data.fileList
}
async function fetchData(id: number) {
loading.value = true
const { data, error } = await fetchGetQuestionLibraryListAll({
questionId: id,
pageIndex: pagination.value.page,
pageSizes: pagination.value.pageSize,
keyWords: searchText.value,
})
// eslint-disable-next-line no-console
console.log(data, 'data')
if (!error) {
questionList.value = data?.data?.QuestionListDetailList || []
pagination.value.itemCount = data?.data?.TotalNum || 0
}
loading.value = false
}
function handleSearch() {
pagination.value.page = 1
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
function handleAddQuestion() {
if (!props.currentCategory) {
window.$message?.warning('请先选择一个分类')
message.warning('请先选择一个分类')
return
}
questionOperation.value = 'add'
currentQuestionId.value = null
questionForm.value = { content: '', answer: '', score: 10, time: 30, imageUrl: '' }
questionForm.value = { name: '', answer: '', type: 0, imageUrl: '', IsGood: 0 }
fileList.value = []
showQuestionModal.value = true
}
function handleEditQuestion(id: string) {
const question = questionList.value.find(q => q.id === id)
function handleEditQuestion(id: number) {
const question = questionList.value.find(q => q.Id === id)
if (question) {
questionOperation.value = 'edit'
currentQuestionId.value = id
questionForm.value = {
content: question.content,
answer: question.answer,
score: question.score,
time: question.time,
imageUrl: (question as any).imageUrl || '',
name: question.Name,
answer: question.Answer,
type: question.Type,
imageUrl: question.ImageUrl || '',
IsGood: question.IsPriority || 0,
}
fileList.value = []
if (question.ImageUrl) {
fileList.value = [{
id: 'existing',
name: 'Existing Image',
status: 'finished',
url: question.ImageUrl,
}]
}
showQuestionModal.value = true
}
}
function handleDeleteQuestion(id: string) {
window.$dialog?.warning({
function handleDeleteQuestion(id: number) {
dialog.warning({
title: '警告',
content: '确定要删除这道题目吗?此操作无法撤销。',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
const index = questionList.value.findIndex(q => q.id === id)
if (index !== -1) {
questionList.value.splice(index, 1)
window.$message?.success('删除成功')
onPositiveClick: async () => {
const { error } = await fetchDeleteQuestionLibrary([id])
if (!error) {
message.success('删除成功')
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
else {
message.error('删除失败')
}
},
})
}
function submitQuestion() {
if (!questionForm.value.content || !questionForm.value.answer) {
window.$message?.error('请填写完整信息')
async function submitQuestion() {
const valid = await questionFormRef.value?.validate()
if (!valid) {
message.error('请填写完整信息')
return
}
// eslint-disable-next-line no-console
console.log('questionForm.value', questionForm.value)
if (questionOperation.value === 'add') {
// Mock add
questionList.value.push({
id: `Q-${Math.floor(Math.random() * 10000)}`,
...questionForm.value,
categoryKey: props.currentCategory?.key, // Add categoryKey
})
window.$message?.success('题目添加成功')
const commonParams = {
name: questionForm.value.name,
answer: questionForm.value.answer,
imageUrl: '',
type: questionForm.value.type as any,
questionId: props.currentCategory.Id,
IsGood: questionForm.value.IsGood,
}
else if (questionOperation.value === 'edit' && currentQuestionId.value) {
const index = questionList.value.findIndex(q => q.id === currentQuestionId.value)
if (index !== -1) {
questionList.value[index] = {
...questionList.value[index],
...questionForm.value,
}
window.$message?.success('题目修改成功')
let fileToUpload: File | null = null
if (questionForm.value.type === 1) { // 图片类型 (1: 图片, 0: 文字 - based on previous fixes)
if (fileList.value.length > 0 && fileList.value[0].file) {
fileToUpload = fileList.value[0].file
}
else if (questionOperation.value === 'add' && fileList.value.length === 0) {
message.error('请上传图片')
return
}
else if (questionOperation.value === 'edit' && fileList.value.length === 0) {
message.error('请上传图片')
return
}
}
if (questionOperation.value === 'add') {
const { error } = await fetchAddQuestionLibrary({
...commonParams,
id: 0,
}, fileToUpload)
if (!error) {
message.success('题目添加成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
message.error('添加失败')
}
}
else if (questionOperation.value === 'edit' && currentQuestionId.value) {
const { error } = await fetchUpdateQuestionLibrary({
...commonParams,
id: currentQuestionId.value,
}, fileToUpload)
if (!error) {
message.success('题目修改成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
message.error('修改失败')
}
}
showQuestionModal.value = false
}
</script>
@ -194,20 +255,20 @@ function submitQuestion() {
<NBreadcrumb>
<NBreadcrumbItem>题库全集</NBreadcrumbItem>
<NBreadcrumbItem v-if="currentCategory && currentCategory.level === 1">
{{ currentCategory.label }}
{{ currentCategory.name }}
</NBreadcrumbItem>
</NBreadcrumb>
<div class="flex items-center justify-between">
<h1 class="m-0 text-xl text-gray-800 font-bold">
{{ currentCategory ? currentCategory.label : '' }}
{{ currentCategory ? currentCategory.name : '' }}
</h1>
</div>
<!-- 搜索框 -->
<div v-if="currentCategory" class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80">
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80" @keyup.enter="handleSearch">
<template #prefix>
<SvgIcon icon="carbon:search" class="text-gray-400" />
</template>
@ -225,7 +286,7 @@ function submitQuestion() {
<!-- 题目列表区域 -->
<div class="flex-1 overflow-y-auto bg-gray-50/50 p-6">
<template v-if="!isLeafSelected">
<template v-if="!hasCategory">
<div class="h-full flex flex-col items-center justify-center text-gray-400">
<NEmpty description="暂无数据">
<template #extra>
@ -235,7 +296,7 @@ function submitQuestion() {
</div>
</template>
<template v-else-if="filteredQuestions.length === 0">
<template v-else-if="questionList.length === 0">
<div class="mt-20 flex justify-center">
<NEmpty description="该分类下暂无题目" />
</div>
@ -244,20 +305,28 @@ function submitQuestion() {
<!-- 题目列表 -->
<template v-else>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<NCard v-for="q in filteredQuestions" :key="q.id" size="small" hoverable class="rounded-xl">
<NCard v-for="q in questionList" :key="q.Id" size="small" hoverable class="rounded-xl">
<template #header>
<div class="flex items-center gap-2">
<NTag size="small" type="primary" :bordered="false">
ID: {{ q.id }}
ID: {{ q.Id }}
</NTag>
<NTag size="small" :type="q.IsGood === 1 ? 'info' : 'default'" :bordered="false">
{{ q.IsGood === 1 ? '优先' : '随机' }}
</NTag>
</div>
</template>
<template #header-extra>
<span class="text-xs text-gray-400 font-mono">{{ q.score }} PTS / {{ q.time }} S</span>
<!-- <span class="text-xs text-gray-400 font-mono">{{ q.score }} PTS / {{ q.time }} S</span> -->
</template>
<div class="py-2 text-base text-gray-700 font-medium">
{{ q.content }}
{{ q.Name }}
</div>
<!-- 图片展示 -->
<div v-if="q.ImageUrl" class="mb-2">
<NImage :src="q.ImageUrl" width="100" class="block rounded-lg" />
</div>
<!-- 标准答案 -->
@ -266,19 +335,19 @@ function submitQuestion() {
STANDARD ANSWER
</div>
<div class="text-green-800 font-bold">
{{ q.answer }}
{{ q.Answer }}
</div>
</div>
<!-- 操作按钮 -->
<template #action>
<div class="flex justify-end gap-2">
<NButton size="tiny" quaternary type="primary" @click="handleEditQuestion(q.id)">
<NButton size="tiny" quaternary type="primary" @click="handleEditQuestion(q.Id)">
<template #icon>
<SvgIcon icon="carbon:edit" />
</template>
</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDeleteQuestion(q.id)">
<NButton size="tiny" quaternary type="error" @click="handleDeleteQuestion(q.Id)">
<template #icon>
<SvgIcon icon="carbon:trash-can" />
</template>
@ -287,35 +356,68 @@ function submitQuestion() {
</template>
</NCard>
</div>
<div class="mt-4 flex justify-end">
<NPagination
v-model:page="pagination.page" v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount" :page-sizes="pagination.pageSizes" show-size-picker
@update:page="pagination.onChange" @update:page-size="pagination.onUpdatePageSize"
>
<template #prefix="{ itemCount }">
{{ itemCount }}
</template>
</NPagination>
</div>
</template>
</div>
<!-- Add Question Drawer -->
<NDrawer v-model:show="showQuestionModal" :width="800">
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
<NForm label-placement="top">
<NFormItem label="题目正文内容">
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div class="w-full overflow-hidden border border-gray-200 rounded-lg">
<!-- <RestBasicEditor v-model="questionForm.content" /> -->
<NInput v-model:value="questionForm.content" type="textarea" class="h-64 w-full" />
<!-- <RestBasicEditor v-model="questionForm.name" /> -->
<NInput v-model:value="questionForm.name" type="textarea" class="h-64 w-full" />
</div>
</NFormItem>
<div class="grid grid-cols-2 gap-4">
<!-- <div class="grid grid-cols-2 gap-4">
<NFormItem label="参考分值 (Pts)">
<NInputNumber v-model:value="questionForm.score" class="w-full" :min="1" />
</NFormItem>
<NFormItem label="限时 (S)">
<NInputNumber v-model:value="questionForm.time" class="w-full" :min="1" />
</NFormItem>
</div>
</div> -->
<!-- 上传图片 -->
<NFormItem label="上传图片">
<RestUpload v-model="questionForm.imageUrl" width="100px" height="100px" hint="请上传题目图片" />
<!-- 类型 -->
<NFormItem label="类型" path="type">
<NSelect v-model:value="questionForm.type" class="w-full" :options="questionTypeOptions" />
</NFormItem>
<NFormItem label="参考标准答案">
<!-- 是否优先使用 -->
<NFormItem label="是否优先使用" path="IsGood">
<NRadioGroup v-model:value="questionForm.IsGood">
<NRadio :value="0">
</NRadio>
<NRadio :value="1">
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 上传图片 -->
<NFormItem v-if="questionForm.type === 1" label="上传图片">
<NUpload
v-model:file-list="fileList" accept="image/*" :max="1" list-type="image-card"
:custom-request="customRequest" @change="handleUploadChange"
>
点击上传
</NUpload>
</NFormItem>
<NFormItem label="参考标准答案" path="answer">
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
</NFormItem>
</NForm>

View File

@ -1,147 +0,0 @@
<script setup lang="ts">
import { jsonClone } from '@sa/utils'
import { computed, ref, watch } from 'vue'
import { enableStatusOptions } from '@/constants/business'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
defineOptions({
name: 'TemplateOperateDrawer',
})
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
interface Props {
operateType: NaiveUI.TableOperateType
rowData?: Api.SystemManage.User | null
}
interface Emits {
(e: 'submitted'): void
}
const visible = defineModel<boolean>('visible', {
default: false,
})
const { formRef, validate, restoreValidation } = useNaiveForm()
const { defaultRequiredRule } = useFormRules()
const title = computed(() => {
const titles: Record<NaiveUI.TableOperateType, string> = {
add: '新增模板',
edit: '编辑模板',
}
return titles[props.operateType]
})
type Model = Pick<
Api.SystemManage.User,
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
>
const model = ref(createDefaultModel())
function createDefaultModel(): Model {
return {
userName: '',
userGender: null,
nickName: '',
userPhone: '',
userEmail: '',
userRoles: [],
status: null,
}
}
type RuleKey = Extract<keyof Model, 'userName' | 'status'>
const rules: Record<RuleKey, App.Global.FormRule> = {
userName: defaultRequiredRule,
status: defaultRequiredRule,
}
function handleInitModel() {
model.value = createDefaultModel()
if (props.operateType === 'edit' && props.rowData) {
Object.assign(model.value, jsonClone(props.rowData))
}
}
function closeDrawer() {
visible.value = false
}
async function handleSubmit() {
await validate()
// request
window.$message?.success('更新成功')
closeDrawer()
emit('submitted')
}
watch(visible, () => {
if (visible.value) {
handleInitModel()
restoreValidation()
}
})
const competitionOptions = [
{ label: '第九届阅读之星大赛', value: '1' },
{ label: '科普阅读大赛', value: '2' },
]
const sizeOptions = [
{ label: '210mmx297mm', value: '1' },
{ label: 'A3', value: '2' },
]
</script>
<template>
<NDrawer v-model:show="visible" display-directive="show" :width="360">
<NDrawerContent :title="title" :native-scrollbar="false" closable>
<NForm ref="formRef" :model="model" :rules="rules">
<NFormItem label="名称" path="userName">
<NInput v-model:value="model.userName" placeholder="请输入名称" />
</NFormItem>
<NFormItem label="模板ID" path="nickName">
<NInput v-model:value="model.nickName" placeholder="请输入模板ID" />
</NFormItem>
<NFormItem label="关联比赛" path="userGender">
<NSelect
v-model:value="model.userGender"
:options="competitionOptions"
placeholder="请选择关联比赛"
/>
</NFormItem>
<NFormItem label="设计尺寸" path="userPhone">
<NSelect
v-model:value="model.userPhone"
:options="sizeOptions"
placeholder="请选择设计尺寸"
/>
</NFormItem>
<NFormItem label="状态" path="status">
<NRadioGroup v-model:value="model.status">
<NRadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value" :label="item.label" />
</NRadioGroup>
</NFormItem>
</NForm>
<template #footer>
<NSpace :size="16">
<NButton @click="closeDrawer">
取消
</NButton>
<NButton type="primary" @click="handleSubmit">
确认
</NButton>
</NSpace>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped></style>

View File

@ -1,92 +0,0 @@
<script setup lang="ts">
import { jsonClone } from '@sa/utils'
import { toRaw } from 'vue'
import { enableStatusOptions } from '@/constants/business'
import { useNaiveForm } from '@/hooks/common/form'
defineOptions({
name: 'TemplateSearch',
})
const emit = defineEmits<Emits>()
interface Emits {
(e: 'search'): void
}
const { formRef, validate, restoreValidation } = useNaiveForm()
const model = defineModel<Api.Template.TemplateSearchParams>('model', { required: true })
const defaultModel = jsonClone(toRaw(model.value))
function resetModel() {
Object.assign(model.value, defaultModel)
}
async function reset() {
await restoreValidation()
resetModel()
}
async function search() {
await validate()
emit('search')
}
const competitionOptions = [
{ label: '第九届阅读之星大赛', value: '1' },
{ label: '科普阅读大赛', value: '2' },
]
const sizeOptions = [
{ label: '210mmx297mm', value: '1' },
{ label: 'A3', value: '2' },
]
</script>
<template>
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<NCollapseItem title="基础信息" name="template-search">
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
<NGrid responsive="screen" item-responsive>
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="templateName" class="pr-24px">
<NInput v-model:value="model.templateName" placeholder="请输入名称" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="competitionId" class="pr-24px">
<NSelect
v-model:value="model.competitionId" placeholder="请选择" :options="competitionOptions as any"
clearable
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="状态" path="status" class="pr-24px">
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="size" class="pr-24px">
<NSelect v-model:value="model.size" placeholder="请选择" :options="sizeOptions" clearable />
</NFormItemGi>
<NFormItemGi span="24" class="pr-24px">
<NSpace class="w-full" justify="end">
<NButton @click="reset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
重置
</NButton>
<NButton type="primary" ghost @click="search">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
搜索
</NButton>
</NSpace>
</NFormItemGi>
</NGrid>
</NForm>
</NCollapseItem>
</NCollapse>
</NCard>
</template>
<style scoped></style>