feat: 添加新建证书的页面和功能;页面添加过渡动画;添加证书有效期, 证书分类菜单;添加证书预览功能

This commit is contained in:
QDKF 2025-08-29 15:39:45 +08:00
parent 7c6c19d8f9
commit 4f90499ada
5 changed files with 633 additions and 31 deletions

View File

Before

Width:  |  Height:  |  Size: 525 B

After

Width:  |  Height:  |  Size: 525 B

View File

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 536 B

View File

@ -194,7 +194,23 @@ const downloadCertificate = (certificate: any) => {
}
const editCertificate = (certificate: any) => {
message.info(`编辑证书: ${certificate.name}`)
// ID
const currentPath = route.path;
const courseIdMatch = currentPath.match(/\/course-editor\/(\d+)/);
const courseId = courseIdMatch ? courseIdMatch[1] : '1';
//
router.push({
path: `/teacher/certificate/new`,
query: {
courseId: courseId,
certificateId: certificate.id,
mode: 'edit',
name: certificate.name,
category: certificate.category
}
});
activeFileMenu.value = null
}

View File

@ -1,14 +1,95 @@
<template>
<div class="certificate-new">
<div class="top-section">
<h1 class="page-title">新建证书11</h1>
<!-- 顶部信息栏 -->
<div class="top-info-bar">
<!-- 返回按钮 -->
<button class="return-btn" @click="goBack">
<span>返回</span>
</button>
<!-- 证书信息 -->
<div class="certificate-info">
<div class="certificate-name-section">
<h2 class="certificate-name">证书证书证书名称</h2>
<img :src="editIconHovered ? '/images/teacher/rechristen-active.png' : '/images/teacher/rechristen.png'"
alt="编辑" class="edit-icon" :class="{ 'preview-mode': isPreviewMode }" @mouseenter="editIconHovered = true"
@mouseleave="editIconHovered = false" v-show="!isPreviewMode" />
</div>
<div class="certificate-details" :class="{ 'preview-mode': isPreviewMode }" v-show="!isPreviewMode">
<div class="detail-item">
<span class="label">证书分类:</span>
<span class="value">{{ selectedCategory }}</span>
<n-popover ref="categoryPopoverRef" trigger="click" placement="bottom-center" :show-arrow="false"
:style="{ '--n-space': '23px', '--n-padding': '0' }">
<template #trigger>
<a href="#" class="link-btn">重新选择</a>
</template>
<div class="category-popover-content">
<div class="category-header">证书分类:</div>
<div class="category-options">
<label class="category-option">
<input type="radio" class="radio-input" value="考试" v-model="selectedCategory"
@change="closeCategoryPopover">
<span class="radio-label">考试</span>
</label>
<label class="category-option">
<input type="radio" class="radio-input" value="学习项目" v-model="selectedCategory"
@change="closeCategoryPopover">
<span class="radio-label">学习项目</span>
</label>
</div>
</div>
</n-popover>
</div>
<n-divider vertical class="detail-divider" />
<div class="detail-item">
<span class="label">有效期:</span>
<span class="value">{{ currentValidityText }}</span>
<n-popover ref="validityPopoverRef" trigger="click" placement="bottom-start" :show-arrow="false"
:style="{ '--n-space': '23px' }">
<template #trigger>
<a href="#" class="link-btn">调整</a>
</template>
<div class="validity-popover-content">
<div class="popover-header">设置证书有效期:</div>
<div class="validity-options">
<label class="validity-option">
<input type="radio" class="radio-input" value="duration" v-model="validityType">
<span class="radio-label">自颁发日起</span>
<div class="duration-input">
<input type="number" class="number-input" v-model="validityDuration" min="0">
<span class="duration-text">月内有效</span>
</div>
</label>
<span class="hint-text"> (设置0则代表证书发出终生有效)</span>
<label class="validity-option">
<input type="radio" class="radio-input" value="date" v-model="validityType">
<span class="radio-label">有效期至</span>
<div class="date-input">
<input type="datetime-local" class="date-field" v-model="validityEndDate">
</div>
</label>
</div>
<div class="popover-footer">
<button class="btn-cancel" @click="closePopover">取消</button>
<button class="btn-confirm" @click="confirmValidity">确认</button>
</div>
</div>
</n-popover>
</div>
</div>
</div>
</div>
<!-- 右侧区域 -->
<div class="right-section" :class="{ 'collapsed': isCollapsed }">
<div class="right-section" :class="{ 'collapsed': isCollapsed, 'preview-mode': isPreviewMode }"
v-show="!isPreviewMode">
<div class="content">
<!-- 顶部按钮 -->
<div class="header-buttons">
<button class="btn btn-preview">证书预览</button>
<button class="btn btn-preview" @click="togglePreviewMode">证书预览</button>
<button class="btn btn-save">保存</button>
</div>
@ -16,29 +97,37 @@
<div class="section">
<h3 class="section-title">证书背景</h3>
<div class="color-palette">
<div class="color-item rainbow" :class="{ 'selected': selectedColor === customColor }" @click="showColorPicker = !showColorPicker">
<input
v-if="showColorPicker"
type="color"
:value="customColor"
@input="selectCustomColor($event.target.value)"
class="color-input-inline"
@click.stop
/>
<div class="color-item rainbow" :class="{ 'selected': selectedColor === customColor }"
@click="showColorPicker = !showColorPicker">
<input v-if="showColorPicker" type="color" :value="customColor"
@input="selectCustomColor($event.target.value)" class="color-input-inline" @click.stop />
</div>
<div class="color-item black" :class="{ 'selected': selectedColor === '#000000' }" @click="selectColor('#000000')"></div>
<div class="color-item dark-gray" :class="{ 'selected': selectedColor === '#333333' }" @click="selectColor('#333333')"></div>
<div class="color-item gray" :class="{ 'selected': selectedColor === '#666666' }" @click="selectColor('#666666')"></div>
<div class="color-item light-gray" :class="{ 'selected': selectedColor === '#999999' }" @click="selectColor('#999999')"></div>
<div class="color-item white" :class="{ 'selected': selectedColor === '#ffffff' }" @click="selectColor('#ffffff')"></div>
<div class="color-item red" :class="{ 'selected': selectedColor === '#ff0000' }" @click="selectColor('#ff0000')"></div>
<div class="color-item orange" :class="{ 'selected': selectedColor === '#ff8000' }" @click="selectColor('#ff8000')"></div>
<div class="color-item pink" :class="{ 'selected': selectedColor === '#ff80ff' }" @click="selectColor('#ff80ff')"></div>
<div class="color-item purple" :class="{ 'selected': selectedColor === '#8000ff' }" @click="selectColor('#8000ff')"></div>
<div class="color-item green" :class="{ 'selected': selectedColor === '#00ff00' }" @click="selectColor('#00ff00')"></div>
<div class="color-item teal" :class="{ 'selected': selectedColor === '#00ffff' }" @click="selectColor('#00ffff')"></div>
<div class="color-item light-blue" :class="{ 'selected': selectedColor === '#80ffff' }" @click="selectColor('#80ffff')"></div>
<div class="color-item blue" :class="{ 'selected': selectedColor === '#0080ff' }" @click="selectColor('#0080ff')"></div>
<div class="color-item black" :class="{ 'selected': selectedColor === '#000000' }"
@click="selectColor('#000000')"></div>
<div class="color-item dark-gray" :class="{ 'selected': selectedColor === '#333333' }"
@click="selectColor('#333333')"></div>
<div class="color-item gray" :class="{ 'selected': selectedColor === '#666666' }"
@click="selectColor('#666666')"></div>
<div class="color-item light-gray" :class="{ 'selected': selectedColor === '#999999' }"
@click="selectColor('#999999')"></div>
<div class="color-item white" :class="{ 'selected': selectedColor === '#ffffff' }"
@click="selectColor('#ffffff')"></div>
<div class="color-item red" :class="{ 'selected': selectedColor === '#ff0000' }"
@click="selectColor('#ff0000')"></div>
<div class="color-item orange" :class="{ 'selected': selectedColor === '#ff8000' }"
@click="selectColor('#ff8000')"></div>
<div class="color-item pink" :class="{ 'selected': selectedColor === '#ff80ff' }"
@click="selectColor('#ff80ff')"></div>
<div class="color-item purple" :class="{ 'selected': selectedColor === '#8000ff' }"
@click="selectColor('#8000ff')"></div>
<div class="color-item green" :class="{ 'selected': selectedColor === '#00ff00' }"
@click="selectColor('#00ff00')"></div>
<div class="color-item teal" :class="{ 'selected': selectedColor === '#00ffff' }"
@click="selectColor('#00ffff')"></div>
<div class="color-item light-blue" :class="{ 'selected': selectedColor === '#80ffff' }"
@click="selectColor('#80ffff')"></div>
<div class="color-item blue" :class="{ 'selected': selectedColor === '#0080ff' }"
@click="selectColor('#0080ff')"></div>
</div>
<div class="template-preview">
<div class="certificate-template">
@ -98,7 +187,8 @@
</div>
</div>
<div class="certificate-content" :style="{ backgroundColor: selectedColor }">
<div class="certificate-content" :class="{ 'preview-mode': isPreviewMode }"
:style="{ backgroundColor: selectedColor }">
<img src="/images/teacher/certificate.png" alt="">
</div>
@ -108,16 +198,28 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
//
let originalPadding = ''
//
const validityPopoverRef = ref()
const categoryPopoverRef = ref()
//
const isCollapsed = ref(false)
//
const isPreviewMode = ref(false)
//
const selectedCategory = ref('考试')
//
const selectedColor = ref('#ffffff')
@ -127,11 +229,30 @@ const customColor = ref('#ff0000')
//
const showColorPicker = ref(false)
//
const editIconHovered = ref(false)
//
const validityType = ref('duration')
const validityDuration = ref(60)
const validityEndDate = ref('2000-10-11T09:00')
const currentValidityText = ref('永久有效')
// /
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
//
const goBack = () => {
router.back()
}
//
const togglePreviewMode = () => {
isPreviewMode.value = !isPreviewMode.value
}
//
const selectColor = (color: string) => {
selectedColor.value = color
@ -144,11 +265,69 @@ const selectCustomColor = (color: string) => {
showColorPicker.value = false
}
//
const closePopover = () => {
if (validityPopoverRef.value) {
validityPopoverRef.value.setShow(false)
}
}
//
const confirmValidity = () => {
console.log('有效期设置:', {
type: validityType.value,
duration: validityDuration.value,
endDate: validityEndDate.value
})
//
if (validityType.value === 'duration') {
if (validityDuration.value === 0) {
currentValidityText.value = '永久有效'
} else {
currentValidityText.value = `自颁发日起${validityDuration.value}月内有效`
}
} else if (validityType.value === 'date') {
if (validityEndDate.value) {
const date = new Date(validityEndDate.value)
currentValidityText.value = `有效期至${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
} else {
currentValidityText.value = '永久有效'
}
}
//
closePopover()
}
//
const closeCategoryPopover = () => {
if (categoryPopoverRef.value) {
categoryPopoverRef.value.setShow(false)
}
}
// URL
onMounted(() => {
console.log('新建证书页面参数:', route.query)
//
if (route.query.mode === 'edit') {
//
const certificateName = route.query.name as string
const certificateCategory = route.query.category as string
if (certificateName) {
//
console.log('编辑证书:', certificateName, certificateCategory)
}
}
//
const routerViewContainer = document.querySelector('.router-view-container') as HTMLElement
if (routerViewContainer) {
@ -189,14 +368,388 @@ onUnmounted(() => {
flex-direction: column;
}
/* 预览模式过渡效果 */
.right-section {
transition: all 0.3s ease;
}
.right-section.preview-mode {
transform: translateX(100%);
}
.certificate-content {
transition: all 0.3s ease;
}
.certificate-content.preview-mode {
transform: scale(0.98);
}
.certificate-details {
transition: all 0.3s ease;
}
.certificate-details.preview-mode {
opacity: 0;
transform: translateY(-10px);
}
.edit-icon {
transition: all 0.3s ease;
}
.edit-icon.preview-mode {
opacity: 0;
transform: scale(0.8);
}
.top-section {
width: 100%;
height: 80px;
background-color: #fff;
position: relative;
margin-bottom: 20px;
}
/* 顶部信息栏样式 */
.top-info-bar {
display: flex;
align-items: center;
gap: 30px;
padding: 20px 30px;
background: #fff;
border-bottom: 1px solid #E8E8E8;
width: 100%;
}
/* 返回按钮 */
.return-btn {
background: #F5F5F5;
border: none;
border-radius: 2px;
padding: 8px 23px;
color: #333;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 60px;
text-align: center;
}
.return-btn:hover {
background: #E8E8E8;
color: #333;
}
/* 证书信息区域 */
.certificate-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0;
}
/* 证书名称区域 */
.certificate-name-section {
display: flex;
align-items: center;
gap: 10px;
}
.certificate-name {
font-size: 16px;
font-weight: 500;
color: #333;
margin: 0;
}
.edit-icon {
width: 15px;
height: 15px;
cursor: pointer;
transition: all 0.3s ease;
}
.edit-icon:hover {
transform: scale(1.1);
}
/* 证书详情区域 */
.certificate-details {
display: flex;
gap: 10px;
align-items: center;
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
}
.label {
font-size: 14px;
color: #666;
}
.value {
font-size: 14px;
color: #666;
font-weight: 500;
}
.link-btn {
color: #0288D1;
text-decoration: none;
font-size: 14px;
cursor: pointer;
transition: color 0.3s ease;
}
.link-btn:hover {
color: #0277BD;
text-decoration: underline;
}
/* 详情分隔线样式 */
:deep(.detail-divider) {
height: 16px;
margin: 0 15px;
}
/* 有效期弹出框样式 */
.validity-popover-content {
border: none;
overflow: hidden;
width: 444px;
height: 301px;
background: #FFFFFF;
}
/* 分类弹出框样式 */
.category-popover-content {
border: none;
overflow: hidden;
width: 230px;
height: 204px;
background: #FFFFFF;
box-shadow: 0px 2px 24px 0px rgba(220, 220, 220, 0.5);
}
/* 覆盖 Naive UI 弹出框的默认背景 */
:deep(.n-popover__content) {
background: #FFFFFF !important;
border: none !important;
}
/* 确保弹出框容器也是白色背景 */
:deep(.n-popover) {
background: #FFFFFF !important;
}
.category-header {
background: #fff;
border-bottom: 1.5px solid #E6E6E6;
font-size: 16px;
font-weight: 500;
color: #333;
text-align: left;
height: 60px;
display: flex;
align-items: center;
margin: 0 20px;
}
.category-options {
display: flex;
flex-direction: column;
height: calc(100% - 60px);
padding: 10px 20px 20px;
}
.category-option {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
padding: 16px 0;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
background: #FFFFFF !important;
}
.category-option:hover {
background: #f8f9fa !important;
}
/* 覆盖 Naive UI 弹出框的默认内边距 */
:deep(.n-popover__content) {
padding: 30px !important;
}
.popover-header {
background: #fff;
border-bottom: 1.5px solid #E6E6E6;
font-size: 16px;
font-weight: 500;
color: #333;
text-align: left;
height: 60px;
display: flex;
align-items: center;
}
.validity-options {
display: flex;
flex-direction: column;
height: 180px;
}
.validity-option {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
padding: 16px 0 16px 16px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
background: #FFFFFF;
}
.validity-option:hover {
border-color: #0288D1;
background: #f8f9fa;
}
.radio-input {
margin-right: 12px;
width: 12px;
height: 12px;
}
.radio-label {
font-size: 16px;
font-weight: 500;
color: #333;
display: inline-block;
min-width: 80px;
}
.duration-input {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
margin-left: 10px;
}
.number-input {
width: 95px;
height: 44px;
padding: 6px 8px;
border: 1.5px solid #E6E6E6;
font-size: 16px;
text-align: center;
background-color: #FCFCFC;
color: #666;
}
.duration-text {
font-size: 16px;
color: #666;
margin-left: 2px;
}
.hint-text {
font-size: 16px;
color: #999;
margin-left: 40px;
display: inline;
}
.date-input {
margin-left: 0;
flex: 1;
}
.date-field {
padding: 8px 8px;
border: 1.5px solid #E6E6E6;
background-color: #FCFCFC;
color: #666666;
font-size: 14px;
width: 100%;
background-image: url('/images/teacher/日历-选中.png');
background-repeat: no-repeat;
background-position: left 8px center;
background-size: 16px 16px;
padding-left: 32px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
height: 36px;
}
/* 隐藏WebKit浏览器的默认日历图标 */
.date-field::-webkit-calendar-picker-indicator {
display: none;
}
/* 隐藏WebKit浏览器的默认清除按钮 */
.date-field::-webkit-clear-button {
display: none;
}
/* 隐藏WebKit浏览器的默认内部阴影 */
.date-field::-webkit-inner-spin-button {
display: none;
}
.popover-footer {
display: flex;
justify-content: flex-end;
gap: 16px;
padding: 20px 0;
height: 61px;
align-items: center;
}
/* 按钮样式 */
.btn-cancel {
color: #0288D1;
padding: 2px 10px;
border-radius: 2px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: all 0.3s ease;
width: 66px;
height: 32px;
background: #E2F5FF;
border: 1px solid #0288D1;
}
.btn-cancel:hover {
background: #f0f8ff;
}
.btn-confirm {
width: 66px;
height: 32px;
background: #0288D1;
color: #fff;
border: none;
padding: 2px 10px;
border-radius: 2px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-confirm:hover {
background: #0277BD;
}
.right-section {
position: absolute;
top: 0;
@ -211,6 +764,10 @@ onUnmounted(() => {
transform: translateX(240px);
}
.right-section.collapsed .content {
opacity: 0;
visibility: hidden;
}
.collapse-button {
position: absolute;
left: -40px;
@ -389,7 +946,7 @@ onUnmounted(() => {
}
.color-item.selected {
border: 2px solid #0288D1;
border: 2px solid #8F93A4;
transform: scale(1.1);
}

View File

@ -103,7 +103,11 @@
<!-- 右侧内容区域 -->
<div class="content-area" :class="{ 'full-width': hideSidebar }">
<router-view />
<router-view v-slot="{ Component, route }">
<transition name="content-transition" mode="out-in" appear>
<component :is="Component" :key="route.path" class="content-component" />
</transition>
</router-view>
</div>
</div>
</template>
@ -305,4 +309,29 @@ const hideSidebar = computed(() => {
background: #e6f7ff;
color: #0288D1;
}
/* 内容区域过渡动画 */
.content-transition-enter-active,
.content-transition-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.content-transition-enter-from {
opacity: 0;
transform: translateX(20px);
}
.content-transition-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.content-transition-enter-to,
.content-transition-leave-from {
opacity: 1;
transform: translateX(0);
}
.content-component {
width: 100%;
}
</style>