mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-12-07 21:13:11 +00:00
1207 lines
37 KiB
Vue
1207 lines
37 KiB
Vue
<template>
|
||
<div>
|
||
<!-- 错误提示 -->
|
||
<v-alert
|
||
v-if="error"
|
||
border="start"
|
||
class="mb-4 mt-3 mx-2"
|
||
closable
|
||
type="error"
|
||
variant="tonal"
|
||
@click:close="error = ''"
|
||
>
|
||
<div class="d-flex align-center">
|
||
<v-icon class="mr-2">mdi-alert-circle</v-icon>
|
||
{{ error }}
|
||
</div>
|
||
</v-alert>
|
||
|
||
<!-- 成功提示 -->
|
||
<v-alert
|
||
v-if="success"
|
||
border="start"
|
||
class="mb-4 mt-3 mx-2"
|
||
closable
|
||
type="success"
|
||
variant="tonal"
|
||
@click:close="success = ''"
|
||
>
|
||
<div class="d-flex align-center">
|
||
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
||
{{ success }}
|
||
</div>
|
||
</v-alert>
|
||
|
||
<!-- 验证错误提示 -->
|
||
<v-alert
|
||
v-if="hasValidationErrors && !loading"
|
||
border="start"
|
||
class="mb-4 mt-3 mx-2"
|
||
type="warning"
|
||
variant="tonal"
|
||
>
|
||
<div class="d-flex align-center">
|
||
<span class="font-weight-bold">配置验证失败,请检查以下问题:</span>
|
||
</div>
|
||
<v-list class="bg-transparent" density="compact">
|
||
<v-list-item
|
||
v-for="(error, index) in validationErrors"
|
||
:key="index"
|
||
class="px-0 py-0"
|
||
>
|
||
<template v-slot:prepend>
|
||
<v-icon color="warning" size="small">mdi-circle-small</v-icon>
|
||
</template>
|
||
<v-list-item-title class="text-body-2">{{ error }}</v-list-item-title>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-alert>
|
||
|
||
<!-- 加载状态 -->
|
||
<v-card v-if="loading" class="my-4" outlined>
|
||
<v-card-text>
|
||
<v-skeleton-loader class="mx-auto" type="article"></v-skeleton-loader>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- 模式切换按钮和操作按钮 -->
|
||
<div v-if="!loading" class="d-flex justify-space-between align-center mb-4">
|
||
<div class="d-flex align-center gap-2">
|
||
<v-btn
|
||
:disabled="!isValidConfig"
|
||
class="mr-2 text-none"
|
||
color="success"
|
||
prepend-icon="mdi-open-in-new"
|
||
variant="elevated"
|
||
@click="openConfig"
|
||
>
|
||
打开 ExamSchedule
|
||
</v-btn>
|
||
|
||
<v-tooltip
|
||
v-if="!isValidConfig"
|
||
activator="parent"
|
||
location="bottom"
|
||
>
|
||
<span>请先完善配置信息后再打开</span>
|
||
</v-tooltip>
|
||
</div>
|
||
<v-btn-toggle
|
||
v-model="isEditMode"
|
||
color="primary"
|
||
divided
|
||
variant="outlined"
|
||
>
|
||
<v-btn
|
||
class="text-error"
|
||
prepend-icon="mdi-delete"
|
||
@click="confirmDelete"
|
||
|
||
>
|
||
删除配置
|
||
</v-btn>
|
||
<v-btn :value="false" prepend-icon="mdi-eye"> 预览</v-btn>
|
||
<v-btn :value="true" prepend-icon="mdi-pencil"> 编辑</v-btn>
|
||
</v-btn-toggle>
|
||
</div>
|
||
|
||
<!-- 预览模式 -->
|
||
<div v-if="!loading && !isEditMode">
|
||
<div class="mb-8">
|
||
<div class="text-h3 font-weight-bold" style="line-height: 1.2">
|
||
{{ localConfig.examName || "未设置考试名称" }}
|
||
</div>
|
||
<div
|
||
class="text-subtitle-1 text-grey"
|
||
style="white-space: pre-wrap; line-height: 1.8"
|
||
>
|
||
{{ localConfig.message || "未设置考试提示" }}
|
||
</div>
|
||
<v-chip v-if="localConfig.room" class="px-4 py-2" size="large">
|
||
<v-icon start>mdi-home</v-icon>
|
||
考场:{{ localConfig.room }}
|
||
</v-chip>
|
||
</div>
|
||
<div
|
||
v-if="localConfig.examInfos && localConfig.examInfos.length > 0"
|
||
class="mb-8"
|
||
>
|
||
<v-row>
|
||
<v-col
|
||
v-for="(examInfo, index) in localConfig.examInfos"
|
||
:key="index"
|
||
cols="12"
|
||
lg="4"
|
||
md="6"
|
||
>
|
||
<v-card class="h-100" hover variant="tonal">
|
||
<v-card-title class="bg-primary-lighten-5 pa-4">
|
||
<div class="d-flex align-center">
|
||
<v-icon class="mr-2">mdi-book-open-page-variant</v-icon>
|
||
<span class="">{{ examInfo.name || "未设置科目" }}</span>
|
||
</div>
|
||
</v-card-title>
|
||
<v-card-text class="pa-4">
|
||
<div class="mb-3">
|
||
<div class="d-flex align-center mb-1">
|
||
<v-icon class="mr-2" color="success" size="small"
|
||
>mdi-clock-start
|
||
</v-icon
|
||
>
|
||
<span class="text-body-2 text-grey-darken-1">开始时间</span>
|
||
</div>
|
||
<div class="text-h6 font-weight-medium text-success">
|
||
{{ examInfo.startFormatted || examInfo.start || "未设置" }}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="d-flex align-center mb-1">
|
||
<v-icon class="mr-2" color="error" size="small"
|
||
>mdi-clock-end
|
||
</v-icon
|
||
>
|
||
<span class="text-body-2 text-grey-darken-1">结束时间</span>
|
||
</div>
|
||
<div class="text-h6 font-weight-medium text-error">
|
||
{{ examInfo.endFormatted || examInfo.end || "未设置" }}
|
||
</div>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
</div>
|
||
<div v-else class="text-center py-12">
|
||
<v-icon class="mb-4" color="grey-lighten-2" size="80">
|
||
mdi-calendar-blank
|
||
</v-icon>
|
||
<div class="text-h5 text-grey-darken-1 mb-2">暂无考试科目安排</div>
|
||
<div class="text-body-1 text-grey mb-4">
|
||
点击上方"添加科目"按钮开始配置考试时间表
|
||
</div>
|
||
<v-btn color="primary" variant="outlined" @click="quickEdit">
|
||
<v-icon start>mdi-plus</v-icon>
|
||
立即添加
|
||
</v-btn>
|
||
</div>
|
||
|
||
<!-- JSON预览 -->
|
||
<v-card border class="mb-4" elevation="2">
|
||
<v-card-title
|
||
class="d-flex align-center text-white cursor-pointer"
|
||
@click="showJsonPreview = !showJsonPreview"
|
||
>
|
||
<v-icon class="mr-2">mdi-code-json</v-icon>
|
||
JSON配置预览
|
||
<v-spacer></v-spacer>
|
||
<v-btn
|
||
color="white"
|
||
prepend-icon="mdi-content-copy"
|
||
size="small"
|
||
variant="outlined"
|
||
@click.stop="copyToClipboard"
|
||
>
|
||
复制
|
||
</v-btn>
|
||
<v-btn
|
||
:icon="showJsonPreview ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||
class="ml-2"
|
||
color="white"
|
||
size="small"
|
||
variant="text"
|
||
>
|
||
</v-btn>
|
||
</v-card-title>
|
||
<v-expand-transition>
|
||
<v-card-text v-show="showJsonPreview" class="pa-4">
|
||
<v-card class="pa-4" variant="tonal">
|
||
<pre class="json-preview"><code>{{ formattedJson }}</code></pre>
|
||
</v-card>
|
||
</v-card-text>
|
||
</v-expand-transition>
|
||
</v-card>
|
||
</div>
|
||
|
||
<!-- 编辑模式 -->
|
||
<div v-if="!loading && isEditMode">
|
||
<!-- 基本信息 -->
|
||
<v-card border class="mb-4" elevation="1">
|
||
<v-card-title class="d-flex align-center">
|
||
<v-icon class="mr-2">mdi-information</v-icon>
|
||
基本信息
|
||
</v-card-title>
|
||
<v-card-text class="pa-4">
|
||
<v-row>
|
||
<v-col cols="12" md="6">
|
||
<v-text-field
|
||
v-model="localConfig.examName"
|
||
:rules="[(v) => !!v || '考试名称不能为空']"
|
||
label="考试名称"
|
||
prepend-inner-icon="mdi-calendar-text"
|
||
required
|
||
variant="outlined"
|
||
></v-text-field>
|
||
</v-col>
|
||
<v-col cols="12" md="6">
|
||
<v-text-field
|
||
v-model="localConfig.room"
|
||
label="考场号(标准ES尚不支持此配置)"
|
||
prepend-inner-icon="mdi-home"
|
||
variant="outlined"
|
||
></v-text-field>
|
||
</v-col>
|
||
</v-row>
|
||
<v-textarea
|
||
v-model="localConfig.message"
|
||
label="考试提示"
|
||
placeholder="输入考试相关的提示信息..."
|
||
prepend-inner-icon="mdi-message-text"
|
||
rows="3"
|
||
variant="outlined"
|
||
></v-textarea>
|
||
|
||
<!-- 默认提示选项 -->
|
||
<v-chip-group
|
||
v-if="!localConfig.message || localConfig.message.trim() === ''"
|
||
class="mt-2"
|
||
column
|
||
>
|
||
<v-chip
|
||
v-for="(tip, index) in defaultExamTips"
|
||
:key="index"
|
||
class="ma-1"
|
||
color="primary"
|
||
size="small"
|
||
variant="outlined"
|
||
@click="selectDefaultTip(tip)"
|
||
>
|
||
<v-icon size="small" start>mdi-plus</v-icon>
|
||
{{ tip }}
|
||
</v-chip>
|
||
</v-chip-group>
|
||
<div class="text-caption text-medium-emphasis ml-2">
|
||
<v-icon class="mr-1" size="small">mdi-lightbulb-outline</v-icon>
|
||
点击上方选项快速添加常用考试提示
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- 考试科目安排 -->
|
||
<v-card border class="mb-4" elevation="1">
|
||
<v-card-title class="d-flex align-center">
|
||
<v-icon class="mr-2">mdi-format-list-bulleted</v-icon>
|
||
考试科目安排
|
||
<v-spacer></v-spacer>
|
||
<v-btn
|
||
color="primary"
|
||
prepend-icon="mdi-plus"
|
||
size="small"
|
||
@click="addExamInfo"
|
||
>
|
||
添加科目
|
||
</v-btn>
|
||
</v-card-title>
|
||
<v-card-text class="pa-0">
|
||
<v-list
|
||
v-if="localConfig.examInfos && localConfig.examInfos.length > 0"
|
||
>
|
||
<v-list-item
|
||
v-for="(examInfo, index) in localConfig.examInfos"
|
||
:key="index"
|
||
class="border-b pa-4"
|
||
>
|
||
<div class="w-100">
|
||
<v-row>
|
||
<v-col cols="12" md="4">
|
||
<v-text-field
|
||
v-model="examInfo.name"
|
||
:rules="[(v) => !!v || '科目名称不能为空']"
|
||
density="comfortable"
|
||
label="科目名称"
|
||
prepend-inner-icon="mdi-book"
|
||
variant="outlined"
|
||
></v-text-field>
|
||
</v-col>
|
||
<v-col cols="12" md="3">
|
||
<v-menu
|
||
v-model="examInfo.startDateMenu"
|
||
:close-on-content-click="false"
|
||
min-width="auto"
|
||
offset-y
|
||
transition="scale-transition"
|
||
>
|
||
<template v-slot:activator="{ props }">
|
||
<v-text-field
|
||
v-model="examInfo.startFormatted"
|
||
:rules="[(v) => !!v || '开始时间不能为空']"
|
||
density="comfortable"
|
||
label="开始时间"
|
||
prepend-inner-icon="mdi-clock-start"
|
||
readonly
|
||
v-bind="props"
|
||
variant="outlined"
|
||
></v-text-field>
|
||
</template>
|
||
<v-card min-width="600">
|
||
<v-card-title class="text-center py-2">
|
||
选择开始时间
|
||
</v-card-title>
|
||
<v-card-text class="pa-0">
|
||
<v-row no-gutters>
|
||
<v-col class="border-e" cols="6">
|
||
<v-date-picker
|
||
v-model="examInfo.startDate"
|
||
color="primary"
|
||
elevation="0"
|
||
locale="zh-cn"
|
||
show-adjacent-months
|
||
@update:model-value="updateStartDateTime(index)"
|
||
></v-date-picker>
|
||
</v-col>
|
||
<v-col cols="6">
|
||
<v-time-picker
|
||
v-model="examInfo.startTime"
|
||
color="primary"
|
||
elevation="0"
|
||
format="24hr"
|
||
scrollable
|
||
@update:model-value="updateStartDateTime(index)"
|
||
></v-time-picker>
|
||
</v-col>
|
||
</v-row>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer></v-spacer>
|
||
<v-btn
|
||
color="grey"
|
||
variant="text"
|
||
@click="examInfo.startDateMenu = false"
|
||
>
|
||
取消
|
||
</v-btn>
|
||
<v-btn
|
||
color="primary"
|
||
variant="text"
|
||
@click="examInfo.startDateMenu = false"
|
||
>
|
||
确定
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-menu>
|
||
</v-col>
|
||
<v-col cols="12" md="3">
|
||
<v-menu
|
||
v-model="examInfo.endDateMenu"
|
||
:close-on-content-click="false"
|
||
min-width="auto"
|
||
offset-y
|
||
transition="scale-transition"
|
||
>
|
||
<template v-slot:activator="{ props }">
|
||
<v-text-field
|
||
v-model="examInfo.endFormatted"
|
||
:rules="[(v) => !!v || '结束时间不能为空']"
|
||
density="comfortable"
|
||
label="结束时间"
|
||
prepend-inner-icon="mdi-clock-end"
|
||
readonly
|
||
v-bind="props"
|
||
variant="outlined"
|
||
></v-text-field>
|
||
</template>
|
||
<v-card min-width="600">
|
||
<v-card-title class="text-center py-2">
|
||
选择结束时间
|
||
</v-card-title>
|
||
<v-card-text class="pa-0">
|
||
<v-row no-gutters>
|
||
<v-col class="border-e" cols="6">
|
||
<v-date-picker
|
||
v-model="examInfo.endDate"
|
||
color="primary"
|
||
elevation="0"
|
||
locale="zh-cn"
|
||
show-adjacent-months
|
||
@update:model-value="updateEndDateTime(index)"
|
||
></v-date-picker>
|
||
</v-col>
|
||
<v-col cols="6">
|
||
<v-time-picker
|
||
v-model="examInfo.endTime"
|
||
color="primary"
|
||
elevation="0"
|
||
format="24hr"
|
||
scrollable
|
||
@update:model-value="updateEndDateTime(index)"
|
||
></v-time-picker>
|
||
</v-col>
|
||
</v-row>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer></v-spacer>
|
||
<v-btn
|
||
color="grey"
|
||
variant="text"
|
||
@click="examInfo.endDateMenu = false"
|
||
>
|
||
取消
|
||
</v-btn>
|
||
<v-btn
|
||
color="primary"
|
||
variant="text"
|
||
@click="examInfo.endDateMenu = false"
|
||
>
|
||
确定
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-menu>
|
||
</v-col>
|
||
<v-col class="d-flex align-center" cols="12" md="2">
|
||
<v-btn
|
||
color="error"
|
||
icon="mdi-delete"
|
||
size="small"
|
||
variant="text"
|
||
@click="removeExamInfo(index)"
|
||
>
|
||
<v-icon>mdi-delete</v-icon>
|
||
</v-btn>
|
||
<v-btn
|
||
v-if="index > 0"
|
||
color="primary"
|
||
icon="mdi-arrow-up"
|
||
size="small"
|
||
variant="text"
|
||
@click="moveExamInfo(index, -1)"
|
||
>
|
||
<v-icon>mdi-arrow-up</v-icon>
|
||
</v-btn>
|
||
<v-btn
|
||
v-if="index < localConfig.examInfos.length - 1"
|
||
color="primary"
|
||
icon="mdi-arrow-down"
|
||
size="small"
|
||
variant="text"
|
||
@click="moveExamInfo(index, 1)"
|
||
>
|
||
<v-icon>mdi-arrow-down</v-icon>
|
||
</v-btn>
|
||
</v-col>
|
||
</v-row>
|
||
</div>
|
||
</v-list-item>
|
||
</v-list>
|
||
<div v-else class="text-center py-8">
|
||
<v-icon class="mb-2" color="grey-lighten-1" size="48">
|
||
mdi-book-plus
|
||
</v-icon>
|
||
<p class="text-body-2 text-grey-darken-1 mb-4">
|
||
暂无考试科目,点击"添加科目"按钮开始添加
|
||
</p>
|
||
<v-btn color="primary" prepend-icon="mdi-plus" @click="addExamInfo">
|
||
添加科目
|
||
</v-btn>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
</div>
|
||
|
||
<!-- 删除确认对话框 -->
|
||
<v-dialog v-model="deleteDialog" max-width="400">
|
||
<v-card>
|
||
<v-card-title class="d-flex align-center">
|
||
<v-icon class="mr-2" color="error">mdi-delete-alert</v-icon>
|
||
确认删除配置
|
||
</v-card-title>
|
||
<v-card-text>
|
||
确定要删除配置 <strong>{{ localConfig.examName || `配置 ${configId}` }}</strong> 吗?
|
||
<br><small class="text-grey">此操作不可撤销,将会删除所有相关数据</small>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer></v-spacer>
|
||
<v-btn
|
||
color="grey"
|
||
variant="text"
|
||
@click="deleteDialog = false"
|
||
>
|
||
取消
|
||
</v-btn>
|
||
<v-btn
|
||
:loading="deleting"
|
||
color="error"
|
||
variant="outlined"
|
||
@click="deleteConfig"
|
||
>
|
||
删除
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import dataProvider from "@/utils/dataProvider";
|
||
|
||
export default {
|
||
name: "ExamConfigEditor",
|
||
props: {
|
||
configId: {
|
||
type: String,
|
||
required: true,
|
||
},
|
||
// 是否在弹框模式下使用
|
||
dialogMode: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
},
|
||
emits: ["saved", "error", "opened", "deleted"],
|
||
data() {
|
||
return {
|
||
localConfig: {
|
||
examName: "",
|
||
message: "",
|
||
room: "",
|
||
examInfos: [],
|
||
},
|
||
loading: false,
|
||
saving: false,
|
||
deleting: false,
|
||
deleteDialog: false,
|
||
error: "",
|
||
success: "",
|
||
isEditMode: false, // 新增:编辑模式状态
|
||
showJsonPreview: false, // 新增:JSON预览显示状态
|
||
defaultExamTips: [
|
||
"请保持卷面整洁,字迹清晰,诚信应考。在听到终考铃时立刻起立,停止作答。",
|
||
"沉着 冷静 细心 守记",
|
||
"答题不守记,自己两行泪。",
|
||
],
|
||
};
|
||
},
|
||
computed: {
|
||
/**
|
||
* 格式化的JSON字符串
|
||
*/
|
||
formattedJson() {
|
||
try {
|
||
return JSON.stringify(this.localConfig, null, 2);
|
||
} catch (err) {
|
||
console.error("格式化JSON时出错:", err);
|
||
return "无效的JSON格式";
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 检查配置是否有效
|
||
*/
|
||
isValidConfig() {
|
||
return (
|
||
this.localConfig.examName &&
|
||
this.localConfig.message &&
|
||
this.localConfig.examInfos &&
|
||
this.localConfig.examInfos.length > 0 &&
|
||
this.localConfig.examInfos.every(
|
||
(info) => info.name && info.start && info.end
|
||
)
|
||
);
|
||
},
|
||
|
||
/**
|
||
* 获取详细的验证错误信息
|
||
*/
|
||
validationErrors() {
|
||
const errors = [];
|
||
|
||
if (
|
||
!this.localConfig.examName ||
|
||
this.localConfig.examName.trim() === ""
|
||
) {
|
||
errors.push("考试名称不能为空");
|
||
}
|
||
|
||
if (!this.localConfig.message || this.localConfig.message.trim() === "") {
|
||
errors.push("考试提示不能为空");
|
||
}
|
||
|
||
if (
|
||
!this.localConfig.examInfos ||
|
||
this.localConfig.examInfos.length === 0
|
||
) {
|
||
errors.push("至少需要添加一个考试科目");
|
||
} else {
|
||
this.localConfig.examInfos.forEach((info, index) => {
|
||
const subjectPrefix = `第${index + 1}个科目`;
|
||
|
||
if (!info.name || info.name.trim() === "") {
|
||
errors.push(`${subjectPrefix}的名称不能为空`);
|
||
}
|
||
|
||
if (!info.start) {
|
||
errors.push(`${subjectPrefix}的开始时间不能为空`);
|
||
}
|
||
|
||
if (!info.end) {
|
||
errors.push(`${subjectPrefix}的结束时间不能为空`);
|
||
}
|
||
|
||
// 检查时间逻辑
|
||
if (info.start && info.end) {
|
||
const startTime = new Date(info.start);
|
||
const endTime = new Date(info.end);
|
||
|
||
if (isNaN(startTime.getTime())) {
|
||
errors.push(`${subjectPrefix}的开始时间格式不正确`);
|
||
}
|
||
|
||
if (isNaN(endTime.getTime())) {
|
||
errors.push(`${subjectPrefix}的结束时间格式不正确`);
|
||
}
|
||
|
||
if (!isNaN(startTime.getTime()) && !isNaN(endTime.getTime())) {
|
||
if (endTime <= startTime) {
|
||
errors.push(`${subjectPrefix}的结束时间必须晚于开始时间`);
|
||
}
|
||
|
||
// 检查考试时长是否合理(不超过24小时)
|
||
const duration = (endTime - startTime) / (1000 * 60 * 60); // 小时
|
||
if (duration > 24) {
|
||
errors.push(`${subjectPrefix}的考试时长不能超过24小时`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 检查科目时间是否有重叠
|
||
for (let i = 0; i < this.localConfig.examInfos.length; i++) {
|
||
for (let j = i + 1; j < this.localConfig.examInfos.length; j++) {
|
||
const info1 = this.localConfig.examInfos[i];
|
||
const info2 = this.localConfig.examInfos[j];
|
||
|
||
if (info1.start && info1.end && info2.start && info2.end) {
|
||
const start1 = new Date(info1.start);
|
||
const end1 = new Date(info1.end);
|
||
const start2 = new Date(info2.start);
|
||
const end2 = new Date(info2.end);
|
||
|
||
if (
|
||
!isNaN(start1.getTime()) &&
|
||
!isNaN(end1.getTime()) &&
|
||
!isNaN(start2.getTime()) &&
|
||
!isNaN(end2.getTime())
|
||
) {
|
||
// 检查时间重叠
|
||
if (start1 < end2 && end1 > start2) {
|
||
errors.push(`第${i + 1}个科目与第${j + 1}个科目的时间有重叠`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return errors;
|
||
},
|
||
|
||
/**
|
||
* 是否有验证错误
|
||
*/
|
||
hasValidationErrors() {
|
||
return this.validationErrors.length > 0;
|
||
},
|
||
},
|
||
watch: {
|
||
configId: {
|
||
immediate: true,
|
||
handler(newId) {
|
||
if (newId) {
|
||
this.loadConfig();
|
||
}
|
||
},
|
||
},
|
||
},
|
||
methods: {
|
||
/**
|
||
* 加载配置数据
|
||
*/
|
||
async loadConfig() {
|
||
this.loading = true;
|
||
this.error = "";
|
||
|
||
try {
|
||
const response = await dataProvider.loadData(`es_${this.configId}`);
|
||
|
||
if (response) {
|
||
this.localConfig = {
|
||
examName: "",
|
||
message: "",
|
||
room: "",
|
||
examInfos: [],
|
||
...response,
|
||
};
|
||
|
||
// 确保examInfos是数组
|
||
if (!Array.isArray(this.localConfig.examInfos)) {
|
||
this.localConfig.examInfos = [];
|
||
}
|
||
|
||
// 转换时间格式并初始化日期选择器数据
|
||
this.localConfig.examInfos.forEach((info) => {
|
||
if (info.start) {
|
||
const startDate = this.parseDateTime(info.start);
|
||
info.start = this.formatDateTimeLocal(startDate);
|
||
info.startDate = startDate;
|
||
info.startTime = this.formatTimeOnly(startDate);
|
||
info.startFormatted = this.formatDisplayDateTime(startDate);
|
||
info.startDateMenu = false;
|
||
}
|
||
if (info.end) {
|
||
const endDate = this.parseDateTime(info.end);
|
||
info.end = this.formatDateTimeLocal(endDate);
|
||
info.endDate = endDate;
|
||
info.endTime = this.formatTimeOnly(endDate);
|
||
info.endFormatted = this.formatDisplayDateTime(endDate);
|
||
info.endDateMenu = false;
|
||
}
|
||
});
|
||
} else {
|
||
console.error("加载配置失败:", response);
|
||
this.error =
|
||
"加载配置失败: " + (response.error?.message || "未知错误");
|
||
this.$emit("error", this.error);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
this.error = "加载配置失败: " + err.message;
|
||
this.$emit("error", this.error);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 保存配置
|
||
*/
|
||
async saveConfig() {
|
||
if (!this.isValidConfig) {
|
||
// 显示详细的验证错误信息
|
||
const errors = this.validationErrors;
|
||
if (errors.length > 0) {
|
||
this.error = `配置验证失败:${errors.join(";")}`;
|
||
} else {
|
||
this.error = "请填写完整的配置信息";
|
||
}
|
||
return false;
|
||
}
|
||
|
||
this.saving = true;
|
||
this.error = "";
|
||
|
||
try {
|
||
// 创建保存用的配置副本,转换时间格式
|
||
const configToSave = {
|
||
...this.localConfig,
|
||
examInfos: this.localConfig.examInfos.map((info) => ({
|
||
...info,
|
||
start: this.formatDisplayDateTime(info.start),
|
||
end: this.formatDisplayDateTime(info.end),
|
||
})),
|
||
};
|
||
|
||
const response = await dataProvider.saveData(
|
||
`es_${this.configId}`,
|
||
configToSave
|
||
);
|
||
|
||
if (response) {
|
||
this.success = "配置保存成功";
|
||
this.$emit("saved", configToSave);
|
||
return true;
|
||
} else {
|
||
this.error =
|
||
"保存配置失败: " + (response.error?.message || "未知错误");
|
||
this.$emit("error", this.error);
|
||
return false;
|
||
}
|
||
} catch (err) {
|
||
this.error = "保存配置失败: " + err;
|
||
this.$emit("error", this.error);
|
||
return false;
|
||
} finally {
|
||
this.saving = false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 添加考试科目
|
||
*/
|
||
addExamInfo() {
|
||
const now = new Date();
|
||
const startTime = new Date(now.getTime() + 60 * 60 * 1000); // 1小时后
|
||
const endTime = new Date(startTime.getTime() + 2 * 60 * 60 * 1000); // 2小时后
|
||
|
||
const examInfo = {
|
||
name: "新科目",
|
||
start: this.formatDateTimeLocal(startTime),
|
||
end: this.formatDateTimeLocal(endTime),
|
||
// 日期选择器相关数据
|
||
startDate: startTime,
|
||
startTime: this.formatTimeOnly(startTime),
|
||
startFormatted: this.formatDisplayDateTime(startTime),
|
||
startDateMenu: false,
|
||
endDate: endTime,
|
||
endTime: this.formatTimeOnly(endTime),
|
||
endFormatted: this.formatDisplayDateTime(endTime),
|
||
endDateMenu: false,
|
||
};
|
||
|
||
this.localConfig.examInfos.push(examInfo);
|
||
},
|
||
|
||
/**
|
||
* 删除考试科目
|
||
*/
|
||
removeExamInfo(index) {
|
||
this.localConfig.examInfos.splice(index, 1);
|
||
},
|
||
|
||
/**
|
||
* 移动考试科目位置
|
||
*/
|
||
moveExamInfo(index, direction) {
|
||
const newIndex = index + direction;
|
||
if (newIndex >= 0 && newIndex < this.localConfig.examInfos.length) {
|
||
const item = this.localConfig.examInfos.splice(index, 1)[0];
|
||
this.localConfig.examInfos.splice(newIndex, 0, item);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 复制JSON到剪贴板
|
||
*/
|
||
async copyToClipboard() {
|
||
try {
|
||
await navigator.clipboard.writeText(this.formattedJson);
|
||
this.success = "JSON已复制到剪贴板";
|
||
} catch (err) {
|
||
this.error = "复制失败: " + err.message;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 切换编辑模式
|
||
*/
|
||
toggleEditMode() {
|
||
this.isEditMode = !this.isEditMode;
|
||
// 清除之前的错误和成功消息
|
||
this.error = "";
|
||
this.success = "";
|
||
},
|
||
|
||
/**
|
||
* 快速编辑 - 直接切换到编辑模式
|
||
*/
|
||
quickEdit() {
|
||
this.isEditMode = true;
|
||
this.error = "";
|
||
this.success = "";
|
||
},
|
||
|
||
/**
|
||
* 选择默认提示
|
||
*/
|
||
selectDefaultTip(tip) {
|
||
if (this.localConfig.message && this.localConfig.message.trim() !== "") {
|
||
// 如果已有内容,追加到现有内容后面
|
||
this.localConfig.message += "\n" + tip;
|
||
} else {
|
||
// 如果没有内容,直接设置
|
||
this.localConfig.message = tip;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 格式化日期时间为datetime-local输入格式
|
||
*/
|
||
formatDateTimeLocal(dateTime) {
|
||
if (!dateTime) return "";
|
||
|
||
let date;
|
||
if (typeof dateTime === "string") {
|
||
// 处理各种可能的日期格式
|
||
if (dateTime.includes("/")) {
|
||
// 格式: 2025/08/29 16:27
|
||
date = new Date(dateTime.replace(/\//g, "-"));
|
||
} else {
|
||
date = new Date(dateTime);
|
||
}
|
||
} else {
|
||
date = new Date(dateTime);
|
||
}
|
||
|
||
if (isNaN(date.getTime())) {
|
||
return "";
|
||
}
|
||
|
||
// 转换为本地时间的ISO字符串,去掉秒和毫秒
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
const hours = String(date.getHours()).padStart(2, "0");
|
||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||
|
||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||
},
|
||
|
||
/**
|
||
* 格式化日期时间为显示格式
|
||
*/
|
||
formatDisplayDateTime(dateTime) {
|
||
if (!dateTime) return "";
|
||
|
||
const date = new Date(dateTime);
|
||
if (isNaN(date.getTime())) {
|
||
return dateTime; // 如果无法解析,返回原值
|
||
}
|
||
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
const hours = String(date.getHours()).padStart(2, "0");
|
||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||
|
||
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
||
},
|
||
|
||
/**
|
||
* 解析日期时间字符串
|
||
*/
|
||
parseDateTime(dateTime) {
|
||
if (!dateTime) return new Date();
|
||
|
||
if (typeof dateTime === "string") {
|
||
// 处理各种可能的日期格式
|
||
if (dateTime.includes("/")) {
|
||
// 格式: 2025/08/29 16:27
|
||
return new Date(dateTime.replace(/\//g, "-"));
|
||
} else {
|
||
return new Date(dateTime);
|
||
}
|
||
} else {
|
||
return new Date(dateTime);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 格式化时间为HH:MM格式
|
||
*/
|
||
formatTimeOnly(dateTime) {
|
||
if (!dateTime) return "00:00";
|
||
|
||
const date = new Date(dateTime);
|
||
if (isNaN(date.getTime())) {
|
||
return "00:00";
|
||
}
|
||
|
||
const hours = String(date.getHours()).padStart(2, "0");
|
||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||
|
||
return `${hours}:${minutes}`;
|
||
},
|
||
|
||
/**
|
||
* 更新开始日期时间
|
||
*/
|
||
updateStartDateTime(index) {
|
||
const examInfo = this.localConfig.examInfos[index];
|
||
if (!examInfo.startDate || !examInfo.startTime) return;
|
||
|
||
// 合并日期和时间
|
||
const date = new Date(examInfo.startDate);
|
||
const [hours, minutes] = examInfo.startTime.split(":");
|
||
date.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||
|
||
// 更新相关字段
|
||
examInfo.start = this.formatDateTimeLocal(date);
|
||
examInfo.startFormatted = this.formatDisplayDateTime(date);
|
||
},
|
||
|
||
/**
|
||
* 更新结束日期时间
|
||
*/
|
||
updateEndDateTime(index) {
|
||
const examInfo = this.localConfig.examInfos[index];
|
||
if (!examInfo.endDate || !examInfo.endTime) return;
|
||
|
||
// 合并日期和时间
|
||
const date = new Date(examInfo.endDate);
|
||
const [hours, minutes] = examInfo.endTime.split(":");
|
||
date.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||
|
||
// 更新相关字段
|
||
examInfo.end = this.formatDateTimeLocal(date);
|
||
examInfo.endFormatted = this.formatDisplayDateTime(date);
|
||
},
|
||
|
||
/**
|
||
* 打开配置
|
||
* 获取配置的云端地址并在新窗口中打开考试页面
|
||
*/
|
||
async openConfig() {
|
||
try {
|
||
// 获取配置的云端访问地址
|
||
const result = await dataProvider.getKeyCloudUrl(`es_${this.configId}`, {
|
||
autoMigrate: true,
|
||
autoConfig: true
|
||
});
|
||
|
||
if (result.success && result.url) {
|
||
// 构建考试页面URL
|
||
const examUrl = `https://es.zerocat.dev/exam/?configUrl=${encodeURIComponent(result.url)}`;
|
||
|
||
// 在新窗口中打开
|
||
window.open(examUrl, '_blank');
|
||
|
||
this.success = '配置已在新窗口中打开';
|
||
this.$emit('opened', {configId: this.configId, url: result.url});
|
||
} else {
|
||
throw new Error(result.error || '获取云端地址失败');
|
||
}
|
||
} catch (err) {
|
||
this.error = '打开配置失败: ' + err.message;
|
||
this.$emit('error', '打开配置失败: ' + err.message);
|
||
}
|
||
},
|
||
|
||
|
||
/**
|
||
* 确认删除配置
|
||
*/
|
||
confirmDelete() {
|
||
this.deleteDialog = true;
|
||
},
|
||
|
||
/**
|
||
* 删除配置
|
||
*/
|
||
async deleteConfig() {
|
||
this.deleting = true;
|
||
try {
|
||
// 获取当前云端的配置列表
|
||
const listData = await dataProvider.loadData('es_list');
|
||
const currentList = listData || [];
|
||
|
||
// 从列表中移除当前配置
|
||
const updatedList = currentList.filter(item => item.id !== this.configId);
|
||
|
||
// 更新云端的配置列表
|
||
const listResponse = await dataProvider.saveData('es_list', updatedList);
|
||
if (!listResponse) {
|
||
throw new Error('更新云端列表失败');
|
||
}
|
||
|
||
this.deleteDialog = false;
|
||
this.$emit("deleted", {
|
||
success: true,
|
||
message: "配置删除成功",
|
||
configId: this.configId
|
||
});
|
||
} catch (error) {
|
||
console.error("删除配置失败:", error);
|
||
this.$emit("deleted", {
|
||
success: false,
|
||
message: "删除失败: " + error.message
|
||
});
|
||
} finally {
|
||
this.deleting = false;
|
||
}
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.border-b {
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
.border-b:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.json-preview {
|
||
border-radius: 8px;
|
||
font-family: "Fira Code", "Courier New", monospace;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
}
|
||
|
||
.json-preview code {
|
||
font-weight: 400;
|
||
}
|
||
|
||
/* 预览模式样式增强 */
|
||
.border-b {
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.border-b:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
/* 日期时间选择器样式 */
|
||
.border-e {
|
||
border-right: 1px solid rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
.datetime-picker-header {
|
||
background-color: #f5f5f5;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
/* 预览卡片阴影效果 */
|
||
.v-card--variant-elevated {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
|
||
}
|
||
|
||
/* 模式切换按钮样式 */
|
||
.v-btn-toggle {
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.v-btn-toggle .v-btn {
|
||
border-radius: 0 !important;
|
||
}
|
||
|
||
.cursor-pointer {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.v-card.hover:hover {
|
||
transform: translateY(-2px);
|
||
transition: transform 0.2s ease-in-out;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||
}
|
||
|
||
.bg-primary-lighten-5 {
|
||
background-color: rgba(var(--v-theme-primary), 0.08) !important;
|
||
}
|
||
|
||
.v-btn-toggle .v-btn:first-child {
|
||
border-top-left-radius: 8px;
|
||
border-bottom-left-radius: 8px;
|
||
}
|
||
|
||
.v-btn-toggle .v-btn:last-child {
|
||
border-top-right-radius: 8px;
|
||
border-bottom-right-radius: 8px;
|
||
}
|
||
</style>
|