视图路径修改
This commit is contained in:
202
schoolNewsWeb/src/views/user/ai-assistant/AIAssistantView.vue
Normal file
202
schoolNewsWeb/src/views/user/ai-assistant/AIAssistantView.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="ai-assistant-page">
|
||||
<!-- 悬浮球入口(可以通过props控制显示/隐藏) -->
|
||||
<div class="floating-button" @click="togglePanel" v-if="!isPanelVisible">
|
||||
<img src="@/assets/imgs/ai-icon.svg" alt="AI助手" />
|
||||
</div>
|
||||
|
||||
<!-- AI助手面板 -->
|
||||
<transition name="slide">
|
||||
<div class="assistant-panel" v-if="isPanelVisible">
|
||||
<div class="panel-header">
|
||||
<h2>AI思政助手</h2>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="handleFileUpload">📎 上传文件</el-button>
|
||||
<el-button size="small" @click="showHistory">📜 历史记录</el-button>
|
||||
<el-button size="small" @click="togglePanel">✕</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<div
|
||||
class="panel-tab"
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<component :is="currentComponent" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 历史对话记录弹窗 -->
|
||||
<el-dialog v-model="historyVisible" title="历史对话记录" width="600px">
|
||||
<DialogHistory @load-conversation="loadConversation" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- 文件解读与记录弹窗 -->
|
||||
<el-dialog v-model="fileDialogVisible" title="文件解读与记录" width="800px">
|
||||
<FileInterpretation />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ElButton, ElDialog } from 'element-plus';
|
||||
import ChatInterface from './components/ChatInterface.vue';
|
||||
import KnowledgeBase from './components/KnowledgeBase.vue';
|
||||
import DialogHistory from './components/DialogHistory.vue';
|
||||
import FileInterpretation from './components/FileInterpretation.vue';
|
||||
|
||||
const isPanelVisible = ref(false);
|
||||
const activeTab = ref('chat');
|
||||
const historyVisible = ref(false);
|
||||
const fileDialogVisible = ref(false);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'chat', label: '对话' },
|
||||
{ key: 'knowledge', label: '知识库' }
|
||||
];
|
||||
|
||||
const componentMap: Record<string, any> = {
|
||||
'chat': ChatInterface,
|
||||
'knowledge': KnowledgeBase
|
||||
};
|
||||
|
||||
const currentComponent = computed(() => componentMap[activeTab.value]);
|
||||
|
||||
function togglePanel() {
|
||||
isPanelVisible.value = !isPanelVisible.value;
|
||||
}
|
||||
|
||||
function showHistory() {
|
||||
historyVisible.value = true;
|
||||
}
|
||||
|
||||
function handleFileUpload() {
|
||||
fileDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function loadConversation(conversation: any) {
|
||||
// TODO: 加载历史对话
|
||||
historyVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ai-assistant-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, #C62828, #E53935);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(198, 40, 40, 0.4);
|
||||
transition: all 0.3s;
|
||||
z-index: 1000;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(198, 40, 40, 0.5);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 450px;
|
||||
height: 650px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #C62828, #E53935);
|
||||
color: white;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
background: #f5f5f5;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.panel-tab {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #C62828;
|
||||
font-weight: 600;
|
||||
background: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="chat-interface">
|
||||
<!-- 消息列表 -->
|
||||
<div class="messages-container" ref="messagesContainer">
|
||||
<div
|
||||
class="message"
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:class="message.role"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<img :src="getAvatar(message.role)" :alt="message.role" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-text" v-html="message.content"></div>
|
||||
<div class="message-time">{{ message.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中动画 -->
|
||||
<div class="message assistant" v-if="isLoading">
|
||||
<div class="message-avatar">
|
||||
<img src="@/assets/imgs/ai-avatar.svg" alt="AI" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入您的问题..."
|
||||
@keydown.enter.prevent="handleSend"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSend"
|
||||
:loading="isLoading"
|
||||
:disabled="!inputMessage.trim()"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from 'vue';
|
||||
import { ElInput, ElButton } from 'element-plus';
|
||||
|
||||
const messagesContainer = ref<HTMLElement | null>(null);
|
||||
const inputMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const messages = ref<any[]>([
|
||||
{
|
||||
id: 1,
|
||||
role: 'assistant',
|
||||
content: '您好!我是AI思政助手,请问有什么可以帮助您的吗?',
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
}
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载历史消息
|
||||
});
|
||||
|
||||
async function handleSend() {
|
||||
if (!inputMessage.value.trim() || isLoading.value) return;
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
role: 'user',
|
||||
content: inputMessage.value,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
};
|
||||
|
||||
messages.value.push(userMessage);
|
||||
const question = inputMessage.value;
|
||||
inputMessage.value = '';
|
||||
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
|
||||
// 模拟AI回复
|
||||
isLoading.value = true;
|
||||
|
||||
// TODO: 调用AI API
|
||||
setTimeout(() => {
|
||||
const aiMessage = {
|
||||
id: Date.now(),
|
||||
role: 'assistant',
|
||||
content: `关于"${question}",我来为您解答...`,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
};
|
||||
|
||||
messages.value.push(aiMessage);
|
||||
isLoading.value = false;
|
||||
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function getAvatar(role: string) {
|
||||
return role === 'user'
|
||||
? '@/assets/imgs/user-avatar.svg'
|
||||
: '@/assets/imgs/ai-avatar.svg';
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-interface {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&.user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
background: #C62828;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
.message-text {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
max-width: 80%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #999;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
:deep(.el-textarea) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="dialog-history">
|
||||
<div class="history-list">
|
||||
<div
|
||||
class="history-item"
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.id"
|
||||
@click="$emit('load-conversation', conversation)"
|
||||
>
|
||||
<div class="item-header">
|
||||
<h4>{{ conversation.title }}</h4>
|
||||
<span class="item-date">{{ conversation.date }}</span>
|
||||
</div>
|
||||
<p class="item-preview">{{ conversation.preview }}</p>
|
||||
<div class="item-footer">
|
||||
<span class="item-count">{{ conversation.messageCount }} 条消息</span>
|
||||
<el-button size="small" type="danger" @click.stop="deleteConversation(conversation)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
const conversations = ref<any[]>([]);
|
||||
|
||||
defineEmits(['load-conversation']);
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载历史对话列表
|
||||
});
|
||||
|
||||
function deleteConversation(conversation: any) {
|
||||
// TODO: 删除对话
|
||||
ElMessage.success('已删除对话');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dialog-history {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #C62828;
|
||||
background: #fff5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
}
|
||||
}
|
||||
|
||||
.item-date {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.item-preview {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="file-interpretation">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="文件上传" name="upload">
|
||||
<div class="upload-section">
|
||||
<el-upload
|
||||
drag
|
||||
action="#"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="handleUploadSuccess"
|
||||
multiple
|
||||
>
|
||||
<div class="upload-icon">📁</div>
|
||||
<div class="upload-text">
|
||||
<p>点击或拖拽文件到此处上传</p>
|
||||
<p class="upload-hint">支持 PDF、Word、TXT 格式</p>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<!-- 已上传文件列表 -->
|
||||
<div class="uploaded-files" v-if="uploadedFiles.length">
|
||||
<h4>已上传文件</h4>
|
||||
<div class="file-list">
|
||||
<div class="file-item" v-for="file in uploadedFiles" :key="file.id">
|
||||
<div class="file-icon">📄</div>
|
||||
<div class="file-info">
|
||||
<h5>{{ file.name }}</h5>
|
||||
<p>{{ file.size }} · {{ file.uploadDate }}</p>
|
||||
</div>
|
||||
<el-button size="small" @click="analyzeFile(file)">解读</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="历史文件" name="history">
|
||||
<div class="history-files">
|
||||
<div class="file-item" v-for="file in historyFiles" :key="file.id">
|
||||
<div class="file-icon">📄</div>
|
||||
<div class="file-info">
|
||||
<h5>{{ file.name }}</h5>
|
||||
<p>上传时间:{{ file.uploadDate }}</p>
|
||||
<p class="file-summary">{{ file.summary }}</p>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-button size="small" @click="viewAnalysis(file)">查看解读</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteFile(file)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElTabs, ElTabPane, ElUpload, ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
const activeTab = ref('upload');
|
||||
const uploadedFiles = ref<any[]>([]);
|
||||
const historyFiles = ref<any[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载历史文件
|
||||
});
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
const allowedTypes = ['application/pdf', 'application/msword', 'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
ElMessage.error('只支持 PDF、Word、TXT 格式');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('文件大小不能超过 10MB');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleUploadSuccess(response: any, file: any) {
|
||||
uploadedFiles.value.push({
|
||||
id: Date.now(),
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
uploadDate: new Date().toLocaleString()
|
||||
});
|
||||
ElMessage.success('文件上传成功');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
function analyzeFile(file: any) {
|
||||
// TODO: 调用文件解读API
|
||||
ElMessage.info('正在解读文件...');
|
||||
}
|
||||
|
||||
function viewAnalysis(file: any) {
|
||||
// TODO: 查看文件解读结果
|
||||
}
|
||||
|
||||
function deleteFile(file: any) {
|
||||
// TODO: 删除文件
|
||||
ElMessage.success('文件已删除');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-interpretation {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.uploaded-files {
|
||||
margin-top: 32px;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.file-list,
|
||||
.history-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
|
||||
h5 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-summary {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="knowledge-base">
|
||||
<div class="knowledge-header">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索知识库..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="knowledge-list">
|
||||
<div
|
||||
class="knowledge-item"
|
||||
v-for="item in filteredKnowledge"
|
||||
:key="item.id"
|
||||
@click="viewKnowledge(item)"
|
||||
>
|
||||
<div class="item-icon">{{ item.icon }}</div>
|
||||
<div class="item-info">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.description }}</p>
|
||||
<div class="item-meta">
|
||||
<span class="item-category">{{ item.category }}</span>
|
||||
<span class="item-date">{{ item.updateDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ElInput } from 'element-plus';
|
||||
|
||||
const searchKeyword = ref('');
|
||||
const knowledgeList = ref<any[]>([]);
|
||||
|
||||
const filteredKnowledge = computed(() => {
|
||||
if (!searchKeyword.value) return knowledgeList.value;
|
||||
return knowledgeList.value.filter(item =>
|
||||
item.title.includes(searchKeyword.value) ||
|
||||
item.description.includes(searchKeyword.value)
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载知识库数据
|
||||
knowledgeList.value = [
|
||||
{
|
||||
id: 1,
|
||||
icon: '📚',
|
||||
title: '党的二十大精神',
|
||||
description: '深入学习党的二十大精神要点',
|
||||
category: '党史学习',
|
||||
updateDate: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: '🎯',
|
||||
title: '社会主义核心价值观',
|
||||
description: '践行社会主义核心价值观',
|
||||
category: '理论学习',
|
||||
updateDate: '2024-01-10'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
function viewKnowledge(item: any) {
|
||||
// TODO: 查看知识详情
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.knowledge-base {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.knowledge-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.knowledge-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.knowledge-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #ffe6e6;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-category {
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
.item-date {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
7
schoolNewsWeb/src/views/user/home/HomeView.vue
Normal file
7
schoolNewsWeb/src/views/user/home/HomeView.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="home-view"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
160
schoolNewsWeb/src/views/user/profile/AccountSettingsView.vue
Normal file
160
schoolNewsWeb/src/views/user/profile/AccountSettingsView.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="account-settings">
|
||||
<div class="settings-section">
|
||||
<h3>修改密码</h3>
|
||||
<el-form :model="passwordForm" :rules="passwordRules" ref="passwordFormRef" label-width="120px">
|
||||
<el-form-item label="当前密码" prop="oldPassword">
|
||||
<el-input v-model="passwordForm.oldPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input v-model="passwordForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="passwordForm.confirmPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleChangePassword">修改密码</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>账号安全</h3>
|
||||
<div class="security-items">
|
||||
<div class="security-item">
|
||||
<div class="item-info">
|
||||
<i class="icon">📱</i>
|
||||
<div>
|
||||
<h4>手机绑定</h4>
|
||||
<p>已绑定手机:138****8888</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button size="small">修改</el-button>
|
||||
</div>
|
||||
|
||||
<div class="security-item">
|
||||
<div class="item-info">
|
||||
<i class="icon">✉️</i>
|
||||
<div>
|
||||
<h4>邮箱绑定</h4>
|
||||
<p>已绑定邮箱:user@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button size="small">修改</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ElForm, ElFormItem, ElInput, ElButton, ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
|
||||
const passwordFormRef = ref<FormInstance>();
|
||||
|
||||
const passwordForm = ref({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const passwordRules: FormRules = {
|
||||
oldPassword: [
|
||||
{ required: true, message: '请输入当前密码', trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== passwordForm.value.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
async function handleChangePassword() {
|
||||
if (!passwordFormRef.value) return;
|
||||
|
||||
try {
|
||||
await passwordFormRef.value.validate();
|
||||
// TODO: 调用修改密码API
|
||||
ElMessage.success('密码修改成功');
|
||||
passwordFormRef.value.resetFields();
|
||||
} catch (error) {
|
||||
console.error('表单验证失败', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.account-settings {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.security-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.security-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
130
schoolNewsWeb/src/views/user/profile/PersonalInfoView.vue
Normal file
130
schoolNewsWeb/src/views/user/profile/PersonalInfoView.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="personal-info">
|
||||
<el-form :model="userForm" label-width="120px" class="info-form">
|
||||
<el-form-item label="头像">
|
||||
<div class="avatar-upload">
|
||||
<img :src="userForm.avatar || defaultAvatar" alt="头像" class="avatar-preview" />
|
||||
<el-button size="small" @click="handleAvatarUpload">更换头像</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="userForm.username" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="userForm.realName" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="性别">
|
||||
<el-radio-group v-model="userForm.gender">
|
||||
<el-radio :label="1">男</el-radio>
|
||||
<el-radio :label="2">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="userForm.phone" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="userForm.email" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部门">
|
||||
<el-input v-model="userForm.deptName" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="个人简介">
|
||||
<el-input
|
||||
v-model="userForm.bio"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="介绍一下自己吧..."
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElForm, ElFormItem, ElInput, ElButton, ElRadio, ElRadioGroup, ElMessage } from 'element-plus';
|
||||
|
||||
// 默认头像
|
||||
const defaultAvatar = new URL('@/assets/imgs/default-avatar.png', import.meta.url).href;
|
||||
|
||||
const userForm = ref({
|
||||
avatar: '',
|
||||
username: '',
|
||||
realName: '',
|
||||
gender: 1,
|
||||
phone: '',
|
||||
email: '',
|
||||
deptName: '',
|
||||
bio: ''
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载用户信息
|
||||
loadUserInfo();
|
||||
});
|
||||
|
||||
function loadUserInfo() {
|
||||
// 模拟数据
|
||||
userForm.value = {
|
||||
avatar: '',
|
||||
username: '平台用户bc7a1b',
|
||||
realName: '张三',
|
||||
gender: 1,
|
||||
phone: '15268425987',
|
||||
email: 'zhangsan@example.com',
|
||||
deptName: '机械学院',
|
||||
bio: ''
|
||||
};
|
||||
}
|
||||
|
||||
function handleAvatarUpload() {
|
||||
// TODO: 上传头像
|
||||
ElMessage.info('上传头像功能开发中');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
// TODO: 保存用户信息
|
||||
ElMessage.success('保存成功');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
// 重置表单
|
||||
loadUserInfo();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.personal-info {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.info-form {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
87
schoolNewsWeb/src/views/user/profile/ProfileView.vue
Normal file
87
schoolNewsWeb/src/views/user/profile/ProfileView.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="user-center-page">
|
||||
<div class="user-card-wrapper">
|
||||
<UserCard/>
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<div class="sidebar-wrapper">
|
||||
<FloatingSidebar :menus="menus" />
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<router-view/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FloatingSidebar } from '@/components/base';
|
||||
import { UserCard } from '@/views/user/user-center/components';
|
||||
import { getParentChildrenRoutes } from '@/utils/routeUtils';
|
||||
import type { SysMenu } from '@/types/menu';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// 从当前路由配置中获取子路由,转换为菜单格式
|
||||
const menus = computed(() => {
|
||||
// 使用工具函数获取父路由的子路由
|
||||
const childRoutes = getParentChildrenRoutes(route);
|
||||
|
||||
if (childRoutes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取父路由路径(用于拼接相对路径)
|
||||
const parentRoute = route.matched[route.matched.length - 2];
|
||||
|
||||
// 将子路由转换为菜单格式
|
||||
return childRoutes
|
||||
.map((child: any) => ({
|
||||
menuID: child.name as string || child.path,
|
||||
name: child.meta?.title as string,
|
||||
url: child.path.startsWith('/') ? child.path : `${parentRoute.path}/${child.path}`,
|
||||
icon: child.meta?.icon as string,
|
||||
orderNum: child.meta?.orderNum as number || 0,
|
||||
} as SysMenu))
|
||||
.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-center-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-card-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sidebar-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="resource-center-view">
|
||||
<CenterHead
|
||||
title="资源中心"
|
||||
:category-name="currentCategoryName"
|
||||
:show-article-mode="showArticle"
|
||||
/>
|
||||
<div class="search-wrapper">
|
||||
<Search @search="handleSearch" />
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<div class="content-container">
|
||||
<ResourceSideBar
|
||||
:activeTagID="currentCategoryId"
|
||||
@category-change="handleCategoryChange"
|
||||
/>
|
||||
<ResourceList
|
||||
v-if="!showArticle"
|
||||
ref="resourceListRef"
|
||||
:tagID="currentCategoryId"
|
||||
:search-keyword="searchKeyword"
|
||||
@resource-click="handleResourceClick"
|
||||
@list-updated="handleListUpdated"
|
||||
/>
|
||||
<ResourceArticle
|
||||
v-if="showArticle"
|
||||
:resource-id="currentResourceId"
|
||||
:tagID="currentCategoryId"
|
||||
:resource-list="resourceList"
|
||||
@resource-change="handleResourceChange"
|
||||
@navigate="handleArticleNavigate"
|
||||
@back-to-list="backToList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ResourceSideBar, ResourceList, ResourceArticle } from './components';
|
||||
import { Search, CenterHead } from '@/components/base';
|
||||
import type { Resource, Tag } from '@/types/resource';
|
||||
|
||||
const showArticle = ref(false);
|
||||
const currentCategoryId = ref('party_history');
|
||||
const currentCategoryName = ref('党史学习');
|
||||
const currentResourceId = ref('');
|
||||
const searchKeyword = ref('');
|
||||
const resourceListRef = ref();
|
||||
const resourceList = ref<Resource[]>([]);
|
||||
|
||||
function handleCategoryChange(category: Tag) {
|
||||
currentCategoryId.value = category.tagID || category.id || '';
|
||||
currentCategoryName.value = category.name || '';
|
||||
searchKeyword.value = '';
|
||||
showArticle.value = false;
|
||||
}
|
||||
|
||||
function handleSearch(keyword: string) {
|
||||
searchKeyword.value = keyword;
|
||||
showArticle.value = false;
|
||||
}
|
||||
|
||||
function handleListUpdated(list: Resource[]) {
|
||||
resourceList.value = list;
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: Resource) {
|
||||
currentResourceId.value = resource.resourceID || '';
|
||||
showArticle.value = true;
|
||||
}
|
||||
|
||||
function handleResourceChange(resourceId: string) {
|
||||
currentResourceId.value = resourceId;
|
||||
// ArticleShowView 会自动重新加载
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
showArticle.value = false;
|
||||
currentResourceId.value = '';
|
||||
}
|
||||
|
||||
// 文章内前后切换时,靠近列表头部或尾部触发列表翻页
|
||||
async function handleArticleNavigate(direction: 'prev' | 'next', resourceId: string) {
|
||||
const list = resourceListRef.value?.getResources?.() || [];
|
||||
const index = list.findIndex((r: any) => r.resourceID === resourceId);
|
||||
if (index === -1) return;
|
||||
const nearHead = index <= 2;
|
||||
const nearTail = index >= list.length - 3;
|
||||
if (nearHead && direction === 'prev') {
|
||||
await resourceListRef.value?.loadPrevPage?.();
|
||||
} else if (nearTail && direction === 'next') {
|
||||
await resourceListRef.value?.loadNextPage?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.resource-center-view {
|
||||
background: #F9F9F9;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
|
||||
:deep(.resource-search) {
|
||||
width: 1200px;
|
||||
height: 60px;
|
||||
padding: 0;
|
||||
|
||||
.search-box {
|
||||
height: 60px;
|
||||
|
||||
input {
|
||||
font-size: 20px;
|
||||
padding: 0 100px 0 40px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
width: 72px;
|
||||
height: 60px;
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
width: 1200px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="resource-bottom">
|
||||
<div class="separator"></div>
|
||||
<div class="nav-link" :class="{ disabled: !prevResource }" @click="handleNavigate('prev')">
|
||||
<span class="nav-label">上一篇:</span>
|
||||
<span class="nav-title">{{ prevResource?.title || '没有了' }}</span>
|
||||
</div>
|
||||
<div class="nav-link" :class="{ disabled: !nextResource }" @click="handleNavigate('next')">
|
||||
<span class="nav-label">下一篇:</span>
|
||||
<span class="nav-title">{{ nextResource?.title || '没有了' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
interface Props {
|
||||
prevResource?: Resource | null;
|
||||
nextResource?: Resource | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [direction: 'prev' | 'next', resource: Resource];
|
||||
}>();
|
||||
|
||||
function handleNavigate(direction: 'prev' | 'next') {
|
||||
const resource = direction === 'prev' ? props.prevResource : props.nextResource;
|
||||
if (resource) {
|
||||
emit('navigate', direction, resource);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.resource-bottom {
|
||||
margin-top: 80px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #E9E9E9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #334155;
|
||||
margin-bottom: 14px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
color: #C62828;
|
||||
|
||||
.nav-title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
color: inherit;
|
||||
transition: text-decoration 0.3s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="resource-collect-btn">
|
||||
<button
|
||||
class="collect-button"
|
||||
:class="{ collected: isCollected }"
|
||||
@click="handleCollect"
|
||||
>
|
||||
<img src="@/assets/imgs/star-icon.svg" alt="collect" />
|
||||
<span>{{ isCollected ? '已收藏' : '收藏' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
isCollected: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
collect: [type: number];
|
||||
}>();
|
||||
|
||||
function handleCollect() {
|
||||
if(props.isCollected) {
|
||||
// 已收藏,取消收藏
|
||||
emit('collect', -1);
|
||||
} else {
|
||||
// 未收藏,收藏
|
||||
emit('collect', 1);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.resource-collect-btn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 80px 0;
|
||||
}
|
||||
|
||||
.collect-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 20px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #979797;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: #979797;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #C62828;
|
||||
|
||||
span {
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
img {
|
||||
filter: brightness(0) saturate(100%) invert(17%) sepia(85%) saturate(3207%) hue-rotate(349deg) brightness(92%) contrast(92%);
|
||||
}
|
||||
}
|
||||
|
||||
&.collected {
|
||||
border-color: #C62828;
|
||||
background: #FFF5F5;
|
||||
|
||||
span {
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
img {
|
||||
filter: brightness(0) saturate(100%) invert(17%) sepia(85%) saturate(3207%) hue-rotate(349deg) brightness(92%) contrast(92%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="resource-article">
|
||||
<ArticleShowView
|
||||
v-if="articleData"
|
||||
:as-dialog="false"
|
||||
:article-data="articleData"
|
||||
:category-list="[]"
|
||||
:show-back-button="true"
|
||||
back-button-text="返回列表"
|
||||
@back="handleBack"
|
||||
/>
|
||||
<div v-else class="loading">加载中...</div>
|
||||
<ResouceCollect
|
||||
:is-collected="isCollected"
|
||||
@collect="handleCollect"
|
||||
/>
|
||||
<ResouceBottom
|
||||
:prev-resource="prevResource"
|
||||
:next-resource="nextResource"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ArticleShowView } from '@/views/public/article';
|
||||
import { ResouceCollect, ResouceBottom } from '@/views/user/resource-center/components';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { CollectionType, type UserCollection } from '@/types';
|
||||
|
||||
interface Props {
|
||||
resourceId?: string;
|
||||
tagID?: string;
|
||||
resourceList?: Resource[];
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'resource-change': [resourceId: string];
|
||||
'back-to-list': [];
|
||||
'navigate': [direction: 'prev' | 'next', resourceId: string];
|
||||
}>();
|
||||
|
||||
const articleData = ref<any>(null);
|
||||
const isCollected = ref(false);
|
||||
const prevResource = ref<Resource | null>(null);
|
||||
const nextResource = ref<Resource | null>(null);
|
||||
|
||||
watch(() => props.resourceId, (newId) => {
|
||||
if (newId) {
|
||||
// 进入加载前先置空,避免子组件读取到 null 字段
|
||||
articleData.value = null;
|
||||
loadArticle(newId);
|
||||
updateAdjacentResources(newId);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.resourceList, () => {
|
||||
if (props.resourceId) {
|
||||
updateAdjacentResources(props.resourceId);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
async function loadArticle(resourceId: string) {
|
||||
try {
|
||||
const res = await resourceApi.getResourceById(resourceId);
|
||||
if (res.success && res.data) {
|
||||
const resourceVO = res.data;
|
||||
articleData.value = {
|
||||
...resourceVO.resource,
|
||||
tags: resourceVO.tags || []
|
||||
};
|
||||
} else {
|
||||
ElMessage.error('加载文章失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文章失败:', error);
|
||||
ElMessage.error('加载文章失败');
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdjacentResources(currentResourceId: string) {
|
||||
if (!props.resourceList || props.resourceList.length === 0) {
|
||||
prevResource.value = null;
|
||||
nextResource.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = props.resourceList.findIndex(r =>
|
||||
String(r.resourceID) === String(currentResourceId)
|
||||
);
|
||||
|
||||
if (currentIndex !== -1) {
|
||||
prevResource.value = currentIndex > 0 ? props.resourceList[currentIndex - 1] : null;
|
||||
nextResource.value = currentIndex < props.resourceList.length - 1 ? props.resourceList[currentIndex + 1] : null;
|
||||
} else {
|
||||
prevResource.value = null;
|
||||
nextResource.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCollect(type: number) {
|
||||
try {
|
||||
const resourceID = articleData.value?.resourceID;
|
||||
if (!resourceID) return;
|
||||
let collect: UserCollection = {
|
||||
collectionType: CollectionType.RESOURCE,
|
||||
collectionID: resourceID,
|
||||
collectionValue: type
|
||||
}
|
||||
const res = await resourceApi.resourceCollect(collect);
|
||||
if (res.success) {
|
||||
if (type === 1) {
|
||||
isCollected.value = true;
|
||||
ElMessage.success('收藏成功');
|
||||
} else if (type === -1) {
|
||||
isCollected.value = false;
|
||||
ElMessage.success('已取消收藏');
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(type === 1 ? '收藏失败' : '取消收藏失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavigate(direction: 'prev' | 'next', resource: Resource) {
|
||||
const resourceId = resource.resourceID;
|
||||
if (resourceId) {
|
||||
emit('resource-change', resourceId);
|
||||
emit('navigate', direction, resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回列表
|
||||
function handleBack() {
|
||||
emit('back-to-list');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.resource-article {
|
||||
flex: 1;
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
padding: 40px 60px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="resource-list">
|
||||
<div class="list-container" ref="listContainerRef">
|
||||
<div
|
||||
v-for="resource in resources"
|
||||
:key="resource.resourceID "
|
||||
class="resource-item"
|
||||
@click="handleResourceClick(resource)"
|
||||
>
|
||||
<div class="resource-cover">
|
||||
<img
|
||||
:src="resource.coverImage ? (FILE_DOWNLOAD_URL + resource.coverImage) : defaultArticleImg"
|
||||
alt="cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="resource-info">
|
||||
<h3 class="resource-title">{{ resource.title }}</h3>
|
||||
<div class="resource-collect">
|
||||
<img src="@/assets/imgs/star-icon.svg" alt="collect" />
|
||||
<span>{{ (resource.collectCount || 0) > 0 ? '已收藏' : '收藏' }}</span>
|
||||
</div>
|
||||
<p class="resource-summary">{{ resource.summary || '暂无简介' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-more">加载中...</div>
|
||||
<div v-if="resources.length === 0 && !loading" class="empty">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, nextTick } from 'vue';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||
import type { Resource, ResourceSearchParams } from '@/types/resource';
|
||||
import type { PageParam } from '@/types';
|
||||
import defaultArticleImg from '@/assets/imgs/article-default.png';
|
||||
|
||||
interface Props {
|
||||
tagID?: string;
|
||||
searchKeyword?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'resource-click': [resource: Resource];
|
||||
'list-updated': [resources: Resource[]];
|
||||
}>();
|
||||
|
||||
const resources = ref<Resource[]>([]);
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = 10;
|
||||
const listContainerRef = ref<HTMLElement>();
|
||||
|
||||
onMounted(() => {
|
||||
loadResources();
|
||||
});
|
||||
|
||||
watch(() => [props.tagID, props.searchKeyword], () => {
|
||||
currentPage.value = 1;
|
||||
loadResources();
|
||||
}, { deep: true });
|
||||
|
||||
async function loadResources() {
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const filter: ResourceSearchParams = {
|
||||
tagID: props.tagID,
|
||||
keyword: props.searchKeyword,
|
||||
// status: 1 // 只加载已发布的
|
||||
};
|
||||
|
||||
const pageParam: PageParam = {
|
||||
pageNumber: currentPage.value,
|
||||
pageSize: pageSize
|
||||
};
|
||||
|
||||
const res = await resourceApi.getResourcePage(pageParam, filter);
|
||||
|
||||
if (res.success && res.dataList) {
|
||||
resources.value = res.dataList;
|
||||
total.value = res.pageDomain?.pageParam.totalElements || 0;
|
||||
|
||||
// 通知父组件列表已更新
|
||||
emit('list-updated', res.dataList);
|
||||
|
||||
// 重置滚动位置到顶部
|
||||
await nextTick();
|
||||
if (listContainerRef.value) {
|
||||
listContainerRef.value.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载资源列表失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page;
|
||||
loadResources();
|
||||
}
|
||||
|
||||
function getResources() {
|
||||
return resources.value;
|
||||
}
|
||||
|
||||
function getPageInfo() {
|
||||
return { currentPage: currentPage.value, total: total.value };
|
||||
}
|
||||
|
||||
async function loadNextPage() {
|
||||
const totalPages = Math.ceil(total.value / pageSize);
|
||||
if (currentPage.value < totalPages) {
|
||||
currentPage.value++;
|
||||
await loadResources();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--;
|
||||
await loadResources();
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: Resource) {
|
||||
emit('resource-click', resource);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
loadResources,
|
||||
getResources,
|
||||
getPageInfo,
|
||||
loadNextPage,
|
||||
loadPrevPage
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.resource-list {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.resource-item {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid #EEEEEE;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.resource-title {
|
||||
color: #C62828;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-cover {
|
||||
width: 202px;
|
||||
height: 123px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.default-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.resource-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-title {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
line-height: 28px;
|
||||
color: #C62828;
|
||||
margin: 0;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.resource-collect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: #979797;
|
||||
}
|
||||
}
|
||||
|
||||
.resource-summary {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #334155;
|
||||
margin: 8px 0 0 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.loading-more,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 14px;
|
||||
color: #979797;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 30px 0;
|
||||
background: #FFFFFF;
|
||||
border-top: 1px solid #EEEEEE;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="resource-sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.tagID || category.id"
|
||||
class="sidebar-item"
|
||||
:class="{ active: (category.tagID || category.id) === activeTagID }"
|
||||
@click="handleCategoryClick(category)"
|
||||
>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<div v-if="(category.tagID || category.id) === activeTagID" class="active-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { resourceTagApi } from '@/apis/resource';
|
||||
import type { Tag, TagType } from '@/types/resource';
|
||||
|
||||
interface Props {
|
||||
activeTagID?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
activeTagID: 'party_history'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'category-change': [category: Tag]; // 改为Tag类型
|
||||
}>();
|
||||
|
||||
const categories = ref<Tag[]>([]); // 改为使用Tag类型(tagType=1表示文章分类)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
});
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
// 使用新的标签API获取文章分类标签(tagType=1)
|
||||
const res = await resourceTagApi.getTagsByType(1); // 1 = 文章分类标签
|
||||
if (res.success && res.dataList) {
|
||||
categories.value = res.dataList;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCategoryClick(category: Tag) {
|
||||
emit('category-change', category);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.resource-sidebar {
|
||||
width: 180px;
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
position: relative;
|
||||
height: 54px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
.category-name {
|
||||
color: #C62828;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.category-name {
|
||||
color: #FFFFFF;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.active-overlay {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 5px;
|
||||
background: #C62828;
|
||||
border-radius: 8px;
|
||||
z-index: 1;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #334155;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ResourceSideBar } from './ResourceSideBar.vue';
|
||||
export { default as ResourceList } from './ResourceList.vue';
|
||||
export { default as ResourceArticle } from './ResourceArticle.vue';
|
||||
export { default as ResouceCollect } from './ResouceCollect.vue';
|
||||
export { default as ResouceBottom } from './ResouceBottom.vue';
|
||||
373
schoolNewsWeb/src/views/user/study-plan/CourseCenterView.vue
Normal file
373
schoolNewsWeb/src/views/user/study-plan/CourseCenterView.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<StudyPlanLayout category-name="课程中心">
|
||||
<!-- 主要内容区 -->
|
||||
<div class="main-content">
|
||||
<div class="container">
|
||||
<!-- 页面标题 -->
|
||||
<h2 class="page-title">课程列表</h2>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-filter-bar">
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索思政资源"
|
||||
class="search-input"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-button
|
||||
type="danger"
|
||||
circle
|
||||
@click="handleSearch"
|
||||
class="search-button"
|
||||
>
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 课程列表 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="courseList.length > 0" class="course-grid">
|
||||
<div
|
||||
v-for="course in courseList"
|
||||
:key="course.courseID"
|
||||
class="course-card"
|
||||
@click="handleCourseClick(course.courseID || '')"
|
||||
>
|
||||
<!-- 课程封面 -->
|
||||
<div class="course-cover">
|
||||
<img
|
||||
:src="course.coverImage ? FILE_DOWNLOAD_URL + course.coverImage : defaultCover"
|
||||
:alt="course.name"
|
||||
class="cover-image"
|
||||
/>
|
||||
<!-- 课程类型标签 -->
|
||||
<div class="course-type-tag">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>视频课程</span>
|
||||
</div>
|
||||
<!-- 分类标签 -->
|
||||
<div class="category-tag">
|
||||
{{ getCategoryName() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 课程信息 -->
|
||||
<div class="course-info">
|
||||
<div class="view-count">
|
||||
{{ formatViewCount(course.viewCount) }}次浏览
|
||||
</div>
|
||||
<h3 class="course-title">{{ course.name }}</h3>
|
||||
<p class="course-desc">
|
||||
{{ course.description || '暂无描述' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<el-empty description="暂无课程" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[6, 12, 24, 48]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StudyPlanLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search, VideoPlay } from '@element-plus/icons-vue';
|
||||
import { courseApi } from '@/apis/study';
|
||||
import type { Course, PageParam } from '@/types';
|
||||
import { StudyPlanLayout } from '@/views/user/study-plan';
|
||||
import defaultCover from '@/assets/imgs/default-course-bg.png'
|
||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||
|
||||
defineOptions({
|
||||
name: 'CourseCenterView'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const courseList = ref<Course[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
// 分页参数
|
||||
const pageParam = ref<PageParam>({
|
||||
pageNumber: 1,
|
||||
pageSize: 6
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadCourseList();
|
||||
});
|
||||
|
||||
// 加载课程列表
|
||||
async function loadCourseList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const filter: Course = {
|
||||
status: 1 // 只显示已上线的课程
|
||||
};
|
||||
|
||||
if (searchKeyword.value) {
|
||||
filter.name = searchKeyword.value;
|
||||
}
|
||||
|
||||
const res = await courseApi.getCoursePage(pageParam.value, filter);
|
||||
|
||||
if (res.success) {
|
||||
courseList.value = res.dataList || [];
|
||||
total.value = res.pageParam?.totalElements || 0;
|
||||
} else {
|
||||
ElMessage.error('加载课程列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载课程列表失败:', error);
|
||||
ElMessage.error('加载课程列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function handleSearch() {
|
||||
pageParam.value.pageNumber = 1;
|
||||
loadCourseList();
|
||||
}
|
||||
|
||||
// 分页大小改变
|
||||
function handleSizeChange() {
|
||||
loadCourseList();
|
||||
}
|
||||
|
||||
// 当前页改变
|
||||
function handleCurrentChange() {
|
||||
loadCourseList();
|
||||
}
|
||||
|
||||
// 点击课程卡片 - 跳转到课程详情路由
|
||||
function handleCourseClick(courseId: string) {
|
||||
console.log('handleCourseClick', courseId);
|
||||
console.log('router', router.getRoutes());
|
||||
router.push({
|
||||
path: '/study-plan/course-detail',
|
||||
query: { courseId }
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化浏览次数
|
||||
function formatViewCount(count?: number): string {
|
||||
if (!count) return '0';
|
||||
if (count >= 10000) {
|
||||
return (count / 10000).toFixed(1) + 'w';
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
// 获取分类名称(这里简化处理,实际应该从课程标签中获取)
|
||||
function getCategoryName(): string {
|
||||
// TODO: 从 courseTags 中获取第一个标签作为分类
|
||||
return '党史学习';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-content {
|
||||
padding: 40px 0 80px;
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.search-filter-bar {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.search-box {
|
||||
max-width: 400px;
|
||||
|
||||
.search-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 30px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #C62828;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: #A82020;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.course-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.course-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 20px rgba(164, 182, 199, 0.2);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.course-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 221px;
|
||||
overflow: hidden;
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.course-type-tag {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(198, 40, 40, 0.9);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -2px;
|
||||
padding: 3px 26px;
|
||||
background: #D1AD79;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 0 0 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .cover-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.course-info {
|
||||
padding: 22px;
|
||||
|
||||
.view-count {
|
||||
position: absolute;
|
||||
top: 232px;
|
||||
right: 22px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.course-desc {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 88px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
</style>
|
||||
48
schoolNewsWeb/src/views/user/study-plan/CourseDetailView.vue
Normal file
48
schoolNewsWeb/src/views/user/study-plan/CourseDetailView.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<CourseDetail
|
||||
v-if="courseId"
|
||||
:course-id="courseId"
|
||||
:show-back-button="true"
|
||||
back-button-text="返回课程列表"
|
||||
@back="handleBack"
|
||||
@start-learning="handleStartLearning"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { CourseDetail } from '@/views/public/course/components';
|
||||
|
||||
defineOptions({
|
||||
name: 'CourseDetailView'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const courseId = computed(() => route.query.courseId as string || '');
|
||||
|
||||
// 返回上一页
|
||||
function handleBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
// 开始学习课程
|
||||
function handleStartLearning(courseId: string, chapterIndex: number, nodeIndex: number) {
|
||||
// 跳转到课程学习页面
|
||||
router.push({
|
||||
path: '/study-plan/course-study',
|
||||
query: {
|
||||
courseId,
|
||||
chapterIndex: chapterIndex.toString(),
|
||||
nodeIndex: nodeIndex.toString()
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
|
||||
55
schoolNewsWeb/src/views/user/study-plan/CourseStudyView.vue
Normal file
55
schoolNewsWeb/src/views/user/study-plan/CourseStudyView.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<CourseLearning
|
||||
v-if="courseId"
|
||||
:course-id="courseId"
|
||||
:chapter-index="chapterIndex"
|
||||
:node-index="nodeIndex"
|
||||
:back-button-text="taskId ? '返回任务详情' : '返回课程详情'"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { CourseLearning } from '@/views/public/course/components';
|
||||
|
||||
defineOptions({
|
||||
name: 'CourseStudyView'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// 从路由参数获取课程ID和节点索引
|
||||
const courseId = computed(() => route.query.courseId as string || '');
|
||||
const chapterIndex = computed(() => parseInt(route.query.chapterIndex as string) || 0);
|
||||
const nodeIndex = computed(() => parseInt(route.query.nodeIndex as string) || 0);
|
||||
const taskId = computed(() => route.query.taskId as string || '');
|
||||
|
||||
// 返回到上一页
|
||||
function handleBack() {
|
||||
// 如果有 taskId,返回任务详情页
|
||||
if (taskId.value) {
|
||||
router.push({
|
||||
path: '/study-plan/task-detail',
|
||||
query: {
|
||||
taskId: taskId.value
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 否则返回课程详情页
|
||||
router.push({
|
||||
path: '/study-plan/course-detail',
|
||||
query: {
|
||||
courseId: courseId.value
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 此组件只是容器,样式由子组件处理
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<LearingTaskDetail
|
||||
:task-id="taskId"
|
||||
:show-back-button="true"
|
||||
back-button-text="返回任务列表"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { LearingTaskDetail } from '@/views/public/task';
|
||||
|
||||
defineOptions({
|
||||
name: 'LearningTaskDetailView'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const taskId = computed(() => route.query.taskId as string || '');
|
||||
|
||||
// 返回任务列表
|
||||
function handleBack() {
|
||||
router.push('/study-plan/tasks');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 此组件只是容器,样式由子组件处理
|
||||
</style>
|
||||
|
||||
60
schoolNewsWeb/src/views/user/study-plan/StudyPlanLayout.vue
Normal file
60
schoolNewsWeb/src/views/user/study-plan/StudyPlanLayout.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="study-plan-layout">
|
||||
<!-- Banner 横幅 -->
|
||||
<CenterHead
|
||||
title="学习中心"
|
||||
:category-name="categoryName"
|
||||
/>
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<MenuNav :menus="menus" @menu-click="handleMenuClick" />
|
||||
|
||||
<!-- 内容插槽 -->
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { MenuNav, CenterHead } from '@/components/base';
|
||||
import { getParentChildrenRoutes } from '@/utils/routeUtils';
|
||||
import type { SysMenu } from '@/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'StudyPlanLayout'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
categoryName?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
categoryName: '学习中心'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const routes = getParentChildrenRoutes(route);
|
||||
// 将路由转换为菜单格式
|
||||
const menus = routes.map((r, index) => ({
|
||||
menuID: r.name?.toString() || `menu-${index}`,
|
||||
name: (r.meta?.title as string) || '',
|
||||
url: r.path || '',
|
||||
orderNum: index
|
||||
})) as SysMenu[];
|
||||
|
||||
// 菜单点击事件
|
||||
function handleMenuClick(menu: SysMenu) {
|
||||
if (menu.url) {
|
||||
router.push(menu.url);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.study-plan-layout {
|
||||
min-height: 100vh;
|
||||
background: #F9F9F9;
|
||||
}
|
||||
</style>
|
||||
565
schoolNewsWeb/src/views/user/study-plan/StudyTasksView.vue
Normal file
565
schoolNewsWeb/src/views/user/study-plan/StudyTasksView.vue
Normal file
@@ -0,0 +1,565 @@
|
||||
<template>
|
||||
<StudyPlanLayout category-name="学习任务">
|
||||
<!-- 主要内容区 -->
|
||||
<div class="main-content">
|
||||
<div class="container">
|
||||
<!-- 任务进度 -->
|
||||
<h2 class="section-title">任务进度</h2>
|
||||
<div class="progress-card">
|
||||
<div class="progress-info">
|
||||
<div class="progress-header">
|
||||
<span class="progress-text">当前学习进度({{ completedCount }}/{{ totalCount }})</span>
|
||||
<span class="progress-percent">{{ progressPercent }}%</span>
|
||||
<span class="progress-level">{{ userLevel }}</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-stats">
|
||||
<div class="stat-card pending">
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">待完成任务</div>
|
||||
<div class="stat-number">{{ pendingCount }}</div>
|
||||
</div>
|
||||
<div class="stat-icon pending-icon">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card completed">
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">已完成任务</div>
|
||||
<div class="stat-number">{{ completedCount }}</div>
|
||||
</div>
|
||||
<div class="stat-icon completed-icon">
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<h2 class="section-title">任务列表</h2>
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="taskList.length > 0" class="task-grid">
|
||||
<div
|
||||
v-for="task in taskList"
|
||||
:key="task.taskID"
|
||||
class="task-card"
|
||||
@click="handleTaskClick(task)"
|
||||
>
|
||||
<!-- 内容容器 -->
|
||||
<div class="task-content">
|
||||
<!-- 状态标签(小标签,自适应宽度) -->
|
||||
<div
|
||||
class="status-tag"
|
||||
:class="{
|
||||
'status-pending': task.status === 0,
|
||||
'status-processing': task.status === 1,
|
||||
'status-completed': task.status === 2
|
||||
}"
|
||||
>
|
||||
{{ task.status === 0 ? '待完成' : task.status === 1 ? '进行中' : '已完成' }}
|
||||
</div>
|
||||
|
||||
<!-- 任务标题 -->
|
||||
<h3 class="task-title">{{ task.name }}</h3>
|
||||
|
||||
<!-- 任务描述 -->
|
||||
<p class="task-desc">{{ task.description || '暂无描述' }}</p>
|
||||
|
||||
<!-- 任务底部信息 -->
|
||||
<div class="task-footer">
|
||||
<span class="task-time">
|
||||
任务时间:{{ formatDate(task.startTime) }}-{{ formatDate(task.endTime) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="getDeadlineStatus(task).show"
|
||||
class="deadline-tag"
|
||||
:class="{
|
||||
'deadline-danger': getDeadlineStatus(task).type === 'danger',
|
||||
'deadline-info': getDeadlineStatus(task).type === 'info',
|
||||
'deadline-success': getDeadlineStatus(task).type === 'success',
|
||||
'deadline-primary': getDeadlineStatus(task).type === 'primary',
|
||||
}"
|
||||
>
|
||||
{{ getDeadlineStatus(task).text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<el-empty description="暂无学习任务" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StudyPlanLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { DocumentCopy, DocumentChecked } from '@element-plus/icons-vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { learningTaskApi } from '@/apis/study';
|
||||
import type { LearningTask, TaskItemVO } from '@/types';
|
||||
import { StudyPlanLayout } from '@/views/user/study-plan';
|
||||
|
||||
defineOptions({
|
||||
name: 'StudyTasksView'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const loading = ref(false);
|
||||
const taskList = ref<LearningTask[]>([]);
|
||||
|
||||
// 统计数据
|
||||
const totalCount = ref(0);
|
||||
const completedCount = ref(0);
|
||||
const pendingCount = ref(0);
|
||||
const userLevel = ref('Level1');
|
||||
|
||||
// 计算进度百分比
|
||||
const progressPercent = computed(() => {
|
||||
if (totalCount.value === 0) return 0;
|
||||
return Math.round((completedCount.value / totalCount.value) * 100);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadTaskList();
|
||||
loadUserProgress();
|
||||
});
|
||||
|
||||
// 获取当前用户ID
|
||||
const getUserID = () => {
|
||||
const userInfo = store.getters['auth/user'];
|
||||
return userInfo?.id || '';
|
||||
};
|
||||
|
||||
// 加载任务列表(用户视角)
|
||||
async function loadTaskList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const userID = getUserID();
|
||||
if (!userID) {
|
||||
ElMessage.warning('请先登录');
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用用户任务分页接口
|
||||
const pageParam = {
|
||||
page: 1,
|
||||
size: 100 // 获取所有任务,不做分页
|
||||
};
|
||||
|
||||
const filter: TaskItemVO = {
|
||||
userID
|
||||
};
|
||||
|
||||
const res = await learningTaskApi.getUserTaskPage(pageParam, filter);
|
||||
|
||||
if (res.success && res.dataList) {
|
||||
taskList.value = res.dataList;
|
||||
} else {
|
||||
ElMessage.error(res.message || '加载学习任务失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载学习任务失败:', error);
|
||||
ElMessage.error(error?.message || '加载学习任务失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户进度统计数据
|
||||
async function loadUserProgress() {
|
||||
try {
|
||||
const userID = getUserID();
|
||||
if (!userID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await learningTaskApi.getUserProgress(userID);
|
||||
|
||||
if (res.success && res.data) {
|
||||
const progressData = res.data;
|
||||
const pending = (progressData.notStartTaskNum || 0) + (progressData.learningTaskNum || 0);
|
||||
// 设置统计数据
|
||||
totalCount.value = progressData.totalTaskNum || 0;
|
||||
completedCount.value = progressData.completedTaskNum || 0;
|
||||
pendingCount.value = pending;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户进度失败:', error);
|
||||
// 不显示错误消息,使用从任务列表计算的统计数据即可
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 点击任务卡片
|
||||
function handleTaskClick(task: LearningTask) {
|
||||
if (!task.taskID) {
|
||||
ElMessage.warning('任务ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用路由跳转到任务详情页
|
||||
router.push({
|
||||
path: '/study-plan/task-detail',
|
||||
query: {
|
||||
taskId: task.taskID
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateString?: string): string {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return `${date.getFullYear()}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getDate().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 获取截止时间状态
|
||||
function getDeadlineStatus(task: LearningTask): { show: boolean; text: string; type: 'danger' | 'info' | 'success' | 'primary' } {
|
||||
if (!task.endTime) {
|
||||
return { show: false, text: '', type: 'primary' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const endTime = new Date(task.endTime);
|
||||
const diffTime = endTime.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (task.status === 2) {
|
||||
// 已完成
|
||||
return { show: true, text: '已完成', type: 'success' };
|
||||
} else if (diffDays < 0) {
|
||||
// 已过期
|
||||
return { show: true, text: '已截止', type: 'info' };
|
||||
} else if (diffDays <= 8) {
|
||||
// 即将截止
|
||||
return { show: true, text: `${diffDays}天后截止`, type: 'danger' };
|
||||
}
|
||||
|
||||
return { show: true, text: `${diffDays}天后截止`, type: 'primary' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-content {
|
||||
padding: 40px 0 80px;
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
// 进度卡片
|
||||
.progress-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 50px;
|
||||
margin-bottom: 60px;
|
||||
box-shadow: 0 17px 22.4px rgba(164, 182, 199, 0.1);
|
||||
|
||||
.progress-info {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.progress-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
.progress-level {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #F7F8F9;
|
||||
border-radius: 27px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(143deg, #FD9082 1%, #FD6D78 99%);
|
||||
border-radius: 27px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 30px;
|
||||
|
||||
.stat-card {
|
||||
background: #FFFDFD;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 10px;
|
||||
padding: 30px 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&.pending {
|
||||
.stat-icon {
|
||||
background: #FFF5F4;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 44px;
|
||||
background: linear-gradient(143deg, #FD9082 1%, #FD6D78 99%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background: #FBFDFF;
|
||||
|
||||
.stat-icon {
|
||||
background: #EFF8FF;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 44px;
|
||||
background: linear-gradient(143deg, #82B7FD 1%, #2F6AFF 99%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 30px;
|
||||
|
||||
.stat-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
text-align: right;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 0.95;
|
||||
color: #334155;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 77px;
|
||||
height: 77px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 任务列表
|
||||
.loading-container {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.task-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 10px;
|
||||
padding: 40px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-height: 278px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 20px rgba(164, 182, 199, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.task-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
// 状态标签 - 小标签,自适应宽度
|
||||
.status-tag {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5714285714285714;
|
||||
border-radius: 2px;
|
||||
|
||||
// 待完成 - 红色
|
||||
&.status-pending {
|
||||
background-color: #FFECE8;
|
||||
color: #F53F3F;
|
||||
}
|
||||
|
||||
// 进行中 - 蓝色
|
||||
&.status-processing {
|
||||
background-color: #E8F7FF;
|
||||
color: #3491FA;
|
||||
}
|
||||
|
||||
// 已完成 - 绿色
|
||||
&.status-completed {
|
||||
background-color: #E8FFEA;
|
||||
color: #00B42A;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.task-desc {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #334155;
|
||||
line-height: 1.5714285714285714;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.task-time {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #979797;
|
||||
line-height: 1.5714285714285714;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 截止时间标签
|
||||
.deadline-tag {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6666666666666667;
|
||||
border-radius: 2px;
|
||||
border: 1px solid;
|
||||
background-color: transparent;
|
||||
|
||||
// 即将截止 - 红色边框
|
||||
&.deadline-danger {
|
||||
border-color: #F53F3F;
|
||||
color: #F53F3F;
|
||||
}
|
||||
|
||||
// 已截止 - 灰色边框
|
||||
&.deadline-info {
|
||||
border-color: #979797;
|
||||
color: #979797;
|
||||
}
|
||||
// 已完成 - 绿色边框
|
||||
&.deadline-success {
|
||||
border-color: #00B42A;
|
||||
color: #00B42A;
|
||||
}
|
||||
// 进行中 - 蓝色边框
|
||||
&.deadline-primary {
|
||||
border-color: #3491FA;
|
||||
color: #3491FA;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
</style>
|
||||
7
schoolNewsWeb/src/views/user/study-plan/index.ts
Normal file
7
schoolNewsWeb/src/views/user/study-plan/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as StudyPlanLayout } from './StudyPlanLayout.vue';
|
||||
export { default as StudyPlanView } from './StudyTasksView.vue';
|
||||
export { default as CourseCenterView } from './CourseCenterView.vue';
|
||||
export { default as StudyTasksView } from './StudyTasksView.vue';
|
||||
export { default as LearningTaskDetailView } from './LearningTaskDetailView.vue';
|
||||
export { default as CourseDetailView } from './CourseDetailView.vue';
|
||||
export { default as CourseStudyView } from './CourseStudyView.vue';
|
||||
177
schoolNewsWeb/src/views/user/user-center/LearningRecordsView.vue
Normal file
177
schoolNewsWeb/src/views/user/user-center/LearningRecordsView.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="learning-records">
|
||||
<div class="records-header">
|
||||
<h2>学习记录</h2>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="records-list">
|
||||
<div class="record-item" v-for="record in records" :key="record.id">
|
||||
<div class="record-icon">
|
||||
<i :class="getRecordIcon(record.type)"></i>
|
||||
</div>
|
||||
<div class="record-content">
|
||||
<h3>{{ record.title }}</h3>
|
||||
<p class="record-description">{{ record.description }}</p>
|
||||
<div class="record-meta">
|
||||
<span class="record-type">{{ record.typeName }}</span>
|
||||
<span class="record-duration">学习时长:{{ record.duration }}分钟</span>
|
||||
<span class="record-date">{{ record.learnDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-progress">
|
||||
<div class="progress-circle" :class="`progress-${record.status}`">
|
||||
<span>{{ record.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElDatePicker } from 'element-plus';
|
||||
|
||||
const dateRange = ref<[Date, Date] | null>(null);
|
||||
const records = ref<any[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载学习记录
|
||||
});
|
||||
|
||||
function handleDateChange() {
|
||||
// TODO: 根据日期筛选记录
|
||||
}
|
||||
|
||||
function getRecordIcon(type: string) {
|
||||
const iconMap: Record<string, string> = {
|
||||
'article': '📄',
|
||||
'video': '🎥',
|
||||
'audio': '🎵',
|
||||
'course': '📚'
|
||||
};
|
||||
return iconMap[type] || '📄';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.learning-records {
|
||||
.records-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #C62828;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.record-description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.record-type {
|
||||
color: #C62828;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.record-duration,
|
||||
.record-date {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-progress {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-circle {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
&.progress-completed {
|
||||
background: #e8f5e9;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.progress-in-progress {
|
||||
background: #fff3e0;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
&.progress-not-started {
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
545
schoolNewsWeb/src/views/user/user-center/MyAchievementsView.vue
Normal file
545
schoolNewsWeb/src/views/user/user-center/MyAchievementsView.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="my-achievements">
|
||||
<div class="achievements-header">
|
||||
<h2>我的成就</h2>
|
||||
<div class="achievement-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">已获得</span>
|
||||
<span class="stat-value">{{ earnedCount }}</span>
|
||||
<span class="stat-total"> / {{ totalCount }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">完成率</span>
|
||||
<span class="stat-value">{{ completionRate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成就类型筛选 -->
|
||||
<div class="filter-tabs">
|
||||
<el-radio-group v-model="selectedType" @change="filterAchievements">
|
||||
<el-radio-button :label="undefined">全部</el-radio-button>
|
||||
<el-radio-button
|
||||
v-for="option in achievementTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-checkbox v-model="showOnlyEarned" @change="filterAchievements">
|
||||
仅显示已获得
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-skeleton :rows="3" animated />
|
||||
</div>
|
||||
|
||||
<!-- 成就网格 -->
|
||||
<div v-else class="achievements-grid">
|
||||
<div
|
||||
class="achievement-item"
|
||||
v-for="achievement in filteredAchievements"
|
||||
:key="achievement.achievementID"
|
||||
:class="{ earned: achievement.obtained, locked: !achievement.obtained }"
|
||||
>
|
||||
<div class="achievement-icon">
|
||||
<el-image
|
||||
:src="getIconUrl(achievement.icon)"
|
||||
:alt="achievement.name"
|
||||
fit="contain"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon><Trophy /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="achievement-badge" v-if="achievement.obtained">
|
||||
<el-icon><Check /></el-icon>
|
||||
</div>
|
||||
<div class="achievement-level" v-if="achievement.level">
|
||||
Lv.{{ achievement.level }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="achievement-info">
|
||||
<div class="achievement-header">
|
||||
<h3>{{ achievement.name }}</h3>
|
||||
<el-tag
|
||||
:type="achievement.type === 1 ? 'success' : 'primary'"
|
||||
size="small"
|
||||
>
|
||||
{{ getAchievementTypeLabel(achievement.type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="achievement-description">{{ achievement.description }}</p>
|
||||
|
||||
<!-- 条件说明 -->
|
||||
<div class="achievement-condition">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ formatConditionValue(achievement.conditionType, achievement.conditionValue) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="achievement-progress" v-if="!achievement.obtained">
|
||||
<div class="progress-info">
|
||||
<span class="progress-label">进度</span>
|
||||
<span class="progress-text">
|
||||
{{ achievement.currentValue || 0 }} / {{ achievement.targetValue || achievement.conditionValue }}
|
||||
</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="achievement.progressPercentage || 0"
|
||||
:color="progressColor"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 获得信息 -->
|
||||
<div class="achievement-footer" v-if="achievement.obtained">
|
||||
<div class="achievement-date">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
<span>{{ formatDate(achievement.obtainTime) }}</span>
|
||||
</div>
|
||||
<div class="achievement-points" v-if="achievement.points">
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>+{{ achievement.points }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未获得时显示积分奖励 -->
|
||||
<div class="achievement-reward" v-else-if="achievement.points">
|
||||
<el-icon><Present /></el-icon>
|
||||
<span>奖励 {{ achievement.points }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-if="!loading && filteredAchievements.length === 0"
|
||||
description="暂无成就数据"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Trophy, Check, InfoFilled, Calendar, Star, Present } from '@element-plus/icons-vue';
|
||||
import { achievementApi } from '@/apis/achievement';
|
||||
import type { AchievementVO } from '@/types';
|
||||
import { AchievementEnumHelper } from '@/types/enums/achievement-enums';
|
||||
import { getAchievementIconUrl } from '@/utils/iconUtils';
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false);
|
||||
const achievements = ref<AchievementVO[]>([]);
|
||||
const selectedType = ref<number | undefined>(undefined);
|
||||
const showOnlyEarned = ref(false);
|
||||
|
||||
// 枚举选项
|
||||
const achievementTypeOptions = AchievementEnumHelper.getAllAchievementTypeOptions();
|
||||
|
||||
// 进度条颜色
|
||||
const progressColor = [
|
||||
{ color: '#f56c6c', percentage: 30 },
|
||||
{ color: '#e6a23c', percentage: 60 },
|
||||
{ color: '#5cb87a', percentage: 100 }
|
||||
];
|
||||
|
||||
// 已获得数量
|
||||
const earnedCount = computed(() => {
|
||||
return achievements.value.filter(a => a.obtained).length;
|
||||
});
|
||||
|
||||
// 总数量
|
||||
const totalCount = computed(() => {
|
||||
return achievements.value.length;
|
||||
});
|
||||
|
||||
// 完成率
|
||||
const completionRate = computed(() => {
|
||||
if (totalCount.value === 0) return 0;
|
||||
return Math.round((earnedCount.value / totalCount.value) * 100);
|
||||
});
|
||||
|
||||
// 筛选后的成就列表
|
||||
const filteredAchievements = computed(() => {
|
||||
let result = achievements.value;
|
||||
|
||||
// 按类型筛选
|
||||
if (selectedType.value !== undefined) {
|
||||
result = result.filter(a => a.type === selectedType.value);
|
||||
}
|
||||
|
||||
// 仅显示已获得
|
||||
if (showOnlyEarned.value) {
|
||||
result = result.filter(a => a.obtained);
|
||||
}
|
||||
|
||||
// 排序:已获得的在前,按等级排序
|
||||
return result.sort((a, b) => {
|
||||
if (a.obtained !== b.obtained) {
|
||||
return a.obtained ? -1 : 1;
|
||||
}
|
||||
return (a.level || 0) - (b.level || 0);
|
||||
});
|
||||
});
|
||||
|
||||
// 获取成就类型标签
|
||||
function getAchievementTypeLabel(type?: number): string {
|
||||
if (type === undefined) return '未知';
|
||||
return AchievementEnumHelper.getAchievementTypeDescription(type);
|
||||
}
|
||||
|
||||
// 格式化条件值显示
|
||||
function formatConditionValue(conditionType?: number, conditionValue?: number): string {
|
||||
if (conditionType === undefined || conditionValue === undefined) return '';
|
||||
return AchievementEnumHelper.formatConditionValue(conditionType, conditionValue);
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取图标完整路径
|
||||
const getIconUrl = getAchievementIconUrl;
|
||||
|
||||
// 筛选成就
|
||||
function filterAchievements() {
|
||||
// 触发计算属性重新计算
|
||||
}
|
||||
|
||||
// 加载成就数据
|
||||
async function loadAchievements() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const result = await achievementApi.getMyAchievements();
|
||||
achievements.value = result.dataList || [];
|
||||
} catch (error) {
|
||||
console.error('加载成就数据失败:', error);
|
||||
ElMessage.error('加载成就数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAchievements();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.my-achievements {
|
||||
padding: 20px 0;
|
||||
|
||||
.achievements-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.achievements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.achievement-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s;
|
||||
background: white;
|
||||
|
||||
&.earned {
|
||||
border-color: #52c41a;
|
||||
background: linear-gradient(135deg, #f6ffed, #ffffff);
|
||||
|
||||
.achievement-icon {
|
||||
:deep(.el-image__inner) {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.locked {
|
||||
opacity: 0.75;
|
||||
|
||||
.achievement-icon {
|
||||
:deep(.el-image__inner) {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover.earned {
|
||||
box-shadow: 0 4px 16px rgba(82, 196, 26, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:hover.locked {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.el-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
color: #ccc;
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.achievement-level {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 8px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.achievement-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.achievement-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.achievement-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
padding: 6px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-progress {
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.progress-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 13px;
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-progress) {
|
||||
.el-progress-bar__outer {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
.achievement-date,
|
||||
.achievement-points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-points {
|
||||
color: #faad14;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #faad14;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
background: #fffbe6;
|
||||
border-radius: 6px;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.achievements-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.achievements-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.achievement-stats {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
197
schoolNewsWeb/src/views/user/user-center/MyFavoritesView.vue
Normal file
197
schoolNewsWeb/src/views/user/user-center/MyFavoritesView.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="my-favorites">
|
||||
<div class="favorites-header">
|
||||
<h2>我的收藏</h2>
|
||||
<div class="filter-tabs">
|
||||
<div
|
||||
class="filter-tab"
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
:class="{ active: activeFilter === filter.key }"
|
||||
@click="activeFilter = filter.key"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="favorites-grid">
|
||||
<div class="favorite-item" v-for="item in filteredFavorites" :key="item.id">
|
||||
<div class="item-thumbnail">
|
||||
<img :src="item.thumbnail" :alt="item.title" />
|
||||
<div class="item-type">{{ item.typeName }}</div>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p class="item-summary">{{ item.summary }}</p>
|
||||
<div class="item-footer">
|
||||
<span class="item-date">收藏于 {{ item.favoriteDate }}</span>
|
||||
<div class="item-actions">
|
||||
<el-button size="small" @click="viewItem(item)">查看</el-button>
|
||||
<el-button size="small" type="danger" @click="removeFavorite(item)">取消收藏</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
const activeFilter = ref('all');
|
||||
const favorites = ref<any[]>([]);
|
||||
|
||||
const filters = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'article', label: '文章' },
|
||||
{ key: 'video', label: '视频' },
|
||||
{ key: 'audio', label: '音频' },
|
||||
{ key: 'course', label: '课程' }
|
||||
];
|
||||
|
||||
const filteredFavorites = computed(() => {
|
||||
if (activeFilter.value === 'all') return favorites.value;
|
||||
return favorites.value.filter(item => item.type === activeFilter.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: 加载收藏数据
|
||||
});
|
||||
|
||||
function viewItem(item: any) {
|
||||
// TODO: 跳转到详情页
|
||||
}
|
||||
|
||||
function removeFavorite(item: any) {
|
||||
// TODO: 取消收藏
|
||||
ElMessage.success('已取消收藏');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.my-favorites {
|
||||
.favorites-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 8px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #ffe6e6;
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #C62828;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.favorites-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.favorite-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #C62828;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.item-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: #f5f5f5;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.item-type {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(198, 40, 40, 0.9);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
padding: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-summary {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.item-date {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
87
schoolNewsWeb/src/views/user/user-center/UserCenterView.vue
Normal file
87
schoolNewsWeb/src/views/user/user-center/UserCenterView.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="user-center-page">
|
||||
<div class="user-card-wrapper">
|
||||
<UserCard/>
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<div class="sidebar-wrapper">
|
||||
<FloatingSidebar :menus="menus" />
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<router-view/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FloatingSidebar } from '@/components/base';
|
||||
import { UserCard } from '@/views/user/user-center/components';
|
||||
import { getParentChildrenRoutes } from '@/utils/routeUtils';
|
||||
import type { SysMenu } from '@/types/menu';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// 从当前路由配置中获取子路由,转换为菜单格式
|
||||
const menus = computed(() => {
|
||||
// 使用工具函数获取父路由的子路由
|
||||
const childRoutes = getParentChildrenRoutes(route);
|
||||
|
||||
if (childRoutes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取父路由路径(用于拼接相对路径)
|
||||
const parentRoute = route.matched[route.matched.length - 2];
|
||||
|
||||
// 将子路由转换为菜单格式
|
||||
return childRoutes
|
||||
.map((child: any) => ({
|
||||
menuID: child.name as string || child.path,
|
||||
name: child.meta?.title as string,
|
||||
url: child.path.startsWith('/') ? child.path : `${parentRoute.path}/${child.path}`,
|
||||
icon: child.meta?.icon as string,
|
||||
orderNum: child.meta?.orderNum as number || 0,
|
||||
} as SysMenu))
|
||||
.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-center-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-card-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sidebar-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
248
schoolNewsWeb/src/views/user/user-center/components/UserCard.vue
Normal file
248
schoolNewsWeb/src/views/user/user-center/components/UserCard.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<!-- 标题栏 -->
|
||||
<div class="card-header">
|
||||
<div class="title-wrapper">
|
||||
<div class="title-bar"></div>
|
||||
<h3 class="title">个人信息</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息区域 -->
|
||||
<div class="user-info-section">
|
||||
<!-- 头像 -->
|
||||
<div class="avatar-wrapper">
|
||||
<img
|
||||
:src="userInfo?.avatar && userInfo.avatar!='default' ? userInfo.avatar : defaultAvatar"
|
||||
:alt="userInfo?.username"
|
||||
class="avatar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 用户详细信息 -->
|
||||
<div class="user-details">
|
||||
<!-- 用户名和性别 -->
|
||||
<div class="user-name-row">
|
||||
<span class="username">{{ userInfo?.username || '未设置昵称' }}</span>
|
||||
<div class="gender-tag" v-if="userInfo?.gender">
|
||||
<img :src="userInfo?.gender === 1 ? maleIcon : femaleIcon" :alt="userInfo?.gender === 1 ? '男' : '女'" class="gender-icon" />
|
||||
<span class="gender-text">{{ userInfo?.gender === 1 ? '男' : '女' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div class="info-row">
|
||||
<span class="info-item">所属部门:{{ userInfo?.deptName || '未分配部门' }}</span>
|
||||
<span class="info-item" v-if="userInfo?.phone">手机号:{{ userInfo?.phone || '未设置' }}</span>
|
||||
<span class="info-item" v-if="userInfo?.email">邮箱:{{ userInfo?.email || '未设置' }}</span>
|
||||
<div class="level-item">
|
||||
<span class="info-label">学习等级:</span>
|
||||
<img :src="levelIcon" alt="等级" class="level-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="edit-btn" @click="handleEdit">编辑资料</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserCard">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { UserVO } from '@/types';
|
||||
import {userProfileApi} from '@/apis/usercenter/profile'
|
||||
import defaultAvatarImg from '@/assets/imgs/default-avatar.png';
|
||||
import maleIcon from '@/assets/imgs/male.svg';
|
||||
import femaleIcon from '@/assets/imgs/female.svg';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getLevelIconUrl } from '@/utils/iconUtils';
|
||||
|
||||
const router = useRouter();
|
||||
const userInfo = ref<UserVO>();
|
||||
|
||||
// 默认头像
|
||||
const defaultAvatar = defaultAvatarImg;
|
||||
|
||||
const levelIcon = computed(() => {
|
||||
const level = userInfo.value?.level || 1;
|
||||
return getLevelIconUrl(level);
|
||||
});
|
||||
|
||||
function handleEdit() {
|
||||
router.push('/profile');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await userProfileApi.getUserProfile();
|
||||
if(res.success){
|
||||
userInfo.value = res.data;
|
||||
}else{
|
||||
ElMessage.error(res.message || '获取用户信息失败');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
min-height: 190px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: #C62828;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
color: #141F38;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 4px 16px;
|
||||
background: rgba(198, 40, 40, 0.05);
|
||||
border: 1px solid #C62828;
|
||||
border-radius: 4px;
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #C62828;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(198, 40, 40, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(198, 40, 40, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
color: #141F38;
|
||||
}
|
||||
|
||||
.gender-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.gender-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.gender-text {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: #141F38;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.level-icon {
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.level-text {
|
||||
font-family: 'PingFang SC', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: #334155;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UserCard } from './UserCard.vue';
|
||||
Reference in New Issue
Block a user