This commit is contained in:
SunWuyuan
2025-03-08 21:51:00 +08:00
parent ec46f6aca2
commit 10b7f3784f
13 changed files with 2450 additions and 1288 deletions
+268
View File
@@ -0,0 +1,268 @@
<template>
<!-- 侧边栏 -->
<v-navigation-drawer
v-model="drawer"
location="right"
temporary
width="400"
>
<v-toolbar color="primary">
<v-toolbar-title class="text-white">
消息记录
<v-chip
v-if="unreadCount"
color="error"
size="small"
class="ml-2"
>
{{ unreadCount }}
</v-chip>
</v-toolbar-title>
<v-spacer />
<v-btn
icon="mdi-delete"
variant="text"
color="white"
@click="clearMessages"
/>
</v-toolbar>
<v-list class="message-list">
<template v-for="(msg, index) in visibleMessages" :key="msg.id">
<v-list-item
:class="{ 'unread': !msg.read }"
class="message-item mb-2"
@click="markAsRead(msg.id)"
>
<template #prepend>
<v-icon
:icon="getIcon(msg.type)"
:color="getColor(msg.type)"
size="small"
/>
</template>
<v-list-item-title>{{ msg.title }}</v-list-item-title>
<v-list-item-subtitle v-if="msg.content">
{{ msg.content }}
</v-list-item-subtitle>
<v-list-item-subtitle class="text-caption">
{{ formatTime(msg.timestamp) }}
</v-list-item-subtitle>
<template #append>
<v-btn
icon="mdi-delete"
variant="text"
size="small"
@click.stop="deleteMessage(msg.id)"
/>
</template>
</v-list-item>
<v-divider v-if="index < visibleMessages.length - 1" />
</template>
<v-btn
v-if="hasMoreMessages"
block
variant="text"
@click="loadMoreMessages"
>
加载更多
</v-btn>
</v-list>
</v-navigation-drawer>
<!-- 消息提示组 -->
<div class="message-container">
<TransitionGroup name="message">
<v-alert
v-for="msg in activeMessages"
:key="msg.id"
:type="msg.type"
variant="tonal"
closable
class="message-alert mb-2"
@click:close="removeActiveMessage(msg.id)"
>
<div class="d-flex align-center">
<span class="font-weight-medium">{{ msg.title }}</span>
<v-spacer />
<span class="text-caption">{{ formatTime(msg.timestamp) }}</span>
</div>
<div
v-if="msg.content"
class="message-content mt-1"
>
{{ msg.content }}
</div>
</v-alert>
</TransitionGroup>
</div>
</template>
<script>
import messageService from '@/utils/message';
import { getSetting } from '@/utils/settings';
import { debounce } from '@/utils/debounce';
export default {
name: 'MessageLog',
data: () => ({
drawer: false,
messages: [],
activeMessages: [],
unreadCount: 0,
maxActiveMessages: getSetting('message.maxActiveMessages'),
messageTimeout: getSetting('message.timeout'),
showSidebar: getSetting('message.showSidebar'),
saveHistory: getSetting('message.saveHistory'),
pageSize: 20,
currentPage: 1,
}),
computed: {
visibleMessages() {
return this.messages.slice(0, this.currentPage * this.pageSize);
},
hasMoreMessages() {
return this.visibleMessages.length < this.messages.length;
}
},
created() {
this.debouncedUpdateMessages = debounce(this.updateMessages, 300);
},
mounted() {
messageService.initialize();
messageService.onSnackbar(this.showMessage);
messageService.onLog(this.debouncedUpdateMessages);
},
methods: {
loadMoreMessages() {
this.currentPage++;
},
showMessage(message) {
if (!this.showSidebar) return;
this.activeMessages.unshift(message);
if (this.activeMessages.length > this.maxActiveMessages) {
this.activeMessages.pop();
}
setTimeout(() => {
this.removeActiveMessage(message.id);
}, this.messageTimeout);
},
removeActiveMessage(id) {
const index = this.activeMessages.findIndex(m => m.id === id);
if (index !== -1) {
this.activeMessages.splice(index, 1);
}
},
updateMessages(messages) {
if (this.saveHistory) {
this.messages = [...messages].reverse();
this.unreadCount = messageService.getUnreadCount();
}
},
markAsRead(id) {
messageService.markAsRead(id);
},
deleteMessage(id) {
messageService.deleteMessage(id);
},
clearMessages() {
messageService.clearMessages();
this.messages = [];
this.activeMessages = [];
},
getIcon(type) {
const icons = {
success: 'mdi-check-circle',
error: 'mdi-alert-circle',
warning: 'mdi-alert',
info: 'mdi-information'
};
return icons[type] || icons.info;
},
getColor(type) {
const colors = {
success: 'success',
error: 'error',
warning: 'warning',
info: 'primary'
};
return colors[type] || colors.info;
},
formatTime: (() => {
const cache = new Map();
return (timestamp) => {
if (cache.has(timestamp)) {
return cache.get(timestamp);
}
const formatted = new Date(timestamp).toLocaleTimeString();
cache.set(timestamp, formatted);
return formatted;
};
})()
}
};
</script>
<style scoped>
.message-container {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 1000;
max-width: 400px;
}
.message-alert {
width: 100%;
}
.message-list {
height: calc(100vh - 64px);
overflow-y: auto;
scroll-behavior: smooth;
will-change: transform;
}
.message-item {
border-left: 3px solid transparent;
contain: content;
}
.message-item.unread {
background-color: rgba(var(--v-theme-primary), 0.05);
}
/* 消息动画 */
.message-enter-active,
.message-leave-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateX(30px);
}
.message-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<template>
<v-card elevation="2" class="settings-card rounded-lg">
<v-card-item>
<template #prepend>
<v-icon
:icon="icon"
size="large"
class="mr-2"
/>
</template>
<v-card-title class="text-h6">{{ title }}</v-card-title>
</v-card-item>
<v-card-text>
<v-progress-linear
v-if="loading"
indeterminate
color="primary"
class="mb-4"
/>
<slot />
</v-card-text>
<v-card-actions v-if="$slots.actions" class="pa-4">
<slot name="actions" />
</v-card-actions>
</v-card>
</template>
<script>
export default {
name: 'SettingsCard',
props: {
title: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
loading: {
type: Boolean,
default: false
}
}
}
</script>
<style scoped>
.settings-card {
height: 100%;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<v-card border>
<v-card-item>
<template #prepend>
<v-icon icon="mdi-information" size="large" class="mr-2" />
</template>
<v-card-title class="text-h6">关于</v-card-title>
</v-card-item>
<v-card-text>
<v-row justify="center" align="center">
<v-col cols="12" md="8" class="text-center">
<v-avatar size="120" class="mb-4">
<v-img
src="https://avatars.githubusercontent.com/u/88357633?v=4"
alt="作者头像"
/>
</v-avatar>
<h2 class="text-h5 mb-2">HomeworkPage</h2>
<p class="text-body-1 mb-4">
由 <a
href="https://github.com/sunwuyuan"
target="_blank"
class="text-decoration-none font-weight-medium"
>Sunwuyuan</a> 开发
</p>
<div class="d-flex justify-center gap-2 flex-wrap">
<v-btn
color="primary"
variant="outlined"
href="https://github.com/SunWuyuan/homeworkpage-frontend"
target="_blank"
prepend-icon="mdi-github"
>
前端 GitHub
</v-btn>
<v-btn
color="primary"
variant="outlined"
href="https://github.com/SunWuyuan/homeworkpage-backend"
target="_blank"
prepend-icon="mdi-github"
>
后端 GitHub
</v-btn>
<v-btn
color="primary"
variant="outlined"
href="https://github.com/SunWuyuan/homeworkpage-backend/issues"
target="_blank"
prepend-icon="mdi-bug"
>
报告问题
</v-btn>
</div>
<p class="mt-4 text-caption text-medium-emphasis">
GPL License © 2024
</p>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'AboutCard'
}
</script>
+344
View File
@@ -0,0 +1,344 @@
<template>
<v-card border>
<v-card-item>
<template #prepend>
<v-icon icon="mdi-account-group" size="large" class="mr-2" />
</template>
<v-card-title class="text-h6">学生列表</v-card-title>
<template #append>
<v-btn
:color="modelValue.advanced ? 'primary' : undefined"
variant="text"
prepend-icon="mdi-code-braces"
@click="toggleAdvanced"
>
{{ modelValue.advanced ? '返回基础编辑' : '高级编辑' }}
</v-btn>
</template>
</v-card-item>
<v-card-text>
<v-progress-linear
v-if="loading"
indeterminate
color="primary"
class="mb-4"
/>
<v-alert
v-if="error"
type="error"
variant="tonal"
closable
class="mb-4"
>
{{ error }}
</v-alert>
<v-expand-transition>
<!-- 普通编辑模式 -->
<div v-if="!modelValue.advanced">
<v-row class="mb-6">
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model="newStudent"
label="添加学生"
placeholder="输入学生姓名后回车添加"
prepend-inner-icon="mdi-account-plus"
variant="outlined"
hide-details
class="mb-4"
@keyup.enter="addStudent"
>
<template #append>
<v-btn
icon="mdi-plus"
variant="text"
color="primary"
:disabled="!newStudent.trim()"
@click="addStudent"
/>
</template>
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col
v-for="(student, index) in modelValue.list"
:key="index"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-hover v-slot="{ isHovering, props }">
<v-card
v-bind="props"
:elevation="isMobile ? 1 : (isHovering ? 4 : 1)"
class="student-card"
border
>
<v-card-text class="d-flex align-center pa-3">
<v-menu location="bottom" :open-on-hover="!isMobile">
<template #activator="{ props: menuProps }">
<v-btn
variant="tonal"
size="small"
class="mr-3 font-weight-medium"
v-bind="menuProps"
>
{{ index + 1 }}
</v-btn>
</template>
<v-list density="compact" nav>
<v-list-item
prepend-icon="mdi-arrow-up-bold"
:disabled="index === 0"
@click="moveStudent(index, 'top')"
>
置顶
</v-list-item>
<v-divider />
<v-list-item
prepend-icon="mdi-arrow-up"
:disabled="index === 0"
@click="moveStudent(index, 'up')"
>
上移
</v-list-item>
<v-list-item
prepend-icon="mdi-arrow-down"
:disabled="index === modelValue.list.length - 1"
@click="moveStudent(index, 'down')"
>
下移
</v-list-item>
</v-list>
</v-menu>
<v-text-field
v-if="editingIndex === index"
v-model="editingName"
density="compact"
variant="underlined"
hide-details
class="flex-grow-1"
autofocus
@keyup.enter="saveEdit"
@blur="saveEdit"
/>
<span
v-else
class="text-body-1 flex-grow-1"
@click="handleClick(index, student)"
>
{{ student }}
</span>
<div class="d-flex gap-1 action-buttons" :class="{ 'opacity-100': isHovering || isMobile }">
<v-btn
icon="mdi-pencil"
variant="text"
color="primary"
size="small"
@click="startEdit(index, student)"
/>
<v-btn
icon="mdi-delete"
variant="text"
color="error"
size="small"
@click="removeStudent(index)"
/>
</div>
</v-card-text>
</v-card>
</v-hover>
</v-col>
</v-row>
</div>
<!-- 高级编辑模式 -->
<div v-else class="pt-2">
<v-textarea
v-model="modelValue.text"
label="批量编辑学生列表"
placeholder="每行输入一个学生姓名"
hint="使用文本编辑模式批量编辑学生名单,保存时会自动去除空行"
persistent-hint
variant="outlined"
rows="10"
@input="handleTextInput"
/>
</div>
</v-expand-transition>
<v-row class="mt-6">
<v-col cols="12" class="d-flex gap-2">
<v-btn
color="primary"
prepend-icon="mdi-content-save"
size="large"
:loading="loading"
:disabled="loading"
@click="$emit('save')"
>
保存名单
</v-btn>
<v-btn
color="error"
variant="outlined"
prepend-icon="mdi-refresh"
size="large"
:loading="loading"
:disabled="loading"
@click="$emit('reload')"
>
重载名单
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'StudentListCard',
props: {
modelValue: {
type: Object,
required: true,
default: () => ({
list: [],
text: '',
advanced: false
})
},
loading: Boolean,
error: String,
isMobile: Boolean
},
data() {
return {
newStudent: '',
editingIndex: -1,
editingName: ''
}
},
emits: ['update:modelValue', 'save', 'reload'],
methods: {
toggleAdvanced() {
const advanced = !this.modelValue.advanced;
const text = advanced ? this.modelValue.list.join('\n') : this.modelValue.text;
this.$emit('update:modelValue', {
...this.modelValue,
advanced,
text
});
},
handleTextInput(value) {
const list = value
.split('\n')
.map(s => s.trim())
.filter(s => s);
this.$emit('update:modelValue', {
...this.modelValue,
text: value,
list
});
},
addStudent() {
const name = this.newStudent.trim();
if (name && !this.modelValue.list.includes(name)) {
const newList = [...this.modelValue.list, name];
this.$emit('update:modelValue', {
...this.modelValue,
list: newList,
text: newList.join('\n')
});
this.newStudent = '';
}
},
removeStudent(index) {
const newList = this.modelValue.list.filter((_, i) => i !== index);
this.$emit('update:modelValue', {
...this.modelValue,
list: newList,
text: newList.join('\n')
});
},
moveStudent(index, direction) {
const newList = [...this.modelValue.list];
let targetIndex;
if (direction === 'top') {
targetIndex = 0;
} else if (direction === 'up') {
targetIndex = index - 1;
} else {
targetIndex = index + 1;
}
if (targetIndex >= 0 && targetIndex < newList.length) {
const [student] = newList.splice(index, 1);
newList.splice(targetIndex, 0, student);
this.$emit('update:modelValue', {
...this.modelValue,
list: newList,
text: newList.join('\n')
});
}
},
startEdit(index, name) {
this.editingIndex = index;
this.editingName = name;
},
saveEdit() {
if (this.editingIndex !== -1 && this.editingName.trim()) {
const newList = [...this.modelValue.list];
newList[this.editingIndex] = this.editingName.trim();
this.$emit('update:modelValue', {
...this.modelValue,
list: newList,
text: newList.join('\n')
});
}
this.editingIndex = -1;
this.editingName = '';
}
}
}
</script>
<style scoped>
.student-card {
transition: all 0.2s ease;
}
.action-buttons {
opacity: 0;
transition: opacity 0.2s ease;
}
@media (max-width: 600px) {
.action-buttons {
opacity: 1;
}
}
</style>