feat:添加试卷分析页面;添加消息中心页面

This commit is contained in:
yuk255 2025-09-05 21:00:10 +08:00
parent 6b685501dd
commit 764064bd80
11 changed files with 3518 additions and 19 deletions

View File

@ -341,7 +341,7 @@ const handleMoveCourse = (course: any) => {
.course-card { .course-card {
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: 190 / 201; /* aspect-ratio: 190 / 201; */
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #D8D8D8; border: 1px solid #D8D8D8;
box-sizing: border-box; box-sizing: border-box;
@ -431,11 +431,10 @@ const handleMoveCourse = (course: any) => {
.course-name { .course-name {
width: 80%; width: 80%;
height: 20px;
font-family: AppleSystemUIFont; font-family: AppleSystemUIFont;
font-size: 14px; font-size: 14px;
color: #333333; color: #333333;
line-height: 20px; line-height: 1.5;
text-align: center; text-align: center;
font-style: normal; font-style: normal;
text-transform: none; text-transform: none;
@ -566,8 +565,6 @@ const handleMoveCourse = (course: any) => {
.course-name { .course-name {
font-size: 16px; font-size: 16px;
height: 24px;
line-height: 24px;
width: 85%; width: 85%;
margin-top: 1px; margin-top: 1px;
/* 文字往上移动 */ /* 文字往上移动 */
@ -606,8 +603,6 @@ const handleMoveCourse = (course: any) => {
.course-name { .course-name {
font-size: 15px; font-size: 15px;
height: 22px;
line-height: 22px;
width: 82%; width: 82%;
margin-top: -1px; margin-top: -1px;
/* 文字往上移动 */ /* 文字往上移动 */
@ -686,8 +681,6 @@ const handleMoveCourse = (course: any) => {
.course-name { .course-name {
font-size: 13px; font-size: 13px;
height: 18px;
line-height: 18px;
width: 78%; width: 78%;
margin-top: -5px; margin-top: -5px;
/* 文字往上移动 */ /* 文字往上移动 */
@ -726,8 +719,6 @@ const handleMoveCourse = (course: any) => {
.course-name { .course-name {
font-size: 12px; font-size: 12px;
height: 16px;
line-height: 16px;
width: 75%; width: 75%;
margin-top: -4px; margin-top: -4px;
/* 文字往上移动 */ /* 文字往上移动 */
@ -789,8 +780,6 @@ const handleMoveCourse = (course: any) => {
.course-name { .course-name {
font-size: 11px; font-size: 11px;
height: 14px;
line-height: 14px;
width: 70%; width: 70%;
margin-top: -3px; margin-top: -3px;
/* 文字往上移动 */ /* 文字往上移动 */

View File

@ -36,6 +36,7 @@ import PersonalCenter from '@/components/admin/PersonalCenter.vue'
import CourseManagement from '@/components/admin/CourseManagement.vue' import CourseManagement from '@/components/admin/CourseManagement.vue'
import MyResources from '@/components/admin/MyResources.vue' import MyResources from '@/components/admin/MyResources.vue'
import StudentManagement from '@/components/admin/StudentManagement.vue' import StudentManagement from '@/components/admin/StudentManagement.vue'
import MessageCenter from '@/views/teacher/message/MessageCenter.vue'
// 课程管理子组件 // 课程管理子组件
import CourseCategory from '@/components/admin/CourseComponents/CourseCategory.vue' import CourseCategory from '@/components/admin/CourseComponents/CourseCategory.vue'
@ -79,6 +80,7 @@ import StudentList from '@/views/teacher/ExamPages/StudentList.vue'
import GradingPage from '@/views/teacher/ExamPages/GradingPage.vue' import GradingPage from '@/views/teacher/ExamPages/GradingPage.vue'
import ExamTaking from '@/views/teacher/ExamPages/ExamTaking.vue' import ExamTaking from '@/views/teacher/ExamPages/ExamTaking.vue'
import ExamNoticeBeforeStart from '@/views/teacher/ExamPages/ExamNoticeBeforeStart.vue' import ExamNoticeBeforeStart from '@/views/teacher/ExamPages/ExamNoticeBeforeStart.vue'
import ExamAnalysis from '@/views/teacher/ExamPages/ExamAnalysis.vue'
import ChapterEditor from '@/views/teacher/course/ChapterEditor.vue' import ChapterEditor from '@/views/teacher/course/ChapterEditor.vue'
@ -294,6 +296,12 @@ const routes: RouteRecordRaw[] = [
component: MyResources, component: MyResources,
meta: { title: '我的资源' } meta: { title: '我的资源' }
}, },
{
path: 'message-center',
name: 'MessageCenter',
component: MessageCenter,
meta: { title: '消息中心' }
},
{ {
path: 'student-management', path: 'student-management',
name: 'StudentManagement', name: 'StudentManagement',
@ -364,6 +372,12 @@ const routes: RouteRecordRaw[] = [
component: ExamLibrary, component: ExamLibrary,
meta: { title: '试卷管理' } meta: { title: '试卷管理' }
}, },
{
path: 'exam-analysis',
name: 'ExamAnalysis',
component: ExamAnalysis,
meta: { title: '试卷分析' }
},
{ {
path: 'marking-center', path: 'marking-center',
name: 'MarkingCenter', name: 'MarkingCenter',

View File

@ -78,14 +78,13 @@
<img :src="activeNavItem === 2 ? '/images/teacher/我的资源(选中).png' : '/images/teacher/我的资源.png'" alt=""> <img :src="activeNavItem === 2 ? '/images/teacher/我的资源(选中).png' : '/images/teacher/我的资源.png'" alt="">
<span>我的资源</span> <span>我的资源</span>
</router-link> </router-link>
<!-- <router-link to="/teacher/my-resources" class="nav-item" :class="{ active: activeNavItem === 2 }" <router-link to="/teacher/message-center" class="nav-item" :class="{ active: activeNavItem === 5 }"
@click="setActiveNavItem(2)"> @click="setActiveNavItem(5)">
<img :src="activeNavItem === 2 ? '/images/teacher/我的资源(选中).png' : '/images/teacher/我的资源.png'" alt=""> <img :src="activeNavItem === 5 ? '/images/teacher/消息中心(选中).png' : '/images/teacher/消息中心.png'" alt="">
<span>消息中心</span> <span>消息中心</span>
</router-link> --> </router-link>
<router-link to="/teacher/personal-center" class="nav-item" :class="{ active: activeNavItem === 3 }" <router-link to="/teacher/personal-center" class="nav-item" :class="{ active: activeNavItem === 3 }"
@click="setActiveNavItem(3)"> @click="setActiveNavItem(3)">
<img :src="activeNavItem === 3 ? '/images/teacher/个人中心(选中).png' : '/images/teacher/个人中心.png'" alt=""> <img :src="activeNavItem === 3 ? '/images/teacher/个人中心(选中).png' : '/images/teacher/个人中心.png'" alt="">
<span>个人中心</span> <span>个人中心</span>
</router-link> </router-link>
@ -131,7 +130,7 @@ const height = window.innerHeight;
console.log(`当前屏幕宽度: ${width}px, 高度: ${height}px`); console.log(`当前屏幕宽度: ${width}px, 高度: ${height}px`);
// //
const activeNavItem = ref(0); // 0: , 1: , 2: , 3: const activeNavItem = ref(0); // 0: , 1: , 2: , 3: , 4: , 5:
const activeSubNavItem = ref(''); // const activeSubNavItem = ref(''); //
const examMenuExpanded = ref(false); // const examMenuExpanded = ref(false); //
const studentMenuExpanded = ref(false); // const studentMenuExpanded = ref(false); //
@ -609,6 +608,8 @@ const updateActiveNavItem = () => {
const arr = ['question-bank', 'exam-library', 'marking-center']; const arr = ['question-bank', 'exam-library', 'marking-center'];
const found = arr.find(item => path.includes(item)); const found = arr.find(item => path.includes(item));
activeSubNavItem.value = found || ''; activeSubNavItem.value = found || '';
} else if(path.includes('message-center')){
activeNavItem.value = 5; //
} }
} }

View File

@ -0,0 +1,883 @@
<template>
<div class="exam-analysis-container">
<div class="header-section">
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<h1 class="title">试卷分析</h1>
</div>
<!-- 考试基本信息 -->
<div class="exam-info-section">
<h2 class="exam-name">{{ examInfo.name }}</h2>
<div class="info-grid">
<div class="info-row">
<div class="info-item">
<span class="label">考试分类</span>
<span class="value">{{ examInfo.category }}</span>
</div>
<div class="info-item">
<span class="label">应考人数</span>
<span class="value">{{ examInfo.totalStudents }}参考率{{ examInfo.participationRate }}%</span>
</div>
</div>
<div class="info-row">
<div class="info-item">
<span class="label">总分</span>
<span class="value">{{ examInfo.totalScore }}</span>
</div>
<div class="info-item">
<span class="label">平均分</span>
<span class="value">{{ examInfo.averageScore }}</span>
</div>
</div>
<div class="info-row">
<div class="info-item">
<span class="label">及格分</span>
<span class="value">{{ examInfo.passScore }}</span>
</div>
<div class="info-item">
<span class="label">最高分</span>
<span class="value">{{ examInfo.highestScore }}</span>
</div>
</div>
<div class="info-row">
<div class="info-item">
<span class="label">考题时长</span>
<span class="value">{{ examInfo.duration }}分钟平均答题时长{{ examInfo.averageDuration }}分钟</span>
</div>
<div class="info-item">
<span class="label">最低分</span>
<span class="value">{{ examInfo.lowestScore }}</span>
</div>
</div>
<div class="info-row">
<div class="info-item">
<span class="label">考试时间</span>
<span class="value">{{ examInfo.startTime }} - {{ examInfo.endTime }}</span>
</div>
<div class="info-item">
<span class="label">平均时长</span>
<span class="value">{{ examInfo.averageDuration }}分钟</span>
</div>
</div>
</div>
</div>
<!-- Tab切换区域 -->
<div class="tab-section">
<n-tabs v-model:value="activeTab" type="line" size="large">
<n-tab-pane name="ranking" tab="学生排名">
<div class="tab-content">
<div class="table-section">
<n-data-table :columns="rankingColumns" :data="rankingData" :pagination="paginationConfig"
class="ranking-table" :scroll-x="1200" />
</div>
<!-- <div class="chart-section">
<v-chart ref="rankingChartRef" class="chart-container" :option="rankingChartOption"
:autoresize="true" />
</div> -->
</div>
</n-tab-pane>
<n-tab-pane name="scores" tab="分数统计">
<div class="tab-content">
<div class="score-stats-grid">
<div class="stat-card">
<h3>分数分布</h3>
<v-chart class="chart-container" :option="scoreDistributionChartOption"
:autoresize="true" />
</div>
<div class="stat-card">
<h3>及格率统计</h3>
<div class="pass-rate-info">
<div class="rate-item">
<span class="rate-label">及格人数</span>
<span class="rate-value">{{ scoreStats.passCount }}</span>
</div>
<div class="rate-item">
<span class="rate-label">不及格人数</span>
<span class="rate-value">{{ scoreStats.failCount }}</span>
</div>
<div class="rate-item">
<span class="rate-label">及格率</span>
<span class="rate-value">{{ scoreStats.passRate }}%</span>
</div>
</div>
</div>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="average" tab="成绩分析">
<div class="tab-content">
<div class="average-stats">
<div class="stat-card">
<h3>学生成绩对比</h3>
<v-chart class="chart-container" :option="studentScoreChartOption" :autoresize="true" />
</div>
<div class="stat-card">
<h3>题型平均分</h3>
<div class="question-type-stats">
<div v-for="type in questionTypeStats" :key="type.type" class="type-stat">
<span class="type-name">{{ type.type }}</span>
<span class="type-score">{{ type.averageScore }}</span>
<span class="type-rate">正确率{{ type.correctRate }}%</span>
</div>
</div>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NTabs, NTabPane, NDataTable } from 'naive-ui';
import type { DataTableColumns } from 'naive-ui';
import { ArrowBackOutline } from '@vicons/ionicons5';
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import {
CanvasRenderer
} from 'echarts/renderers';
import {
BarChart,
PieChart,
LineChart
} from 'echarts/charts';
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent
} from 'echarts/components';
use([
CanvasRenderer,
BarChart,
PieChart,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent
]);
const route = useRoute();
const router = useRouter();
const examId = ref<string | null>(null);
//
const windowWidth = ref(window.innerWidth);
// Tab
const activeTab = ref('ranking');
//
const examInfo = ref({
name: '试卷示例',
category: '考试分类',
totalStudents: 25,
participationRate: 92.00,
totalScore: 115,
averageScore: 76.5,
passScore: 69,
highestScore: 98,
duration: 60,
averageDuration: 45.2,
startTime: '2025-08-14 20:41:00',
endTime: '2025-08-17 20:41:00',
lowestScore: 42
});
//
const rankingColumns: DataTableColumns = [
{ title: '排名', key: 'rank', width: 80, align: 'center', fixed: 'left' },
{ title: '学生姓名', key: 'studentName', width: 120, fixed: 'left' },
{ title: '学号', key: 'studentId', width: 120, align: 'center' },
{ title: '班级', key: 'className', width: 120, align: 'center' },
{ title: '总分', key: 'totalScore', width: 100, align: 'center' },
{ title: '得分', key: 'score', width: 100, align: 'center' },
{ title: '得分率', key: 'scoreRate', width: 100, align: 'center' },
{ title: '考试时长', key: 'examDuration', width: 120, align: 'center' },
{ title: '提交时间', key: 'submitTime', width: 150, align: 'center' },
{ title: '状态', key: 'status', width: 100, align: 'center' }
];
//
const rankingData = ref([
{
rank: 1,
studentName: '张三',
studentId: '2021001001',
className: '计算机1班',
totalScore: 115,
score: 98,
scoreRate: '85.22%',
examDuration: '45分钟',
submitTime: '2025-08-14 21:15:32',
status: '已完成'
},
{
rank: 2,
studentName: '李四',
studentId: '2021001002',
className: '计算机1班',
totalScore: 111,
score: 96,
scoreRate: '83.48%',
examDuration: '42分钟',
submitTime: '2025-08-14 21:12:18',
status: '已完成'
},
{
rank: 3,
studentName: '王五',
studentId: '2021002001',
className: '计算机2班',
totalScore: 110,
score: 94,
scoreRate: '81.74%',
examDuration: '38分钟',
submitTime: '2025-08-14 21:08:45',
status: '已完成'
},
{
rank: 4,
studentName: '赵六',
studentId: '2021003001',
className: '软件工程1班',
totalScore: 100,
score: 92,
scoreRate: '80.00%',
examDuration: '50分钟',
submitTime: '2025-08-14 21:25:12',
status: '已完成'
},
{
rank: 5,
studentName: '钱七',
studentId: '2021003002',
className: '软件工程1班',
totalScore: 99,
score: 89,
scoreRate: '77.39%',
examDuration: '47分钟',
submitTime: '2025-08-14 21:22:33',
status: '已完成'
},
{
rank: 6,
studentName: '孙八',
studentId: '2021004001',
className: '软件工程2班',
totalScore: 98,
score: 87,
scoreRate: '75.65%',
examDuration: '44分钟',
submitTime: '2025-08-14 21:19:28',
status: '已完成'
},
{
rank: 7,
studentName: '周九',
studentId: '2021005001',
className: '网络工程班',
totalScore: 95,
score: 85,
scoreRate: '73.91%',
examDuration: '52分钟',
submitTime: '2025-08-14 21:27:46',
status: '已完成'
},
{
rank: 8,
studentName: '吴十',
studentId: '2021002002',
className: '计算机2班',
totalScore: 88,
score: 82,
scoreRate: '71.30%',
examDuration: '41分钟',
submitTime: '2025-08-14 21:16:55',
status: '已完成'
},
{
rank: 9,
studentName: '郑一',
studentId: '2021001003',
className: '计算机1班',
totalScore: 80,
score: 78,
scoreRate: '67.83%',
examDuration: '55分钟',
submitTime: '2025-08-14 21:30:21',
status: '已完成'
},
{
rank: 10,
studentName: '王二',
studentId: '2021004002',
className: '软件工程2班',
totalScore: 79,
score: 75,
scoreRate: '65.22%',
examDuration: '48分钟',
submitTime: '2025-08-14 21:23:17',
status: '已完成'
}
]);
//
const paginationConfig = ref({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
showQuickJumper: true
});
//
const scoreStats = ref({
passCount: 115,
failCount: 28,
passRate: 80.42
});
//
const questionTypeStats = ref([
{ type: '单选题', averageScore: 18.5, correctRate: 74 },
{ type: '多选题', averageScore: 12.8, correctRate: 64 },
{ type: '判断题', averageScore: 8.9, correctRate: 89 },
{ type: '填空题', averageScore: 15.2, correctRate: 76 },
{ type: '简答题', averageScore: 21.1, correctRate: 70 }
]);
//
// const rankingChartOption = computed(() => ({
// title: {
// text: '',
// left: 'center',
// textStyle: {
// fontSize: windowWidth.value < 768 ? 14 : 16
// }
// },
// tooltip: {
// trigger: 'axis',
// axisPointer: {
// type: 'shadow'
// }
// },
// grid: {
// left: windowWidth.value < 768 ? '10%' : '3%',
// right: windowWidth.value < 768 ? '10%' : '4%',
// bottom: windowWidth.value < 768 ? '15%' : '3%',
// containLabel: true
// },
// xAxis: {
// type: 'category',
// data: ['0-20', '21-40', '41-60', '61-80', '81-100'],
// axisLabel: {
// fontSize: windowWidth.value < 768 ? 10 : 12,
// rotate: windowWidth.value < 480 ? 45 : 0
// }
// },
// yAxis: {
// type: 'value',
// name: '',
// nameTextStyle: {
// fontSize: windowWidth.value < 768 ? 10 : 12
// },
// axisLabel: {
// fontSize: windowWidth.value < 768 ? 10 : 12
// }
// },
// series: [
// {
// name: '',
// type: 'bar',
// data: [0, 1, 2, 4, 3], //
// itemStyle: {
// color: '#5470c6'
// }
// }
// ]
// }));
//
const studentScoreChartOption = computed(() => ({
title: {
text: 'Top 10 学生成绩排行',
left: 'center',
textStyle: {
fontSize: windowWidth.value < 768 ? 14 : 16
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: '{b}<br/>成绩: {c}分'
},
grid: {
left: windowWidth.value < 768 ? '10%' : '3%',
right: windowWidth.value < 768 ? '10%' : '4%',
bottom: windowWidth.value < 768 ? '15%' : '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: rankingData.value.slice(0, 10).map(item => item.studentName),
axisLabel: {
fontSize: windowWidth.value < 768 ? 10 : 12,
rotate: windowWidth.value < 480 ? 45 : 0
}
},
yAxis: {
type: 'value',
name: '分数',
nameTextStyle: {
fontSize: windowWidth.value < 768 ? 10 : 12
},
axisLabel: {
fontSize: windowWidth.value < 768 ? 10 : 12
},
max: Math.max(...rankingData.value.slice(0, 10).map(item => item.totalScore)) + 10,
min: 0
},
series: [
{
name: '总分',
type: 'bar',
data: rankingData.value.slice(0, 10).map(item => item.totalScore),
itemStyle: {
color: '#91cc75'
}
}
]
}));
//
const scoreDistributionChartOption = computed(() => ({
title: {
text: '分数分布',
left: 'center',
textStyle: {
fontSize: windowWidth.value < 768 ? 14 : 16
}
},
tooltip: {
trigger: 'item'
},
legend: {
orient: windowWidth.value < 768 ? 'horizontal' : 'vertical',
left: windowWidth.value < 768 ? 'center' : 'left',
top: windowWidth.value < 768 ? 'bottom' : 'middle',
itemWidth: windowWidth.value < 768 ? 15 : 25,
itemHeight: windowWidth.value < 768 ? 10 : 14,
textStyle: {
fontSize: windowWidth.value < 768 ? 10 : 12
}
},
series: [
{
name: '分数分布',
type: 'pie',
radius: windowWidth.value < 768 ? '40%' : '50%',
center: windowWidth.value < 768 ? ['50%', '40%'] : ['50%', '50%'],
data: [
{ value: 40, name: '优秀(90-100分)' },
{ value: 68, name: '良好(80-89分)' },
{ value: 35, name: '中等(70-79分)' },
{ value: 22, name: '及格(60-69分)' },
{ value: 28, name: '不及格(0-59分)' }
],
label: {
fontSize: windowWidth.value < 768 ? 10 : 12
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}));
const goBack = () => {
router.back();
};
onMounted(() => {
examId.value = route.query.examId as string || null;
//
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
window.addEventListener('resize', handleResize);
//
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
});
</script>
<style scoped>
.exam-analysis-container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
height: 100%;
}
.header-section {
display: flex;
align-items: center;
padding-bottom: 20px;
border-bottom: 1px solid #E6E6E6;
margin-bottom: 20px;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 500;
margin-left: 6px;
}
.breadcrumb {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
}
.separator {
margin: 0 8px;
color: #999;
}
.current {
color: #1890ff;
}
/* 考试信息区域 */
.exam-info-section {
margin-bottom: 24px;
padding: 24px;
background-color: #f8f9fa;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.exam-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
color: #333;
border-bottom: 2px solid #1890ff;
padding-bottom: 8px;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.info-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
}
.info-item {
display: flex;
align-items: center;
}
.label {
color: #666;
min-width: 100px;
font-weight: 500;
}
.value {
color: #333;
font-weight: 600;
}
/* Tab区域 */
.tab-section {
background-color: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* 表格区域 */
.table-section {
margin-bottom: 32px;
}
.ranking-table {
border-radius: 6px;
overflow: hidden;
}
/* 图表区域 */
.chart-section {
margin-top: 24px;
border-radius: 8px;
}
.chart-container {
width: 100%;
height: 400px;
min-height: 300px;
}
/* 分数统计页面 */
.score-stats-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
align-items: start;
}
.stat-card {
padding: 24px;
background-color: #f8f9fa;
border-radius: 8px;
height: fit-content;
}
.stat-card h3 {
margin: 0 0 20px 0;
font-size: 16px;
font-weight: 600;
color: #333;
border-bottom: 2px solid #1890ff;
padding-bottom: 8px;
}
.pass-rate-info {
display: flex;
flex-direction: column;
gap: 16px;
}
.rate-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #fff;
border-radius: 6px;
border: 1px solid #e6e6e6;
transition: all 0.3s ease;
}
.rate-item:hover {
border-color: #1890ff;
}
.rate-label {
color: #666;
font-weight: 500;
}
.rate-value {
color: #333;
font-weight: 600;
font-size: 16px;
}
/* 平均分页面 */
.average-stats {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
align-items: start;
}
.question-type-stats {
display: flex;
flex-direction: column;
gap: 12px;
}
.type-stat {
display: flex;
align-items: center;
padding: 12px 16px;
background-color: #fff;
border-radius: 6px;
border: 1px solid #e6e6e6;
transition: all 0.3s ease;
}
.type-stat:hover {
border-color: #1890ff;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.1);
}
.type-name {
color: #666;
min-width: 80px;
font-weight: 500;
}
.type-score {
color: #333;
font-weight: 600;
margin-right: 12px;
font-size: 16px;
}
.type-rate {
color: #999;
font-size: 12px;
margin-left: auto;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.score-stats-grid,
.average-stats {
grid-template-columns: 1fr;
}
.chart-container {
height: 350px;
}
}
@media (max-width: 768px) {
.exam-analysis-container {
padding: 16px;
}
.header-section {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.info-row {
grid-template-columns: 1fr;
gap: 16px;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.label {
min-width: auto;
font-size: 14px;
}
.value {
font-size: 16px;
}
.tab-content {
padding: 16px;
}
.chart-container {
height: 300px;
}
.stat-card {
padding: 16px;
}
.rate-item,
.type-stat {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.type-rate {
margin-left: 0;
}
}
@media (max-width: 480px) {
.exam-analysis-container {
padding: 12px;
}
.exam-name {
font-size: 16px;
}
.title {
font-size: 18px;
}
.chart-container {
height: 250px;
}
.ranking-table {
font-size: 12px;
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.exam-analysis-container {
background-color: #1a1a1a;
color: #fff;
}
.exam-info-section,
.stat-card,
.chart-section {
background-color: #2a2a2a;
border-color: #404040;
}
.rate-item,
.type-stat {
background-color: #333;
border-color: #404040;
}
.label,
.type-name {
color: #ccc;
}
.value,
.rate-value,
.type-score {
color: #fff;
}
}
</style>

View File

@ -108,6 +108,9 @@ const createColumns = ({
render(row) { render(row) {
const buttons: VNode[] = []; const buttons: VNode[] = [];
if (row.status === '发布中') { if (row.status === '发布中') {
buttons.push(
h(NButton, { size: 'small', type: 'primary', ghost: true, style: 'margin: 0 3px;', onClick: () => handleAction('试卷分析', row) }, { default: () => '试卷分析' })
);
buttons.push( buttons.push(
h(NButton, { size: 'small', type: 'primary', ghost: true, style: 'margin: 0 3px;', onClick: () => handleAction('批阅', row) }, { default: () => '批阅' }) h(NButton, { size: 'small', type: 'primary', ghost: true, style: 'margin: 0 3px;', onClick: () => handleAction('批阅', row) }, { default: () => '批阅' })
); );
@ -146,6 +149,10 @@ const examData = ref<Exam[]>([
const columns = createColumns({ const columns = createColumns({
handleAction: (action, row) => { handleAction: (action, row) => {
if(action === '试卷分析'){
router.push({ name: 'ExamAnalysis', query: { examId: row.id } });
return;
}
message.info(`执行操作: ${action} on row ${row.id}`); message.info(`执行操作: ${action} on row ${row.id}`);
}, },
}); });

View File

@ -0,0 +1,147 @@
<template>
<div class="message-center">
<!-- 顶部Tab导航 -->
<div class="tab-container">
<n-tabs v-model:value="activeTab" type="line" size="large" class="message-tabs">
<n-tab-pane name="notification" tab="即时消息">
<template #tab>
<div class="tab-item">
<span>即时消息</span>
<n-badge :value="notificationCount" :show="notificationCount > 0" class="tab-badge">
</n-badge>
</div>
</template>
</n-tab-pane>
<n-tab-pane name="comment" tab="评论和@">
<template #tab>
<div class="tab-item">
<span>评论和@</span>
<n-badge :value="commentCount" :show="commentCount > 0" class="tab-badge">
</n-badge>
</div>
</template>
</n-tab-pane>
<n-tab-pane name="favorite" tab="赞和收藏">
<template #tab>
<div class="tab-item">
<span>赞和收藏</span>
<n-badge :value="favoriteCount" :show="favoriteCount > 0" class="tab-badge">
</n-badge>
</div>
</template>
</n-tab-pane>
<n-tab-pane name="system" tab="系统消息">
<template #tab>
<div class="tab-item">
<span>系统消息</span>
<n-badge :value="systemCount" :show="systemCount > 0" class="tab-badge">
</n-badge>
</div>
</template>
</n-tab-pane>
</n-tabs>
</div>
<!-- Tab内容区域 -->
<div class="tab-content">
<div v-show="activeTab === 'notification'">
<NotificationMessages />
</div>
<div v-show="activeTab === 'comment'">
<CommentLikes />
</div>
<div v-show="activeTab === 'favorite'">
<FavoriteMessages />
</div>
<div v-show="activeTab === 'system'">
<SystemMessages />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NBadge, NTabs, NTabPane } from 'naive-ui'
//
import NotificationMessages from './components/NotificationMessages.vue'
import CommentLikes from './components/CommentLikes.vue'
import FavoriteMessages from './components/FavoriteMessages.vue'
import SystemMessages from './components/SystemMessages.vue'
//
const activeTab = ref('notification') // tab
//
const notificationCount = ref(5) //
const commentCount = ref(3) // @
const favoriteCount = ref(0) //
const systemCount = ref(0) //
//
onMounted(() => {
//
loadMessageCounts()
})
//
const loadMessageCounts = () => {
// TODO: API
// 使
}
</script>
<style scoped>
.message-center {
padding: 20px;
background-color: #fff;
}
.tab-container {
margin-bottom: 20px;
}
.message-tabs {
--n-tab-text-color: #666;
--n-tab-text-color-active: #1890ff;
--n-bar-color: #1890ff;
}
.tab-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.tab-badge {
position: relative;
top: -2px;
}
.tab-content {
min-height: 400px;
}
/* 调整badge样式 */
:deep(.n-badge) {
--n-color: #ff4d4f;
--n-text-color: #fff;
--n-font-size: 12px;
}
/* 调整tabs整体样式 */
:deep(.n-tabs .n-tabs-nav .n-tabs-nav-scroll-content) {
gap: 32px;
}
:deep(.n-tabs .n-tabs-tab) {
padding: 12px 0;
font-size: 16px;
font-weight: 500;
}
</style>

View File

@ -0,0 +1,550 @@
<template>
<div class="message-center">
<!-- 消息列表 -->
<div class="message-list">
<div v-for="message in messages" :key="message.id" class="message-item">
<!-- 用户头像 -->
<div class="avatar-container">
<img :src="message.avatar" :alt="message.username" class="avatar" />
</div>
<!-- 消息内容 -->
<div class="message-content">
<!-- 评论 -->
<div class="message-header" v-if="message.type === 0">
<div>
<span class="username">
{{ message.username }}评论了我
</span>
<span class="content">{{ message.content }}</span>
</div>
<span class="timestamp">{{ message.timestamp }}</span>
</div>
<!-- @ -->
<div class="message-header" v-if="message.type === 1">
<div>
<span class="username">
{{ message.username }}@了我
</span>
<span class="content">{{ message.content }}</span>
</div>
<span class="timestamp">{{ message.timestamp }}</span>
</div>
<div class="message-text">回复我的课程: <span class="course-info">{{ message.courseInfo }}</span></div>
<!-- 操作按钮 -->
<div class="message-btns">
<div class="message-actions">
<button class="action-btn" @click="toggleReply(message.id)">
<n-icon size="16">
<ChatbubbleEllipsesOutline />
</n-icon>
回复
</button>
<button class="action-btn" @click="toggleFavorite(message.id)">
<!-- <i class="icon-favorite" :class="{ active: message.isFavorited }"></i> -->
<n-icon size="16">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32">
<path
d="M26 12h-6V6a3.003 3.003 0 0 0-3-3h-2.133a2.01 2.01 0 0 0-1.98 1.717l-.845 5.917L8.465 16H2v14h21a7.008 7.008 0 0 0 7-7v-7a4.005 4.005 0 0 0-4-4zM8 28H4V18h4zm20-5a5.006 5.006 0 0 1-5 5H10V17.303l3.958-5.937l.91-6.366H17a1 1 0 0 1 1 1v8h8a2.002 2.002 0 0 1 2 2z"
fill="currentColor"></path>
</svg>
</n-icon>
点赞
</button>
<button class="action-btn delete-btn">
<n-icon size="16">
<TrashOutline />
</n-icon>
删除
</button>
</div>
<button class="action-btn delete-btn">
<n-icon size="16">
<WarningOutline />
</n-icon>
举报
</button>
</div>
<!-- 回复输入框 -->
<div v-if="message.showReplyBox" class="reply-box">
<textarea v-model="message.replyContent" placeholder="回复内容:" class="reply-textarea" rows="3"></textarea>
<div class="reply-actions">
<button class="cancel-btn" @click="cancelReply(message.id)">取消</button>
<button class="send-btn" @click="sendReply(message.id)">发送</button>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(1)">首页</button>
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">上一页</button>
<button v-for="page in visiblePages" :key="page" class="page-btn" :class="{ active: page === currentPage }"
@click="goToPage(page)">
{{ page }}
</button>
<span v-if="showEllipsis" class="ellipsis">...</span>
<button class="page-btn" @click="goToPage(totalPages)">{{ totalPages }}</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">下一页</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(totalPages)">尾页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ChatbubbleEllipsesOutline, TrashOutline, WarningOutline } from '@vicons/ionicons5'
//
interface Message {
id: number
type: number
username: string
avatar: string
courseInfo: string
content: string
timestamp: string
isLiked: boolean
isFavorited: boolean
showReplyBox: boolean
replyContent: string
}
//
const messages = ref<Message[]>([
{
id: 1,
type: 1,
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '这里是老师留言的内容了',
timestamp: '7月20日',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 2,
type: 0,
username: '叶仲学习分子',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '还好期末考成分学的记忆',
timestamp: '7月20日 12:41',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 3,
type: 1,
username: '课程学习端课学',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '没事多看看课程你就懂了',
timestamp: '7月20日',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
}
])
//
const currentPage = ref(1)
const totalPages = ref(29)
//
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, currentPage.value + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
//
const showEllipsis = computed(() => {
return currentPage.value + 2 < totalPages.value - 1
})
//
onMounted(() => {
loadMessages()
})
//
const loadMessages = () => {
// TODO: API
}
const toggleFavorite = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message) {
message.isFavorited = !message.isFavorited
}
}
const toggleReply = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message) {
//
messages.value.forEach(m => {
if (m.id !== messageId) {
m.showReplyBox = false
m.replyContent = ''
}
})
//
message.showReplyBox = !message.showReplyBox
if (message.showReplyBox) {
message.replyContent = ''
}
}
}
const cancelReply = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message) {
message.showReplyBox = false
message.replyContent = ''
}
}
const sendReply = (messageId: number) => {
const message = messages.value.find(m => m.id === messageId)
if (message && message.replyContent.trim()) {
// TODO: API
console.log('发送回复:', message.replyContent)
message.showReplyBox = false
message.replyContent = ''
}
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadMessages()
}
}
</script>
<style scoped>
.message-center {
background-color: #fff;
}
.message-list {
padding: 0;
}
.message-item {
position: relative;
display: flex;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.message-item:hover {
background-color: #fafafa;
}
.message-item.unread {
background-color: #f6f8ff;
}
.unread-dot {
position: absolute;
left: 8px;
top: 20px;
width: 6px;
height: 6px;
background-color: #ff4d4f;
border-radius: 50%;
}
.avatar-container {
margin-right: 12px;
flex-shrink: 0;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.username {
color: #1890ff;
font-weight: 500;
font-size: 14px;
}
.content {
color: #000;
font-weight: 600;
font-size: 14px;
margin-left: 4px;
}
.course-info {
color: #666;
}
.timestamp {
color: #999;
font-size: 12px;
margin-left: auto;
}
.message-text {
color: #999999;
background-color: #F5F8FB;
padding: 6px;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
word-break: break-word;
}
.message-btns{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.message-actions {
display: flex;
gap: 16px;
align-items: center;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: #666;
font-size: 12px;
cursor: pointer;
padding: 4px 0;
transition: color 0.2s;
}
.action-btn:hover {
color: #1890ff;
}
.action-btn.delete-btn:hover {
color: #ff4d4f;
}
.action-btn i {
font-size: 14px;
}
.action-btn i.active {
color: #1890ff;
}
/* 图标样式 - 使用伪元素模拟图标 */
.icon-like::before {
content: "👍";
}
.icon-favorite::before {
content: "⭐";
}
.icon-reply::before {
content: "💬";
}
.icon-delete::before {
content: "🗑️";
}
.reply-box {
margin-top: 16px;
padding: 16px;
background-color: #fafafa;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.reply-textarea {
width: 100%;
min-height: 80px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
resize: vertical;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.reply-textarea:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.reply-textarea::placeholder {
color: #bfbfbf;
}
.reply-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}
.cancel-btn,
.send-btn {
padding: 6px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: #fff;
border: 1px solid #d9d9d9;
color: #666;
}
.cancel-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.send-btn {
background: #1890ff;
border: 1px solid #1890ff;
color: #fff;
}
.send-btn:hover {
background: #40a9ff;
border-color: #40a9ff;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 32px 20px;
border-top: 1px solid #f0f0f0;
background-color: #fff;
}
.page-btn {
padding: 8px 12px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.page-btn:hover:not(:disabled) {
border-color: #1890ff;
color: #1890ff;
}
.page-btn.active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
.page-btn:disabled {
color: #bfbfbf;
border-color: #f0f0f0;
cursor: not-allowed;
background: #fafafa;
}
.ellipsis {
color: #bfbfbf;
font-size: 14px;
padding: 0 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-item {
padding: 12px 16px;
}
.message-header {
flex-direction: column;
align-items: flex-start;
}
.timestamp {
margin-left: 0;
}
.message-actions {
flex-wrap: wrap;
gap: 12px;
}
.pagination {
flex-wrap: wrap;
gap: 4px;
}
.page-btn {
min-width: 32px;
height: 32px;
font-size: 12px;
padding: 6px 8px;
}
}
</style>

View File

@ -0,0 +1,513 @@
<template>
<div class="message-center">
<!-- 消息列表 -->
<div class="message-list">
<div v-for="message in messages" :key="message.id" class="message-item">
<!-- 用户头像 -->
<div class="avatar-container">
<img :src="message.avatar" :alt="message.username" class="avatar" />
</div>
<!-- 消息内容 -->
<div class="message-content">
<!-- 点赞消息 -->
<div class="message-header" v-if="message.type === 0">
<div>
<span class="username">
{{ message.username }}赞了我的评论
</span>
<span class="content">{{ message.content }}</span>
</div>
<span class="timestamp">{{ message.timestamp }}</span>
</div>
<!-- 收藏消息 -->
<div class="message-header" v-if="message.type === 1">
<div class="header-left">
<div>
<span class="username">
{{ message.username }}收藏了我的课程
</span>
<span class="course-info">{{ message.courseInfo }}</span>
</div>
<span class="timestamp">{{ message.timestamp }}</span>
</div>
<!-- 课程封面图片 - 只有收藏消息才显示 -->
<div v-if="message.type === 1 && message.courseImage" class="course-image-container">
<img :src="message.courseImage" :alt="message.courseInfo" class="course-image" />
</div>
</div>
<div class="message-text" v-if="message.type === 0">课程:
<span class="course-info">{{ message.courseInfo }}</span>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(1)">首页</button>
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">上一页</button>
<button v-for="page in visiblePages" :key="page" class="page-btn" :class="{ active: page === currentPage }"
@click="goToPage(page)">
{{ page }}
</button>
<span v-if="showEllipsis" class="ellipsis">...</span>
<button class="page-btn" @click="goToPage(totalPages)">{{ totalPages }}</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">下一页</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(totalPages)">尾页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
//
interface Message {
id: number
type: number // 0-, 1-
username: string
avatar: string
courseInfo: string
content: string
timestamp: string
courseImage?: string
isLiked: boolean
isFavorited: boolean
showReplyBox: boolean
replyContent: string
}
//
const messages = ref<Message[]>([
{
id: 1,
type: 0, // -
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '这里老师营养饮用的说明了',
timestamp: '7月20日\n12:41',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 2,
type: 1, // -
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '',
timestamp: '7月20日\n12:41',
courseImage: 'https://picsum.photos/300/200',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 3,
type: 0, // -
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '这里老师营养饮用的说明了',
timestamp: '7月20日\n12:41',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 4,
type: 1, // -
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '',
timestamp: '7月20日\n12:41',
courseImage: 'https://picsum.photos/300/200',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 5,
type: 1, // -
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '',
timestamp: '7月20日\n12:41',
courseImage: 'https://picsum.photos/300/200',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 6,
type: 0, // -
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '这里老师营养饮用的说明了',
timestamp: '7月20日\n12:41',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
},
{
id: 7,
type: 0, // -
username: '王建华化学老师',
avatar: 'https://picsum.photos/200/200',
courseInfo: '《教师小学期制实验》',
content: '这里老师营养饮用的说明了',
timestamp: '7月20日\n12:41',
isLiked: false,
isFavorited: false,
showReplyBox: false,
replyContent: ''
}
])
//
const currentPage = ref(1)
const totalPages = ref(29)
//
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, currentPage.value + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
//
const showEllipsis = computed(() => {
return currentPage.value + 2 < totalPages.value - 1
})
//
onMounted(() => {
loadMessages()
})
//
const loadMessages = () => {
// TODO: API
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadMessages()
}
}
</script>
<style scoped>
.message-center {
background-color: #fff;
}
.message-list {
padding: 0;
}
.message-item {
position: relative;
display: flex;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.message-item:hover {
background-color: #fafafa;
}
.avatar-container {
margin-right: 12px;
flex-shrink: 0;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.header-left{
display: flex;
gap: 8px;
flex-direction: column;
}
.username {
color: #1890ff;
font-weight: 500;
font-size: 14px;
}
.content {
color: #000;
font-weight: 600;
font-size: 14px;
margin-left: 4px;
}
.course-info{
font-weight: 500;
font-size: 14px;
color: #585858;
}
.course-info {
color: #666;
}
.timestamp {
color: #999;
font-size: 12px;
white-space: nowrap;
}
.message-text {
color: #999999;
background-color: #F5F8FB;
padding: 6px;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
word-break: break-word;
}
.course-image-container {
text-align: right;
}
.course-image {
max-width: 150px;
height: auto;
border-radius: 8px;
border: 1px solid #e8e8e8;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.message-btns {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.message-actions {
display: flex;
gap: 16px;
align-items: center;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: #666;
font-size: 12px;
cursor: pointer;
padding: 4px 0;
transition: color 0.2s;
}
.action-btn:hover {
color: #1890ff;
}
.action-btn.delete-btn:hover {
color: #ff4d4f;
}
.reply-box {
margin-top: 16px;
padding: 16px;
background-color: #fafafa;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.reply-textarea {
width: 100%;
min-height: 80px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
resize: vertical;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.reply-textarea:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.reply-textarea::placeholder {
color: #bfbfbf;
}
.reply-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}
.cancel-btn,
.send-btn {
padding: 6px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: #fff;
border: 1px solid #d9d9d9;
color: #666;
}
.cancel-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.send-btn {
background: #1890ff;
border: 1px solid #1890ff;
color: #fff;
}
.send-btn:hover {
background: #40a9ff;
border-color: #40a9ff;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 32px 20px;
border-top: 1px solid #f0f0f0;
background-color: #fff;
}
.page-btn {
padding: 8px 12px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.page-btn:hover:not(:disabled) {
border-color: #1890ff;
color: #1890ff;
}
.page-btn.active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
.page-btn:disabled {
color: #bfbfbf;
border-color: #f0f0f0;
cursor: not-allowed;
background: #fafafa;
}
.ellipsis {
color: #bfbfbf;
font-size: 14px;
padding: 0 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-item {
padding: 12px 16px;
}
.message-header {
flex-direction: column;
align-items: flex-start;
}
.timestamp {
margin-left: 0;
}
.message-actions {
flex-wrap: wrap;
gap: 12px;
}
.course-image {
max-width: 100%;
}
.pagination {
flex-wrap: wrap;
gap: 4px;
}
.page-btn {
min-width: 32px;
height: 32px;
font-size: 12px;
padding: 6px 8px;
}
}
</style>

View File

@ -0,0 +1,208 @@
<template>
<div class="message-input-container">
<div class="input-wrapper">
<n-input
v-model:value="messageText"
type="textarea"
:placeholder="placeholder"
:autosize="{ minRows: 1, maxRows: 4 }"
:bordered="false"
class="message-input"
@keydown="handleKeyDown"
@input="handleInput"
/>
<div class="input-actions">
<n-button
type="primary"
size="medium"
:disabled="!canSend"
class="send-button"
@click="handleSend"
>
发送
</n-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NInput, NButton } from 'naive-ui'
// Props
interface Props {
placeholder?: string
maxLength?: number
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入消息内容...',
maxLength: 500,
disabled: false
})
// Emits
const emit = defineEmits<{
send: [message: string]
input: [value: string]
}>()
//
const messageText = ref('')
//
const canSend = computed(() => {
return messageText.value.trim().length > 0 && !props.disabled
})
//
const handleSend = () => {
if (canSend.value) {
emit('send', messageText.value.trim())
messageText.value = ''
}
}
const handleInput = (value: string) => {
emit('input', value)
}
const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl/Cmd + Enter
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault()
handleSend()
}
}
//
defineExpose({
focus: () => {
//
},
clear: () => {
messageText.value = ''
}
})
</script>
<style scoped>
.message-input-container {
position: sticky;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
border-top: 1px solid #e8e8e8;
padding: 16px 20px;
z-index: 100;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 12px;
max-width: 1200px;
margin: 0 auto;
}
.message-input {
flex: 1;
background: #f8f9fa;
border-radius: 12px;
padding: 12px 16px;
min-height: 44px;
font-size: 14px;
line-height: 1.5;
transition: all 0.2s ease;
}
.message-input:hover {
background: #f0f2f5;
}
.message-input:focus-within {
background: #ffffff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
}
.send-button {
height: 44px;
padding: 0 24px;
border-radius: 12px;
font-weight: 500;
font-size: 14px;
white-space: nowrap;
transition: all 0.2s ease;
}
.send-button:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
/* 自定义Naive UI样式 */
:deep(.n-input .n-input__textarea) {
background: transparent !important;
border: none !important;
padding: 0 !important;
font-size: 14px;
line-height: 1.5;
resize: none;
}
:deep(.n-input .n-input__textarea::placeholder) {
color: #999;
font-size: 14px;
}
:deep(.n-input.n-input--focus .n-input__textarea) {
background: transparent !important;
box-shadow: none !important;
}
:deep(.n-button.n-button--primary-type) {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border: none;
}
:deep(.n-button.n-button--primary-type:not(.n-button--disabled):hover) {
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
}
:deep(.n-button.n-button--primary-type.n-button--disabled) {
background: #d9d9d9;
color: #fff;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-input-container {
padding: 12px 16px;
}
.input-wrapper {
gap: 8px;
}
.send-button {
height: 40px;
padding: 0 16px;
font-size: 13px;
}
.message-input {
padding: 10px 14px;
min-height: 40px;
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,780 @@
<template>
<div class="chat-container">
<!-- 左侧联系人/群组列表 -->
<div class="contacts-panel">
<!-- 联系人列表头部 -->
<div class="contacts-header">
<h3 class="contacts-title">全部信息</h3>
</div>
<!-- 联系人列表 -->
<div class="contacts-list">
<div v-for="contact in contacts" :key="contact.id" class="contact-item"
:class="{ active: contact.id === activeContactId, unread: contact.unreadCount > 0 }"
@click="selectContact(contact.id)">
<div class="contact-avatar">
<img :src="contact.avatar" :alt="contact.name" />
<div v-if="contact.type === 'group'" class="group-indicator">
<n-icon size="12" color="#fff">
<PeopleOutline />
</n-icon>
</div>
</div>
<div class="contact-info">
<div class="contact-header">
<span class="contact-name">{{ contact.name }}</span>
<span class="contact-time">{{ contact.lastMessageTime }}</span>
</div>
<div class="contact-preview">
<span class="last-message">{{ contact.lastMessage }}</span>
<n-badge v-if="contact.unreadCount > 0" :value="contact.unreadCount" :max="99" class="unread-badge" />
</div>
</div>
</div>
</div>
</div>
<!-- 右侧聊天区域 -->
<div class="chat-panel">
<div v-if="!activeContactId" class="chat-empty">
<div class="empty-content">
<n-icon size="64" color="#d9d9d9">
<ChatbubbleEllipsesOutline />
</n-icon>
<p class="empty-text">选择一个对话开始聊天</p>
</div>
</div>
<div v-else class="chat-content">
<!-- 聊天头部 -->
<div class="chat-header">
<div></div>
<div class="chat-user-info">
<div class="chat-user-details">
<h4 class="chat-user-name">{{ activeContact?.name }}</h4>
</div>
</div>
<div class="chat-actions">
<n-button text>
<n-icon size="18">
<EllipsisVertical />
</n-icon>
</n-button>
</div>
</div>
<!-- 聊天消息区域 -->
<div class="chat-messages" ref="messagesContainer">
<div class="messages-content">
<div v-for="message in currentMessages" :key="message.id" class="message-wrapper">
<!-- 日期分隔符 -->
<div v-if="message.showDateDivider" class="date-divider">
<span class="date-text">{{ message.dateText }}</span>
</div>
<!-- 消息内容 -->
<div class="message-item" :class="{ 'message-own': message.isOwn }">
<div v-if="!message.isOwn" class="message-avatar">
<img :src="message.avatar" :alt="message.senderName" />
</div>
<div class="message-content">
<div v-if="!message.isOwn" class="message-sender">{{ message.senderName }}</div>
<!-- 文本消息 -->
<div v-if="message.type === 'text'" class="message-bubble">
<p class="message-text">{{ message.content }}</p>
</div>
<!-- 图片消息 -->
<div v-else-if="message.type === 'image'" class="message-bubble image-bubble">
<img :src="message.content" class="message-image" @click="previewImage(message.content)" />
</div>
<!-- 文件消息 -->
<div v-else-if="message.type === 'file'" class="message-bubble file-bubble">
<div class="file-info">
<n-icon size="20" color="#1890ff">
<DocumentOutline />
</n-icon>
<div class="file-details">
<span class="file-name">{{ message.fileName }}</span>
<span class="file-size">{{ message.fileSize }}</span>
</div>
</div>
</div>
<div class="message-time">{{ message.time }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 消息输入区域 -->
<div class="chat-input-area">
<MessageInput @send="handleSendMessage" placeholder="请输入消息内容..." ref="messageInputRef" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { NIcon, NBadge } from 'naive-ui'
import {
EllipsisVertical,
PeopleOutline,
ChatbubbleEllipsesOutline,
DocumentOutline
} from '@vicons/ionicons5'
import MessageInput from './MessageInput.vue'
//
interface Contact {
id: number
name: string
avatar: string
type: 'user' | 'group'
lastMessage: string
lastMessageTime: string
unreadCount: number
isOnline?: boolean
memberCount?: number
}
//
interface Message {
id: number
contactId: number
type: 'text' | 'image' | 'file'
content: string
senderName: string
avatar: string
time: string
isOwn: boolean
showDateDivider?: boolean
dateText?: string
fileName?: string
fileSize?: string
}
//
const activeContactId = ref<number | null>(null)
const messagesContainer = ref<HTMLElement>()
const messageInputRef = ref()
//
const contacts = ref<Contact[]>([
{
id: 1,
name: '李小多',
avatar: 'https://picsum.photos/40/40?random=1',
type: 'user',
lastMessage: '这里是智慧你人的语法数字和信息',
lastMessageTime: '10:22',
unreadCount: 0,
isOnline: true
},
{
id: 2,
name: '直播学习小组群 (9)',
avatar: 'https://picsum.photos/40/40?random=2',
type: 'group',
lastMessage: '这里新是智慧你人的语法数字和信息',
lastMessageTime: '2024年7月23日',
unreadCount: 0,
memberCount: 9
},
{
id: 3,
name: '王明',
avatar: 'https://picsum.photos/40/40?random=3',
type: 'user',
lastMessage: '好的,我知道了',
lastMessageTime: '昨天',
unreadCount: 2,
isOnline: false
},
{
id: 4,
name: '张老师',
avatar: 'https://picsum.photos/40/40?random=4',
type: 'user',
lastMessage: '明天的课程安排已经发布',
lastMessageTime: '昨天',
unreadCount: 1,
isOnline: true
},
{
id: 5,
name: '陆娜娜',
avatar: 'https://picsum.photos/40/40?random=5',
type: 'user',
lastMessage: '课程资料我已经整理好了',
lastMessageTime: '昨天',
unreadCount: 0,
isOnline: false
},
{
id: 6,
name: '李科度',
avatar: 'https://picsum.photos/40/40?random=6',
type: 'user',
lastMessage: '下次见面详谈',
lastMessageTime: '昨天',
unreadCount: 0,
isOnline: true
},
{
id: 7,
name: '王小滑',
avatar: 'https://picsum.photos/40/40?random=7',
type: 'user',
lastMessage: '收到,谢谢!',
lastMessageTime: '昨天',
unreadCount: 0,
isOnline: false
}
])
//
const messages = ref<Message[]>([
{
id: 1,
contactId: 1,
type: 'text',
content: '这里新是智慧你人的语法数字和信息章,多归程回目记录',
senderName: '李小多',
avatar: 'https://picsum.photos/40/40?random=1',
time: '10:22',
isOwn: false,
showDateDivider: true,
dateText: '2024年7月23日'
},
{
id: 2,
contactId: 1,
type: 'text',
content: '收到',
senderName: '我',
avatar: 'https://picsum.photos/40/40?random=me',
time: '10:23',
isOwn: true
},
{
id: 3,
contactId: 2,
type: 'image',
content: 'https://picsum.photos/300/200?random=1',
senderName: '张三',
avatar: 'https://picsum.photos/40/40?random=8',
time: '10:25',
isOwn: false
},
{
id: 4,
contactId: 2,
type: 'file',
content: '',
senderName: '李四',
avatar: 'https://picsum.photos/40/40?random=9',
time: '10:30',
isOwn: false,
fileName: '2025年全家爱词学习人工智能老师考级试卷-点击下载.pptx',
fileSize: '2.5MB'
}
])
//
const activeContact = computed(() => {
return contacts.value.find(contact => contact.id === activeContactId.value)
})
const currentMessages = computed(() => {
return messages.value.filter(message => message.contactId === activeContactId.value)
})
//
const selectContact = (contactId: number) => {
activeContactId.value = contactId
//
const contact = contacts.value.find(c => c.id === contactId)
if (contact) {
contact.unreadCount = 0
}
nextTick(() => {
scrollToBottom()
})
}
const handleSendMessage = (content: string) => {
if (!activeContactId.value) return
const newMessage: Message = {
id: Date.now(),
contactId: activeContactId.value,
type: 'text',
content,
senderName: '我',
avatar: 'https://picsum.photos/40/40?random=me',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
isOwn: true
}
messages.value.push(newMessage)
//
const contact = contacts.value.find(c => c.id === activeContactId.value)
if (contact) {
contact.lastMessage = content
contact.lastMessageTime = newMessage.time
}
nextTick(() => {
scrollToBottom()
})
}
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
const previewImage = (src: string) => {
// TODO:
console.log('预览图片:', src)
}
//
onMounted(() => {
//
if (contacts.value.length > 0) {
selectContact(contacts.value[0].id)
}
})
</script>
<style scoped>
.chat-container {
display: flex;
height: 1000px;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
/* 左侧联系人面板 */
.contacts-panel {
width: 300px;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
background: #fafafa;
}
.contacts-header {
padding: 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
}
.contacts-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
.contacts-actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
background: none;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #666;
transition: all 0.2s;
}
.action-btn:hover {
background: #f0f0f0;
color: #1890ff;
}
.contacts-list {
flex: 1;
overflow-y: auto;
}
.contact-item {
display: flex;
padding: 12px 20px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.contact-item:hover {
background: #f8f9fa;
}
.contact-item.active {
background: #e6f7ff;
}
.contact-item.unread {
background: #fff7e6;
}
.contact-avatar {
position: relative;
margin-right: 12px;
flex-shrink: 0;
}
.contact-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.group-indicator {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
background: #1890ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
}
.contact-info {
flex: 1;
min-width: 0;
}
.contact-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.contact-name {
font-size: 14px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-time {
font-size: 12px;
color: #999;
flex-shrink: 0;
}
.contact-preview {
display: flex;
justify-content: space-between;
align-items: center;
}
.last-message {
font-size: 13px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.unread-badge {
flex-shrink: 0;
}
/* 右侧聊天面板 */
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
}
.chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-content {
text-align: center;
}
.empty-text {
margin-top: 16px;
color: #999;
font-size: 14px;
}
.chat-content {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa;
}
.chat-user-info {
display: flex;
align-items: center;
}
.chat-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
margin-right: 12px;
}
.chat-user-details h4 {
font-size: 16px;
font-weight: 600;
color: #333;
}
.chat-user-status {
font-size: 12px;
color: #999;
}
.chat-actions {
display: flex;
gap: 8px;
}
.chat-action-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px 0;
}
.messages-content {
padding: 0 20px;
}
.message-wrapper {
margin-bottom: 16px;
}
.date-divider {
text-align: center;
margin: 16px 0;
}
.date-text {
display: inline-block;
padding: 4px 12px;
background: #f0f0f0;
color: #666;
font-size: 12px;
border-radius: 12px;
}
.message-item {
display: flex;
margin-bottom: 12px;
}
.message-item.message-own {
justify-content: flex-end;
}
.message-avatar {
margin-right: 8px;
flex-shrink: 0;
}
.message-avatar img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.message-content {
max-width: 60%;
}
.message-own .message-content {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-sender {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.message-bubble {
background: #f0f0f0;
padding: 10px 14px;
border-radius: 12px;
position: relative;
}
.message-own .message-bubble {
background: #1890ff;
color: #fff;
}
.message-text {
margin: 0;
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.image-bubble {
padding: 4px;
background: transparent;
}
.message-image {
max-width: 200px;
max-height: 150px;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.message-image:hover {
transform: scale(1.02);
}
.file-bubble {
background: #f8f9fa;
border: 1px solid #e8e8e8;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
}
.file-details {
display: flex;
flex-direction: column;
}
.file-name {
font-size: 13px;
color: #333;
font-weight: 500;
}
.file-size {
font-size: 11px;
color: #999;
}
.message-time {
font-size: 11px;
color: #999;
margin-top: 4px;
text-align: right;
}
.message-own .message-time {
color: rgba(255, 255, 255, 0.8);
}
/* 自定义滚动条 */
.contacts-list::-webkit-scrollbar,
.chat-messages::-webkit-scrollbar {
width: 4px;
}
.contacts-list::-webkit-scrollbar-track,
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
}
.contacts-list::-webkit-scrollbar-thumb,
.chat-messages::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 2px;
}
.contacts-list::-webkit-scrollbar-thumb:hover,
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #999;
}
/* 响应式设计 */
@media (max-width: 768px) {
.chat-container {
height: 100vh;
}
.contacts-panel {
width: 280px;
}
.contact-item {
margin: 0 8px 1px 8px;
padding: 10px 16px;
}
.chat-header {
padding: 12px 16px;
}
.messages-content {
padding: 0 16px;
}
.message-content {
max-width: 75%;
}
}
</style>

View File

@ -0,0 +1,407 @@
<template>
<div class="system-messages">
<!-- 消息列表 -->
<div class="message-list">
<div v-for="message in messages" :key="message.id" class="message-item" :class="{ unread: !message.isRead }">
<!-- 未读消息圆点 -->
<!-- <div v-if="!message.isRead" class="unread-dot"></div> -->
<!-- 系统图标 -->
<div class="avatar-container">
<div class="system-avatar">
<n-icon size="24" color="#1890ff">
<NotificationsOutline />
</n-icon>
</div>
</div>
<!-- 消息内容 -->
<div class="message-content">
<!-- 消息头部 -->
<div class="message-header">
<div class="message-title">
<span class="title">{{ message.title }}</span>
</div>
<span class="timestamp">{{ message.timestamp }}</span>
</div>
<!-- 消息内容 -->
<div class="message-text">
{{ message.content }}
<n-button type="info" text icon-placement="right" style="margin-left: 6px;">
查看详情
<template #icon>
<NIcon>
<ChevronForward />
</NIcon>
</template>
</n-button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="messages.length === 0" class="empty-state">
<n-icon size="48" color="#d9d9d9">
<NotificationsOffOutline />
</n-icon>
<p class="empty-text">暂无系统消息</p>
</div>
</div>
<!-- 分页 -->
<div class="pagination" v-if="messages.length > 0">
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(1)">首页</button>
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">上一页</button>
<button v-for="page in visiblePages" :key="page" class="page-btn" :class="{ active: page === currentPage }"
@click="goToPage(page)">
{{ page }}
</button>
<span v-if="showEllipsis" class="ellipsis">...</span>
<button class="page-btn" @click="goToPage(totalPages)">{{ totalPages }}</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">下一页</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(totalPages)">尾页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NIcon } from 'naive-ui'
import { NotificationsOutline, NotificationsOffOutline, ChevronForward } from '@vicons/ionicons5'
//
interface SystemMessage {
id: number
title: string
content: string
timestamp: string
isRead: boolean
type: 'info' | 'warning' | 'success' | 'error'
}
//
const messages = ref<SystemMessage[]>([
{
id: 1,
title: '你的会员已专属权利已更新,保来享取今日内障,仅此一天!',
content: '好消息好消息BiliBiliWorld2024年在线大学6月29日周六正式开课',
timestamp: '7月20日 12:41',
isRead: false,
type: 'info'
},
{
id: 2,
title: '你的会员已专属权利已更新,保来享取今日内障,仅此一天!',
content: '好消息好消息BiliBiliWorld2024年在线大学6月29日周六正式开课',
timestamp: '7月20日 12:41',
isRead: false,
type: 'info'
},
{
id: 3,
title: '你的会员已专属权利已更新,保来享取今日内障,仅此一天!',
content: '好消息好消息BiliBiliWorld2024年在线大学6月29日周六正式开课',
timestamp: '7月20日 12:41',
isRead: false,
type: 'info'
},
{
id: 4,
title: '你的会员已专属权利已更新,保来享取今日内障,仅此一天!',
content: '好消息好消息BiliBiliWorld2024年在线大学6月29日周六正式开课',
timestamp: '7月20日 12:41',
isRead: false,
type: 'info'
},
{
id: 5,
title: '你的会员已专属权利已更新,保来享取今日内障,仅此一天!',
content: '好消息好消息BiliBiliWorld2024年在线大学6月29日周六正式开课',
timestamp: '7月20日 12:41',
isRead: false,
type: 'info'
},
{
id: 6,
title: '你的会员已专属权利已更新,保来享取今日内障,仅此一天!',
content: '好消息好消息BiliBiliWorld2024年在线大学6月29日周六正式开课',
timestamp: '7月20日 12:41',
isRead: false,
type: 'info'
}
])
//
const currentPage = ref(1)
const totalPages = ref(29)
//
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, currentPage.value + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
//
const showEllipsis = computed(() => {
return currentPage.value + 2 < totalPages.value - 1
})
//
onMounted(() => {
loadMessages()
})
//
const loadMessages = () => {
// TODO: API
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadMessages()
}
}
</script>
<style scoped>
.system-messages {
background-color: #fff;
}
.message-list {
padding: 0;
}
.message-item {
position: relative;
display: flex;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.unread-dot {
position: absolute;
left: 8px;
top: 20px;
width: 6px;
height: 6px;
background-color: #ff4d4f;
border-radius: 50%;
}
.avatar-container {
margin-right: 12px;
flex-shrink: 0;
}
.system-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #e6f7ff;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #91d5ff;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 8px;
gap: 12px;
}
.message-title {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.system-tag {
display: inline-block;
padding: 2px 8px;
background-color: #1890ff;
color: white;
font-size: 12px;
border-radius: 10px;
font-weight: 500;
flex-shrink: 0;
}
.title {
color: #000;
font-weight: 600;
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.timestamp {
color: #999;
font-size: 12px;
flex-shrink: 0;
align-self: flex-start;
}
.message-text {
color: #666;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
word-break: break-word;
}
.message-actions {
display: flex;
gap: 16px;
align-items: center;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: #666;
font-size: 12px;
cursor: pointer;
padding: 4px 0;
transition: color 0.2s;
}
.action-btn:hover {
color: #1890ff;
}
.action-btn.delete-btn:hover {
color: #ff4d4f;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.empty-text {
margin-top: 16px;
color: #999;
font-size: 14px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 32px 20px;
border-top: 1px solid #f0f0f0;
background-color: #fff;
}
.page-btn {
padding: 8px 12px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.page-btn:hover:not(:disabled) {
border-color: #1890ff;
color: #1890ff;
}
.page-btn.active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
.page-btn:disabled {
color: #bfbfbf;
border-color: #f0f0f0;
cursor: not-allowed;
background: #fafafa;
}
.ellipsis {
color: #bfbfbf;
font-size: 14px;
padding: 0 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-item {
padding: 12px 16px;
}
.message-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.message-title {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.timestamp {
align-self: flex-start;
}
.message-actions {
flex-wrap: wrap;
gap: 12px;
}
.pagination {
flex-wrap: wrap;
gap: 4px;
}
.page-btn {
min-width: 32px;
height: 32px;
font-size: 12px;
padding: 6px 8px;
}
}
</style>