提交 9a36ae69 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增实验大赛

上级 0466f110
......@@ -44,7 +44,9 @@ onMounted(() => {
<section class="drag-panel">
<div v-if="!isLeftShow" class="drag-panel-left" :class="{ 'is-hidden': !leftPanelVisible }">
<div class="drag-cover" v-if="dragFlag"></div>
<slot name="left"></slot>
<div class="drag-panel-left-content">
<slot name="left"></slot>
</div>
<div class="panel-resize" id="panel-resize"></div>
<div class="panel-icon" @click="leftPanelVisible = !leftPanelVisible">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 86" aria-hidden="true" width="16" height="86">
......@@ -73,7 +75,6 @@ onMounted(() => {
.drag-panel {
display: flex;
height: calc(100vh - 110px);
gap: 20px;
}
.drag-panel-left {
position: relative;
......@@ -83,9 +84,11 @@ onMounted(() => {
border-radius: 6px;
box-sizing: border-box;
transition: all 0.1s;
margin-right: 20px;
&.is-hidden {
width: 0 !important;
padding: 0;
margin-right: 0;
.panel-resize {
display: none;
}
......@@ -118,6 +121,10 @@ onMounted(() => {
cursor: pointer;
}
}
.drag-panel-left-content {
overflow: hidden;
height: 100%;
}
.drag-panel-right {
position: relative;
flex: 1;
......
......@@ -153,6 +153,8 @@ const appConfigList = [
hosts: ['ccsc'],
dmlURL: import.meta.env.VITE_DML_PRO_URL,
loginURL: import.meta.env.VITE_SWSJFXS_LOGIN_URL,
studentMenus: [],
homeUrl: '/student/lab/competition',
},
]
......
......@@ -218,3 +218,19 @@ export function deleteExperiment(data: { experiment_id: string }) {
export function getLiveCommodity(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-commodity/all', { params })
}
// 获取实验大赛规则
export function getCompetitionRule(params: { experiment_id: string }) {
return httpRequest.get('/api/resource/v1/backend/experiment-competition-rule/detail', { params })
}
// 保存实验大赛规则
export function saveCompetitionRule(data: {
experiment_id: string
name: string
start_time: string
end_time: string
questions: string
}) {
return httpRequest.post('/api/resource/v1/backend/experiment-competition-rule/save', data)
}
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { getCompetitionRule, saveCompetitionRule } from '../api'
interface Props {
id: string
}
const props = defineProps<Props>()
const dialogVisible = ref(false)
const formRef = ref()
const form = reactive({
name: '',
start_time: '',
end_time: '',
questions: '1',
})
const rules = ref({
name: [{ required: true, message: '请输入' }],
start_time: [{ required: true, message: '请选择' }],
end_time: [{ required: true, message: '请选择' }],
questions: [{ required: true, message: '请选择' }],
})
const options = ref([{ label: '“悦颜坊”美妆护肤案例', value: '1' }])
async function fetchInfo() {
const res = await getCompetitionRule({ experiment_id: props.id })
Object.assign(form, res.data.detail)
}
watch(
dialogVisible,
(value) => {
if (value) fetchInfo()
},
{ immediate: true }
)
// 提交
async function handleSubmit() {
await formRef.value?.validate()
await saveCompetitionRule({ ...form, experiment_id: props.id })
ElMessage.success('保存成功')
dialogVisible.value = false
}
</script>
<template>
<el-button type="primary" @click="dialogVisible = true">编辑大赛规则</el-button>
<el-dialog title="编辑大赛规则" :close-on-click-modal="false" width="500px" v-model="dialogVisible" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="80px">
<el-form-item label="大赛名称" prop="name">
<el-input v-model="form.name" placeholder="请输入大赛名称" />
</el-form-item>
<el-form-item label="开始时间" prop="start_time">
<el-date-picker
v-model="form.start_time"
type="datetime"
placeholder="请选择大赛开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%" />
</el-form-item>
<el-form-item label="结束时间" prop="end_time">
<el-date-picker
v-model="form.end_time"
type="datetime"
placeholder="请选择大赛结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%" />
</el-form-item>
<el-form-item label="试题" prop="questions">
<el-select v-model="form.questions" style="width: 100%" placeholder="请选择" filterable>
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button round auto-insert-space @click="dialogVisible = false">关闭</el-button>
<el-button type="primary" round auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
......@@ -17,6 +17,7 @@ const ViewExam = defineAsyncComponent(() => import('../components/ViewExam.vue')
const CopyDialog = defineAsyncComponent(() => import('../components/CopyDialog.vue'))
const DMLFormDialog = defineAsyncComponent(() => import('../components/DMLFormDialog.vue'))
const GradeRulesDialog = defineAsyncComponent(() => import('../components/GradeRulesDialog.vue'))
const CompetitionRuleDialog = defineAsyncComponent(() => import('../components/CompetitionRuleDialog.vue'))
interface Props {
id: string
......@@ -147,6 +148,7 @@ function handleUpdateGradeRules() {
<router-link :to="`/admin/lab/experiment/report/${detail?.id}`" target="_blank">编辑报告规则</router-link>
</el-button>
</template>
<CompetitionRuleDialog :id="id"></CompetitionRuleDialog>
</div>
</div>
</template>
......
......@@ -150,3 +150,26 @@ export function getExperimentCaseDetail(params: { experiment_id: string }) {
export function getExperimentExample(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/student/experiment-cases2/detail', { params })
}
// 获取实验大赛信息
export function getExperimentCompetition(params: { experiment_id: string }) {
// return {
// code: 0,
// message: 'OK',
// data: {
// detail: {
// experiment_id: '7264836112785342464',
// name: 'test',
// start_time: '2025-11-01 00:00:00',
// end_time: '2025-11-11 00:00:00',
// questions: '1',
// },
// },
// }
return httpRequest.get('/api/lab/v1/experiment/competition/detail', { params })
}
// 提交考试
export function submitCompetition(data: { experiment_id: string }) {
return httpRequest.post('/api/lab/v1/experiment/competition/submit', data)
}
<script setup lang="ts">
import { useCookies } from '@vueuse/integrations/useCookies'
import { getExperimentExamList } from '../api'
import { useAppConfig } from '@/composables/useAppConfig'
const appConfig = useAppConfig()
// import { useAppConfig } from '@/composables/useAppConfig'
// const appConfig = useAppConfig()
interface Props {
experiment_id: string
......@@ -28,7 +28,7 @@ const currentExam = computed(() => {
// 考试平台 URL
const examURL = computed(() => {
if (!currentExam.value) return ''
return appConfig.system === 'x' && props.examStatus === 0
return props.examStatus === 0
? `${import.meta.env.VITE_EXAM_SHOW_URL}/exam/${
currentExam.value?.exam_id
}?has_time=0&has_submit=0&has_save=1&show_answer=1`
......
import { useGetCourseList } from './useGetCourseList'
import type { ExperimentType } from '../types'
import { getExperiment, getExperimentCompetition, submitCompetition } from '../api'
interface StudentStatus {
has_submitted: boolean
}
interface Competition {
name: string
start_time: string
end_time: string
student_status: StudentStatus
}
export function useCompetition() {
const competition = ref<Competition>()
const { courses } = useGetCourseList()
const experiments = ref<ExperimentType[]>([])
const courseId = ref('')
const experimentId = ref('')
watchEffect(() => {
if (courses.value.length) {
courseId.value = courses.value[0].id
experiments.value = courses.value[0].experiments
}
})
watchEffect(() => {
if (experiments.value.length) {
experimentId.value = experiments.value[0].id
}
})
const experimentInfo = ref<ExperimentType>()
const fetchExperiment = async () => {
return false
const res = await getExperiment({ experiment_id: experimentId.value })
experimentInfo.value = res.data.detail
}
const fetchExperimentCompetition = async () => {
const res = await getExperimentCompetition({ experiment_id: experimentId.value })
competition.value = res.data.detail
}
watchEffect(() => {
if (experimentId.value) {
fetchExperiment()
fetchExperimentCompetition()
}
})
const submit = async () => {
await submitCompetition({ experiment_id: experimentId.value })
await fetchExperimentCompetition()
}
return { competition, courseId, experimentId, experimentInfo, submit }
}
......@@ -9,7 +9,8 @@ export const routes: Array<RouteRecordRaw> = [
children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: 'report/:id', component: () => import('./views/Report.vue'), props: true },
{ path: 'report/view/:id', component: () => import('./views/ReportView.vue'), props: true }
]
}
{ path: 'report/view/:id', component: () => import('./views/ReportView.vue'), props: true },
{ path: 'competition', component: () => import('./views/Competition.vue') },
],
},
]
<script setup lang="ts">
import DragPanel from '@/components/DragPanel.vue'
import { useCompetition } from '../composables/useCompetition'
import { useAppConfig } from '@/composables/useAppConfig'
import { useNow } from '@vueuse/core'
import dayjs from 'dayjs'
import { formatDuration } from '@/utils/utils'
import { ElMessage, ElMessageBox } from 'element-plus'
const appConfig = useAppConfig()
const Case = defineAsyncComponent(() => import('../components/Case.vue'))
const { courseId, experimentId, competition, submit } = useCompetition()
const LAB_URL = computed<string>(() => {
return `${appConfig.dmlURL || import.meta.env.VITE_DML_URL}/live/test?experiment_id=${experimentId.value}`
})
const isEntry = ref(false)
const now = useNow({ interval: 1000 })
const isSubmitted = computed(() => {
return competition.value?.student_status?.has_submitted
})
const canEnter = computed(() => {
if (isSubmitted.value) return false
if (!competition.value?.start_time || !competition.value?.end_time) return false
const current = dayjs(now.value)
const start = dayjs(competition.value.start_time)
const end = dayjs(competition.value.end_time)
return current.isAfter(start) && current.isBefore(end)
})
const countdown = computed(() => {
if (!competition.value?.start_time || !competition.value?.end_time) {
return { label: '倒计时', value: '--:--:--' }
}
const current = dayjs(now.value)
const end = dayjs(competition.value.end_time)
if (current.isBefore(end)) {
return { label: '距离考试结束', value: formatDuration(end.diff(current)) }
}
return { label: '考试已结束', value: '00:00:00' }
})
const handleEntry = () => {
isEntry.value = true
}
const handleSubmit = async () => {
ElMessageBox.confirm('此操作将会提交该实验,状态会变为已提交,您将不能再操作该实验,确定提交考试吗?', '提示').then(
async () => {
await submit()
ElMessage.success('提交成功')
isEntry.value = false
}
)
}
</script>
<template>
<DragPanel v-if="isEntry">
<template #left>
<div class="left-box">
<h2 class="left-box-title">实操试题</h2>
<div class="left-box-content">
<Case :course_id="courseId" :experiment_id="experimentId" />
</div>
</div>
</template>
<template #right>
<AppCard>
<div class="competition-header">
<div class="time">
<div class="label">倒计时:</div>
<div class="value">{{ countdown.value }}</div>
</div>
<el-button type="primary" @click="handleSubmit">提交考试</el-button>
</div>
</AppCard>
<div class="iframe-box">
<iframe
allow="camera; microphone"
allowfullscreen
:src="LAB_URL"
frameborder="0"
class="iframe"
ref="iframeRef"></iframe>
</div>
</template>
</DragPanel>
<template v-else>
<div class="welcome-box" v-if="competition?.name">
<div class="welcome-box-header">
<div class="welcome-box-header-content">
<h1>{{ competition?.name }}</h1>
<h2>考试时间</h2>
<div class="line"></div>
<p>{{ competition?.start_time }}{{ competition?.end_time }}</p>
</div>
</div>
<el-button v-if="canEnter" type="primary" size="large" @click="handleEntry" class="entry-btn"
>进入实操考试</el-button
>
<el-button plain v-if="isSubmitted" type="primary" size="large" class="entry-btn">考试已提交</el-button>
</div>
<el-empty description="暂无比赛" v-else />
</template>
</template>
<style lang="scss" scoped>
.left-box {
height: 100%;
display: flex;
flex-direction: column;
}
.left-box-title {
font-size: 22px;
font-weight: 600;
color: var(--main-color);
line-height: 32px;
text-align: center;
margin-bottom: 20px;
}
.left-box-content {
flex: 1;
}
.competition-header {
display: flex;
align-items: center;
justify-content: space-between;
.time {
display: flex;
align-items: center;
gap: 4px;
.label {
font-size: 14px;
color: var(--main-color);
font-weight: 500;
}
.value {
font-size: 24px;
font-weight: 700;
letter-spacing: 2px;
color: var(--main-color);
line-height: 1;
}
}
}
.iframe-box {
position: relative;
flex: 1;
width: 100%;
margin-top: 20px;
display: flex;
}
.iframe {
width: 100%;
height: 100%;
}
.welcome-box {
margin: -20px;
height: calc(100vh - 66px);
.entry-btn {
display: block;
width: 300px;
margin: 100px auto;
}
}
.welcome-box-header {
height: 50%;
background: url('/public/images/competition_bg.jpg') no-repeat top center;
background-size: cover;
display: flex;
align-items: flex-end;
}
.welcome-box-header-content {
width: 100%;
max-width: 1000px;
margin: 0 auto;
color: #fff;
padding: 40px 0;
h1 {
font-size: 36px;
font-weight: 600;
color: #fff;
line-height: 50px;
letter-spacing: 7px;
text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
}
h2 {
margin-top: 20px;
font-size: 18px;
font-weight: 700;
color: #fff;
line-height: 25px;
text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
}
.line {
width: 24px;
height: 2px;
background: #ffffff;
box-shadow: 0 2px 4px #00000080;
margin: 10px 0;
}
p {
font-size: 18px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #fff;
line-height: 25px;
text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
}
}
</style>
......@@ -11,10 +11,10 @@ import { saveAs } from 'file-saver'
import html2pdf from 'html2pdf.js'
import { useCookies } from '@vueuse/integrations/useCookies'
import { useAppConfig } from '@/composables/useAppConfig'
import { useLiveMonitor } from '@/composables/useLiveMonitor'
// import { useLiveMonitor } from '@/composables/useLiveMonitor'
const appConfig = useAppConfig()
useLiveMonitor({ autoStart: !!appConfig.liveMonitor })
// useLiveMonitor({ autoStart: !!appConfig.liveMonitor })
const Question = defineAsyncComponent(() => import('../components/Question.vue'))
const Info = defineAsyncComponent(() => import('../components/Info.vue'))
......
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAppConfig } from '@/composables/useAppConfig'
const appConfig = useAppConfig()
const router = createRouter({
history: createWebHistory(),
......@@ -23,8 +26,8 @@ router.beforeEach(async (to, from, next) => {
return
}
}
if (to.path === '/' && user.role?.id === 1) {
next('/student/lab')
if (to.path === '/' && user.role?.id === 1 && appConfig.homeUrl) {
next(appConfig.homeUrl)
return
}
next()
......
export function formatDuration(ms: number) {
const totalSeconds = Math.max(Math.floor(ms / 1000), 0)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const pad = (num: number) => num.toString().padStart(2, '0')
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
}
......@@ -40,6 +40,11 @@ export default defineConfig(({ mode }) => ({
// changeOrigin: true,
// rewrite: path => path.replace(/^\/api\/resource/, '')
// },
'/api/dev': {
target: 'http://localhost:20081',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/dev/, ''),
},
'/api': 'https://saas-lab.ezijing.com',
},
},
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论