Files
ClassHomeworkBoard/src/components/ExamConfigEditor.vue
T
Henry_Du c7fdc11dbd
Azure Static Web Apps CI/CD / Close Pull Request Job (push) Skipped
Build and Deploy / build (push) Failing after 9m48s
Azure Static Web Apps CI/CD / Build and Deploy Job (push) Failing after 9m50s
首次提交
2026-10-24 01:29:24 +08:00

2169 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 #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-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="text-none"
color="success"
prepend-icon="mdi-open-in-new"
variant="elevated"
@click="openConfig"
>
打开 ExamSchedule
</v-btn>
<v-btn
:disabled="!isValidConfig"
class="text-none"
color="primary"
prepend-icon="mdi-link-variant"
variant="elevated"
@click="copyConfigUrl"
>
复制远程链接
</v-btn>
<v-btn
:disabled="!isValidConfig"
class="text-none"
color="primary"
prepend-icon="mdi-download"
variant="elevated"
@click="downloadAsJson"
>
JSON 文件
</v-btn>
<v-btn
:disabled="!isValidConfig"
class="text-none"
color="primary"
prepend-icon="mdi-download"
variant="elevated"
@click="downloadAsEa2"
>
.ea2 文件
</v-btn>
<v-btn
:disabled="!isValidConfig"
class="text-none"
color="secondary"
prepend-icon="mdi-play-circle"
variant="elevated"
@click="openInEa2Player"
>
拉起EA2播放器
</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>
配置预览
<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-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>{{ formattedStorageJson }}</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 bg-primary-lighten-5 pa-4">
<v-icon class="mr-2">
mdi-information
</v-icon>
<span class="font-weight-bold">基本信息</span>
</v-card-title>
<v-card-text class="pa-6">
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="localConfig.examName"
:rules="[
(v) => !!v || '考试名称不能为空',
(v) => (v && v.trim().length > 0) || '考试名称不能为空白字符',
(v) => !v || v.length <= 100 || '考试名称不能超过100个字符'
]"
clearable
density="comfortable"
label="考试名称"
placeholder="如:2025年高考模拟考试"
prepend-inner-icon="mdi-calendar-text"
required
variant="outlined"
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="localConfig.room"
clearable
density="comfortable"
label="考场号(仅 ExamSchedule-Management 支持此配置)"
placeholder="如:一号考场"
prepend-inner-icon="mdi-home"
variant="outlined"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<span class="text-subtitle-2 font-weight-bold d-block mb-2">
<v-icon
size="small"
class="mr-1"
>mdi-message-text</v-icon>
考试提示
</span>
<v-textarea
v-model="localConfig.message"
:rules="[
(v) => !!v || '考试提示不能为空',
(v) => (v && v.trim().length > 0) || '考试提示不能为空白字符'
]"
clearable
density="comfortable"
label="输入考试相关的提示信息..."
no-resize
placeholder="例如:请保持卷面整洁,诚信应考。在听到终考铃时立刻停止作答。"
rows="3"
variant="outlined"
/>
<!-- 默认提示选项 -->
<div
v-if="!localConfig.message || localConfig.message.trim() === ''"
class="mt-3"
>
<v-chip-group
class="d-flex gap-2"
column
>
<v-chip
v-for="(tip, index) in defaultExamTips"
:key="index"
class="cursor-pointer"
color="primary"
size="small"
variant="outlined"
@click="selectDefaultTip(tip)"
>
<v-icon
size="small"
start
>
mdi-plus
</v-icon>
{{ tip.substring(0, 20) }}...
</v-chip>
</v-chip-group>
<div class="text-caption text-medium-emphasis mt-2 ml-2">
<v-icon
class="mr-1"
size="x-small"
>
mdi-lightbulb-outline
</v-icon>
点击上方选项快速添加常用考试提示
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 考试科目安排 -->
<v-card
border
class="mb-4"
elevation="1"
>
<v-card-title class="d-flex align-center bg-success-lighten-5 pa-4">
<v-icon class="mr-2">
mdi-format-list-bulleted
</v-icon>
<span class="font-weight-bold">考试科目安排</span>
<v-spacer />
<!-- 提醒时间开关 -->
<div class="d-flex align-center mr-4">
<v-switch
v-model="enableCustomAlertTime"
color="primary"
density="compact"
hide-details
@change="toggleAlertTimeMode"
>
<template #label>
<span class="text-body-2">自定义提醒时间</span>
</template>
</v-switch>
</div>
<v-btn
color="success"
prepend-icon="mdi-plus"
size="small"
variant="elevated"
@click="addExamInfo"
>
添加科目
</v-btn>
</v-card-title>
<v-card-text class="pa-0">
<v-list
v-if="localConfig.examInfos && localConfig.examInfos.length > 0"
class="py-0"
>
<v-list-item
v-for="(examInfo, index) in localConfig.examInfos"
:key="index"
class="border-b pa-4 hover-highlight"
>
<div class="w-100">
<!-- 科目序号指示器 -->
<div class="d-flex align-center mb-3">
<v-chip
:color="index % 2 === 0 ? 'primary' : 'secondary'"
size="small"
variant="tonal"
class="mr-3"
>
<v-icon
start
size="small"
>
mdi-numeric-{{ index + 1 }}-circle
</v-icon>
第 {{ index + 1 }} 科目
</v-chip>
<!-- 考试时长显示 -->
<v-spacer />
<div class="d-flex gap-1">
<v-btn
v-if="index > 0"
color="primary"
icon="mdi-arrow-up"
size="x-small"
variant="text"
@click="moveExamInfo(index, -1)"
>
<v-tooltip
activator="parent"
location="bottom"
>
上移
</v-tooltip>
</v-btn>
<v-btn
v-if="index < localConfig.examInfos.length - 1"
color="primary"
icon="mdi-arrow-down"
size="x-small"
variant="text"
@click="moveExamInfo(index, 1)"
>
<v-tooltip
activator="parent"
location="bottom"
>
下移
</v-tooltip>
</v-btn>
<v-btn
color="error"
icon="mdi-delete"
size="x-small"
variant="text"
@click="removeExamInfo(index)"
>
<v-tooltip
activator="parent"
location="bottom"
>
删除
</v-tooltip>
</v-btn>
</div>
</div>
<v-row class="align-start">
<v-col
cols="12"
md="4"
>
<!-- 科目名称自动完成选择器 -->
<v-autocomplete
v-model="examInfo.name"
:items="availableSubjects"
:rules="[
(v) => !!v || '科目名称不能为空',
(v) => (v && v.trim().length > 0) || '科目名称不能为空白字符'
]"
clearable
density="comfortable"
item-title="name"
label="科目名称"
no-data-text="没有可用科目,请输入自定义名称"
prepend-inner-icon="mdi-book"
variant="outlined"
>
<template #prepend-item>
<v-list-item
v-if="customSubjectInput"
title="自定义:"
>
<template #append>
<span class="text-primary font-weight-bold">{{ customSubjectInput }}</span>
</template>
</v-list-item>
<v-divider v-if="customSubjectInput" />
</template>
</v-autocomplete>
</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 #activator="{ props }">
<v-text-field
v-model="examInfo.startFormatted"
:rules="[
(v) => !!v || '开始时间不能为空',
(v) => validateTimeFormat(v, '开始时间'),
() => validateNoTimeOverlap(examInfo, index)
]"
density="comfortable"
label="开始时间"
placeholder="2025/01/01 09:00"
prepend-inner-icon="mdi-clock-start"
v-bind="props"
variant="outlined"
@blur="updateStartDateTimeFromInput(index)"
>
<template #append>
<v-icon>mdi-calendar-clock</v-icon>
</template>
</v-text-field>
</template>
<v-card min-width="500">
<v-card-title class="text-center py-3 bg-primary-lighten-5">
<v-icon
class="mr-2"
color="primary"
>
mdi-clock-start
</v-icon>
选择开始时间
</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-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-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey"
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 #activator="{ props }">
<v-text-field
v-model="examInfo.endFormatted"
:rules="[
(v) => !!v || '结束时间不能为空',
(v) => validateTimeFormat(v, '结束时间'),
() => validateEndAfterStart(examInfo),
() => validateNoTimeOverlap(examInfo, index)
]"
density="comfortable"
label="结束时间"
placeholder="2025/01/01 11:00"
prepend-inner-icon="mdi-clock-end"
v-bind="props"
variant="outlined"
@blur="updateEndDateTimeFromInput(index)"
>
<template #append>
<v-icon>mdi-calendar-clock</v-icon>
</template>
</v-text-field>
</template>
<v-card min-width="500">
<v-card-title class="text-center py-3 bg-error-lighten-5">
<v-icon
class="mr-2"
color="error"
>
mdi-clock-end
</v-icon>
选择结束时间
</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="error"
elevation="0"
locale="zh-cn"
show-adjacent-months
@update:model-value="updateEndDateTime(index)"
/>
</v-col>
<v-col cols="6">
<v-time-picker
v-model="examInfo.endTime"
color="error"
elevation="0"
format="24hr"
scrollable
@update:model-value="updateEndDateTime(index)"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="examInfo.endDateMenu = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-col>
<v-col
cols="12"
md="2"
>
<v-text-field
v-model="examInfo.durationMinutes"
:rules="[
(v) => !!v || '时长不能为空',
(v) => !isNaN(v) || '时长必须是数字',
(v) => parseInt(v) > 0 || '时长必须大于0',
(v) => parseInt(v) <= 1440 || '时长不能超过1440分钟(24小时)'
]"
type="number"
min="1"
max="1440"
density="comfortable"
label="时长(分钟)"
placeholder="例如:120"
prepend-inner-icon="mdi-timer"
variant="outlined"
:hint="durationHint(examInfo)"
persistent-hint
@blur="updateDurationFromInput(index)"
/>
</v-col>
<!-- 提醒时间输入框(仅在启用自定义时显示) -->
<v-col
v-if="enableCustomAlertTime"
cols="12"
md="2"
>
<v-text-field
v-model="examInfo.alertTime"
:rules="[
(v) => !!v || '提醒时间不能为空',
(v) => !isNaN(v) || '提醒时间必须是数字',
(v) => parseInt(v) >= 0 || '提醒时间不能为负数',
(v) => parseInt(v) <= 120 || '提醒时间不能超过120分钟'
]"
type="number"
min="0"
max="120"
density="comfortable"
label="提醒时间(分钟)"
placeholder="例如:15"
prepend-inner-icon="mdi-bell-ring"
variant="outlined"
hint="考试结束前提醒"
persistent-hint
/>
</v-col>
</v-row>
</div>
</v-list-item>
</v-list>
<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="success"
prepend-icon="mdi-plus"
size="large"
variant="elevated"
@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-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预览显示状态
availableSubjects: [], // 可用的科目列表
customSubjectInput: "", // 自定义科目输入
enableCustomAlertTime: false, // 是否启用自定义提醒时间
defaultExamTips: [
"请保持卷面整洁,字迹清晰,诚信应考。在听到终考铃时立刻起立,停止作答。",
"沉着 冷静 细心 守记",
"答题不守记,自己两行泪。",
],
};
},
computed: {
/**
* 格式化的JSON字符串(旧版,完整数据)
*/
formattedJson() {
try {
return JSON.stringify(this.localConfig, null, 2);
} catch (err) {
console.error("格式化JSON时出错:", err);
return "无效的JSON格式";
}
},
/**
* 格式化的存储格式JSON字符串(只包含核心字段)
*/
formattedStorageJson() {
try {
const storageConfig = {
examName: this.localConfig.examName,
message: this.localConfig.message,
room: this.localConfig.room,
examInfos: this.localConfig.examInfos.map((info) => ({
name: info.name,
start: this.formatDisplayDateTime(info.start),
end: this.formatDisplayDateTime(info.end),
alertTime: parseInt(info.alertTime) || 15,
})),
};
return JSON.stringify(storageConfig, 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();
}
},
},
},
created() {
this.loadSubjects();
},
methods: {
/**
* 加载可用的科目列表
*/
async loadSubjects() {
try {
const response = await dataProvider.loadData("classworks-config-subject");
// 兼容返回数组或包含 value 属性的对象
const dataArray = Array.isArray(response) ? response : (response && Array.isArray(response.value) ? response.value : null);
if (response && response.success !== false && dataArray) {
this.availableSubjects = dataArray
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map(subject => ({
name: subject.name,
order: subject.order ?? 0
}));
} else {
// 使用默认科目列表
this.availableSubjects = [
{ name: '语文', order: 0 },
{ name: '数学', order: 1 },
{ name: '英语', order: 2 },
{ name: '物理', order: 3 },
{ name: '化学', order: 4 },
{ name: '生物', order: 5 },
{ name: '政治', order: 6 },
{ name: '历史', order: 7 },
{ name: '地理', order: 8 },
];
}
} catch (error) {
console.warn('加载科目列表失败,使用默认列表:', error);
// 使用默认科目列表
this.availableSubjects = [
{ name: '语文', order: 0 },
{ name: '数学', order: 1 },
{ name: '英语', order: 2 },
{ name: '物理', order: 3 },
{ name: '化学', order: 4 },
{ name: '生物', order: 5 },
{ name: '政治', order: 6 },
{ name: '历史', order: 7 },
{ name: '地理', order: 8 },
];
}
},
/**
* 自动填充剩余科目的时间(基于最后一个科目,每个科目间隔10分钟)
*/
autoFillRemaining() {
if (this.localConfig.examInfos.length === 0) return;
let lastEndTime = null;
// 找到最后已设置的时间
for (let i = this.localConfig.examInfos.length - 1; i >= 0; i--) {
if (this.localConfig.examInfos[i].end) {
lastEndTime = new Date(this.localConfig.examInfos[i].end);
break;
}
}
// 如果没有任何已设置的时间,使用当前时间
if (!lastEndTime) {
lastEndTime = new Date();
}
// 从前往后填充
for (let i = 0; i < this.localConfig.examInfos.length; i++) {
const examInfo = this.localConfig.examInfos[i];
// 如果已经有时间,跳过但更新 lastEndTime
if (examInfo.end) {
lastEndTime = new Date(examInfo.end);
continue;
}
// 计算新的开始和结束时间
const startTime = new Date(lastEndTime.getTime() + 10 * 60 * 1000); // 前一个结束时间 + 10分钟
const endTime = new Date(startTime.getTime() + 2 * 60 * 60 * 1000); // 考试时长2小时
// 更新科目时间
examInfo.start = this.formatDateTimeLocal(startTime);
examInfo.startDate = startTime;
examInfo.startTime = this.formatTimeOnly(startTime);
examInfo.startFormatted = this.formatDisplayDateTime(startTime);
examInfo.end = this.formatDateTimeLocal(endTime);
examInfo.endDate = endTime;
examInfo.endTime = this.formatTimeOnly(endTime);
examInfo.endFormatted = this.formatDisplayDateTime(endTime);
lastEndTime = endTime;
}
this.success = '已自动填充所有科目的时间(间隔10分钟)';
},
/**
* 验证时间格式
*/
validateTimeFormat(value, fieldName) {
if (!value) return true; // 空值由必填验证处理
// 匹配格式: YYYY/MM/DD HH:mm 或 YYYY-MM-DD HH:mm
const match = value.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/);
if (!match) {
return `${fieldName}格式不正确,请使用格式:2025/01/01 09:00`;
}
const [, year, month, day, hour, minute] = match;
const y = parseInt(year);
const m = parseInt(month);
const d = parseInt(day);
const h = parseInt(hour);
const min = parseInt(minute);
// 验证日期范围
if (m < 1 || m > 12) return `${fieldName}月份不合法(1-12)`;
if (d < 1 || d > 31) return `${fieldName}日期不合法(1-31)`;
if (h < 0 || h > 23) return `${fieldName}小时不合法(0-23)`;
if (min < 0 || min > 59) return `${fieldName}分钟不合法(0-59)`;
// 验证日期是否真实存在
const date = new Date(y, m - 1, d, h, min);
if (isNaN(date.getTime())) {
return `${fieldName}日期不存在`;
}
// 验证月份和日期是否匹配(防止2月30日等情况)
if (date.getMonth() !== m - 1 || date.getDate() !== d) {
return `${fieldName}日期不存在`;
}
return true;
},
/**
* 验证结束时间晚于开始时间
*/
validateEndAfterStart(examInfo) {
if (!examInfo.startFormatted || !examInfo.endFormatted) return true;
try {
const start = new Date(examInfo.start || examInfo.startFormatted.replace(/\//g, '-'));
const end = new Date(examInfo.end || examInfo.endFormatted.replace(/\//g, '-'));
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return true; // 格式错误由其他验证处理
}
if (end <= start) {
return '结束时间必须晚于开始时间';
}
// 检查时长是否合理(不超过24小时)
const duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
if (duration > 24) {
return '考试时长不能超过24小时';
}
return true;
} catch (error) {
return true;
}
},
/**
* 验证科目时间不重叠
*/
validateNoTimeOverlap(currentExamInfo, currentIndex) {
if (!currentExamInfo.startFormatted || !currentExamInfo.endFormatted) {
return true; // 时间未设置时不验证重叠
}
try {
const currentStart = new Date(currentExamInfo.start || currentExamInfo.startFormatted.replace(/\//g, '-'));
const currentEnd = new Date(currentExamInfo.end || currentExamInfo.endFormatted.replace(/\//g, '-'));
if (isNaN(currentStart.getTime()) || isNaN(currentEnd.getTime())) {
return true; // 格式错误由其他验证处理
}
// 检查与其他科目的时间重叠
for (let i = 0; i < this.localConfig.examInfos.length; i++) {
if (i === currentIndex) continue; // 跳过当前科目
const otherExamInfo = this.localConfig.examInfos[i];
if (!otherExamInfo.start || !otherExamInfo.end) continue;
const otherStart = new Date(otherExamInfo.start);
const otherEnd = new Date(otherExamInfo.end);
if (isNaN(otherStart.getTime()) || isNaN(otherEnd.getTime())) continue;
// 检查时间重叠:当前开始时间在其他时间段内 或 当前结束时间在其他时间段内 或 当前时间段完全包含其他时间段
const isOverlap = (
(currentStart >= otherStart && currentStart < otherEnd) || // 当前开始时间在其他时间段内
(currentEnd > otherStart && currentEnd <= otherEnd) || // 当前结束时间在其他时间段内
(currentStart <= otherStart && currentEnd >= otherEnd) // 当前时间段完全包含其他时间段
);
if (isOverlap) {
const otherSubjectName = otherExamInfo.name || `第${i + 1}个科目`;
return `时间与"${otherSubjectName}"重叠`;
}
}
return true;
} catch (error) {
return true;
}
},
/**
* 切换提醒时间模式
*/
toggleAlertTimeMode() {
if (!this.enableCustomAlertTime) {
// 关闭自定义时,将所有提醒时间设为15分钟
this.localConfig.examInfos.forEach(info => {
info.alertTime = 15;
});
}
},
/**
* 计算考试时长
*/
getExamDuration(examInfo) {
if (!examInfo.start || !examInfo.end) return '';
try {
const startTime = new Date(examInfo.start);
const endTime = new Date(examInfo.end);
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) {
return '';
}
const durationMs = endTime.getTime() - startTime.getTime();
const durationMinutes = Math.round(durationMs / (1000 * 60));
if (durationMinutes < 60) {
return `${durationMinutes}分钟`;
}
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
if (minutes === 0) {
return `${hours}小时`;
} else {
return `${hours}小时${minutes}分钟`;
}
} catch (error) {
return '';
}
},
/**
* 从输入框更新开始时间
*/
updateStartDateTimeFromInput(index) {
if (index === undefined || !this.localConfig.examInfos[index]) return;
const examInfo = this.localConfig.examInfos[index];
const formatted = examInfo.startFormatted;
if (!formatted) return;
// 尝试解析输入格式: 2025/01/01 09:00 或 2025-01-01 09:00
const match = formatted.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/);
if (!match) return;
const [, year, month, day, hour, minute] = match;
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
if (isNaN(date.getTime())) return;
examInfo.startDate = date;
examInfo.startTime = this.formatTimeOnly(date);
examInfo.start = this.formatDateTimeLocal(date);
this.updateStartDateTime(index);
},
/**
* 从输入框更新结束时间
*/
updateEndDateTimeFromInput(index) {
if (index === undefined || !this.localConfig.examInfos[index]) return;
const examInfo = this.localConfig.examInfos[index];
const formatted = examInfo.endFormatted;
if (!formatted) return;
// 尝试解析输入格式: 2025/01/01 11:00 或 2025-01-01 11:00
const match = formatted.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/);
if (!match) return;
const [, year, month, day, hour, minute] = match;
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
if (isNaN(date.getTime())) return;
examInfo.endDate = date;
examInfo.endTime = this.formatTimeOnly(date);
examInfo.end = this.formatDateTimeLocal(date);
this.updateEndDateTime(index);
},
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;
}
// 初始化时长(分钟)- 前端计算
try {
if (info.start && info.end) {
const s = new Date(info.start);
const e = new Date(info.end);
const diff = Math.round((e.getTime() - s.getTime()) / (1000 * 60));
if (diff > 0 && diff <= 24 * 60) {
info.durationMinutes = diff;
} else {
info.durationMinutes = 120;
}
} else {
info.durationMinutes = 120;
}
} catch (_) {
info.durationMinutes = 120;
}
// 初始化提醒时间 - 处理数据迁移
if (info.alertTime === undefined || info.alertTime === null) {
info.alertTime = 15; // 旧数据默认15分钟
} else {
info.alertTime = parseInt(info.alertTime) || 15;
}
});
// 检测是否有自定义提醒时间
const hasCustomAlertTime = this.localConfig.examInfos.some(
info => info.alertTime !== 15
);
this.enableCustomAlertTime = hasCustomAlertTime;
} 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 = {
examName: this.localConfig.examName,
message: this.localConfig.message,
room: this.localConfig.room,
examInfos: this.localConfig.examInfos.map((info) => ({
name: info.name,
start: this.formatDisplayDateTime(info.start),
end: this.formatDisplayDateTime(info.end),
alertTime: parseInt(info.alertTime) || 15, // 默认15分钟
})),
};
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() {
// 获取最后一个科目的结束时间,或使用当前时间
let baseTime = new Date();
if (this.localConfig.examInfos.length > 0) {
const lastExamInfo = this.localConfig.examInfos[this.localConfig.examInfos.length - 1];
if (lastExamInfo.end) {
baseTime = new Date(lastExamInfo.end);
}
}
// 新科目开始时间 = 上一个科目结束时间 + 10分钟
const startTime = new Date(baseTime.getTime() + 10 * 60 * 1000);
// 新科目结束时间 = 开始时间 + 2小时
const endTime = new Date(startTime.getTime() + 2 * 60 * 60 * 1000);
const examInfo = {
name: "",
start: this.formatDateTimeLocal(startTime),
end: this.formatDateTimeLocal(endTime),
durationMinutes: 120,
alertTime: 15, // 默认提醒时间15分钟
// 日期选择器相关数据
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.formattedStorageJson);
this.$message.success('配置已复制到剪贴板');
} catch (err) {
this.error = "复制失败: " + err.message;
}
},
/**
* 下载为JSON文件
*/
downloadAsJson() {
try {
const blob = new Blob([this.formattedStorageJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.localConfig.examName || 'exam-config'}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.$message?.success('已下载 JSON 文件');
} catch (err) {
this.error = '下载失败: ' + err.message;
}
},
/**
* 下载为EA2文件(ExamAware2格式)
*/
downloadAsEa2() {
try {
const blob = new Blob([this.formattedStorageJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.localConfig.examName || 'exam-config'}.ea2`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.$message?.success('已下载 ExamAware2 知试 (.ea2)文件');
} catch (err) {
this.error = '下载失败: ' + err.message;
}
},
/**
* 复制配置链接(用于ExamSchedule)
*/
async copyConfigUrl() {
try {
// 获取配置的云端访问地址
const result = await dataProvider.getKeyCloudUrl(`es_${this.configId}`, {
autoMigrate: true,
autoConfig: true
});
if (result.success && result.url) {
// 直接复制KV地址
await navigator.clipboard.writeText(result.url);
this.$message.success('云端地址已复制到剪贴板');
} else {
throw new Error(result.error || '获取云端地址失败');
}
} 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;
}
},
/**
* 时长提示(人性化显示)
*/
durationHint(examInfo) {
const m = parseInt(examInfo?.durationMinutes);
if (isNaN(m) || m <= 0) return "";
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
const mm = m % 60;
return mm === 0 ? `${h} 小时` : `${h} 小时 ${mm} 分钟`;
},
/**
* 从时长输入更新结束时间
*/
updateDurationFromInput(index) {
const examInfo = this.localConfig.examInfos[index];
let m = parseInt(examInfo.durationMinutes);
if (isNaN(m) || m <= 0) m = 120;
if (m > 24 * 60) m = 24 * 60;
examInfo.durationMinutes = m;
// 需要有开始时间作为基准
if (!examInfo.startDate || !examInfo.startTime) {
// 如果尚未拆分,尝试从 start 解析
if (examInfo.start) {
const s = new Date(examInfo.start);
if (!isNaN(s.getTime())) {
examInfo.startDate = s;
examInfo.startTime = this.formatTimeOnly(s);
}
}
}
if (examInfo.startDate && examInfo.startTime) {
const s = new Date(examInfo.startDate);
const [sh, sm] = String(examInfo.startTime).split(":");
s.setHours(parseInt(sh), parseInt(sm), 0, 0);
const e = new Date(s.getTime() + m * 60 * 1000);
examInfo.endDate = e;
examInfo.endTime = this.formatTimeOnly(e);
examInfo.end = this.formatDateTimeLocal(e);
examInfo.endFormatted = this.formatDisplayDateTime(e);
}
},
/**
* 格式化日期时间为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);
// 根据已有时长或默认2小时自动更新结束时间
let durationMinutes = parseInt(examInfo.durationMinutes);
if (isNaN(durationMinutes) || durationMinutes <= 0 || durationMinutes > 24 * 60) {
// 若未设定有效时长,则尝试根据已设结束时间计算
try {
let existingEndDate = null;
if (examInfo.endDate && examInfo.endTime) {
existingEndDate = new Date(examInfo.endDate);
const [eh, em] = String(examInfo.endTime).split(":");
existingEndDate.setHours(parseInt(eh), parseInt(em), 0, 0);
} else if (examInfo.end) {
existingEndDate = new Date(examInfo.end);
}
if (existingEndDate && !isNaN(existingEndDate.getTime())) {
const diff = Math.round((existingEndDate.getTime() - date.getTime()) / (1000 * 60));
if (diff > 0 && diff <= 24 * 60) {
durationMinutes = diff;
}
}
} catch (_) {}
}
if (isNaN(durationMinutes) || durationMinutes <= 0 || durationMinutes > 24 * 60) {
durationMinutes = 120;
}
const newEnd = new Date(date.getTime() + durationMinutes * 60 * 1000);
examInfo.endDate = newEnd;
examInfo.endTime = this.formatTimeOnly(newEnd);
examInfo.end = this.formatDateTimeLocal(newEnd);
examInfo.endFormatted = this.formatDisplayDateTime(newEnd);
examInfo.durationMinutes = durationMinutes;
},
/**
* 更新结束日期时间
*/
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);
// 同步考试时长
try {
if (examInfo.startDate && examInfo.startTime) {
const s = new Date(examInfo.startDate);
const [sh, sm] = String(examInfo.startTime).split(":");
s.setHours(parseInt(sh), parseInt(sm), 0, 0);
const diff = Math.round((date.getTime() - s.getTime()) / (1000 * 60));
if (diff > 0 && diff <= 24 * 60) {
examInfo.durationMinutes = diff;
}
}
} catch (_) {}
},
/**
* 打开配置
* 获取配置的云端地址并在新窗口中打开考试页面
*/
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.examaware.cn/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;
}
},
/**
* 拉起EA2播放器
* 将配置JSON转换为base64并通过examaware://协议打开
*/
openInEa2Player() {
try {
// 获取存储格式的JSON字符串
const configToSave = {
examName: this.localConfig.examName,
message: this.localConfig.message,
room: this.localConfig.room,
examInfos: this.localConfig.examInfos.map((info) => ({
name: info.name,
start: this.formatDisplayDateTime(info.start),
end: this.formatDisplayDateTime(info.end),
alertTime: parseInt(info.alertTime) || 15,
})),
};
const jsonString = JSON.stringify(configToSave);
// 转换为base64
const base64Data = btoa(unescape(encodeURIComponent(jsonString)));
// 构建examaware://协议URL
const ea2Url = `examaware://player?data=${encodeURIComponent(base64Data)}`;
// 尝试打开
window.location.href = ea2Url;
this.$message?.success('正在拉起 ExamAware2 播放器...');
} catch (err) {
this.error = '拉起播放器失败: ' + err.message;
}
},
},
};
</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;
}
.bg-success-lighten-5 {
background-color: rgba(var(--v-theme-success), 0.08) !important;
}
.bg-error-lighten-5 {
background-color: rgba(var(--v-theme-error), 0.08) !important;
}
/* 科目编辑项悬停效果 */
.hover-highlight {
transition: background-color 0.2s ease;
}
.hover-highlight:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
}
.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>