feat(admin): 新增模板管理功能并完善用户认证

- 新增模板管理模块,包含模板列表和详情页面
- 重构用户认证逻辑,支持用户ID存储和API调用
- 新增排行榜API接口和类型定义
- 更新环境配置,添加开发环境配置
- 优化表格操作钩子,增强类型安全性
- 新增文件上传工具类和验证工具函数
- 更新路由配置,支持模板详情页面导航
- 修复登录表单默认值问题
This commit is contained in:
2026-01-27 09:48:42 +08:00
parent 264f600250
commit 5a48a65ef8
56 changed files with 2907 additions and 434 deletions

View File

@ -20,7 +20,7 @@ interface FormModel {
}
const model: FormModel = reactive({
userName: 'Admin',
userName: '111111',
password: '123456',
})

View File

@ -35,7 +35,7 @@ const competitionData = ref({
rounds: [ // 赛题包配置
{
id: 'round_default',
name: '第一轮:主赛环节',
name: '星·辞海遨游',
roundType: roundTypeOptions[0].value,
questions: Array.from({ length: 10 }).map(() => ({
value: null,
@ -203,7 +203,7 @@ function handleReset() {
type="primary" size="large" class="px-10 text-lg font-bold shadow-blue-500/30 shadow-lg"
@click="nextStep"
>
{{ currentStep === 4 ? '确认发布比赛' : '下一步' }}
{{ currentStep === 3 ? '确认发布比赛' : '下一步' }}
<template #icon>
<icon-ic-baseline-arrow-forward v-if="currentStep < 4" class="text-icon" />
<icon-ic-baseline-check v-else class="text-icon" />

View File

@ -2,7 +2,7 @@
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business'
import { useQuestionConfig } from './useQuestionConfig'
const props = defineProps<{
@ -11,7 +11,15 @@ const props = defineProps<{
id: string
name: string
roundType: string
questions: Array<{ id: string, value: string | null, time: number, title: string, scoreType: Api.Competition.QuestionScoreType }>
questions: Array<{
id: string
value: string | null
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
uiType?: string
templateId?: string
}>
}>
}
}>()
@ -20,7 +28,6 @@ const {
getRoundTime,
totalStats,
questionOptions,
timeOptions,
addRound,
removeRound,
addQuestion,
@ -29,6 +36,25 @@ const {
reset,
} = useQuestionConfig(props)
const templateIdOptions: SelectOption[] = [
{
label: '模板1',
value: 't1',
},
{
label: '模版2',
value: 't2',
},
{
label: '模版3',
value: 't3',
},
{
label: '模版4',
value: 't4',
},
]
// 模态框控制
const showAddModal = ref(false)
const newRoundForm = ref({
@ -39,7 +65,7 @@ const newRoundForm = ref({
function handleOpenAddModal() {
newRoundForm.value = {
name: `${(props.modelValue?.rounds?.length || 0) + 1}汉字听写`,
name: `${(props.modelValue?.rounds?.length || 0) + 1}星·诗词大会`,
count: 10,
roundType: roundTypeOptions[0].value,
}
@ -51,7 +77,7 @@ function handleConfirmAdd() {
window.$message?.warning('请输入环节名称')
return
}
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType)
addRound(newRoundForm.value.name, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
showAddModal.value = false
}
@ -145,7 +171,7 @@ defineExpose({ validate, reset })
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
<NTag type="primary" class="!text-white">
<NTag type="primary" class="!text-blue-500">
{{ roundTypeOptions.find((item) => item.value === round.roundType)?.label || '未知类型' }}
</NTag>
<div class="flex items-center gap-3">
@ -196,70 +222,97 @@ defineExpose({ validate, reset })
<!-- 题目列表 -->
<VueDraggable
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-2"
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-3"
ghost-class="ghost"
>
<div
v-for="(item, qIdx) in round.questions" :key="item.id"
class="group/item flex items-center gap-2 border border-gray-100 rounded-xl bg-white p-2 px-2 transition-all hover:border-blue-200 hover:shadow-sm"
class="group/item flex flex-col gap-3 border border-gray-100 rounded-xl bg-white p-3 transition-all hover:border-blue-200 hover:shadow-sm"
>
<div class="flex items-center gap-1">
<div
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
>
<icon-mdi-drag class="text-xl" />
<!-- 第一行序号标题删除按钮 -->
<div class="flex items-center justify-between gap-3">
<div class="flex flex-1 items-center gap-3">
<div class="flex items-center gap-2">
<div
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
>
<icon-mdi-drag class="text-xl" />
</div>
<span
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
>
{{ String(qIdx + 1).padStart(2, '0') }}
</span>
</div>
<!-- 标题 -->
<div class="flex flex-1 items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-title class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题</span>
<NInput
v-model:value="item.title" class="flex-1 !bg-transparent" size="small" :bordered="false"
placeholder="请输入题目标题"
/>
</div>
</div>
<span
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
>{{
String(qIdx + 1).padStart(2, '0') }}</span>
<!-- 删除按钮 -->
<NButton
text class="transition-opacity !text-gray-300 hover:!text-red-500"
@click="removeQuestion(roundIndex, qIdx)"
>
<icon-ic-outline-delete class="!text-xl" />
</NButton>
</div>
<!-- 题型 -->
<div class="flex-1">
<NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型..."
:bordered="false" class="font-medium"
/>
</div>
<!-- 第二行配置项 -->
<div class="grid grid-cols-12 gap-3">
<!-- 题型 -->
<div class="col-span-4">
<NSelect
v-model:value="item.value" :options="questionOptions" placeholder="请选择题目类型" size="small"
class="font-medium"
/>
</div>
<!-- 时间 -->
<div class="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
v-model:value="item.time" :options="timeOptions" class="w-26 !bg-transparent" size="small"
:bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- UItype -->
<div class="col-span-4">
<NSelect
v-model:value="item.uiType" :options="uiTypeOptions" placeholder="请选择UI类型"
size="small" class="font-medium"
/>
</div>
<!-- 分数规则 -->
<div class="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="w-28 !bg-transparent" size="small" :bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- templateId -->
<div class="col-span-4">
<NSelect
v-model:value="item.templateId" :options="templateIdOptions" placeholder="请选择模板"
size="small" class="font-medium"
/>
</div>
<!-- 标题 -->
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-title class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题</span>
<NInput v-model:value="item.title" class="w-28 !bg-transparent" size="small" :bordered="false" />
</div>
<!-- 时间 -->
<div class="col-span-6 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
v-model:value="item.time" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
:bordered="false"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- 删除按钮 -->
<NButton
text
class="ml-2 opacity-0 transition-opacity !text-gray-300 group-hover/item:opacity-100 hover:!text-red-500"
@click="removeQuestion(roundIndex, qIdx)"
>
<icon-ic-outline-delete class="!text-xl" />
</NButton>
<!-- 分数规则 -->
<div class="col-span-6 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"
/>
<span class="text-xs text-gray-400 font-bold"></span>
</div>
</div>
</div>
</VueDraggable>
</div>
@ -285,7 +338,7 @@ defineExpose({ validate, reset })
<NForm size="large">
<NFormItem label="环节/题包名称">
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 汉字听写" />
<NInput v-model:value="newRoundForm.name" placeholder="例如:第一轮 星·诗词大会" />
</NFormItem>
<NFormItem label="环节/题包类型">
<NSelect

View File

@ -8,6 +8,8 @@ export interface QuestionItem {
time: number
title: string
scoreType: Api.Competition.QuestionScoreType
uiType?: string
templateId?: string
}
export interface RoundModel {
@ -49,12 +51,12 @@ export function useQuestionConfig(props: any) {
})
const questionOptions = [
{ label: '汉字听写 - 根据提示书写汉字', value: 'hanzi' },
{ label: '同音字辨析 - 根据拼音书写', value: 'tongyin' },
{ label: '偏旁部首 - 按要求写字', value: 'pianpang' },
{ label: '词语听写 - 综合练习', value: 'ciyu' },
{ label: '成语填空 - 四字成语', value: 'chengyu' },
{ label: '反义词挑战', value: 'fanyi' },
{ label: '请根据提示书写正确的汉字-jiū 表示小鸟的叫声。', value: 'hanzi' },
{ label: '请根据提示书写正确的汉字-“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。', value: 'tongyin' },
{ label: '请写出含有“车”的汉字。', value: 'pianpang' },
{ label: '请根据拼音书写正确的词语。', value: 'ciyu' },
{ label: '请写出含有反义字的四字成语。 - 例如:“不日而月”', value: 'chengyu' },
{ label: '请根据图片书写正确的成语。', value: 'fanyi' },
{ label: '近义词挑战', value: 'jinyi' },
{ label: '看图猜字', value: 'kantu' },
{ label: '古诗词接龙', value: 'gushi' },
@ -63,14 +65,6 @@ export function useQuestionConfig(props: any) {
{ label: '极速成语接龙 - 每题20秒', value: 'speed20' },
]
const timeOptions = [
{ label: '15s', value: 15 },
{ label: '30s', value: 30 },
{ label: '45s', value: 45 },
{ label: '60s', value: 60 },
{ label: '90s', value: 90 },
]
// 新增轮次
function addRound(customName?: string, customCount?: number, roundType?: Api.Competition.CompetitionRoundType) {
if (!props.modelValue.rounds) {
@ -141,8 +135,35 @@ export function useQuestionConfig(props: any) {
return false
}
for (let j = 0; j < round.questions.length; j++) {
if (!round.questions[j].value) {
window.$message?.error(`${round.name}${j + 1}未选择题型`)
const question = round.questions[j]
const questionPrefix = `${round.name}${j + 1}`
if (!question.value) {
window.$message?.error(`${questionPrefix}未选择题型`)
return false
}
if (!question.uiType) {
window.$message?.error(`${questionPrefix}未选择UI类型`)
return false
}
if (!question.templateId) {
window.$message?.error(`${questionPrefix}未选择模板`)
return false
}
if (!question.time) {
window.$message?.error(`${questionPrefix}未设置答题时间`)
return false
}
if (!question.scoreType) {
window.$message?.error(`${questionPrefix}未设置分数规则`)
return false
}
if (!question.title) {
window.$message?.error(`${questionPrefix}未填写标题`)
return false
}
if (question.score === null || question.score === undefined) {
window.$message?.error(`${questionPrefix}未设置分数`)
return false
}
}
@ -161,7 +182,6 @@ export function useQuestionConfig(props: any) {
getRoundTime,
totalStats,
questionOptions,
timeOptions,
addRound,
removeRound,
addQuestion,

View File

@ -90,6 +90,7 @@ const questionForm = ref({
answer: '',
score: 10,
time: 30,
imageUrl: '',
})
const isLeafSelected = computed(() => {
@ -119,7 +120,7 @@ function handleAddQuestion() {
}
questionOperation.value = 'add'
currentQuestionId.value = null
questionForm.value = { content: '', answer: '', score: 10, time: 30 }
questionForm.value = { content: '', answer: '', score: 10, time: 30, imageUrl: '' }
showQuestionModal.value = true
}
@ -133,6 +134,7 @@ function handleEditQuestion(id: string) {
answer: question.answer,
score: question.score,
time: question.time,
imageUrl: (question as any).imageUrl || '',
}
showQuestionModal.value = true
}
@ -159,6 +161,8 @@ function submitQuestion() {
window.$message?.error('请填写完整信息')
return
}
// eslint-disable-next-line no-console
console.log('questionForm.value', questionForm.value)
if (questionOperation.value === 'add') {
// Mock add
@ -306,6 +310,11 @@ function submitQuestion() {
</NFormItem>
</div>
<!-- 上传图片 -->
<NFormItem label="上传图片">
<RestUpload v-model="questionForm.imageUrl" width="100px" height="100px" hint="请上传题目图片" />
</NFormItem>
<NFormItem label="参考标准答案">
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
</NFormItem>

View File

@ -3,22 +3,18 @@ import { NButton, NCard, NTag } from 'naive-ui'
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
import { fetchGetUserList } from '@/service/api'
import { fetchRankList } from '@/service/api'
import { useAppStore } from '@/store/modules/app'
import RankSearch from './modules/rank-search.vue'
const appStore = useAppStore()
const router = useRouter()
const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1,
size: 10,
status: null,
userName: null,
userGender: null,
nickName: null,
userPhone: null,
userEmail: null,
const searchParams: Api.Rank.UserSearchParams = reactive({
currentPage: 1,
pageSize: 10,
publishStatus: undefined,
competitionActivityName: '',
})
const rankTitle = '排行榜管理列表'
@ -28,8 +24,8 @@ const statusMap: Record<string, string> = {
2: '待审核',
}
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchGetUserList(searchParams),
const { columns, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchRankList(searchParams as any),
transform: (response) => {
const transformed = defaultTransform(response)
// Mocking data for Rank Management based on User API response
@ -39,12 +35,12 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
const mockScores = ['145', '180', '120']
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45']
transformed.data.forEach((item, index) => {
transformed.data.forEach((item: any, index) => {
const mockIndex = index % 3
item.userName = mockCompetitions[mockIndex] // Competition Name
item.userEmail = mockDates[mockIndex] // Activity Date (using userEmail field)
item.userPhone = mockScales[mockIndex] // Team Scale (using userPhone field)
item.nickName = mockScores[mockIndex] // Max Score (using nickName field)
item.competitionActivityName = mockCompetitions[mockIndex] // Competition Name
item.competitionActivityDate = mockDates[mockIndex] // Activity Date (using userEmail field)
item.competitionTeamScale = mockScales[mockIndex] // Team Scale (using userPhone field)
item.competitionMaxScore = mockScores[mockIndex] // Max Score (using nickName field)
// item.createTime is typically available, we'll simulate update time
item.createTime = mockTimes[mockIndex]
// Status: 1 (Published), 2 (Pending)
@ -53,42 +49,45 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
return transformed
},
onPaginationParamsChange: (params) => {
searchParams.current = params.page
searchParams.size = params.pageSize
searchParams.currentPage = params.page || 1
searchParams.pageSize = params.pageSize || 10
},
columns: () => [
{
type: 'selection',
align: 'center',
width: 48,
width: 40,
},
{
key: 'userName',
title: '序号',
align: 'center',
key: 'index',
width: 80,
render: (row, index) => index + 1,
},
{
key: 'competitionActivityName',
title: '比赛活动名称',
align: 'left',
minWidth: 200,
},
{
key: 'userEmail',
key: 'competitionActivityDate',
title: '活动日期',
align: 'center',
minWidth: 120,
},
{
key: 'userPhone',
key: 'competitionTeamScale',
title: '队伍规模',
align: 'center',
minWidth: 100,
},
{
key: 'nickName',
key: 'competitionMaxScore',
title: '最高积分',
align: 'center',
minWidth: 100,
render: row => (
<span class="text-primary font-bold">
{row.nickName}
{' '}
{row.competitionMaxScore}
Pts
</span>
),
@ -97,13 +96,11 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'createTime',
title: '最后更新时间',
align: 'center',
minWidth: 160,
},
{
key: 'status',
title: '发布状态',
align: 'center',
width: 100,
render: (row) => {
if (row.status === null)
return null
@ -116,7 +113,6 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'operate',
title: '操作',
align: 'center',
width: 120,
render: row => (
<div class="flex-center">
<NButton type="primary" text onClick={() => edit(row.id)}>
@ -129,12 +125,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
})
const {
drawerVisible,
operateType,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
} = useTableOperate(data, 'id', getData)
function edit(id: number) {
@ -149,7 +140,7 @@ function handleBatchPublish() {
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<RankSearch v-model:model="searchParams" @search="getDataByPage" />
<NCard :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<NCard :bordered="false" page-size="small" class="card-wrapper sm:flex-1-hidden">
<template #header>
<div class="flex items-center justify-between">
<div>
@ -173,7 +164,7 @@ function handleBatchPublish() {
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
</template> -->
<NDataTable
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" page-size="small" striped
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
:pagination="mobilePagination" class="sm:h-full"
/>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { jsonClone } from '@sa/utils'
import { computed, toRaw } from 'vue'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
import { toRaw } from 'vue'
import { useNaiveForm } from '@/hooks/common/form'
defineOptions({
name: 'RankSearch',
@ -15,18 +15,7 @@ interface Emits {
const { formRef, validate, restoreValidation } = useNaiveForm()
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
const { patternRules } = useFormRules()
return {
userEmail: patternRules.email,
userPhone: patternRules.phone,
}
})
const model = defineModel<Api.Rank.UserSearchParams>('model', { required: true })
const defaultModel = jsonClone(toRaw(model.value))
@ -54,14 +43,14 @@ const publishStatusOptions = [
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<NCollapseItem title="筛选查询" name="rank-search">
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="100">
<NForm ref="formRef" :model="model" label-placement="left" :label-width="100">
<NGrid responsive="screen" item-responsive>
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="userName" class="pr-24px">
<NInput v-model:value="model.userName" placeholder="请输入比赛活动名称" />
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="competitionActivityName" class="pr-24px">
<NInput v-model:value="model.competitionActivityName" placeholder="请输入比赛活动名称" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:8" label="发布状态" path="status" class="pr-24px">
<NFormItemGi span="24 s:12 m:8" label="发布状态" path="publishStatus" class="pr-24px">
<NSelect
v-model:value="model.status"
v-model:value="model.publishStatus"
placeholder="请选择"
:options="publishStatusOptions"
clearable

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { jsonClone } from '@sa/utils'
import { computed, toRaw } from 'vue'
import { toRaw } from 'vue'
import { enableStatusOptions } from '@/constants/business'
import { useFormRules, useNaiveForm } from '@/hooks/common/form'
import { useNaiveForm } from '@/hooks/common/form'
defineOptions({
name: 'TemplateSearch',
@ -16,18 +16,7 @@ interface Emits {
const { formRef, validate, restoreValidation } = useNaiveForm()
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true })
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
const { patternRules } = useFormRules()
return {
userEmail: patternRules.email,
userPhone: patternRules.phone,
}
})
const model = defineModel<Api.Template.TemplateSearchParams>('model', { required: true })
const defaultModel = jsonClone(toRaw(model.value))
@ -60,22 +49,22 @@ const sizeOptions = [
<NCard :bordered="false" size="small" class="card-wrapper">
<NCollapse>
<NCollapseItem title="基础信息" name="template-search">
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="80">
<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="userName" class="pr-24px">
<NInput v-model:value="model.userName" placeholder="请输入名称" />
<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="userGender" class="pr-24px">
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="competitionId" class="pr-24px">
<NSelect
v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
v-model:value="model.competitionId" placeholder="请选择" :options="competitionOptions as any"
clearable
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" label="状态" path="userStatus" class="pr-24px">
<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="userPhone" class="pr-24px">
<NSelect v-model:value="model.userPhone" placeholder="请选择" :options="sizeOptions" clearable />
<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">

View File

@ -0,0 +1,8 @@
<script lang="ts" setup>
</script>
<template>
<div class="">
<h1>模板详情</h1>
</div>
</template>

View File

@ -1,23 +1,24 @@
<script setup lang="tsx">
import { NButton, NPopconfirm, NTag } from 'naive-ui'
import { reactive } from 'vue'
import { useRouterPush } from '@/hooks/common/router'
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table'
import { fetchGetUserList } from '@/service/api'
import { fetchTemplateList } from '@/service/api'
import { useAppStore } from '@/store/modules/app'
import TemplateOperateDrawer from './modules/template-operate-drawer.vue'
import TemplateSearch from './modules/template-search.vue'
const { routerPushByKey } = useRouterPush()
const appStore = useAppStore()
const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1,
size: 10,
const searchParams: Api.Template.TemplateSearchParams = reactive({
currentPage: 1,
pageSize: 10,
status: null,
userName: null,
userGender: null,
nickName: null,
userPhone: null,
userEmail: null,
templateName: undefined,
competitionId: undefined,
size: undefined,
})
const templateTitle = '模板管理'
@ -33,19 +34,22 @@ const statusMap: Record<string, string> = {
}
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
api: () => fetchGetUserList(searchParams),
api: () => fetchTemplateList(searchParams),
transform: (response) => {
const transformed = defaultTransform(response)
transformed.data.forEach((item, index) => {
item.nickName = `MD${String(index + 1).padStart(6, '0')}`
item.userPhone = '210mmx297mm'
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1'
transformed.data.forEach((item: any, index) => {
item.code = `MD${String(index + 1).padStart(6, '0')}`
item.size = '210mmx297mm'
item.competitionId = (item.competitionId === '1' || item.competitionId === '2') ? item.competitionId : '1'
// Mock templateName if not present
if (!item.templateName)
item.templateName = item.userName || '测试模板'
})
return transformed
},
onPaginationParamsChange: (params) => {
searchParams.current = params.page
searchParams.size = params.pageSize
searchParams.currentPage = params.page || 1
searchParams.pageSize = params.pageSize || 10
},
columns: () => [
{
@ -61,26 +65,26 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
render: (_, index) => index + 1,
},
{
key: 'userName',
key: 'templateName',
title: '名称',
align: 'center',
},
{
key: 'nickName',
key: 'code',
title: '模板ID',
align: 'center',
},
{
key: 'userGender',
key: 'competitionId',
title: '关联比赛',
align: 'center',
render: (row) => {
const label = competitionMap[row.userGender as string] || '未知比赛'
render: (row: any) => {
const label = competitionMap[row.competitionId as string] || '未知比赛'
return <span>{label}</span>
},
},
{
key: 'userPhone',
key: 'size',
title: '设计尺寸',
align: 'center',
},
@ -88,8 +92,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'status',
title: '状态',
align: 'center',
width: 100,
render: (row) => {
render: (row: Api.Template.CommonRecord) => {
if (row.status === null) {
return null
}
@ -101,7 +104,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
key: 'operate',
title: '操作',
align: 'center',
// width: 430,
width: 430,
render: row => (
<div class="flex-center gap-8px">
<NButton type="primary" ghost size="small" onClick={() => edit(row.id)}>
@ -113,7 +116,7 @@ const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagi
<NButton size="small" onClick={() => { }}>
预览
</NButton>
<NButton size="small" onClick={() => { }}>
<NButton size="small" onClick={() => handlePageInfo(row.id)}>
页面信息
</NButton>
<NButton size="small" onClick={() => { }}>
@ -159,12 +162,16 @@ function handleDelete() {
function edit(id: number) {
handleEdit(id)
}
function handlePageInfo(id: number) {
routerPushByKey('template_template-detail', { query: { id: id.toString() } })
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
<NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<NCard :title="templateTitle" :bordered="false" page-size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
@ -172,8 +179,8 @@ function edit(id: number) {
/>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
v-model:checked-row-keys="checkedRowKeys" :columns="columns as any" :data="data" page-size="small"
striped :flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
:pagination="mobilePagination" class="sm:h-full"
/>
<TemplateOperateDrawer

View File

@ -0,0 +1,147 @@
<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

@ -0,0 +1,92 @@
<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>