视图修改、接口修改

This commit is contained in:
2025-10-28 19:04:35 +08:00
parent 98c73632bd
commit c5c134fbb3
96 changed files with 7122 additions and 4194 deletions

View File

@@ -0,0 +1,172 @@
<template>
<div class="admin-layout">
<!-- 头部区域主标题和副标题 -->
<div class="admin-layout-header">
<div class="header-content">
<h1 class="main-title">{{ title }}</h1>
<p class="subtitle" v-if="subtitle">{{ subtitle }}</p>
</div>
</div>
<!-- 标签页导航当前路由的兄弟路由 -->
<div class="admin-layout-tabs" v-if="menus.length > 0">
<div class="tab-item-container">
<router-link
v-for="menu in menus"
:key="menu.path"
:to="getFullPath(menu)"
class="tab-item"
:class="{ active: isActive(menu) }"
>
<img
v-if="menu.meta?.icon"
:src="String(PUBLIC_IMG_PATH + '/' + menu.meta.icon)"
class="tab-icon"
:alt="String(menu.meta?.title || '')"
/>
<span class="tab-text">{{ menu.meta?.title }}</span>
</router-link>
</div>
</div>
<!-- 内容区域 -->
<div class="admin-layout-content">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, RouteRecordRaw } from 'vue-router';
import { getParentChildrenRoutes } from '@/utils/routeUtils';
import { PUBLIC_IMG_PATH} from '@/config'
// 定义 props
defineProps<{
title: string;
subtitle?: string;
}>();
const route = useRoute();
// 获取兄弟路由(计算属性,确保响应式更新)
const menus = computed(() => {
return getParentChildrenRoutes(route);
});
// 获取完整路径
function getFullPath(menu: RouteRecordRaw): string {
// 如果路径是相对路径,需要拼接父路径
if (menu.path?.startsWith('/')) {
return menu.path;
}
// 获取父路径
const parentPath = route.matched.length >= 2
? route.matched[route.matched.length - 2].path
: '';
return `${parentPath}/${menu.path}`.replace(/\/+/g, '/');
}
// 判断是否为当前激活路由
function isActive(menu: RouteRecordRaw): boolean {
const fullPath = getFullPath(menu);
return route.path === fullPath || route.path.startsWith(fullPath + '/');
}
</script>
<style scoped lang="scss">
.admin-layout {
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.admin-layout-header {
padding: 4px 0 24px 0;
.header-content {
.main-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 4px 0;
line-height: 28px;
color: #1D2129;
}
.subtitle {
font-size: 14px;
margin: 0;
line-height: 22px;
color: #86909C;
}
}
}
.admin-layout-tabs {
display: flex;
gap: 0;
padding: 0;
overflow-x: auto;
margin-bottom: 24px;
.tab-item-container {
display: flex;
border-radius: 15px;
gap: 0;
overflow-x: auto;
}
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: #C9CDD4;
border-radius: 2px;
}
.tab-item {
position: relative;
display: flex;
align-items: center;
background-color: #FFFFFF;
gap: 8px;
padding: 14px 20px;
font-size: 14px;
color: #4E5969;
text-decoration: none;
white-space: nowrap;
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
.tab-icon {
width: 16px;
height: 16px;
object-fit: contain;
flex-shrink: 0;
}
.tab-text {
line-height: 1;
}
&:hover {
color: #165DFF;
}
&.active {
color: #165DFF;
font-weight: 500;
border-bottom-color: #165DFF;
}
}
}
.admin-layout-content {
flex: 1;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1 @@
export { default as AdminLayout } from './AdminLayout.vue';

View File

@@ -1,12 +1,16 @@
<template>
<div class="achievement-management">
<div class="header">
<h2>成就管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增成就
</el-button>
</div>
<AdminLayout
title="成就管理"
subtitle="管理用户成就、徽章等激励内容"
>
<div class="achievement-management">
<div class="header">
<h2>成就管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增成就
</el-button>
</div>
<!-- 筛选条件 -->
<div class="filter-bar">
@@ -305,7 +309,8 @@
</el-table-column>
</el-table>
</el-dialog>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -316,6 +321,11 @@ import { achievementApi } from '@/apis/achievement';
import type { Achievement, UserAchievement } from '@/types';
import { AchievementEnumHelper } from '@/types/enums/achievement-enums';
import { getAchievementIconUrl } from '@/utils/iconUtils';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'AchievementManagementView'
});
// 响应式数据
const loading = ref(false);
@@ -544,7 +554,9 @@ onMounted(() => {
<style scoped lang="scss">
.achievement-management {
padding: 20px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
}
.header {

View File

@@ -1,75 +1,80 @@
<template>
<div class="ai-config">
<el-form :model="configForm" label-width="150px" class="config-form">
<el-divider content-position="left">模型配置</el-divider>
<el-form-item label="AI模型">
<el-select v-model="configForm.model" placeholder="选择AI模型">
<el-option label="GPT-3.5" value="gpt-3.5" />
<el-option label="GPT-4" value="gpt-4" />
<el-option label="Claude" value="claude" />
</el-select>
</el-form-item>
<AdminLayout title="AI配置" subtitle="AI配置">
<div class="ai-config">
<el-form :model="configForm" label-width="150px" class="config-form">
<el-divider content-position="left">模型配置</el-divider>
<el-form-item label="AI模型">
<el-select v-model="configForm.model" placeholder="选择AI模型">
<el-option label="GPT-3.5" value="gpt-3.5" />
<el-option label="GPT-4" value="gpt-4" />
<el-option label="Claude" value="claude" />
</el-select>
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="configForm.apiKey" type="password" show-password />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="configForm.apiKey" type="password" show-password />
</el-form-item>
<el-form-item label="API地址">
<el-input v-model="configForm.apiUrl" />
</el-form-item>
<el-form-item label="API地址">
<el-input v-model="configForm.apiUrl" />
</el-form-item>
<el-divider content-position="left">对话配置</el-divider>
<el-divider content-position="left">对话配置</el-divider>
<el-form-item label="温度值">
<el-slider v-model="configForm.temperature" :min="0" :max="2" :step="0.1" show-input />
<span class="help-text">控制回答的随机性值越大回答越随机</span>
</el-form-item>
<el-form-item label="温度值">
<el-slider v-model="configForm.temperature" :min="0" :max="2" :step="0.1" show-input />
<span class="help-text">控制回答的随机性值越大回答越随机</span>
</el-form-item>
<el-form-item label="最大token数">
<el-input-number v-model="configForm.maxTokens" :min="100" :max="4000" />
</el-form-item>
<el-form-item label="最大token数">
<el-input-number v-model="configForm.maxTokens" :min="100" :max="4000" />
</el-form-item>
<el-form-item label="历史对话轮数">
<el-input-number v-model="configForm.historyTurns" :min="1" :max="20" />
</el-form-item>
<el-form-item label="历史对话轮数">
<el-input-number v-model="configForm.historyTurns" :min="1" :max="20" />
</el-form-item>
<el-divider content-position="left">功能配置</el-divider>
<el-divider content-position="left">功能配置</el-divider>
<el-form-item label="启用流式输出">
<el-switch v-model="configForm.enableStreaming" />
</el-form-item>
<el-form-item label="启用流式输出">
<el-switch v-model="configForm.enableStreaming" />
</el-form-item>
<el-form-item label="启用文件解读">
<el-switch v-model="configForm.enableFileInterpretation" />
</el-form-item>
<el-form-item label="启用文件解读">
<el-switch v-model="configForm.enableFileInterpretation" />
</el-form-item>
<el-form-item label="启用知识库检索">
<el-switch v-model="configForm.enableKnowledgeRetrieval" />
</el-form-item>
<el-form-item label="启用知识库检索">
<el-switch v-model="configForm.enableKnowledgeRetrieval" />
</el-form-item>
<el-form-item label="系统提示词">
<el-input
v-model="configForm.systemPrompt"
type="textarea"
:rows="6"
placeholder="设置AI助手的角色和行为..."
/>
</el-form-item>
<el-form-item label="系统提示词">
<el-input
v-model="configForm.systemPrompt"
type="textarea"
:rows="6"
placeholder="设置AI助手的角色和行为..."
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
<el-button @click="handleTest">测试连接</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
<el-button @click="handleTest">测试连接</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElForm, ElFormItem, ElSelect, ElOption, ElInput, ElSlider, ElInputNumber, ElSwitch, ElButton, ElDivider, ElMessage } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'AIConfigView'
});
const configForm = ref({
model: 'gpt-3.5',
apiKey: '',

View File

@@ -1,16 +1,18 @@
<template>
<div class="ai-management">
<h1 class="page-title">智能体管理</h1>
<AdminLayout title="智能体管理" subtitle="智能体管理">
<div class="ai-management">
<h1 class="page-title">智能体管理</h1>
<el-tabs v-model="activeTab">
<el-tab-pane label="基础配置" name="config">
<AIConfig />
</el-tab-pane>
<el-tab-pane label="知识库管理" name="knowledge">
<KnowledgeManagement />
</el-tab-pane>
</el-tabs>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="基础配置" name="config">
<AIConfig />
</el-tab-pane>
<el-tab-pane label="知识库管理" name="knowledge">
<KnowledgeManagement />
</el-tab-pane>
</el-tabs>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -18,7 +20,10 @@ import { ref } from 'vue';
import { ElTabs, ElTabPane } from 'element-plus';
import AIConfig from './components/AIConfig.vue';
import KnowledgeManagement from './components/KnowledgeManagement.vue';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'AIManagementView'
});
const activeTab = ref('config');
</script>

View File

@@ -1,58 +1,63 @@
<template>
<div class="knowledge-management">
<div class="action-bar">
<el-button type="primary" @click="showCreateDialog">+ 新增知识</el-button>
<el-button @click="handleImport">批量导入</el-button>
<el-input
v-model="searchKeyword"
placeholder="搜索知识..."
style="width: 300px"
clearable
<AdminLayout title="知识库管理" subtitle="知识库管理">
<div class="knowledge-management">
<div class="action-bar">
<el-button type="primary" @click="showCreateDialog">+ 新增知识</el-button>
<el-button @click="handleImport">批量导入</el-button>
<el-input
v-model="searchKeyword"
placeholder="搜索知识..."
style="width: 300px"
clearable
/>
</div>
<el-table :data="knowledgeList" style="width: 100%">
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="tags" label="标签" width="200">
<template #default="{ row }">
<el-tag v-for="tag in row.tags" :key="tag" size="small" style="margin-right: 4px;">
{{ tag }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
@change="toggleStatus(row)"
/>
</template>
</el-table-column>
<el-table-column prop="updateDate" label="更新时间" width="150" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editKnowledge(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteKnowledge(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<el-table :data="knowledgeList" style="width: 100%">
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="tags" label="标签" width="200">
<template #default="{ row }">
<el-tag v-for="tag in row.tags" :key="tag" size="small" style="margin-right: 4px;">
{{ tag }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
@change="toggleStatus(row)"
/>
</template>
</el-table-column>
<el-table-column prop="updateDate" label="更新时间" width="150" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editKnowledge(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteKnowledge(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElButton, ElInput, ElTable, ElTableColumn, ElTag, ElSwitch, ElPagination, ElMessage } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'KnowledgeManagementView'
});
const searchKeyword = ref('');
const currentPage = ref(1);
const pageSize = ref(10);

View File

@@ -1,33 +1,43 @@
<template>
<div class="column-management">
<div class="action-bar">
<el-button type="primary" @click="showCreateDialog">+ 新增栏目</el-button>
</div>
<AdminLayout
title="内容管理"
subtitle="管理网站横幅、栏目、标签等内容信息"
>
<div class="column-management">
<div class="action-bar">
<el-button type="primary" @click="showCreateDialog">+ 新增栏目</el-button>
</div>
<el-table :data="columns" style="width: 100%" row-key="id" :tree-props="{children: 'children'}">
<el-table-column prop="name" label="栏目名称" min-width="200" />
<el-table-column prop="code" label="栏目编码" width="150" />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'">
{{ row.status ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editColumn(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteColumn(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-table :data="columns" style="width: 100%" row-key="id" :tree-props="{children: 'children'}">
<el-table-column prop="name" label="栏目名称" min-width="200" />
<el-table-column prop="code" label="栏目编码" width="150" />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'">
{{ row.status ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editColumn(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteColumn(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElButton, ElTable, ElTableColumn, ElTag, ElMessage } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'ColumnManagementView'
});
const columns = ref<any[]>([]);
@@ -55,11 +65,12 @@ function deleteColumn(row: any) {
<style lang="scss" scoped>
.column-management {
background: #FFFFFF;
padding: 20px;
border-radius: 14px;
}
.action-bar {
margin-bottom: 20px;
}
</style>

View File

@@ -1,41 +1,30 @@
<template>
<div class="language-management">
<h1 class="page-title">语言管理</h1>
<el-tabs v-model="activeTab">
<el-tab-pane label="Banner管理" name="banner">
<BannerManagement />
</el-tab-pane>
<el-tab-pane label="资源栏目管理" name="column">
<ColumnManagement />
</el-tab-pane>
<el-tab-pane label="标签管理" name="tag">
<TagManagement />
</el-tab-pane>
</el-tabs>
</div>
<AdminLayout
title="内容管理"
subtitle="管理网站横幅、栏目、标签等内容信息"
>
<div class="content-management">
<el-empty description="请使用顶部标签页切换到Banner管理、标签管理或栏目管理" />
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElTabs, ElTabPane } from 'element-plus';
import BannerManagement from './components/BannerManagement.vue';
import ColumnManagement from './components/ColumnManagement.vue';
import TagManagement from './components/TagManagement.vue';
import { AdminLayout } from '@/views/admin';
const activeTab = ref('banner');
defineOptions({
name: 'ContentManagementView'
});
</script>
<style lang="scss" scoped>
.language-management {
padding: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #141F38;
margin-bottom: 24px;
.content-management {
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,53 +1,49 @@
<template>
<div class="tag-management">
<div class="action-bar">
<el-button type="primary" @click="showCreateDialog">+ 新增标签</el-button>
<el-input
v-model="searchKeyword"
placeholder="搜索标签..."
style="width: 300px"
clearable
@change="handleSearch"
/>
</div>
<AdminLayout
title="标签管理"
subtitle="各类标签信息"
>
<div class="tag-management">
<!-- 顶部操作栏 -->
<div class="header">
<h2 class="title">数据标签管理</h2>
<button class="add-button" @click="showCreateDialog">
<img src="@/assets/imgs/plus.svg" alt="添加" />
<span>添加标签</span>
</button>
</div>
<el-table :data="filteredTags" style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="标签名称" min-width="150" />
<el-table-column prop="tagType" label="标签类型" width="120">
<template #default="{ row }">
<el-tag :type="getTagTypeColor(row.tagType)">
{{ getTagTypeName(row.tagType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="color" label="颜色" width="120">
<template #default="{ row }">
<div class="color-display">
<div class="color-preview" :style="{ background: row.color }"></div>
<span>{{ row.color }}</span>
<!-- 标签卡片网格 -->
<div v-loading="loading" class="tag-grid">
<div
v-for="tag in tags"
:key="tag.tagID"
class="tag-card"
>
<div class="tag-content">
<div class="tag-info">
<div class="tag-dot" :style="{ background: tag.color }"></div>
<span class="tag-name">{{ tag.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="orderNum" label="排序" width="80" />
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="editTag(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="deleteTag(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- <div class="tag-badge">{{ tag.usageCount || 0 }}</div> -->
</div>
<div class="tag-actions">
<button class="edit-button" @click="editTag(tag)">
<img src="@/assets/imgs/edit.svg" alt="编辑" />
<span>编辑</span>
</button>
<button class="delete-button" @click="deleteTag(tag)">
<img src="@/assets/imgs/trashbin.svg" alt="删除" />
</button>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && tags.length === 0" class="empty-state">
暂无标签数据
</div>
</div>
<!-- 创建/编辑标签对话框 -->
<el-dialog
@@ -84,17 +80,6 @@
placeholder="请输入标签描述"
/>
</el-form-item>
<el-form-item label="排序号" prop="orderNum">
<el-input-number v-model="currentTag.orderNum" :min="0" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="currentTag.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
@@ -102,16 +87,21 @@
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { resourceTagApi } from '@/apis/resource';
import type { Tag } from '@/types/resource';
import {AdminLayout} from '@/views/admin';
defineOptions({
name: 'TagManagementView'
});
const searchKeyword = ref('');
const tags = ref<Tag[]>([]);
const loading = ref(false);
const dialogVisible = ref(false);
@@ -123,9 +113,7 @@ const currentTag = ref<Partial<Tag>>({
name: '',
tagType: 1,
color: '#409EFF',
description: '',
orderNum: 0,
status: 1
description: ''
});
const rules: FormRules = {
@@ -140,16 +128,6 @@ const rules: FormRules = {
]
};
// 过滤后的标签列表
const filteredTags = computed(() => {
if (!searchKeyword.value) return tags.value;
const keyword = searchKeyword.value.toLowerCase();
return tags.value.filter(tag =>
tag.name?.toLowerCase().includes(keyword) ||
tag.description?.toLowerCase().includes(keyword)
);
});
onMounted(() => {
loadTags();
});
@@ -161,6 +139,8 @@ async function loadTags() {
const result = await resourceTagApi.getTagList();
if (result.success) {
tags.value = result.dataList || [];
// TODO: 加载每个标签的使用计数
// 这里可以调用 API 获取每个标签被使用的资源数量
} else {
ElMessage.error(result.message || '加载标签列表失败');
}
@@ -172,11 +152,6 @@ async function loadTags() {
}
}
// 搜索处理
function handleSearch() {
// 搜索由computed自动处理
}
// 显示创建对话框
function showCreateDialog() {
isEdit.value = false;
@@ -184,9 +159,7 @@ function showCreateDialog() {
name: '',
tagType: 1,
color: '#409EFF',
description: '',
orderNum: 0,
status: 1
description: ''
};
dialogVisible.value = true;
}
@@ -259,61 +232,208 @@ async function handleSubmit() {
function handleDialogClose() {
formRef.value?.resetFields();
}
// 获取标签类型名称
function getTagTypeName(type?: number): string {
const map: Record<number, string> = {
1: '文章分类',
2: '课程分类',
3: '任务分类'
};
return map[type || 1] || '未知';
}
// 获取标签类型颜色
function getTagTypeColor(type?: number): string {
const map: Record<number, string> = {
1: 'primary',
2: 'success',
3: 'warning'
};
return map[type || 1] || '';
}
// 格式化时间
function formatTime(time?: string): string {
if (!time) return '-';
return new Date(time).toLocaleString('zh-CN');
}
</script>
<style lang="scss" scoped>
.tag-management {
padding: 20px;
display: flex;
flex-direction: column;
gap: 48px;
box-sizing: border-box;
background: white;
padding: 24px;
border-radius: 14px;
}
.action-bar {
.header {
display: flex;
gap: 16px;
margin-bottom: 20px;
justify-content: space-between;
align-items: center;
}
.color-display {
.title {
margin: 0;
font-family: 'Inter', 'PingFang SC', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 1.5em;
letter-spacing: -0.02em;
color: #101828;
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: #E7000B;
border: none;
border-radius: 8px;
color: #FFFFFF;
font-family: 'Inter', 'PingFang SC', sans-serif;
font-weight: 500;
font-size: 14px;
line-height: 1.43em;
letter-spacing: -0.01em;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #c50009;
}
&:active {
background: #a80008;
}
svg {
flex-shrink: 0;
}
}
.tag-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
min-height: 200px;
}
.tag-card {
display: flex;
flex-direction: column;
gap: 36px;
padding: 16px;
background: #F9FAFB;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
box-sizing: border-box;
}
.tag-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.tag-info {
display: flex;
align-items: center;
gap: 8px;
}
.color-preview {
width: 40px;
height: 24px;
border-radius: 4px;
border: 1px solid #e0e0e0;
.tag-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.tag-name {
font-family: 'Inter', 'PingFang SC', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 1.5em;
letter-spacing: -0.02em;
color: #101828;
}
.tag-badge {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
background: #ECEEF2;
border: 1px solid transparent;
border-radius: 8px;
font-family: 'Inter', 'PingFang SC', sans-serif;
font-weight: 500;
font-size: 12px;
line-height: 1.33em;
color: #030213;
}
.tag-actions {
display: flex;
gap: 8px;
}
.edit-button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
font-family: 'Inter', 'PingFang SC', sans-serif;
font-weight: 500;
font-size: 14px;
line-height: 1.43em;
letter-spacing: -0.01em;
color: #0A0A0A;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
}
svg {
flex-shrink: 0;
}
}
.delete-button {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 32px;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #fff5f5;
border-color: rgba(231, 0, 11, 0.2);
}
svg {
flex-shrink: 0;
}
}
.empty-state {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 0;
font-size: 14px;
color: #909399;
}
.color-picker-wrapper {
display: flex;
align-items: center;
}
// 响应式调整
@media (max-width: 1400px) {
.tag-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 900px) {
.tag-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,218 +1,220 @@
<template>
<div class="log-management">
<div class="header">
<h2>执行日志</h2>
<el-button type="danger" @click="handleCleanLogs">
<el-icon><Delete /></el-icon>
清理日志
</el-button>
</div>
<!-- 搜索筛选区域 -->
<div class="search-bar">
<div class="search-item">
<span class="search-label">任务名称</span>
<el-input
v-model="searchForm.taskName"
placeholder="请输入任务名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">任务分组</span>
<el-input
v-model="searchForm.taskGroup"
placeholder="请输入任务分组"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">执行状态</span>
<el-select
v-model="searchForm.executeStatus"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</div>
<div class="search-actions">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
<AdminLayout title="执行日志" subtitle="执行日志管理">
<div class="log-management">
<div class="header">
<h2>执行日志</h2>
<el-button type="danger" @click="handleCleanLogs">
<el-icon><Delete /></el-icon>
清理日志
</el-button>
</div>
</div>
<!-- 日志列表 -->
<el-table
:data="logList"
style="width: 100%"
v-loading="loading"
border
stripe
>
<el-table-column prop="taskName" label="任务名称" min-width="150" />
<el-table-column prop="taskGroup" label="任务分组" width="120" />
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
<el-table-column prop="methodName" label="方法名称" width="120" />
<el-table-column label="执行状态" width="100">
<template #default="{ row }">
<el-tag :type="row.executeStatus === 1 ? 'success' : 'danger'" size="small">
{{ row.executeStatus === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="executeDuration" label="执行时长" width="100">
<template #default="{ row }">
{{ row.executeDuration }}ms
</template>
</el-table-column>
<el-table-column prop="startTime" label="开始时间" width="180" />
<el-table-column prop="endTime" label="结束时间" width="180" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleViewDetail(row)"
<!-- 搜索筛选区域 -->
<div class="search-bar">
<div class="search-item">
<span class="search-label">任务名称</span>
<el-input
v-model="searchForm.taskName"
placeholder="请输入任务名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">任务分组</span>
<el-input
v-model="searchForm.taskGroup"
placeholder="请输入任务分组"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">执行状态</span>
<el-select
v-model="searchForm.executeStatus"
placeholder="请选择状态"
clearable
style="width: 150px"
>
查看详情
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</div>
<div class="search-actions">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
</div>
<!-- 日志列表 -->
<el-table
:data="logList"
style="width: 100%"
v-loading="loading"
border
stripe
>
<el-table-column prop="taskName" label="任务名称" min-width="150" />
<el-table-column prop="taskGroup" label="任务分组" width="120" />
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
<el-table-column prop="methodName" label="方法名称" width="120" />
<el-table-column label="执行状态" width="100">
<template #default="{ row }">
<el-tag :type="row.executeStatus === 1 ? 'success' : 'danger'" size="small">
{{ row.executeStatus === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="executeDuration" label="执行时长" width="100">
<template #default="{ row }">
{{ row.executeDuration }}ms
</template>
</el-table-column>
<el-table-column prop="startTime" label="开始时间" width="180" />
<el-table-column prop="endTime" label="结束时间" width="180" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="执行日志详情"
width="700px"
>
<div class="detail-content" v-if="currentLog">
<div class="detail-item">
<span class="detail-label">任务名称</span>
<span class="detail-value">{{ currentLog.taskName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">任务分组</span>
<span class="detail-value">{{ currentLog.taskGroup }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Bean名称</span>
<span class="detail-value">{{ currentLog.beanName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">方法名称</span>
<span class="detail-value">{{ currentLog.methodName }}</span>
</div>
<div class="detail-item" v-if="currentLog.methodParams">
<span class="detail-label">方法参数</span>
<span class="detail-value">{{ currentLog.methodParams }}</span>
</div>
<div class="detail-item">
<span class="detail-label">执行状态</span>
<el-tag :type="currentLog.executeStatus === 1 ? 'success' : 'danger'" size="small">
{{ currentLog.executeStatus === 1 ? '成功' : '失败' }}
</el-tag>
</div>
<div class="detail-item">
<span class="detail-label">执行时长</span>
<span class="detail-value">{{ currentLog.executeDuration }}ms</span>
</div>
<div class="detail-item">
<span class="detail-label">开始时间</span>
<span class="detail-value">{{ currentLog.startTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">结束时间</span>
<span class="detail-value">{{ currentLog.endTime }}</span>
</div>
<div class="detail-item" v-if="currentLog.executeMessage">
<span class="detail-label">执行结果</span>
<div class="detail-message">{{ currentLog.executeMessage }}</div>
</div>
<div class="detail-item" v-if="currentLog.exceptionInfo">
<span class="detail-label">异常信息</span>
<div class="detail-exception">{{ currentLog.exceptionInfo }}</div>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 清理日志对话框 -->
<el-dialog
v-model="cleanDialogVisible"
title="清理日志"
width="400px"
>
<div class="clean-dialog-content">
<el-alert
title="清理操作不可恢复,请谨慎操作!"
type="warning"
:closable="false"
show-icon
/>
<div class="clean-item">
<span class="clean-label">保留天数</span>
<el-input-number
v-model="cleanDays"
:min="1"
:max="365"
controls-position="right"
/>
<span class="clean-tip"></span>
</div>
<div class="clean-desc">
将删除 {{ cleanDays }} 天前的所有执行日志
</div>
</div>
<template #footer>
<el-button @click="cleanDialogVisible = false">取消</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
@click="handleConfirmClean"
:loading="submitting"
>
删除
确定清理
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</el-dialog>
</div>
<!-- 详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="执行日志详情"
width="700px"
>
<div class="detail-content" v-if="currentLog">
<div class="detail-item">
<span class="detail-label">任务名称</span>
<span class="detail-value">{{ currentLog.taskName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">任务分组</span>
<span class="detail-value">{{ currentLog.taskGroup }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Bean名称</span>
<span class="detail-value">{{ currentLog.beanName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">方法名称</span>
<span class="detail-value">{{ currentLog.methodName }}</span>
</div>
<div class="detail-item" v-if="currentLog.methodParams">
<span class="detail-label">方法参数</span>
<span class="detail-value">{{ currentLog.methodParams }}</span>
</div>
<div class="detail-item">
<span class="detail-label">执行状态</span>
<el-tag :type="currentLog.executeStatus === 1 ? 'success' : 'danger'" size="small">
{{ currentLog.executeStatus === 1 ? '成功' : '失败' }}
</el-tag>
</div>
<div class="detail-item">
<span class="detail-label">执行时长</span>
<span class="detail-value">{{ currentLog.executeDuration }}ms</span>
</div>
<div class="detail-item">
<span class="detail-label">开始时间</span>
<span class="detail-value">{{ currentLog.startTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">结束时间</span>
<span class="detail-value">{{ currentLog.endTime }}</span>
</div>
<div class="detail-item" v-if="currentLog.executeMessage">
<span class="detail-label">执行结果</span>
<div class="detail-message">{{ currentLog.executeMessage }}</div>
</div>
<div class="detail-item" v-if="currentLog.exceptionInfo">
<span class="detail-label">异常信息</span>
<div class="detail-exception">{{ currentLog.exceptionInfo }}</div>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 清理日志对话框 -->
<el-dialog
v-model="cleanDialogVisible"
title="清理日志"
width="400px"
>
<div class="clean-dialog-content">
<el-alert
title="清理操作不可恢复,请谨慎操作!"
type="warning"
:closable="false"
show-icon
/>
<div class="clean-item">
<span class="clean-label">保留天数</span>
<el-input-number
v-model="cleanDays"
:min="1"
:max="365"
controls-position="right"
/>
<span class="clean-tip"></span>
</div>
<div class="clean-desc">
将删除 {{ cleanDays }} 天前的所有执行日志
</div>
</div>
<template #footer>
<el-button @click="cleanDialogVisible = false">取消</el-button>
<el-button
type="danger"
@click="handleConfirmClean"
:loading="submitting"
>
确定清理
</el-button>
</template>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -221,7 +223,10 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { Delete, Search, Refresh } from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import type { CrontabLog, PageParam } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'LogManagementView'
});
// 数据状态
const loading = ref(false);
const submitting = ref(false);

View File

@@ -1,266 +1,268 @@
<template>
<div class="news-crawler">
<div class="header">
<h2>新闻爬虫配置</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增爬虫
</el-button>
</div>
<AdminLayout title="新闻爬虫" subtitle="新闻爬虫配置">
<div class="news-crawler">
<div class="header">
<h2>新闻爬虫配置</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增爬虫
</el-button>
</div>
<!-- 说明卡片 -->
<el-alert
title="新闻爬虫说明"
type="info"
:closable="false"
style="margin-bottom: 20px"
>
<p>新闻爬虫配置允许系统自动从指定的新闻源抓取最新新闻内容</p>
<p>配置完成后系统会按照设定的Cron表达式定时执行爬取任务</p>
</el-alert>
<!-- 说明卡片 -->
<el-alert
title="新闻爬虫说明"
type="info"
:closable="false"
style="margin-bottom: 20px"
>
<p>新闻爬虫配置允许系统自动从指定的新闻源抓取最新新闻内容</p>
<p>配置完成后系统会按照设定的Cron表达式定时执行爬取任务</p>
</el-alert>
<!-- 搜索筛选区域 -->
<div class="search-bar">
<div class="search-item">
<span class="search-label">爬虫名称</span>
<el-input
v-model="searchForm.taskName"
placeholder="请输入爬虫名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
<!-- 搜索筛选区域 -->
<div class="search-bar">
<div class="search-item">
<span class="search-label">爬虫名称</span>
<el-input
v-model="searchForm.taskName"
placeholder="请输入爬虫名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">状态</span>
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="运行中" :value="1" />
<el-option label="已暂停" :value="0" />
</el-select>
</div>
<div class="search-actions">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
</div>
<!-- 爬虫配置列表 -->
<div class="crawler-list">
<el-row :gutter="20">
<el-col :span="8" v-for="crawler in crawlerList" :key="crawler.taskId">
<el-card class="crawler-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="card-title">
<el-icon class="title-icon"><DocumentCopy /></el-icon>
<span>{{ crawler.taskName }}</span>
</div>
<el-tag
:type="crawler.status === 1 ? 'success' : 'info'"
size="small"
>
{{ crawler.status === 1 ? '运行中' : '已暂停' }}
</el-tag>
</div>
</template>
<div class="card-content">
<div class="info-item">
<span class="info-label">Bean名称</span>
<span class="info-value">{{ crawler.beanName }}</span>
</div>
<div class="info-item">
<span class="info-label">方法名称</span>
<span class="info-value">{{ crawler.methodName }}</span>
</div>
<div class="info-item">
<span class="info-label">执行周期</span>
<span class="info-value">{{ crawler.cronExpression }}</span>
</div>
<div class="info-item" v-if="crawler.description">
<span class="info-label">描述</span>
<span class="info-value">{{ crawler.description }}</span>
</div>
</div>
<template #footer>
<div class="card-actions">
<el-button
v-if="crawler.status === 0"
type="success"
size="small"
@click="handleStart(crawler)"
>
<el-icon><VideoPlay /></el-icon>
启动
</el-button>
<el-button
v-else
type="warning"
size="small"
@click="handlePause(crawler)"
>
<el-icon><VideoPause /></el-icon>
暂停
</el-button>
<el-button
type="primary"
size="small"
@click="handleExecute(crawler)"
>
<el-icon><Promotion /></el-icon>
执行一次
</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(crawler)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(crawler)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</template>
</el-card>
</el-col>
</el-row>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && crawlerList.length === 0"
description="暂无爬虫配置"
style="margin-top: 40px"
/>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[9, 18, 36]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<div class="search-item">
<span class="search-label">状态</span>
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="运行中" :value="1" />
<el-option label="已暂停" :value="0" />
</el-select>
</div>
<div class="search-actions">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
</div>
<!-- 爬虫配置列表 -->
<div class="crawler-list">
<el-row :gutter="20">
<el-col :span="8" v-for="crawler in crawlerList" :key="crawler.taskId">
<el-card class="crawler-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="card-title">
<el-icon class="title-icon"><DocumentCopy /></el-icon>
<span>{{ crawler.taskName }}</span>
</div>
<el-tag
:type="crawler.status === 1 ? 'success' : 'info'"
size="small"
>
{{ crawler.status === 1 ? '运行中' : '已暂停' }}
</el-tag>
</div>
</template>
<div class="card-content">
<div class="info-item">
<span class="info-label">Bean名称</span>
<span class="info-value">{{ crawler.beanName }}</span>
</div>
<div class="info-item">
<span class="info-label">方法名称</span>
<span class="info-value">{{ crawler.methodName }}</span>
</div>
<div class="info-item">
<span class="info-label">执行周期</span>
<span class="info-value">{{ crawler.cronExpression }}</span>
</div>
<div class="info-item" v-if="crawler.description">
<span class="info-label">描述</span>
<span class="info-value">{{ crawler.description }}</span>
</div>
</div>
<template #footer>
<div class="card-actions">
<el-button
v-if="crawler.status === 0"
type="success"
size="small"
@click="handleStart(crawler)"
>
<el-icon><VideoPlay /></el-icon>
启动
</el-button>
<el-button
v-else
type="warning"
size="small"
@click="handlePause(crawler)"
>
<el-icon><VideoPause /></el-icon>
暂停
</el-button>
<el-button
type="primary"
size="small"
@click="handleExecute(crawler)"
>
<el-icon><Promotion /></el-icon>
执行一次
</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(crawler)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(crawler)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</template>
</el-card>
</el-col>
</el-row>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && crawlerList.length === 0"
description="暂无爬虫配置"
style="margin-top: 40px"
/>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[9, 18, 36]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑爬虫' : '新增爬虫'"
width="700px"
@close="resetForm"
>
<div class="form-content">
<div class="form-item">
<span class="form-label required">爬虫名称</span>
<el-input
v-model="formData.taskName"
placeholder="请输入爬虫名称"
clearable
/>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑爬虫' : '新增爬虫'"
width="700px"
@close="resetForm"
>
<div class="form-content">
<div class="form-item">
<span class="form-label required">爬虫名称</span>
<el-input
v-model="formData.taskName"
placeholder="请输入爬虫名称"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">Bean名称</span>
<el-input
v-model="formData.beanName"
placeholder="请输入Spring Bean名称newsCrawlerTask"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">方法名称</span>
<el-input
v-model="formData.methodName"
placeholder="请输入要执行的方法名crawlNews"
clearable
/>
</div>
<div class="form-item">
<span class="form-label">方法参数</span>
<el-input
v-model="formData.methodParams"
type="textarea"
:rows="3"
placeholder="请输入方法参数JSON格式可选"
clearable
/>
<span class="form-tip">
示例{"source":"xinhua","category":"education"}
</span>
</div>
<div class="form-item">
<span class="form-label required">Cron表达式</span>
<el-input
v-model="formData.cronExpression"
placeholder="请输入Cron表达式"
clearable
>
<template #append>
<el-button @click="validateCron">验证</el-button>
</template>
</el-input>
<span class="form-tip">
常用示例<br/>
- 0 0 */6 * * ? 每6小时执行一次<br/>
- 0 0 8,12,18 * * ? 每天8点1218点执行<br/>
- 0 0/30 * * * ? 每30分钟执行一次
</span>
</div>
<div class="form-item">
<span class="form-label">爬虫描述</span>
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入爬虫描述"
/>
</div>
<div class="form-item">
<span class="form-label">是否允许并发</span>
<el-radio-group v-model="formData.concurrent">
<el-radio :label="1">允许</el-radio>
<el-radio :label="0">禁止</el-radio>
</el-radio-group>
<span class="form-tip">
建议禁止并发避免重复抓取
</span>
</div>
</div>
<div class="form-item">
<span class="form-label required">Bean名称</span>
<el-input
v-model="formData.beanName"
placeholder="请输入Spring Bean名称newsCrawlerTask"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">方法名称</span>
<el-input
v-model="formData.methodName"
placeholder="请输入要执行的方法名crawlNews"
clearable
/>
</div>
<div class="form-item">
<span class="form-label">方法参数</span>
<el-input
v-model="formData.methodParams"
type="textarea"
:rows="3"
placeholder="请输入方法参数JSON格式可选"
clearable
/>
<span class="form-tip">
示例{"source":"xinhua","category":"education"}
</span>
</div>
<div class="form-item">
<span class="form-label required">Cron表达式</span>
<el-input
v-model="formData.cronExpression"
placeholder="请输入Cron表达式"
clearable
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
>
<template #append>
<el-button @click="validateCron">验证</el-button>
</template>
</el-input>
<span class="form-tip">
常用示例<br/>
- 0 0 */6 * * ? 每6小时执行一次<br/>
- 0 0 8,12,18 * * ? 每天8点1218点执行<br/>
- 0 0/30 * * * ? 每30分钟执行一次
</span>
</div>
<div class="form-item">
<span class="form-label">爬虫描述</span>
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入爬虫描述"
/>
</div>
<div class="form-item">
<span class="form-label">是否允许并发</span>
<el-radio-group v-model="formData.concurrent">
<el-radio :label="1">允许</el-radio>
<el-radio :label="0">禁止</el-radio>
</el-radio-group>
<span class="form-tip">
建议禁止并发避免重复抓取
</span>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
>
确定
</el-button>
</template>
</el-dialog>
</div>
确定
</el-button>
</template>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -269,7 +271,10 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Search, Refresh, DocumentCopy, VideoPlay, VideoPause, Promotion, Edit, Delete } from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import type { CrontabTask, PageParam } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'NewsCrawlerView'
});
// 数据状态
const loading = ref(false);
const submitting = ref(false);

View File

@@ -1,245 +1,247 @@
<template>
<div class="task-management">
<div class="header">
<h2>定时任务管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增任务
</el-button>
</div>
<!-- 搜索筛选区域 -->
<div class="search-bar">
<div class="search-item">
<span class="search-label">任务名称</span>
<el-input
v-model="searchForm.taskName"
placeholder="请输入任务名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">任务分组</span>
<el-input
v-model="searchForm.taskGroup"
placeholder="请输入任务分组"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="search-item">
<span class="search-label">状态</span>
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<el-option label="运行中" :value="1" />
<el-option label="已暂停" :value="0" />
</el-select>
</div>
<div class="search-actions">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
<AdminLayout title="定时任务" subtitle="定时任务管理">
<div class="task-management">
<div class="header">
<h2>定时任务管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增任务
</el-button>
</div>
</div>
<!-- 任务列表 -->
<el-table
:data="taskList"
style="width: 100%"
v-loading="loading"
border
stripe
>
<el-table-column prop="taskName" label="任务名称" min-width="150" />
<el-table-column prop="taskGroup" label="任务分组" width="120" />
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
<el-table-column prop="methodName" label="方法名称" width="120" />
<el-table-column prop="cronExpression" label="Cron表达式" min-width="150" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '运行中' : '已暂停' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="并发" width="80">
<template #default="{ row }">
<el-tag :type="row.concurrent === 1 ? 'success' : 'warning'" size="small">
{{ row.concurrent === 1 ? '允许' : '禁止' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 0"
type="success"
size="small"
@click="handleStart(row)"
>
启动
</el-button>
<el-button
v-else
type="warning"
size="small"
@click="handlePause(row)"
>
暂停
</el-button>
<el-button
type="primary"
size="small"
@click="handleExecute(row)"
>
执行一次
</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑任务' : '新增任务'"
width="700px"
@close="resetForm"
>
<div class="form-content">
<div class="form-item">
<span class="form-label required">任务名称</span>
<!-- 搜索筛选区域 -->
<div class="search-bar">
<div class="search-item">
<span class="search-label">任务名称</span>
<el-input
v-model="formData.taskName"
placeholder="请输入任务名称"
v-model="searchForm.taskName"
placeholder="请输入任务名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="form-item">
<span class="form-label required">任务分组</span>
<div class="search-item">
<span class="search-label">任务分组</span>
<el-input
v-model="formData.taskGroup"
placeholder="请输入任务分组SYSTEM、BUSINESS"
v-model="searchForm.taskGroup"
placeholder="请输入任务分组"
clearable
@keyup.enter="handleSearch"
style="width: 200px"
/>
</div>
<div class="form-item">
<span class="form-label required">Bean名称</span>
<el-input
v-model="formData.beanName"
placeholder="请输入Spring Bean名称"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">方法名称</span>
<el-input
v-model="formData.methodName"
placeholder="请输入要执行的方法名"
clearable
/>
</div>
<div class="form-item">
<span class="form-label">方法参数</span>
<el-input
v-model="formData.methodParams"
placeholder="请输入方法参数JSON格式可选"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">Cron表达式</span>
<el-input
v-model="formData.cronExpression"
placeholder="请输入Cron表达式0 0 2 * * ?"
<div class="search-item">
<span class="search-label">状态</span>
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 150px"
>
<template #append>
<el-button @click="validateCron">验证</el-button>
</template>
</el-input>
<span class="form-tip">
格式 年可选
示例0 0 2 * * ? 表示每天凌晨2点执行
</span>
</div>
<div class="form-item">
<span class="form-label">是否允许并发</span>
<el-radio-group v-model="formData.concurrent">
<el-radio :label="1">允许</el-radio>
<el-radio :label="0">禁止</el-radio>
</el-radio-group>
</div>
<div class="form-item">
<span class="form-label">错过执行策略</span>
<el-select v-model="formData.misfirePolicy" placeholder="请选择策略">
<el-option label="立即执行" :value="1" />
<el-option label="执行一次" :value="2" />
<el-option label="放弃执行" :value="3" />
<el-option label="运行中" :value="1" />
<el-option label="已暂停" :value="0" />
</el-select>
</div>
<div class="form-item">
<span class="form-label">任务描述</span>
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入任务描述"
/>
<div class="search-actions">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
>
确定
</el-button>
</template>
</el-dialog>
</div>
<!-- 任务列表 -->
<el-table
:data="taskList"
style="width: 100%"
v-loading="loading"
border
stripe
>
<el-table-column prop="taskName" label="任务名称" min-width="150" />
<el-table-column prop="taskGroup" label="任务分组" width="120" />
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
<el-table-column prop="methodName" label="方法名称" width="120" />
<el-table-column prop="cronExpression" label="Cron表达式" min-width="150" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '运行中' : '已暂停' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="并发" width="80">
<template #default="{ row }">
<el-tag :type="row.concurrent === 1 ? 'success' : 'warning'" size="small">
{{ row.concurrent === 1 ? '允许' : '禁止' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 0"
type="success"
size="small"
@click="handleStart(row)"
>
启动
</el-button>
<el-button
v-else
type="warning"
size="small"
@click="handlePause(row)"
>
暂停
</el-button>
<el-button
type="primary"
size="small"
@click="handleExecute(row)"
>
执行一次
</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑任务' : '新增任务'"
width="700px"
@close="resetForm"
>
<div class="form-content">
<div class="form-item">
<span class="form-label required">任务名称</span>
<el-input
v-model="formData.taskName"
placeholder="请输入任务名称"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">任务分组</span>
<el-input
v-model="formData.taskGroup"
placeholder="请输入任务分组SYSTEM、BUSINESS"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">Bean名称</span>
<el-input
v-model="formData.beanName"
placeholder="请输入Spring Bean名称"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">方法名称</span>
<el-input
v-model="formData.methodName"
placeholder="请输入要执行的方法名"
clearable
/>
</div>
<div class="form-item">
<span class="form-label">方法参数</span>
<el-input
v-model="formData.methodParams"
placeholder="请输入方法参数JSON格式可选"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">Cron表达式</span>
<el-input
v-model="formData.cronExpression"
placeholder="请输入Cron表达式0 0 2 * * ?"
clearable
>
<template #append>
<el-button @click="validateCron">验证</el-button>
</template>
</el-input>
<span class="form-tip">
格式 年可选
示例0 0 2 * * ? 表示每天凌晨2点执行
</span>
</div>
<div class="form-item">
<span class="form-label">是否允许并发</span>
<el-radio-group v-model="formData.concurrent">
<el-radio :label="1">允许</el-radio>
<el-radio :label="0">禁止</el-radio>
</el-radio-group>
</div>
<div class="form-item">
<span class="form-label">错过执行策略</span>
<el-select v-model="formData.misfirePolicy" placeholder="请选择策略">
<el-option label="立即执行" :value="1" />
<el-option label="执行一次" :value="2" />
<el-option label="放弃执行" :value="3" />
</el-select>
</div>
<div class="form-item">
<span class="form-label">任务描述</span>
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入任务描述"
/>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -248,7 +250,10 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Search, Refresh } from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import type { CrontabTask, PageParam } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'TaskManagementView'
});
// 数据状态
const loading = ref(false);
const submitting = ref(false);

View File

@@ -1,60 +1,65 @@
<template>
<div class="login-logs">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名..."
style="width: 200px"
clearable
<AdminLayout title="登录日志" subtitle="登录日志管理">
<div class="login-logs">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名..."
style="width: 200px"
clearable
/>
<el-select v-model="loginStatus" placeholder="登录状态" style="width: 150px" clearable>
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleExport">导出</el-button>
<el-button type="danger" @click="handleClear">清空日志</el-button>
</div>
<el-table :data="logs" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="ipAddress" label="IP地址" width="140" />
<el-table-column prop="location" label="登录地点" width="150" />
<el-table-column prop="browser" label="浏览器" width="120" />
<el-table-column prop="os" label="操作系统" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="信息" min-width="150" />
<el-table-column prop="loginTime" label="登录时间" width="180" />
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<el-select v-model="loginStatus" placeholder="登录状态" style="width: 150px" clearable>
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleExport">导出</el-button>
<el-button type="danger" @click="handleClear">清空日志</el-button>
</div>
<el-table :data="logs" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="ipAddress" label="IP地址" width="140" />
<el-table-column prop="location" label="登录地点" width="150" />
<el-table-column prop="browser" label="浏览器" width="120" />
<el-table-column prop="os" label="操作系统" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="信息" min-width="150" />
<el-table-column prop="loginTime" label="登录时间" width="180" />
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage, ElMessageBox } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'LoginLogsView'
});
const searchKeyword = ref('');
const loginStatus = ref('');
const dateRange = ref<[Date, Date] | null>(null);

View File

@@ -1,72 +1,78 @@
<template>
<div class="operation-logs">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或操作..."
style="width: 250px"
clearable
<AdminLayout title="操作日志" subtitle="操作日志管理">
<div class="operation-logs">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或操作..."
style="width: 250px"
clearable
/>
<el-select v-model="operationType" placeholder="操作类型" style="width: 150px" clearable>
<el-option label="新增" value="create" />
<el-option label="修改" value="update" />
<el-option label="删除" value="delete" />
<el-option label="查询" value="read" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
<el-table :data="logs" style="width: 100%">
<el-table-column prop="username" label="操作人" width="120" />
<el-table-column prop="module" label="操作模块" width="120" />
<el-table-column prop="operation" label="操作类型" width="100">
<template #default="{ row }">
<el-tag :type="getOperationType(row.operation)">
{{ getOperationText(row.operation) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="操作描述" min-width="200" />
<el-table-column prop="ipAddress" label="IP地址" width="140" />
<el-table-column prop="duration" label="耗时(ms)" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operationTime" label="操作时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<el-select v-model="operationType" placeholder="操作类型" style="width: 150px" clearable>
<el-option label="新增" value="create" />
<el-option label="修改" value="update" />
<el-option label="删除" value="delete" />
<el-option label="查询" value="read" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
</AdminLayout>
<el-table :data="logs" style="width: 100%">
<el-table-column prop="username" label="操作人" width="120" />
<el-table-column prop="module" label="操作模块" width="120" />
<el-table-column prop="operation" label="操作类型" width="100">
<template #default="{ row }">
<el-tag :type="getOperationType(row.operation)">
{{ getOperationText(row.operation) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="操作描述" min-width="200" />
<el-table-column prop="ipAddress" label="IP地址" width="140" />
<el-table-column prop="duration" label="耗时(ms)" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operationTime" label="操作时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'OperationLogsView'
});
const searchKeyword = ref('');
const operationType = ref('');
const dateRange = ref<[Date, Date] | null>(null);

View File

@@ -1,98 +1,103 @@
<template>
<div class="system-config">
<el-form :model="configForm" label-width="150px" class="config-form">
<el-divider content-position="left">基本设置</el-divider>
<el-form-item label="系统名称">
<el-input v-model="configForm.systemName" />
</el-form-item>
<AdminLayout title="系统配置" subtitle="系统配置">
<div class="system-config">
<el-form :model="configForm" label-width="150px" class="config-form">
<el-divider content-position="left">基本设置</el-divider>
<el-form-item label="系统名称">
<el-input v-model="configForm.systemName" />
</el-form-item>
<el-form-item label="系统Logo">
<el-upload
class="logo-uploader"
action="#"
:show-file-list="false"
:before-upload="beforeLogoUpload"
>
<img v-if="configForm.logo" :src="configForm.logo" class="logo-preview" />
<el-icon v-else class="logo-uploader-icon">+</el-icon>
</el-upload>
</el-form-item>
<el-form-item label="系统Logo">
<el-upload
class="logo-uploader"
action="#"
:show-file-list="false"
:before-upload="beforeLogoUpload"
>
<img v-if="configForm.logo" :src="configForm.logo" class="logo-preview" />
<el-icon v-else class="logo-uploader-icon">+</el-icon>
</el-upload>
</el-form-item>
<el-form-item label="版权信息">
<el-input v-model="configForm.copyright" />
</el-form-item>
<el-form-item label="版权信息">
<el-input v-model="configForm.copyright" />
</el-form-item>
<el-divider content-position="left">安全设置</el-divider>
<el-divider content-position="left">安全设置</el-divider>
<el-form-item label="启用验证码">
<el-switch v-model="configForm.enableCaptcha" />
</el-form-item>
<el-form-item label="启用验证码">
<el-switch v-model="configForm.enableCaptcha" />
</el-form-item>
<el-form-item label="密码最小长度">
<el-input-number v-model="configForm.minPasswordLength" :min="6" :max="20" />
</el-form-item>
<el-form-item label="密码最小长度">
<el-input-number v-model="configForm.minPasswordLength" :min="6" :max="20" />
</el-form-item>
<el-form-item label="会话超时(分钟)">
<el-input-number v-model="configForm.sessionTimeout" :min="5" :max="1440" />
</el-form-item>
<el-form-item label="会话超时(分钟)">
<el-input-number v-model="configForm.sessionTimeout" :min="5" :max="1440" />
</el-form-item>
<el-form-item label="登录失败锁定">
<el-switch v-model="configForm.enableLoginLock" />
</el-form-item>
<el-form-item label="登录失败锁定">
<el-switch v-model="configForm.enableLoginLock" />
</el-form-item>
<el-form-item label="锁定阈值(次)" v-if="configForm.enableLoginLock">
<el-input-number v-model="configForm.loginLockThreshold" :min="3" :max="10" />
</el-form-item>
<el-form-item label="锁定阈值(次)" v-if="configForm.enableLoginLock">
<el-input-number v-model="configForm.loginLockThreshold" :min="3" :max="10" />
</el-form-item>
<el-divider content-position="left">功能设置</el-divider>
<el-divider content-position="left">功能设置</el-divider>
<el-form-item label="启用用户注册">
<el-switch v-model="configForm.enableRegister" />
</el-form-item>
<el-form-item label="启用用户注册">
<el-switch v-model="configForm.enableRegister" />
</el-form-item>
<el-form-item label="启用评论功能">
<el-switch v-model="configForm.enableComment" />
</el-form-item>
<el-form-item label="启用评论功能">
<el-switch v-model="configForm.enableComment" />
</el-form-item>
<el-form-item label="启用文件上传">
<el-switch v-model="configForm.enableFileUpload" />
</el-form-item>
<el-form-item label="启用文件上传">
<el-switch v-model="configForm.enableFileUpload" />
</el-form-item>
<el-form-item label="文件上传大小限制(MB)">
<el-input-number v-model="configForm.maxFileSize" :min="1" :max="100" />
</el-form-item>
<el-form-item label="文件上传大小限制(MB)">
<el-input-number v-model="configForm.maxFileSize" :min="1" :max="100" />
</el-form-item>
<el-divider content-position="left">邮件设置</el-divider>
<el-divider content-position="left">邮件设置</el-divider>
<el-form-item label="启用邮件通知">
<el-switch v-model="configForm.enableEmail" />
</el-form-item>
<el-form-item label="启用邮件通知">
<el-switch v-model="configForm.enableEmail" />
</el-form-item>
<el-form-item label="SMTP服务器" v-if="configForm.enableEmail">
<el-input v-model="configForm.smtpHost" />
</el-form-item>
<el-form-item label="SMTP服务器" v-if="configForm.enableEmail">
<el-input v-model="configForm.smtpHost" />
</el-form-item>
<el-form-item label="SMTP端口" v-if="configForm.enableEmail">
<el-input-number v-model="configForm.smtpPort" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="SMTP端口" v-if="configForm.enableEmail">
<el-input-number v-model="configForm.smtpPort" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="发件人邮箱" v-if="configForm.enableEmail">
<el-input v-model="configForm.senderEmail" />
</el-form-item>
<el-form-item label="发件人邮箱" v-if="configForm.enableEmail">
<el-input v-model="configForm.senderEmail" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElForm, ElFormItem, ElInput, ElInputNumber, ElSwitch, ElButton, ElDivider, ElUpload, ElIcon, ElMessage } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'SystemConfigView'
});
const configForm = ref({
systemName: '红色思政学习平台',
logo: '',

View File

@@ -1,19 +1,21 @@
<template>
<div class="system-logs">
<h1 class="page-title">系统日志</h1>
<AdminLayout title="系统日志" subtitle="系统日志管理">
<div class="system-logs">
<h1 class="page-title">系统日志</h1>
<el-tabs v-model="activeTab">
<el-tab-pane label="登录日志" name="login">
<LoginLogs />
</el-tab-pane>
<el-tab-pane label="操作日志" name="operation">
<OperationLogs />
</el-tab-pane>
<el-tab-pane label="系统配置" name="config">
<SystemConfig />
</el-tab-pane>
</el-tabs>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="登录日志" name="login">
<LoginLogs />
</el-tab-pane>
<el-tab-pane label="操作日志" name="operation">
<OperationLogs />
</el-tab-pane>
<el-tab-pane label="系统配置" name="config">
<SystemConfig />
</el-tab-pane>
</el-tabs>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -22,7 +24,10 @@ import { ElTabs, ElTabPane } from 'element-plus';
import LoginLogs from './components/LoginLogs.vue';
import OperationLogs from './components/OperationLogs.vue';
import SystemConfig from './components/SystemConfig.vue';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'SystemLogsView'
});
const activeTab = ref('login');
</script>

View File

@@ -1,15 +1,19 @@
<template>
<div class="article-management">
<div class="action-bar">
<el-button type="primary" @click="showCreateDialog">+ 新增文章</el-button>
<el-button @click="handleDataCollection">数据采集</el-button>
<el-input
v-model="searchKeyword"
placeholder="搜索文章..."
style="width: 300px"
clearable
/>
</div>
<AdminLayout
title="资源管理"
subtitle="管理文章、资源、数据等内容"
>
<div class="article-management">
<div class="action-bar">
<el-button type="primary" @click="showCreateDialog">+ 新增文章</el-button>
<el-button @click="handleDataCollection">数据采集</el-button>
<el-input
v-model="searchKeyword"
placeholder="搜索文章..."
style="width: 300px"
clearable
/>
</div>
<el-table :data="articles" style="width: 100%">
<el-table-column prop="title" label="文章标题" min-width="200" />
@@ -61,10 +65,16 @@
@edit="handleEditFromView"
@close="showViewDialog = false"
/>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'ArticleManagementView'
});
import { ref, onMounted } from 'vue';
import { ElButton, ElInput, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
@@ -245,7 +255,9 @@ function handleCurrentChange(val: number) {
<style lang="scss" scoped>
.article-management {
padding: 20px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
}
.action-bar {

View File

@@ -1,21 +1,31 @@
<template>
<div class="data-records">
<el-tabs v-model="activeTab">
<el-tab-pane label="菜单管理" name="menu">
<!-- 菜单管理已在manage/system中实现这里可以引用或重新实现 -->
<div class="redirect-info">
<p>菜单管理功能已在系统管理模块中实现</p>
<el-button type="primary" @click="goToMenuManage">前往菜单管理</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
<AdminLayout
title="资源管理"
subtitle="管理文章、资源、数据等内容"
>
<div class="data-records">
<el-tabs v-model="activeTab">
<el-tab-pane label="菜单管理" name="menu">
<!-- 菜单管理已在manage/system中实现这里可以引用或重新实现 -->
<div class="redirect-info">
<p>菜单管理功能已在系统管理模块中实现</p>
<el-button type="primary" @click="goToMenuManage">前往菜单管理</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElTabs, ElTabPane, ElButton } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'DataRecordsView'
});
const router = useRouter();
const activeTab = ref('menu');
@@ -27,7 +37,9 @@ function goToMenuManage() {
<style lang="scss" scoped>
.data-records {
padding: 20px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
}
.redirect-info {

View File

@@ -1,37 +1,30 @@
<template>
<div class="resource-management">
<h1 class="page-title">资源管理</h1>
<el-tabs v-model="activeTab" class="resource-tabs">
<el-tab-pane label="文章储备" name="articles">
<ArticleManagement />
</el-tab-pane>
<el-tab-pane label="数据记录" name="data">
<DataRecords />
</el-tab-pane>
</el-tabs>
</div>
<AdminLayout
title="资源管理"
subtitle="管理文章、资源、数据等内容"
>
<div class="resource-management">
<el-empty description="请使用顶部标签页切换到对应的资源管理功能" />
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElTabs, ElTabPane } from 'element-plus';
import ArticleManagement from './components/ArticleManagement.vue';
import DataRecords from './components/DataRecords.vue';
import { AdminLayout } from '@/views/admin';
const activeTab = ref('articles');
defineOptions({
name: 'ResourceManagementView'
});
</script>
<style lang="scss" scoped>
.resource-management {
padding: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #141F38;
margin-bottom: 24px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,23 +1,33 @@
<template>
<div class="course-management">
<CourseList
v-if="currentView === 'list'"
@add="handleAdd"
@edit="handleEdit"
/>
<CourseAdd
v-else-if="currentView === 'add' || currentView === 'edit'"
:courseID="currentCourseId"
@success="handleSuccess"
@cancel="handleCancel"
/>
</div>
<AdminLayout
title="学习管理"
subtitle="管理课程、学习任务、学习记录等信息"
>
<div class="course-management">
<CourseList
v-if="currentView === 'list'"
@add="handleAdd"
@edit="handleEdit"
/>
<CourseAdd
v-else-if="currentView === 'add' || currentView === 'edit'"
:courseID="currentCourseId"
@success="handleSuccess"
@cancel="handleCancel"
/>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { CourseList, CourseAdd } from '@/views/public/course/components';
import type { Course } from '@/types/study';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'CourseManagementView'
});
type ViewType = 'list' | 'add' | 'edit';
@@ -47,7 +57,8 @@ function handleCancel() {
<style lang="scss" scoped>
.course-management {
height: 100%;
background: #f5f7fa;
background: #FFFFFF;
border-radius: 14px;
min-height: 500px;
}
</style>

View File

@@ -1,22 +1,29 @@
<template>
<div class="study-management">
<h1 class="page-title">学习管理</h1>
<AdminLayout title="学习管理" subtitle="学习记录管理">
<div class="study-management">
<h1 class="page-title">学习管理</h1>
<el-tabs v-model="activeTab">
<el-tabs v-model="activeTab">
<el-tab-pane label="学习记录" name="task-records">
<StudyRecords />
</el-tab-pane>
</el-tabs>
</div>
</AdminLayout>
<el-tab-pane label="学习记录" name="task-records">
<StudyRecords />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElTabs, ElTabPane } from 'element-plus';
import StudyRecords from './components/StudyRecords.vue';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'StudyManagementView'
});
const activeTab = ref('task-publish');
</script>
<style lang="scss" scoped>

View File

@@ -1,70 +1,75 @@
<template>
<div class="study-records">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户..."
style="width: 200px"
clearable
/>
<el-select v-model="selectedTask" placeholder="选择任务" style="width: 200px" clearable>
<el-option
v-for="task in tasks"
:key="task.id"
:label="task.name"
:value="task.id"
<AdminLayout title="学习记录" subtitle="学习记录管理">
<div class="study-records">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户..."
style="width: 200px"
clearable
/>
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
<el-select v-model="selectedTask" placeholder="选择任务" style="width: 200px" clearable>
<el-option
v-for="task in tasks"
:key="task.id"
:label="task.name"
:value="task.id"
/>
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
<el-table :data="records" style="width: 100%">
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="taskName" label="任务名称" min-width="180" />
<el-table-column prop="progress" label="完成进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress" />
</template>
</el-table-column>
<el-table-column prop="duration" label="学习时长" width="120" />
<el-table-column prop="startDate" label="开始时间" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
<el-table :data="records" style="width: 100%">
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="taskName" label="任务名称" min-width="180" />
<el-table-column prop="progress" label="完成进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress" />
</template>
</el-table-column>
<el-table-column prop="duration" label="学习时长" width="120" />
<el-table-column prop="startDate" label="开始时间" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElProgress, ElPagination, ElMessage } from 'element-plus';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'StudyRecordsView'
});
const searchKeyword = ref('');
const selectedTask = ref('');
const dateRange = ref<[Date, Date] | null>(null);

View File

@@ -1,24 +1,26 @@
<template>
<div class="task-management">
<LearningTaskList
v-if="currentView === 'list'"
@add="handleAdd"
@edit="handleEdit"
/>
<LearningTaskAdd
v-else-if="currentView === 'add' || currentView === 'edit'"
:task-i-d="currentTaskId"
@success="handleSuccess"
@cancel="handleCancel"
/>
</div>
<AdminLayout title="任务管理" subtitle="任务管理">
<div class="task-management">
<LearningTaskList
v-if="currentView === 'list'"
@add="handleAdd"
@edit="handleEdit"
/>
<LearningTaskAdd
v-else-if="currentView === 'add' || currentView === 'edit'"
:task-i-d="currentTaskId"
@success="handleSuccess"
@cancel="handleCancel"
/>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { LearningTaskList, LearningTaskAdd } from '@/views/public/task';
import type { LearningTask } from '@/types/study';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'TaskManageView'
});

View File

@@ -1,12 +1,16 @@
<template>
<div class="dept-manage">
<div class="header">
<h2>部门管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增部门
</el-button>
</div>
<AdminLayout
title="系统管理"
subtitle="管理用户、角色、权限、部门等系统信息"
>
<div class="dept-manage">
<div class="header">
<h2>部门管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增部门
</el-button>
</div>
<div class="tree-container">
<el-tree
@@ -163,12 +167,18 @@
</template>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { deptApi } from '@/apis/system/dept';
import { roleApi } from '@/apis/system/role';
import { SysDept, SysRole } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'DeptManageView'
});
import { ref, onMounted, reactive, computed } from 'vue';
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus, OfficeBuilding } from '@element-plus/icons-vue';
@@ -605,7 +615,9 @@ async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string
<style scoped lang="scss">
.dept-manage {
padding: 20px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
.header {
display: flex;

View File

@@ -1,12 +1,16 @@
<template>
<div class="menu-manage">
<div class="header">
<h2>菜单管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增菜单
</el-button>
</div>
<AdminLayout
title="系统管理"
subtitle="管理用户、角色、权限、部门等系统信息"
>
<div class="menu-manage">
<div class="header">
<h2>菜单管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增菜单
</el-button>
</div>
<div class="tree-container">
<el-tree
@@ -208,12 +212,18 @@
</template>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { menuApi } from '@/apis/system/menu';
import { permissionApi } from '@/apis/system/permission';
import { SysMenu, SysPermission } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'MenuManageView'
});
import { ref, onMounted, reactive, computed } from 'vue';
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
@@ -692,7 +702,9 @@ async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string
<style scoped lang="scss">
.menu-manage {
padding: 20px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
.header {
display: flex;

View File

@@ -1,12 +1,16 @@
<template>
<div class="module-permission-manage">
<div class="header">
<h2>模块权限管理</h2>
<el-button type="primary" @click="handleAddModule">
<el-icon><Plus /></el-icon>
新增模块
</el-button>
</div>
<AdminLayout
title="系统管理"
subtitle="管理用户、角色、权限、部门等系统信息"
>
<div class="module-permission-manage">
<div class="header">
<h2>模块权限管理</h2>
<el-button type="primary" @click="handleAddModule">
<el-icon><Plus /></el-icon>
新增模块
</el-button>
</div>
<div class="content-wrapper">
<!-- 左侧模块树 -->
@@ -389,7 +393,8 @@
</el-button>
</template>
</el-dialog>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -401,6 +406,11 @@ import { permissionApi } from '@/apis/system/permission';
import { menuApi } from '@/apis/system/menu';
import { roleApi } from '@/apis/system/role';
import type { SysModule, SysPermission, SysMenu, SysRole } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'ModulePermissionManageView'
});
// 数据状态
const moduleLoading = ref(false);
@@ -873,9 +883,9 @@ onMounted(() => {
<style scoped lang="scss">
.module-permission-manage {
padding: 20px;
background-color: #fff;
border-radius: 4px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
height: calc(100vh - 120px);
display: flex;
flex-direction: column;

View File

@@ -1,12 +1,16 @@
<template>
<div class="role-manage">
<div class="header">
<h2>角色管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增角色
</el-button>
</div>
<AdminLayout
title="系统管理"
subtitle="管理用户、角色、权限、部门等系统信息"
>
<div class="role-manage">
<div class="header">
<h2>角色管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增角色
</el-button>
</div>
<el-table
:data="roleList"
@@ -148,13 +152,19 @@
</el-button>
</template>
</el-dialog>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { roleApi } from '@/apis/system/role';
import { permissionApi } from '@/apis/system/permission';
import { SysRole, SysPermission } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'RoleManageView'
});
import { ref, onMounted, reactive } from 'vue';
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
@@ -415,7 +425,9 @@ onMounted(() => {
<style scoped lang="scss">
.role-manage {
padding: 20px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
.header {
display: flex;

View File

@@ -1,217 +1,230 @@
<template>
<div class="user-manage">
<div class="header">
<h2>用户管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增用户
</el-button>
</div>
<el-table
:data="userList"
style="width: 100%"
v-loading="loading"
border
stripe
row-key="userID"
>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="phone" label="手机号" min-width="120" />
<el-table-column prop="realName" label="真实姓名" min-width="100" />
<el-table-column prop="nickname" label="昵称" min-width="100" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'success' : 'danger'">
{{ row.status === 0 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="creatorName" label="创建人" width="120" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
link
>
编辑
</el-button>
<el-button
type="primary"
size="small"
@click="handleBindDeptRole(row)"
link
>
绑定部门角色
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
link
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑用户对话框 -->
<el-dialog
v-model="userDialogVisible"
:title="isEdit ? '编辑用户' : '新增用户'"
width="600px"
@close="resetForm"
>
<el-form
ref="userFormRef"
:model="currentUser"
:rules="userFormRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="currentUser.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input
v-model="currentUser.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="currentUser.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="currentUser.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="currentUser.realName" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="currentUser.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="currentUser.status" placeholder="请选择状态">
<el-option label="正常" :value="0" />
<el-option label="禁用" :value="1" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveUser" :loading="submitting">
{{ isEdit ? '更新' : '创建' }}
<AdminLayout
title="系统管理"
subtitle="管理用户、角色、权限、部门等系统信息"
>
<div class="user-manage">
<div class="header">
<h2>用户管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增用户
</el-button>
</template>
</el-dialog>
<!-- 绑定部门角色对话框 -->
<el-dialog
v-model="bindDeptRoleDialogVisible"
title="绑定部门角色"
width="800px"
@close="resetBindForm"
>
<div class="bind-info">
<el-alert
:title="`用户:${currentUser.username} (${currentUser.realName})`"
type="info"
:closable="false"
style="margin-bottom: 20px"
/>
</div>
<el-table :data="bindList" style="width: 100%" border stripe>
<el-table-column width="80" label="绑定状态">
<el-table
:data="userList"
style="width: 100%"
v-loading="loading"
border
stripe
row-key="userID"
>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="phone" label="手机号" min-width="120" />
<el-table-column prop="realName" label="真实姓名" min-width="100" />
<el-table-column prop="nickname" label="昵称" min-width="100" />
<el-table-column prop="deptName" label="部门" min-width="120" />
<el-table-column prop="roleName" label="角色" min-width="120" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag
:type="isSelected(row) ? 'success' : 'info'"
size="small"
>
{{ isSelected(row) ? '已绑定' : '未绑定' }}
<el-tag :type="row.status === 0 ? 'success' : 'danger'">
{{ row.status === 0 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="deptName" label="部门" min-width="150" />
<el-table-column prop="roleName" label="角色" min-width="150" />
<el-table-column label="操作" width="100">
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
:type="isSelected(row) ? 'danger' : 'primary'"
type="primary"
size="small"
@click="toggleSelection(row)"
@click="handleEdit(row)"
link
>
{{ isSelected(row) ? '解绑' : '绑定' }}
编辑
</el-button>
<el-button
type="primary"
size="small"
@click="handleBindDeptRole(row)"
link
>
绑定部门角色
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
link
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="bind-stats" style="margin-top: 20px">
<el-alert
:title="`已绑定 ${selectedBindings.length} 个部门角色,未绑定 ${bindList.length - selectedBindings.length} 个部门角色`"
type="info"
:closable="false"
show-icon
<!-- 分页组件 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="bindDeptRoleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveBindings" :loading="submitting">
保存
</el-button>
</template>
</el-dialog>
</div>
<!-- 新增/编辑用户对话框 -->
<el-dialog
v-model="userDialogVisible"
:title="isEdit ? '编辑用户' : '新增用户'"
width="600px"
@close="resetForm"
>
<el-form
ref="userFormRef"
:model="currentUser"
:rules="userFormRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="currentUser.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input
v-model="currentUser.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="currentUser.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="currentUser.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="currentUser.realName" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="currentUser.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="currentUser.status">
<el-radio :value="0">正常</el-radio>
<el-radio :value="1">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUserForm" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
<!-- 绑定部门角色对话框 -->
<el-dialog
v-model="bindDialogVisible"
title="绑定部门角色"
width="600px"
@close="resetBindForm"
>
<el-form
ref="bindFormRef"
:model="bindForm"
label-width="100px"
>
<el-form-item label="选择部门">
<el-tree-select
v-model="bindForm.deptId"
:data="deptTree"
check-strictly
:render-after-expand="false"
placeholder="请选择部门"
/>
</el-form-item>
<el-form-item label="选择角色">
<el-select v-model="bindForm.roleIds" multiple placeholder="请选择角色" style="width: 100%">
<el-option
v-for="role in roles"
:key="role.roleID"
:label="role.name"
:value="role.roleID"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitBindForm" :loading="binding">
确定
</el-button>
</template>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { userApi } from '@/apis/system/user';
import { deptApi } from '@/apis/system/dept';
import { roleApi } from '@/apis/system/role';
import type { SysUser, UserDeptRoleVO, UserVO, SysDeptRole} from '@/types';
import { userApi, deptApi, roleApi } from '@/apis/system';
import type { SysUser, SysRole, PageParam, UserVO, UserDeptRoleVO } from '@/types';
import { AdminLayout } from '@/views/admin';
// 扩展部门角色类型,包含绑定状态和显示信息
interface DeptRoleBindItem extends SysDeptRole {
deptName?: string;
roleName?: string;
isBound?: boolean;
}
defineOptions({
name: 'UserManageView'
});
// 响应式数据
const loading = ref(false);
const submitting = ref(false);
const userList = ref<UserVO[]>([]);
const binding = ref(false);
const userList = ref<UserVO[]>([]);
const deptTree = ref<any[]>([]);
const roles = ref<SysRole[]>([]);
// 分页参数
const pageParam = ref<PageParam>({
pageNumber: 1,
pageSize: 10
});
const total = ref(0);
// 对话框控制
const userDialogVisible = ref(false);
const bindDeptRoleDialogVisible = ref(false);
const bindDialogVisible = ref(false);
const isEdit = ref(false);
// 当前操作的用户
const currentUser = ref<SysUser & { realName?: string; nickname?: string }>({});
const userFormRef = ref<FormInstance>();
const bindFormRef = ref<FormInstance>();
// 绑定相关数据
const bindList = ref<DeptRoleBindItem[]>([]);
const selectedBindings = ref<string[]>([]); // 存储选中的绑定项的唯一标识
const currentUser = ref<UserVO & { password?: string }>({
status: 0
});
// 表单验证规则
const userFormRules = {
const bindForm = ref<{
userId?: string;
deptId?: string;
roleIds: string[];
}>({
roleIds: []
});
const userFormRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
@@ -219,34 +232,20 @@ const userFormRules = {
]
};
// 生成唯一标识
function getBindingKey(item: DeptRoleBindItem): string {
return `${item.deptID}_${item.roleID}`;
}
onMounted(() => {
loadUsers();
loadDepts();
loadRoles();
});
// 检查是否已选中
function isSelected(item: DeptRoleBindItem): boolean {
return selectedBindings.value.includes(getBindingKey(item));
}
// 切换选择状态
function toggleSelection(item: DeptRoleBindItem) {
const key = getBindingKey(item);
const index = selectedBindings.value.indexOf(key);
if (index > -1) {
selectedBindings.value.splice(index, 1);
} else {
selectedBindings.value.push(key);
}
}
// 加载用户列表
async function loadUserList() {
async function loadUsers() {
try {
loading.value = true;
const result = await userApi.getUserList({id: ""});
userList.value = result.dataList || [];
const result = await userApi.getUserPage(pageParam.value);
if (result.success) {
userList.value = result.dataList || [];
total.value = result.pageParam?.totalElements || 0;
}
} catch (error) {
console.error('加载用户列表失败:', error);
ElMessage.error('加载用户列表失败');
@@ -255,267 +254,176 @@ async function loadUserList() {
}
}
// 加载部门和角色数据用于名称映射
async function loadDeptAndRoleData() {
async function loadDepts() {
try {
const [deptResult, roleResult] = await Promise.all([
deptApi.getAllDepts(),
roleApi.getAllRoles()
]);
const depts = deptResult.dataList || [];
const roles = roleResult.dataList || [];
const deptMap = new Map(depts.map(d => [d.deptID, d.name || '未知部门']));
const roleMap = new Map(roles.map(r => [r.roleID, r.name || '未知角色']));
return { deptMap, roleMap };
const result = await deptApi.getAllDepts();
if (result.success) {
deptTree.value = result.dataList || [];
}
} catch (error) {
console.error('加载部门角色数据失败:', error);
return { deptMap: new Map(), roleMap: new Map() };
console.error('加载部门列表失败:', error);
}
}
// 加载部门角色绑定列表
async function loadDeptRoleList(): Promise<DeptRoleBindItem[]> {
async function loadRoles() {
try {
const [deptRoleResult, { deptMap, roleMap }] = await Promise.all([
deptApi.getDeptRoleList({id: ""}),
loadDeptAndRoleData()
]);
const deptRoles = deptRoleResult.dataList || [];
return deptRoles.map(item => ({
...item,
deptName: deptMap.get(item.deptID || '') || `部门${item.deptID}`,
roleName: roleMap.get(item.roleID || '') || `角色${item.roleID}`,
isBound: false // 初始状态为未绑定,需要根据实际绑定情况设置
}));
const result = await roleApi.getRoleList({});
if (result.success) {
roles.value = result.dataList || [];
}
} catch (error) {
console.error('加载部门角色列表失败:', error);
return [];
console.error('加载角色列表失败:', error);
}
}
// 新增用户
function handleAdd() {
isEdit.value = false;
currentUser.value = {};
currentUser.value = { status: 0 };
userDialogVisible.value = true;
}
// 编辑用户
function handleEdit(row: UserVO) {
isEdit.value = true;
// 将UserVO转换为SysUser
currentUser.value = {
id: row.id,
username: row.username,
email: row.email,
phone: row.phone,
wechatID: row.wechatID,
status: row.status,
createTime: row.createTime,
updateTime: row.updateTime,
deleteTime: row.deleteTime,
deleted: row.deleted,
realName: row.realName,
nickname: row.nickname
};
currentUser.value = { ...row };
userDialogVisible.value = true;
}
// 删除用户
async function handleDelete(row: UserVO) {
try {
await ElMessageBox.confirm(
`确定要删除用户 "${row.username}" 吗?`,
'确认删除',
`确定要删除用户"${row.username}"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
type: 'warning',
}
);
await userApi.deleteUser(row);
ElMessage.success('删除成功');
await loadUserList();
} catch (error) {
const result = await userApi.deleteUser({ id: row.id } as SysUser);
if (result.success) {
ElMessage.success('删除成功');
loadUsers();
} else {
ElMessage.error(result.message || '删除失败');
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除用户失败:', error);
ElMessage.error('删除用户失败');
ElMessage.error('删除失败');
}
}
}
// 保存用户
async function saveUser() {
try {
submitting.value = true;
if (isEdit.value) {
await userApi.updateUser(currentUser.value);
ElMessage.success('更新成功');
} else {
await userApi.createUser(currentUser.value);
ElMessage.success('创建成功');
}
userDialogVisible.value = false;
await loadUserList();
} catch (error) {
console.error('保存用户失败:', error);
ElMessage.error('保存用户失败');
} finally {
submitting.value = false;
}
}
// 绑定部门角色
async function handleBindDeptRole(row: UserVO) {
// 将UserVO转换为SysUser
currentUser.value = {
id: row.id,
username: row.username,
email: row.email,
phone: row.phone,
wechatID: row.wechatID,
status: row.status,
createTime: row.createTime,
updateTime: row.updateTime,
deleteTime: row.deleteTime,
deleted: row.deleted
function handleBindDeptRole(row: UserVO) {
bindForm.value = {
userId: row.id,
deptId: undefined,
roleIds: []
};
try {
// 加载全部部门和角色数据
bindList.value = await loadDeptRoleList();
// 加载已绑定部门角色
const deptRoleResult = await userApi.getUserDeptRole({userID: currentUser.value.id});
const deptRoles = deptRoleResult.dataList || [];
// 初始化选中状态为已绑定的项
selectedBindings.value = bindList.value
.filter(item => deptRoles.some(deptRole =>
deptRole.deptID === item.deptID && deptRole.roleID === item.roleID
))
.map(item => getBindingKey(item));
bindDeptRoleDialogVisible.value = true;
} catch (error) {
console.error('加载绑定数据失败:', error);
ElMessage.error('加载绑定数据失败');
}
bindDialogVisible.value = true;
}
// 保存绑定设置
async function saveBindings() {
if (!currentUser.value || !currentUser.value.id) {
ElMessage.error('用户信息不完整');
return;
}
async function submitUserForm() {
if (!userFormRef.value) return;
try {
await userFormRef.value.validate();
submitting.value = true;
// 获取当前已绑定的部门角色
const currentBoundResult = await userApi.getUserDeptRole({userID: currentUser.value.id});
const currentBoundItems = currentBoundResult.dataList || [];
const currentBoundKeys = currentBoundItems.map(item => `${item.deptID}_${item.roleID}`);
// 找出需要绑定的项(新增的)
const itemsToBind = bindList.value.filter(item => {
const key = getBindingKey(item);
return selectedBindings.value.includes(key) && !currentBoundKeys.includes(key);
});
// 找出需要解绑的项(移除的)
const itemsToUnbind = bindList.value.filter(item => {
const key = getBindingKey(item);
return !selectedBindings.value.includes(key) && currentBoundKeys.includes(key);
});
// 执行绑定操作 - 一次性发送所有绑定项
if (itemsToBind.length > 0) {
const userDeptRoleVO: UserDeptRoleVO = {
user: currentUser.value,
users: [currentUser.value],
userDeptRoles: itemsToBind.map(item => ({
deptID: item.deptID,
roleID: item.roleID
}))
};
await userApi.bindUserDeptRole(userDeptRoleVO);
let result;
if (isEdit.value) {
result = await userApi.updateUser(currentUser.value as SysUser);
} else {
result = await userApi.createUser(currentUser.value as SysUser);
}
// 执行解绑操作 - 一次性发送所有解绑项
if (itemsToUnbind.length > 0) {
const userDeptRoleVO: UserDeptRoleVO = {
user: currentUser.value,
users: [currentUser.value],
userDeptRoles: itemsToUnbind.map(item => ({
deptID: item.deptID,
roleID: item.roleID
}))
};
await userApi.unbindUserDeptRole(userDeptRoleVO);
if (result.success) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功');
userDialogVisible.value = false;
loadUsers();
} else {
ElMessage.error(result.message || (isEdit.value ? '更新失败' : '创建失败'));
}
ElMessage.success('部门角色绑定保存成功');
bindDeptRoleDialogVisible.value = false;
// 刷新用户列表
await loadUserList();
} catch (error) {
console.error('保存部门角色绑定失败:', error);
ElMessage.error('保存部门角色绑定失败');
console.error('提交失败:', error);
} finally {
submitting.value = false;
}
}
// 重置表单
async function submitBindForm() {
try {
binding.value = true;
// 构建 UserDeptRoleVO 对象
const userDeptRoleVO: UserDeptRoleVO = {
user: { id: bindForm.value.userId } as SysUser,
depts: bindForm.value.deptId ? [{ id: bindForm.value.deptId }] : [],
roles: bindForm.value.roleIds.map(roleId => ({ id: roleId }))
};
const result = await userApi.bindUserDeptRole(userDeptRoleVO);
if (result.success) {
ElMessage.success('绑定成功');
bindDialogVisible.value = false;
loadUsers();
} else {
ElMessage.error(result.message || '绑定失败');
}
} catch (error) {
console.error('绑定失败:', error);
ElMessage.error('绑定失败');
} finally {
binding.value = false;
}
}
function resetForm() {
currentUser.value = {};
userFormRef.value?.resetFields();
}
// 重置绑定表单
function resetBindForm() {
bindList.value = [];
selectedBindings.value = [];
bindFormRef.value?.resetFields();
}
// 组件挂载时加载数据
onMounted(() => {
loadUserList();
});
function handlePageChange(page: number) {
pageParam.value.pageNumber = page;
loadUsers();
}
function handleSizeChange(size: number) {
pageParam.value.pageSize = size;
pageParam.value.pageNumber = 1; // 改变每页数量时,重置到第一页
loadUsers();
}
</script>
<style scoped>
<style lang="scss" scoped>
.user-manage {
padding: 20px;
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #101828;
}
}
.header h2 {
margin: 0;
}
.bind-info {
margin-bottom: 20px;
}
.bind-stats {
margin-top: 20px;
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 24px;
}
</style>

View File

@@ -1,324 +1,48 @@
<template>
<div class="article-add-view">
<div class="page-header">
<el-button @click="handleBack" :icon="ArrowLeft">返回</el-button>
<h1 class="page-title">{{ isEdit ? '编辑文章' : '创建文章' }}</h1>
</div>
<div class="article-form">
<el-form ref="formRef" :model="articleForm" :rules="rules" label-width="100px" label-position="top">
<!-- 标题 -->
<el-form-item label="文章标题" prop="resource.title">
<el-input v-model="articleForm.resource.title" placeholder="请输入文章标题" maxlength="100" show-word-limit />
</el-form-item>
<!-- 分类和标签 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="文章分类" prop="resource.tagID">
<el-select v-model="articleForm.resource.tagID" placeholder="请选择分类" style="width: 100%" :loading="categoryLoading" value-key="tagID">
<el-option
v-for="category in categoryList"
:key="category.tagID || category.id"
:label="category.name"
:value="category.tagID||''"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 封面图 -->
<el-form-item label="封面图片">
<FileUpload
:as-dialog="false"
list-type="cover"
v-model:cover-url="articleForm.resource.coverImage"
accept="image/*"
:max-size="2"
module="article"
tip="建议尺寸 800x450"
@success="handleCoverUploadSuccess"
@remove="removeCover"
/>
</el-form-item>
<!-- 文章内容 -->
<el-form-item label="文章内容" prop="resource.content">
<RichTextComponent ref="editorRef" v-model="articleForm.resource.content" height="500px"
placeholder="请输入文章内容..." />
</el-form-item>
<!-- 发布设置 -->
<el-form-item label="发布设置">
<el-row :gutter="20">
<el-col :span="8">
<el-checkbox v-model="articleForm.resource.allowComment">允许评论</el-checkbox>
</el-col>
<el-col :span="8">
<el-checkbox v-model="articleForm.resource.isTop">置顶文章</el-checkbox>
</el-col>
<el-col :span="8">
<el-checkbox v-model="articleForm.resource.isRecommend">推荐文章</el-checkbox>
</el-col>
</el-row>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button type="primary" @click="handlePublish" :loading="publishing">
{{ isEdit ? '保存修改' : '立即发布' }}
</el-button>
<el-button @click="handleSaveDraft" :loading="savingDraft">
保存草稿
</el-button>
<el-button @click="handlePreview">
预览
</el-button>
<el-button @click="handleBack">
取消
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 文章预览组件 -->
<ArticleShowView
v-model="previewVisible"
:as-dialog="true"
title="文章预览"
width="900px"
:article-data="articleForm.resource"
:category-list="categoryList"
:show-edit-button="false"
@close="previewVisible = false"
/>
</div>
<ArticleAdd
v-if="articleId !== undefined"
:article-id="articleId"
:show-back-button="true"
back-button-text="返回"
@back="handleBack"
@publish-success="handlePublishSuccess"
@save-draft-success="handleSaveDraftSuccess"
/>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
ElForm,
ElFormItem,
ElInput,
ElSelect,
ElOption,
ElButton,
ElRow,
ElCol,
ElCheckbox,
ElMessage
} from 'element-plus';
import { ArrowLeft } from '@element-plus/icons-vue';
import { RichTextComponent } from '@/components/text';
import { FileUpload } from '@/components/file';
import { ArticleShowView } from './index';
import { resourceTagApi, resourceApi } from '@/apis/resource';
import { ResourceVO, Tag, TagType } from '@/types/resource';
import { ArticleAdd } from './components';
defineOptions({
name: 'ArticleAddView'
});
const router = useRouter();
const route = useRoute();
const formRef = ref();
const editorRef = ref();
const publishing = ref(false);
const savingDraft = ref(false);
const previewVisible = ref(false);
const articleId = computed(() => route.query.id as string | undefined);
// 是否编辑模式
const isEdit = ref(false);
// 数据状态
const categoryList = ref<Tag[]>([]); // 改为使用Tag类型tagType=1表示文章分类
const tagList = ref<Tag[]>([]);
const categoryLoading = ref(false);
const tagLoading = ref(false);
// 表单数据
const articleForm = ref<ResourceVO>({
resource: {
},
tags: [],
});
// 表单验证规则
const rules = {
'resource.title': [
{ required: true, message: '请输入文章标题', trigger: 'blur' },
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
],
'resource.tagID': [
{ required: true, message: '请选择文章分类', trigger: 'change' }
],
'resource.content': [
{ required: true, message: '请输入文章内容', trigger: 'blur' }
]
};
// 加载分类列表使用标签APItagType=1表示文章分类标签
async function loadCategoryList() {
try {
categoryLoading.value = true;
// 使用新的标签API获取文章分类标签tagType=1
const result = await resourceTagApi.getTagsByType(TagType.ARTICLE_CATEGORY);
if (result.success) {
// 数组数据从 dataList 获取
categoryList.value = result.dataList || [];
} else {
ElMessage.error(result.message || '加载分类失败');
}
} catch (error) {
console.error('加载分类失败:', error);
ElMessage.error('加载分类失败');
} finally {
categoryLoading.value = false;
}
}
// 加载标签列表
async function loadTagList() {
try {
tagLoading.value = true;
const result = await resourceTagApi.getTagList();
if (result.success) {
// 数组数据从 dataList 获取
tagList.value = result.dataList || [];
} else {
ElMessage.error(result.message || '加载标签失败');
}
} catch (error) {
console.error('加载标签失败:', error);
ElMessage.error('加载标签失败');
} finally {
tagLoading.value = false;
}
}
// 返回
// 返回上一页
function handleBack() {
router.back();
router.back();
}
// 发布文章
async function handlePublish() {
try {
await formRef.value?.validate();
publishing.value = true;
// TODO: 调用API发布文章
console.log('发布文章:', articleForm);
resourceApi.createResource(articleForm.value).then(res => {
if (res.success) {
ElMessage.success('发布成功');
} else {
ElMessage.error(res.message || '发布失败');
}
});
} catch (error) {
console.error('发布失败:', error);
} finally {
publishing.value = false;
}
// 发布成功后跳转到文章详情页
function handlePublishSuccess(id: string) {
router.push({
path: '/article/show',
query: { articleId: id }
});
}
// 保存草稿
async function handleSaveDraft() {
savingDraft.value = true;
try {
// TODO: 调用API保存草稿
console.log('保存草稿:', articleForm);
await new Promise(resolve => setTimeout(resolve, 1000));
ElMessage.success('草稿已保存');
} catch (error) {
console.error('保存失败:', error);
ElMessage.error('保存失败');
} finally {
savingDraft.value = false;
}
// 保存草稿成功
function handleSaveDraftSuccess() {
// 可以添加其他逻辑
console.log('草稿已保存');
}
// 预览
function handlePreview() {
console.log(articleForm.value.resource.content);
if (!articleForm.value.resource.title) {
ElMessage.warning('请先输入文章标题');
return;
}
previewVisible.value = true;
}
// 封面上传成功
function handleCoverUploadSuccess(files: any[]) {
if (files && files.length > 0) {
// coverUrl已经通过v-model:cover-url自动更新了
console.log('封面上传成功:', files[0]);
}
}
// 删除封面
function removeCover() {
articleForm.value.resource.coverImage = '';
}
onMounted(async () => {
// 并行加载分类和标签数据
await Promise.all([
loadCategoryList(),
loadTagList()
]);
// 如果是编辑模式,加载文章数据
const id = route.query.id;
if (id) {
try {
isEdit.value = true;
const result = await resourceApi.getResourceById(id as string);
if (result.success && result.data) {
articleForm.value = result.data;
} else {
ElMessage.error(result.message || '加载文章失败');
}
} catch (error) {
console.error('加载文章失败:', error);
ElMessage.error('加载文章失败');
}
}
});
</script>
<style lang="scss" scoped>
.article-add-view {
min-height: 100vh;
background: #f5f7fa;
padding: 24px;
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
}
.article-form {
background: white;
border-radius: 8px;
padding: 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -1,889 +1,31 @@
<template>
<!-- Dialog 模式 -->
<el-dialog
v-if="asDialog"
v-model="visible"
:title="title"
:width="width"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="article-show-container">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="8" animated />
</div>
<div v-else-if="currentArticleData">
<!-- 文章头部信息 -->
<div class="article-header">
<h1 class="article-title">{{ currentArticleData.title }}</h1>
<div class="article-meta-info">
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
发布时间{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
</div>
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
浏览次数{{ currentArticleData.viewCount }}
</div>
<div class="meta-item" v-if="currentArticleData.source">
来源{{ currentArticleData.source }}
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="separator"></div>
<!-- 文章内容 -->
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
</div>
<el-empty v-else description="加载文章失败" />
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
<el-button v-if="showEditButton" type="primary" @click="handleEdit">编辑</el-button>
</template>
</el-dialog>
<!-- 路由页面模式 -->
<div v-else class="article-page-view">
<!-- 返回按钮 -->
<div v-if="showBackButton" class="back-header">
<el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<!-- 文章内容 -->
<div v-else-if="currentArticleData" class="article-wrapper">
<div class="article-show-container">
<!-- 文章头部信息 -->
<div class="article-header">
<h1 class="article-title">{{ currentArticleData.title }}</h1>
<div class="article-meta-info">
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
发布时间{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
</div>
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
浏览次数{{ currentArticleData.viewCount }}
</div>
<div class="meta-item" v-if="currentArticleData.source">
来源{{ currentArticleData.source }}
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="separator"></div>
<!-- 文章内容 -->
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
</div>
</div>
<!-- 加载失败 -->
<div v-else class="error-container">
<el-empty description="加载文章失败" />
</div>
</div>
<ArticleShow
:resource-id="articleId"
:show-back-button="true"
back-button-text="返回"
@back="handleBack"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { ArrowLeft } from '@element-plus/icons-vue';
import { resourceApi } from '@/apis/resource';
import { learningRecordApi, learningHistoryApi } from '@/apis/study';
import { useStore } from 'vuex';
import type { ResourceCategory, Resource, ResourceVO, LearningRecord, TbLearningHistory } from '@/types';
import { ArticleShow } from './components';
defineOptions({
name: 'ArticleShowView'
});
interface Props {
modelValue?: boolean; // Dialog 模式下的显示状态
asDialog?: boolean; // 是否作为 Dialog 使用
title?: string; // Dialog 标题
width?: string; // Dialog 宽度
articleData?: Resource; // 文章数据Dialog 模式使用)
resourceID?: string; // 资源ID路由模式使用
categoryList?: Array<ResourceCategory>; // 分类列表
showEditButton?: boolean; // 是否显示编辑按钮
showBackButton?: boolean; // 是否显示返回按钮(路由模式)
backButtonText?: string; // 返回按钮文本
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
asDialog: false, // 默认作为路由页面使用
title: '文章预览',
width: '900px',
articleData: () => ({}),
categoryList: () => [],
showEditButton: false,
showBackButton: true,
backButtonText: '返回'
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'close': [];
'edit': [];
'back': [];
}>();
const router = useRouter();
const route = useRoute();
const store = useStore();
const loading = ref(false);
const loadedArticleData = ref<Resource | null>(null);
const articleId = computed(() => route.query.articleId as string || '');
// 学习记录相关
const learningRecord = ref<LearningRecord | null>(null);
const learningStartTime = ref(0);
const learningTimer = ref<number | null>(null);
const hasVideoCompleted = ref(false);
const totalVideos = ref(0); // 视频总数
const completedVideos = ref<Set<number>>(new Set()); // 已完成的视频索引
const userInfo = computed(() => store.getters['auth/user']);
// 学习历史记录相关
const learningHistory = ref<TbLearningHistory | null>(null);
const historyStartTime = ref(0);
const historyTimer = ref<number | null>(null);
// 当前显示的文章数据
const currentArticleData = computed(() => {
// Dialog 模式使用传入的 articleData
if (props.asDialog) {
return props.articleData;
}
// 路由模式:优先使用传入的 articleData否则使用加载的数据
if (props.articleData && Object.keys(props.articleData).length > 0) {
return props.articleData;
}
return loadedArticleData.value;
});
// Dialog 显示状态
const visible = computed({
get: () => props.asDialog ? props.modelValue : false,
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
});
onMounted(() => {
// 路由模式下,从路由参数加载文章
if (!props.asDialog) {
const articleId = route.query.articleId as string;
const taskId = route.query.taskId as string;
// 如果传入了 articleData则不需要从路由加载
if (props.articleData && Object.keys(props.articleData).length > 0) {
loadedArticleData.value = props.articleData;
const resourceID = props.articleData.resourceID;
if (resourceID) {
// 创建学习历史记录(每次进入都创建新记录)
createHistoryRecord(resourceID);
// 如果有taskId还要创建学习记录
if (taskId || route.query.taskId) {
loadLearningRecord(resourceID);
}
}
// 初始化视频监听
nextTick().then(() => {
setTimeout(() => {
initVideoListeners();
}, 300);
});
} else if (articleId) {
// 从路由参数加载
loadArticle(articleId);
// 如果有 taskId表示是任务学习需要创建/加载学习记录
if (taskId) {
loadLearningRecord(articleId);
}
} else {
// 既没有传入数据,也没有路由参数,显示错误
ElMessage.error('文章ID不存在');
}
}
});
onBeforeUnmount(() => {
// 组件销毁前保存学习进度
if (learningRecord.value && !learningRecord.value.isComplete) {
saveLearningProgress();
}
stopLearningTimer();
// 保存学习历史记录
if (learningHistory.value) {
saveHistoryRecord();
}
stopHistoryTimer();
});
// 监听 articleData 变化(用于 ResourceArticle 切换文章)
watch(() => props.articleData, async (newData, oldData) => {
if (!props.asDialog) {
// 如果从有数据变成null或者切换到新文章都需要保存当前历史
if (learningHistory.value && (oldData && (!newData || oldData.resourceID !== newData.resourceID))) {
await saveHistoryRecord();
stopHistoryTimer();
}
// 加载新文章数据
if (newData && Object.keys(newData).length > 0) {
loadedArticleData.value = newData;
// 为新文章创建学习历史记录
const resourceID = newData.resourceID;
if (resourceID) {
await createHistoryRecord(resourceID);
}
}
// 重新初始化视频监听
nextTick().then(() => {
setTimeout(() => {
initVideoListeners();
}, 300);
});
}
}, { deep: true });
// 加载文章数据
async function loadArticle(resourceID: string) {
loading.value = true;
try {
const res = await resourceApi.getResourceById(resourceID);
if (res.success && res.data) {
// ResourceVO 包含 resource 对象
const resourceVO = res.data as ResourceVO;
loadedArticleData.value = resourceVO.resource || res.data as Resource;
// 增加浏览次数
await resourceApi.incrementViewCount(resourceID);
// 创建学习历史记录(每次进入都创建新记录)
await createHistoryRecord(resourceID);
// 等待 DOM 更新后监听视频(增加延迟确保 DOM 完全渲染)
await nextTick();
setTimeout(() => {
initVideoListeners();
}, 300); // 延迟 300ms 确保 DOM 完全渲染
} else {
ElMessage.error(res.message || '加载文章失败');
loadedArticleData.value = null;
}
} catch (error) {
console.error('加载文章失败:', error);
ElMessage.error('加载文章失败');
loadedArticleData.value = null;
} finally {
loading.value = false;
}
}
// 加载学习记录
async function loadLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const res = await learningRecordApi.getRecordList({
userID: userInfo.value.id,
resourceType: 1, // 资源类型:文章
resourceID: resourceID
});
if (res.success && res.dataList && res.dataList.length > 0) {
learningRecord.value = res.dataList[0];
// 如果已完成,不需要启动计时器
if (learningRecord.value.isComplete) {
return;
}
} else {
// 没有记录,创建新的
await createLearningRecord(resourceID);
}
// 开始学习计时
startLearningTimer();
} catch (error) {
console.error('加载学习记录失败:', error);
}
}
// 创建学习记录
async function createLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const taskId = route.query.taskId as string;
const res = await learningRecordApi.createRecord({
userID: userInfo.value.id,
resourceType: 1, // 资源类型:文章
resourceID: resourceID,
taskID: taskId || undefined,
duration: 0,
progress: 0,
isComplete: false
});
if (res.success && res.data) {
learningRecord.value = res.data;
ElMessage.success('开始学习');
}
} catch (error) {
console.error('创建学习记录失败:', error);
}
}
// 开始学习计时
function startLearningTimer() {
learningStartTime.value = Date.now();
// 每10秒保存一次学习进度
learningTimer.value = window.setInterval(() => {
// 如果文章已完成,停止定时器
if (learningRecord.value?.isComplete) {
stopLearningTimer();
return;
}
saveLearningProgress();
}, 10000);
}
// 停止学习计时
function stopLearningTimer() {
if (learningTimer.value) {
clearInterval(learningTimer.value);
learningTimer.value = null;
}
}
// 保存学习进度
async function saveLearningProgress() {
if (!userInfo.value?.id || !learningRecord.value) return;
// 如果文章已完成,不再保存进度
if (learningRecord.value.isComplete) {
return;
}
const currentTime = Date.now();
const duration = Math.floor((currentTime - learningStartTime.value) / 1000);
try {
const updatedRecord = {
id: learningRecord.value.id,
userID: userInfo.value.id,
resourceType: 1,
resourceID: route.query.articleId as string,
duration: (learningRecord.value.duration || 0) + duration,
progress: hasVideoCompleted.value ? 100 : 50, // 如果视频播放完成进度100%
isComplete: hasVideoCompleted.value
};
await learningRecordApi.updateRecord(updatedRecord);
// 更新本地记录
learningRecord.value.duration = updatedRecord.duration;
learningRecord.value.progress = updatedRecord.progress;
learningRecord.value.isComplete = updatedRecord.isComplete;
// 重置开始时间
learningStartTime.value = currentTime;
// 如果已完成,标记完成
if (hasVideoCompleted.value) {
await markArticleComplete();
}
} catch (error) {
console.error('保存学习进度失败:', error);
}
}
// 标记文章完成
async function markArticleComplete() {
if (!userInfo.value?.id || !learningRecord.value) return;
try {
await learningRecordApi.markComplete({
id: learningRecord.value.id,
taskID: route.query.taskId as string,
userID: userInfo.value.id,
resourceType: 1,
resourceID: route.query.articleId as string,
isComplete: true
});
ElMessage.success('文章学习完成!');
stopLearningTimer();
} catch (error) {
console.error('标记完成失败:', error);
}
}
// 初始化视频监听器
function initVideoListeners() {
// 尝试多种选择器查找文章内容区域
const selectors = [
'.article-show-container .article-content.ql-editor',
'.article-wrapper .article-content.ql-editor',
'.article-content.ql-editor',
'.article-show-container .article-content',
'.article-wrapper .article-content',
'.article-content'
];
let articleContent: Element | null = null;
for (const selector of selectors) {
articleContent = document.querySelector(selector);
if (articleContent) {
break;
}
}
if (!articleContent) {
return;
}
const videos = articleContent.querySelectorAll('video');
if (videos.length === 0) {
// 没有视频,默认阅读即完成
hasVideoCompleted.value = true;
return;
}
// 初始化视频数量和完成状态
totalVideos.value = videos.length;
completedVideos.value.clear();
// 监听所有视频的播放结束事件
videos.forEach((video, index) => {
const videoElement = video as HTMLVideoElement;
// 移除旧的监听器
videoElement.removeEventListener('ended', () => handleVideoEnded(index));
// 添加新的监听器,传递视频索引
videoElement.addEventListener('ended', () => handleVideoEnded(index));
});
}
// 处理视频播放结束
function handleVideoEnded(videoIndex: number) {
// 标记该视频已完成
completedVideos.value.add(videoIndex);
const completedCount = completedVideos.value.size;
console.log(`✅ 视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
// 检查是否所有视频都已完成
if (completedCount >= totalVideos.value) {
if (!hasVideoCompleted.value) {
hasVideoCompleted.value = true;
ElMessage.success(`所有视频播放完成 (${totalVideos.value}/${totalVideos.value})`);
// 立即保存学习进度并标记完成
saveLearningProgress();
}
} else {
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
}
}
// ==================== 学习历史记录功能 ====================
// 创建学习历史记录
async function createHistoryRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const res = await learningHistoryApi.recordResourceView(
userInfo.value.id,
resourceID,
0 // 初始时长为0
);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log('✅ 学习历史记录创建成功:', learningHistory.value);
// 开始计时
startHistoryTimer();
}
} catch (error) {
console.error('❌ 创建学习历史记录失败:', error);
}
}
// 开始学习历史计时
function startHistoryTimer() {
historyStartTime.value = Date.now();
// 每30秒保存一次学习历史
historyTimer.value = window.setInterval(() => {
saveHistoryRecord();
}, 30000); // 30秒
}
// 停止学习历史计时
function stopHistoryTimer() {
if (historyTimer.value) {
clearInterval(historyTimer.value);
historyTimer.value = null;
}
}
// 保存学习历史记录
async function saveHistoryRecord() {
if (!userInfo.value?.id || !learningHistory.value) return;
const currentTime = Date.now();
const duration = Math.floor((currentTime - historyStartTime.value) / 1000); // 秒
// 如果时长太短小于1秒不保存
if (duration < 1) return;
try {
const updatedHistory: TbLearningHistory = {
...learningHistory.value,
duration: (learningHistory.value.duration || 0) + duration,
endTime: new Date().toISOString()
};
// 调用API更新学习历史
const res = await learningHistoryApi.recordLearningHistory(updatedHistory);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log(`💾 学习历史已保存 - 累计时长: ${learningHistory.value.duration}`);
}
// 重置开始时间
historyStartTime.value = currentTime;
} catch (error) {
console.error('❌ 保存学习历史失败:', error);
}
}
// 格式化日期简单格式YYYY-MM-DD
function formatDateSimple(date: string | Date): string {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 关闭处理
function handleClose() {
// 非Dialog模式下关闭时保存学习历史
if (!props.asDialog && learningHistory.value) {
saveHistoryRecord();
stopHistoryTimer();
}
if (props.asDialog) {
visible.value = false;
}
emit('close');
}
// 编辑处理
function handleEdit() {
emit('edit');
}
// 返回处理
// 返回上一页
function handleBack() {
// 返回前保存学习进度
if (learningRecord.value && !learningRecord.value.isComplete) {
saveLearningProgress();
}
stopLearningTimer();
const taskId = route.query.taskId as string;
// 如果有 taskId返回任务详情
if (taskId) {
router.push({
path: '/study-plan/task-detail',
query: { taskId }
});
} else {
emit('back');
}
router.back();
}
// 暴露方法
defineExpose({
open: () => {
if (props.asDialog) {
visible.value = true;
}
},
close: handleClose
});
</script>
<style lang="scss" scoped>
// 路由页面模式样式
.article-page-view {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 60px;
}
.back-header {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.loading-container {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
background: #fff;
border-radius: 8px;
}
.article-wrapper {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.error-container {
padding: 80px 24px;
text-align: center;
}
.loading-state {
padding: 20px 0;
}
// Dialog 和路由模式共用的文章内容样式
.article-show-container {
max-width: 100%;
margin: 0 auto;
background: #fff;
}
.article-header {
margin-bottom: 36px;
}
.article-title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 28px;
line-height: 28px;
color: #141F38;
margin: 0 0 30px 0;
text-align: center;
}
.article-meta-info {
display: flex;
align-items: center;
gap: 48px;
margin-top: 30px;
justify-content: center;
}
.meta-item {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 28px;
color: #979797;
}
.separator {
width: 100%;
height: 1px;
background: #E9E9E9;
margin-bottom: 40px;
}
.article-cover {
margin-bottom: 24px;
text-align: center;
}
.cover-image {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.article-content {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 16px;
line-height: 30px;
color: #334155;
// 继承富文本编辑器的样式
:deep(img) {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
:deep(video),
:deep(iframe) {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
// 对齐方式样式 - 图片和视频分别处理
:deep(.ql-align-center) {
text-align: center !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
:deep(.ql-align-right) {
text-align: right !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
:deep(.ql-align-left) {
text-align: left !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
// 其他富文本样式
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
margin: 24px 0 16px 0;
font-weight: bold;
color: #303133;
}
:deep(p) {
margin: 16px 0;
}
:deep(blockquote) {
margin: 16px 0;
padding: 16px;
background: #f5f7fa;
border-left: 4px solid #409eff;
border-radius: 4px;
}
:deep(code) {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
:deep(pre) {
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
margin: 16px 0;
code {
background: none;
padding: 0;
}
}
:deep(ul), :deep(ol) {
margin: 16px 0;
padding-left: 24px;
}
:deep(li) {
margin: 8px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
:deep(th), :deep(td) {
border: 1px solid #ebeef5;
padding: 8px 12px;
text-align: left;
}
:deep(th) {
background: #f5f7fa;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,332 @@
<template>
<div class="article-add-view">
<div class="page-header">
<el-button @click="handleBack" :icon="ArrowLeft">返回</el-button>
<h1 class="page-title">{{ isEdit ? '编辑文章' : '创建文章' }}</h1>
</div>
<div class="article-form">
<el-form ref="formRef" :model="articleForm" :rules="rules" label-width="100px" label-position="top">
<!-- 标题 -->
<el-form-item label="文章标题" prop="resource.title">
<el-input v-model="articleForm.resource.title" placeholder="请输入文章标题" maxlength="100" show-word-limit />
</el-form-item>
<!-- 分类和标签 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="文章分类" prop="resource.tagID">
<el-select v-model="articleForm.resource.tagID" placeholder="请选择分类" style="width: 100%" :loading="categoryLoading" value-key="tagID">
<el-option
v-for="category in categoryList"
:key="category.tagID || category.id"
:label="category.name"
:value="category.tagID||''"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 封面图 -->
<el-form-item label="封面图片">
<FileUpload
:as-dialog="false"
list-type="cover"
v-model:cover-url="articleForm.resource.coverImage"
accept="image/*"
:max-size="2"
module="article"
tip="建议尺寸 800x450"
@success="handleCoverUploadSuccess"
@remove="removeCover"
/>
</el-form-item>
<!-- 文章内容 -->
<el-form-item label="文章内容" prop="resource.content">
<RichTextComponent ref="editorRef" v-model="articleForm.resource.content" height="500px"
placeholder="请输入文章内容..." />
</el-form-item>
<!-- 发布设置 -->
<el-form-item label="发布设置">
<el-row :gutter="20">
<el-col :span="8">
<el-checkbox v-model="articleForm.resource.allowComment">允许评论</el-checkbox>
</el-col>
<el-col :span="8">
<el-checkbox v-model="articleForm.resource.isTop">置顶文章</el-checkbox>
</el-col>
<el-col :span="8">
<el-checkbox v-model="articleForm.resource.isRecommend">推荐文章</el-checkbox>
</el-col>
</el-row>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button type="primary" @click="handlePublish" :loading="publishing">
{{ isEdit ? '保存修改' : '立即发布' }}
</el-button>
<el-button @click="handleSaveDraft" :loading="savingDraft">
保存草稿
</el-button>
<el-button @click="handlePreview">
预览
</el-button>
<el-button @click="handleBack">
取消
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 文章预览组件 -->
<ArticleShow
v-model="previewVisible"
:as-dialog="true"
title="文章预览"
width="900px"
:article-data="articleForm.resource"
:category-list="categoryList"
:show-edit-button="false"
@close="previewVisible = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {
ElForm,
ElFormItem,
ElInput,
ElSelect,
ElOption,
ElButton,
ElRow,
ElCol,
ElCheckbox,
ElMessage
} from 'element-plus';
import { ArrowLeft } from '@element-plus/icons-vue';
import { RichTextComponent } from '@/components/text';
import { FileUpload } from '@/components/file';
import { ArticleShow } from '.';
import { resourceTagApi, resourceApi } from '@/apis/resource';
import { ResourceVO, Tag, TagType } from '@/types/resource';
defineOptions({
name: 'ArticleAdd'
});
interface Props {
articleId?: string;
showBackButton?: boolean;
backButtonText?: string;
}
const props = withDefaults(defineProps<Props>(), {
showBackButton: true,
backButtonText: '返回'
});
const emit = defineEmits<{
'back': [];
'publish-success': [id: string];
'save-draft-success': [];
}>();
const formRef = ref();
const editorRef = ref();
const publishing = ref(false);
const savingDraft = ref(false);
const previewVisible = ref(false);
// 是否编辑模式
const isEdit = ref(false);
// 数据状态
const categoryList = ref<Tag[]>([]);
const tagList = ref<Tag[]>([]);
const categoryLoading = ref(false);
const tagLoading = ref(false);
// 表单数据
const articleForm = ref<ResourceVO>({
resource: {},
tags: [],
});
// 表单验证规则
const rules = {
'resource.title': [
{ required: true, message: '请输入文章标题', trigger: 'blur' },
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
],
'resource.tagID': [
{ required: true, message: '请选择文章分类', trigger: 'change' }
],
'resource.content': [
{ required: true, message: '请输入文章内容', trigger: 'blur' }
]
};
// 加载分类列表
async function loadCategoryList() {
try {
categoryLoading.value = true;
const result = await resourceTagApi.getTagsByType(TagType.ARTICLE_CATEGORY);
if (result.success) {
categoryList.value = result.dataList || [];
} else {
ElMessage.error(result.message || '加载分类失败');
}
} catch (error) {
console.error('加载分类失败:', error);
ElMessage.error('加载分类失败');
} finally {
categoryLoading.value = false;
}
}
// 加载标签列表
async function loadTagList() {
try {
tagLoading.value = true;
const result = await resourceTagApi.getTagList();
if (result.success) {
tagList.value = result.dataList || [];
} else {
ElMessage.error(result.message || '加载标签失败');
}
} catch (error) {
console.error('加载标签失败:', error);
ElMessage.error('加载标签失败');
} finally {
tagLoading.value = false;
}
}
// 返回
function handleBack() {
emit('back');
}
// 发布文章
async function handlePublish() {
try {
await formRef.value?.validate();
publishing.value = true;
const result = await resourceApi.createResource(articleForm.value);
if (result.success) {
ElMessage.success('发布成功');
emit('publish-success', result.data?.resource?.resourceID || '');
} else {
ElMessage.error(result.message || '发布失败');
}
} catch (error) {
console.error('发布失败:', error);
} finally {
publishing.value = false;
}
}
// 保存草稿
async function handleSaveDraft() {
savingDraft.value = true;
try {
// TODO: 调用API保存草稿
console.log('保存草稿:', articleForm);
await new Promise(resolve => setTimeout(resolve, 1000));
ElMessage.success('草稿已保存');
emit('save-draft-success');
} catch (error) {
console.error('保存失败:', error);
ElMessage.error('保存失败');
} finally {
savingDraft.value = false;
}
}
// 预览
function handlePreview() {
if (!articleForm.value.resource.title) {
ElMessage.warning('请先输入文章标题');
return;
}
previewVisible.value = true;
}
// 封面上传成功
function handleCoverUploadSuccess(files: any[]) {
if (files && files.length > 0) {
console.log('封面上传成功:', files[0]);
}
}
// 删除封面
function removeCover() {
articleForm.value.resource.coverImage = '';
}
onMounted(async () => {
// 并行加载分类和标签数据
await Promise.all([
loadCategoryList(),
loadTagList()
]);
// 如果是编辑模式,加载文章数据
if (props.articleId) {
try {
isEdit.value = true;
const result = await resourceApi.getResourceById(props.articleId);
if (result.success && result.data) {
articleForm.value = result.data;
} else {
ElMessage.error(result.message || '加载文章失败');
}
} catch (error) {
console.error('加载文章失败:', error);
ElMessage.error('加载文章失败');
}
}
});
</script>
<style lang="scss" scoped>
.article-add-view {
min-height: 100vh;
background: #f5f7fa;
padding: 24px;
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
}
.article-form {
background: white;
border-radius: 8px;
padding: 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,887 @@
<template>
<!-- Dialog 模式 -->
<el-dialog
v-if="asDialog"
v-model="visible"
:title="title"
:width="width"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="article-show-container">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="8" animated />
</div>
<div v-else-if="currentArticleData">
<!-- 文章头部信息 -->
<div class="article-header">
<h1 class="article-title">{{ currentArticleData.title }}</h1>
<div class="article-meta-info">
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
发布时间{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
</div>
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
浏览次数{{ currentArticleData.viewCount }}
</div>
<div class="meta-item" v-if="currentArticleData.source">
来源{{ currentArticleData.source }}
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="separator"></div>
<!-- 文章内容 -->
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
</div>
<el-empty v-else description="加载文章失败" />
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
<el-button v-if="showEditButton" type="primary" @click="handleEdit">编辑</el-button>
</template>
</el-dialog>
<!-- 路由页面模式 -->
<div v-else class="article-page-view">
<!-- 返回按钮 -->
<div v-if="showBackButton" class="back-header">
<el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<!-- 文章内容 -->
<div v-else-if="currentArticleData" class="article-wrapper">
<div class="article-show-container">
<!-- 文章头部信息 -->
<div class="article-header">
<h1 class="article-title">{{ currentArticleData.title }}</h1>
<div class="article-meta-info">
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
发布时间{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
</div>
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
浏览次数{{ currentArticleData.viewCount }}
</div>
<div class="meta-item" v-if="currentArticleData.source">
来源{{ currentArticleData.source }}
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="separator"></div>
<!-- 文章内容 -->
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
</div>
</div>
<!-- 加载失败 -->
<div v-else class="error-container">
<el-empty description="加载文章失败" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { ArrowLeft } from '@element-plus/icons-vue';
import { resourceApi } from '@/apis/resource';
import { learningRecordApi, learningHistoryApi } from '@/apis/study';
import { useStore } from 'vuex';
import type { Resource, ResourceVO, LearningRecord, TbLearningHistory } from '@/types';
defineOptions({
name: 'ArticleShow'
});
interface Props {
modelValue?: boolean; // Dialog 模式下的显示状态
asDialog?: boolean; // 是否作为 Dialog 使用
title?: string; // Dialog 标题
width?: string; // Dialog 宽度
articleData?: Resource; // 文章数据Dialog 模式使用)
resourceID?: string; // 资源ID路由模式使用
showEditButton?: boolean; // 是否显示编辑按钮
showBackButton?: boolean; // 是否显示返回按钮(路由模式)
backButtonText?: string; // 返回按钮文本
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
asDialog: false, // 默认作为路由页面使用
title: '文章预览',
width: '900px',
articleData: () => ({}),
showEditButton: false,
showBackButton: true,
backButtonText: '返回'
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'close': [];
'edit': [];
'back': [];
}>();
const router = useRouter();
const route = useRoute();
const store = useStore();
const loading = ref(false);
const loadedArticleData = ref<Resource | null>(null);
// 学习记录相关
const learningRecord = ref<LearningRecord | null>(null);
const learningStartTime = ref(0);
const learningTimer = ref<number | null>(null);
const hasVideoCompleted = ref(false);
const totalVideos = ref(0); // 视频总数
const completedVideos = ref<Set<number>>(new Set()); // 已完成的视频索引
const userInfo = computed(() => store.getters['auth/user']);
// 学习历史记录相关
const learningHistory = ref<TbLearningHistory | null>(null);
const historyStartTime = ref(0);
const historyTimer = ref<number | null>(null);
// 当前显示的文章数据
const currentArticleData = computed(() => {
// Dialog 模式使用传入的 articleData
if (props.asDialog) {
return props.articleData;
}
// 路由模式:优先使用传入的 articleData否则使用加载的数据
if (props.articleData && Object.keys(props.articleData).length > 0) {
return props.articleData;
}
return loadedArticleData.value;
});
// Dialog 显示状态
const visible = computed({
get: () => props.asDialog ? props.modelValue : false,
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
});
onMounted(() => {
// 路由模式下,从路由参数加载文章
if (!props.asDialog) {
const articleId = route.query.articleId as string;
const taskId = route.query.taskId as string;
// 如果传入了 articleData则不需要从路由加载
if (props.articleData && Object.keys(props.articleData).length > 0) {
loadedArticleData.value = props.articleData;
const resourceID = props.articleData.resourceID;
if (resourceID) {
// 创建学习历史记录(每次进入都创建新记录)
createHistoryRecord(resourceID);
// 如果有taskId还要创建学习记录
if (taskId || route.query.taskId) {
loadLearningRecord(resourceID);
}
}
// 初始化视频监听
nextTick().then(() => {
setTimeout(() => {
initVideoListeners();
}, 300);
});
} else if (articleId) {
// 从路由参数加载
loadArticle(articleId);
// 如果有 taskId表示是任务学习需要创建/加载学习记录
if (taskId) {
loadLearningRecord(articleId);
}
} else {
// 既没有传入数据,也没有路由参数,显示错误
ElMessage.error('文章ID不存在');
}
}
});
onBeforeUnmount(() => {
// 组件销毁前保存学习进度
if (learningRecord.value && !learningRecord.value.isComplete) {
saveLearningProgress();
}
stopLearningTimer();
// 保存学习历史记录
if (learningHistory.value) {
saveHistoryRecord();
}
stopHistoryTimer();
});
// 监听 articleData 变化(用于 ResourceArticle 切换文章)
watch(() => props.articleData, async (newData, oldData) => {
if (!props.asDialog) {
// 如果从有数据变成null或者切换到新文章都需要保存当前历史
if (learningHistory.value && (oldData && (!newData || oldData.resourceID !== newData.resourceID))) {
await saveHistoryRecord();
stopHistoryTimer();
}
// 加载新文章数据
if (newData && Object.keys(newData).length > 0) {
loadedArticleData.value = newData;
// 为新文章创建学习历史记录
const resourceID = newData.resourceID;
if (resourceID) {
await createHistoryRecord(resourceID);
}
}
// 重新初始化视频监听
nextTick().then(() => {
setTimeout(() => {
initVideoListeners();
}, 300);
});
}
}, { deep: true });
// 加载文章数据
async function loadArticle(resourceID: string) {
loading.value = true;
try {
const res = await resourceApi.getResourceById(resourceID);
if (res.success && res.data) {
// ResourceVO 包含 resource 对象
const resourceVO = res.data as ResourceVO;
loadedArticleData.value = resourceVO.resource || res.data as Resource;
// 增加浏览次数
await resourceApi.incrementViewCount(resourceID);
// 创建学习历史记录(每次进入都创建新记录)
await createHistoryRecord(resourceID);
// 等待 DOM 更新后监听视频(增加延迟确保 DOM 完全渲染)
await nextTick();
setTimeout(() => {
initVideoListeners();
}, 300); // 延迟 300ms 确保 DOM 完全渲染
} else {
ElMessage.error(res.message || '加载文章失败');
loadedArticleData.value = null;
}
} catch (error) {
console.error('加载文章失败:', error);
ElMessage.error('加载文章失败');
loadedArticleData.value = null;
} finally {
loading.value = false;
}
}
// 加载学习记录
async function loadLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const res = await learningRecordApi.getRecordList({
userID: userInfo.value.id,
resourceType: 1, // 资源类型:文章
resourceID: resourceID
});
if (res.success && res.dataList && res.dataList.length > 0) {
learningRecord.value = res.dataList[0];
// 如果已完成,不需要启动计时器
if (learningRecord.value.isComplete) {
return;
}
} else {
// 没有记录,创建新的
await createLearningRecord(resourceID);
}
// 开始学习计时
startLearningTimer();
} catch (error) {
console.error('加载学习记录失败:', error);
}
}
// 创建学习记录
async function createLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const taskId = route.query.taskId as string;
const res = await learningRecordApi.createRecord({
userID: userInfo.value.id,
resourceType: 1, // 资源类型:文章
resourceID: resourceID,
taskID: taskId || undefined,
duration: 0,
progress: 0,
isComplete: false
});
if (res.success && res.data) {
learningRecord.value = res.data;
ElMessage.success('开始学习');
}
} catch (error) {
console.error('创建学习记录失败:', error);
}
}
// 开始学习计时
function startLearningTimer() {
learningStartTime.value = Date.now();
// 每10秒保存一次学习进度
learningTimer.value = window.setInterval(() => {
// 如果文章已完成,停止定时器
if (learningRecord.value?.isComplete) {
stopLearningTimer();
return;
}
saveLearningProgress();
}, 10000);
}
// 停止学习计时
function stopLearningTimer() {
if (learningTimer.value) {
clearInterval(learningTimer.value);
learningTimer.value = null;
}
}
// 保存学习进度
async function saveLearningProgress() {
if (!userInfo.value?.id || !learningRecord.value) return;
// 如果文章已完成,不再保存进度
if (learningRecord.value.isComplete) {
return;
}
const currentTime = Date.now();
const duration = Math.floor((currentTime - learningStartTime.value) / 1000);
try {
const updatedRecord = {
id: learningRecord.value.id,
userID: userInfo.value.id,
resourceType: 1,
resourceID: route.query.articleId as string,
duration: (learningRecord.value.duration || 0) + duration,
progress: hasVideoCompleted.value ? 100 : 50, // 如果视频播放完成进度100%
isComplete: hasVideoCompleted.value
};
await learningRecordApi.updateRecord(updatedRecord);
// 更新本地记录
learningRecord.value.duration = updatedRecord.duration;
learningRecord.value.progress = updatedRecord.progress;
learningRecord.value.isComplete = updatedRecord.isComplete;
// 重置开始时间
learningStartTime.value = currentTime;
// 如果已完成,标记完成
if (hasVideoCompleted.value) {
await markArticleComplete();
}
} catch (error) {
console.error('保存学习进度失败:', error);
}
}
// 标记文章完成
async function markArticleComplete() {
if (!userInfo.value?.id || !learningRecord.value) return;
try {
await learningRecordApi.markComplete({
id: learningRecord.value.id,
taskID: route.query.taskId as string,
userID: userInfo.value.id,
resourceType: 1,
resourceID: route.query.articleId as string,
isComplete: true
});
ElMessage.success('文章学习完成!');
stopLearningTimer();
} catch (error) {
console.error('标记完成失败:', error);
}
}
// 初始化视频监听器
function initVideoListeners() {
// 尝试多种选择器查找文章内容区域
const selectors = [
'.article-show-container .article-content.ql-editor',
'.article-wrapper .article-content.ql-editor',
'.article-content.ql-editor',
'.article-show-container .article-content',
'.article-wrapper .article-content',
'.article-content'
];
let articleContent: Element | null = null;
for (const selector of selectors) {
articleContent = document.querySelector(selector);
if (articleContent) {
break;
}
}
if (!articleContent) {
return;
}
const videos = articleContent.querySelectorAll('video');
if (videos.length === 0) {
// 没有视频,默认阅读即完成
hasVideoCompleted.value = true;
return;
}
// 初始化视频数量和完成状态
totalVideos.value = videos.length;
completedVideos.value.clear();
// 监听所有视频的播放结束事件
videos.forEach((video, index) => {
const videoElement = video as HTMLVideoElement;
// 移除旧的监听器
videoElement.removeEventListener('ended', () => handleVideoEnded(index));
// 添加新的监听器,传递视频索引
videoElement.addEventListener('ended', () => handleVideoEnded(index));
});
}
// 处理视频播放结束
function handleVideoEnded(videoIndex: number) {
// 标记该视频已完成
completedVideos.value.add(videoIndex);
const completedCount = completedVideos.value.size;
console.log(`✅ 视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
// 检查是否所有视频都已完成
if (completedCount >= totalVideos.value) {
if (!hasVideoCompleted.value) {
hasVideoCompleted.value = true;
ElMessage.success(`所有视频播放完成 (${totalVideos.value}/${totalVideos.value})`);
// 立即保存学习进度并标记完成
saveLearningProgress();
}
} else {
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
}
}
// ==================== 学习历史记录功能 ====================
// 创建学习历史记录
async function createHistoryRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const res = await learningHistoryApi.recordResourceView(
userInfo.value.id,
resourceID,
0 // 初始时长为0
);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log('✅ 学习历史记录创建成功:', learningHistory.value);
// 开始计时
startHistoryTimer();
}
} catch (error) {
console.error('❌ 创建学习历史记录失败:', error);
}
}
// 开始学习历史计时
function startHistoryTimer() {
historyStartTime.value = Date.now();
// 每30秒保存一次学习历史
historyTimer.value = window.setInterval(() => {
saveHistoryRecord();
}, 30000); // 30秒
}
// 停止学习历史计时
function stopHistoryTimer() {
if (historyTimer.value) {
clearInterval(historyTimer.value);
historyTimer.value = null;
}
}
// 保存学习历史记录
async function saveHistoryRecord() {
if (!userInfo.value?.id || !learningHistory.value) return;
const currentTime = Date.now();
const duration = Math.floor((currentTime - historyStartTime.value) / 1000); // 秒
// 如果时长太短小于1秒不保存
if (duration < 1) return;
try {
const updatedHistory: TbLearningHistory = {
...learningHistory.value,
duration: (learningHistory.value.duration || 0) + duration,
endTime: new Date().toISOString()
};
// 调用API更新学习历史
const res = await learningHistoryApi.recordLearningHistory(updatedHistory);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log(`💾 学习历史已保存 - 累计时长: ${learningHistory.value.duration}`);
}
// 重置开始时间
historyStartTime.value = currentTime;
} catch (error) {
console.error('❌ 保存学习历史失败:', error);
}
}
// 格式化日期简单格式YYYY-MM-DD
function formatDateSimple(date: string | Date): string {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 关闭处理
function handleClose() {
// 非Dialog模式下关闭时保存学习历史
if (!props.asDialog && learningHistory.value) {
saveHistoryRecord();
stopHistoryTimer();
}
if (props.asDialog) {
visible.value = false;
}
emit('close');
}
// 编辑处理
function handleEdit() {
emit('edit');
}
// 返回处理
function handleBack() {
// 返回前保存学习进度
if (learningRecord.value && !learningRecord.value.isComplete) {
saveLearningProgress();
}
stopLearningTimer();
const taskId = route.query.taskId as string;
// 如果有 taskId返回任务详情
if (taskId) {
router.push({
path: '/study-plan/task-detail',
query: { taskId }
});
} else {
emit('back');
}
}
// 暴露方法
defineExpose({
open: () => {
if (props.asDialog) {
visible.value = true;
}
},
close: handleClose
});
</script>
<style lang="scss" scoped>
// 路由页面模式样式
.article-page-view {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 60px;
}
.back-header {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.loading-container {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
background: #fff;
border-radius: 8px;
}
.article-wrapper {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.error-container {
padding: 80px 24px;
text-align: center;
}
.loading-state {
padding: 20px 0;
}
// Dialog 和路由模式共用的文章内容样式
.article-show-container {
max-width: 100%;
margin: 0 auto;
background: #fff;
}
.article-header {
margin-bottom: 36px;
}
.article-title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 28px;
line-height: 28px;
color: #141F38;
margin: 0 0 30px 0;
text-align: center;
}
.article-meta-info {
display: flex;
align-items: center;
gap: 48px;
margin-top: 30px;
justify-content: center;
}
.meta-item {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 28px;
color: #979797;
}
.separator {
width: 100%;
height: 1px;
background: #E9E9E9;
margin-bottom: 40px;
}
.article-cover {
margin-bottom: 24px;
text-align: center;
}
.cover-image {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.article-content {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 16px;
line-height: 30px;
color: #334155;
// 继承富文本编辑器的样式
:deep(img) {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
:deep(video),
:deep(iframe) {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
// 对齐方式样式 - 图片和视频分别处理
:deep(.ql-align-center) {
text-align: center !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
:deep(.ql-align-right) {
text-align: right !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
:deep(.ql-align-left) {
text-align: left !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
// 其他富文本样式
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
margin: 24px 0 16px 0;
font-weight: bold;
color: #303133;
}
:deep(p) {
margin: 16px 0;
}
:deep(blockquote) {
margin: 16px 0;
padding: 16px;
background: #f5f7fa;
border-left: 4px solid #409eff;
border-radius: 4px;
}
:deep(code) {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
:deep(pre) {
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
margin: 16px 0;
code {
background: none;
padding: 0;
}
}
:deep(ul), :deep(ol) {
margin: 16px 0;
padding-left: 24px;
}
:deep(li) {
margin: 8px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
:deep(th), :deep(td) {
border: 1px solid #ebeef5;
padding: 8px 12px;
text-align: left;
}
:deep(th) {
background: #f5f7fa;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as ArticleAdd } from './ArticleAdd.vue';
export { default as ArticleShow } from './ArticleShow.vue';

View File

@@ -1,3 +1,4 @@
export { default as ArticleAddView } from './ArticleAddView.vue';
export { default as ArticleShowView } from './ArticleShowView.vue';
export * from './card';
export * from './card';
export * from './components';

View File

@@ -1,8 +1,7 @@
<template>
<div class="banner-card">
<div class="banner-content" @click="handleLearn">
<!-- <img :src="FILE_DOWNLOAD_URL + props.banner.imageUrl" alt="banner" class="banner-image"> -->
<span>test</span>
<img :src="FILE_DOWNLOAD_URL + props.banner.imageUrl" alt="banner" class="banner-image">
</div>
</div>
</template>
@@ -16,12 +15,14 @@ const router = useRouter();
const props = defineProps<{
banner: Banner;
}>();
console.log(props.banner);
function handleLearn() {
if (props.banner.linkType === 1) {
router.push(`/resource/${props.banner.linkID}`);
console.log(`/resource/${props.banner.linkID}`);
router.push(`/article/show?articleId=${props.banner.linkID}`);
} else if (props.banner.linkType === 2) {
router.push(`/course/${props.banner.linkID}`);
console.log(`/course/${props.banner.linkID}`);
router.push(`/study-plan/course-detail?courseId=${props.banner.linkID}`);
} else if (props.banner.linkType === 3) {
window.open(props.banner.linkUrl, '_blank');
}
@@ -40,6 +41,10 @@ function handleLearn() {
position: relative;
background-color: red;
:hover {
cursor: pointer;
}
.banner-image {
width: 100%;
height: 100%;

View File

@@ -8,7 +8,7 @@
<el-form
ref="formRef"
:model="currentCourseVO"
:model="currentCourseItemVO"
:rules="rules"
label-width="120px"
>
@@ -18,17 +18,17 @@
<span class="card-header">基本信息</span>
</template>
<el-form-item label="课程名称" prop="course.name">
<el-form-item label="课程名称" prop="name">
<el-input
id="course-name"
v-model="currentCourseVO.course.name"
v-model="currentCourseItemVO.name"
placeholder="请输入课程名称"
:disabled="!editMode"
/>
</el-form-item>
<!-- 课程封面 - 移除 label 关联 -->
<el-form-item prop="course.coverImage">
<el-form-item prop="coverImage">
<template #label>
<span>课程封面</span>
</template>
@@ -36,7 +36,7 @@
v-if="editMode"
:as-dialog="false"
list-type="cover"
v-model:cover-url="currentCourseVO.course.coverImage"
v-model:cover-url="currentCourseItemVO.coverImage"
accept="image/*"
:max-size="2"
module="course"
@@ -45,24 +45,24 @@
@remove="handleCoverRemove"
/>
<div v-else class="cover-preview">
<img v-if="currentCourseVO.course.coverImage" :src="FILE_DOWNLOAD_URL + currentCourseVO.course.coverImage" alt="课程封面" />
<img v-if="currentCourseItemVO.coverImage" :src="FILE_DOWNLOAD_URL + currentCourseItemVO.coverImage" alt="课程封面" />
<span v-else class="no-cover">暂无封面</span>
</div>
</el-form-item>
<el-form-item label="授课老师" prop="course.teacher">
<el-form-item label="授课老师" prop="teacher">
<el-input
id="course-teacher"
v-model="currentCourseVO.course.teacher"
v-model="currentCourseItemVO.teacher"
placeholder="请输入授课老师"
:disabled="!editMode"
/>
</el-form-item>
<el-form-item label="课程时长" prop="course.duration">
<el-form-item label="课程时长" prop="duration">
<el-input-number
id="course-duration"
v-model="currentCourseVO.course.duration"
v-model="currentCourseItemVO.duration"
:min="0"
placeholder="分钟"
:disabled="!editMode"
@@ -70,10 +70,10 @@
<span class="ml-2">分钟</span>
</el-form-item>
<el-form-item label="课程描述" prop="course.description">
<el-form-item label="课程描述" prop="description">
<el-input
id="course-description"
v-model="currentCourseVO.course.description"
v-model="currentCourseItemVO.description"
type="textarea"
:rows="3"
placeholder="请输入课程描述"
@@ -81,19 +81,19 @@
/>
</el-form-item>
<el-form-item label="排序号" prop="course.orderNum">
<el-form-item label="排序号" prop="orderNum">
<el-input-number
id="course-orderNum"
v-model="currentCourseVO.course.orderNum"
v-model="currentCourseItemVO.orderNum"
:min="0"
:disabled="!editMode"
/>
</el-form-item>
<el-form-item label="状态" prop="course.status">
<el-form-item label="状态" prop="status">
<el-radio-group
id="course-status"
v-model="currentCourseVO.course.status"
v-model="currentCourseItemVO.status"
disabled
>
<el-radio :label="0">未上线</el-radio>
@@ -115,19 +115,19 @@
</div>
</template>
<div v-if="currentCourseVO.courseChapters.length === 0" class="empty-tip">
<div v-if="!currentCourseItemVO.chapters || currentCourseItemVO.chapters.length === 0" class="empty-tip">
暂无章节请点击"添加章节"按钮添加
</div>
<el-collapse v-model="activeChapters">
<el-collapse-item
v-for="(chapterVO, chapterIndex) in currentCourseVO.courseChapters"
v-for="(chapterVO, chapterIndex) in currentCourseItemVO.chapters"
:key="chapterIndex"
:name="chapterIndex"
>
<template #title>
<div class="chapter-title">
<span>章节 {{ chapterIndex + 1 }}: {{ chapterVO.chapter.name || '未命名章节' }}</span>
<span>章节 {{ chapterIndex + 1 }}: {{ chapterVO.name || '未命名章节' }}</span>
<div v-if="editMode" class="chapter-actions" @click.stop>
<el-button
type="danger"
@@ -144,12 +144,12 @@
<!-- 章节信息 -->
<el-form-item
:label="`章节${chapterIndex + 1}名称`"
:prop="`courseChapters.${chapterIndex}.chapter.name`"
:prop="`chapters.${chapterIndex}.name`"
:rules="[{ required: true, message: '请输入章节名称', trigger: 'blur' }]"
>
<el-input
:id="`chapter-${chapterIndex}-name`"
v-model="chapterVO.chapter.name"
v-model="chapterVO.name"
placeholder="请输入章节名称"
:disabled="!editMode"
/>
@@ -157,11 +157,11 @@
<el-form-item
:label="`章节${chapterIndex + 1}排序`"
:prop="`courseChapters.${chapterIndex}.chapter.orderNum`"
:prop="`chapters.${chapterIndex}.orderNum`"
>
<el-input-number
:id="`chapter-${chapterIndex}-orderNum`"
v-model="chapterVO.chapter.orderNum"
v-model="chapterVO.orderNum"
:min="0"
:disabled="!editMode"
/>
@@ -177,13 +177,13 @@
</el-button>
</div>
<div v-if="chapterVO.nodes.length === 0" class="empty-tip">
<div v-if="!chapterVO.chapters || chapterVO.chapters.length === 0" class="empty-tip">
暂无学习节点
</div>
<div v-else class="nodes-list">
<el-card
v-for="(node, nodeIndex) in chapterVO.nodes"
v-for="(node, nodeIndex) in chapterVO.chapters"
:key="nodeIndex"
class="node-card"
shadow="hover"
@@ -205,7 +205,7 @@
<el-form-item
label="节点名称"
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.name`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.name`"
>
<el-input
:id="`node-${chapterIndex}-${nodeIndex}-name`"
@@ -217,7 +217,7 @@
<el-form-item
label="节点类型"
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.nodeType`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.nodeType`"
>
<el-radio-group
:id="`node-${chapterIndex}-${nodeIndex}-nodeType`"
@@ -234,7 +234,7 @@
<el-form-item
v-if="node.nodeType === 0"
label="选择文章"
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.resourceID`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.resourceID`"
>
<el-select
:id="`node-${chapterIndex}-${nodeIndex}-resourceID`"
@@ -259,7 +259,7 @@
<!-- 富文本编辑 - 移除 label 关联 -->
<el-form-item
v-if="node.nodeType === 1"
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.content`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.content`"
>
<template #label>
<span>内容编辑</span>
@@ -270,7 +270,7 @@
<!-- 文件上传 - 移除 label 关联 -->
<el-form-item
v-if="node.nodeType === 2"
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.videoUrl`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.videoUrl`"
>
<template #label>
<span>上传文件</span>
@@ -295,7 +295,7 @@
<el-form-item
label="节点时长"
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.duration`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.duration`"
>
<el-input-number
:id="`node-${chapterIndex}-${nodeIndex}-duration`"
@@ -308,7 +308,7 @@
<!-- 是否必修 - 使用 template #label -->
<el-form-item
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.isRequired`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.isRequired`"
>
<template #label>
<span>是否必修</span>
@@ -324,7 +324,7 @@
<el-form-item
label="排序号"
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.orderNum`"
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.orderNum`"
>
<el-input-number
:id="`node-${chapterIndex}-${nodeIndex}-orderNum`"
@@ -359,7 +359,7 @@ import { FileUpload } from '@/components/file';
import { RichTextComponent } from '@/components/text';
import { courseApi } from '@/apis/study';
import { resourceApi } from '@/apis/resource';
import type { CourseNode, CourseVO, ChapterVO } from '@/types/study';
import type { CourseItemVO } from '@/types/study';
import type { Resource } from '@/types/resource';
import type { SysFile } from '@/types';
import { FILE_DOWNLOAD_URL } from '@/config';
@@ -367,13 +367,6 @@ defineOptions({
name: 'CourseAdd'
});
// 节点扩展类型(用于前端交互)
interface NodeWithExtras extends CourseNode {
loading?: boolean;
articleOptions?: Resource[];
searchMethod?: (query: string) => void;
}
interface Props {
courseID?: string;
}
@@ -390,26 +383,23 @@ const activeChapters = ref<number[]>([]);
const editMode = ref(true);
// 原始数据(用于比对)
const originalCourseVO = ref<CourseVO>();
const originalCourseItemVO = ref<CourseItemVO>();
// 当前编辑的数据
const currentCourseVO = ref<CourseVO>({
course: {
name: '',
coverImage: '',
description: '',
teacher: '',
duration: 0,
status: 0,
orderNum: 0
},
courseChapters: [],
courseTags: []
const currentCourseItemVO = ref<CourseItemVO>({
name: '',
coverImage: '',
description: '',
teacher: '',
duration: 0,
status: 0,
orderNum: 0,
chapters: []
});
// 表单验证规则
const rules = {
'course.name': [{ required: true, message: '请输入课程名称', trigger: 'blur' }],
'course.teacher': [{ required: true, message: '请输入授课老师', trigger: 'blur' }]
'name': [{ required: true, message: '请输入课程名称', trigger: 'blur' }],
'teacher': [{ required: true, message: '请输入授课老师', trigger: 'blur' }]
};
onMounted(() => {
@@ -426,30 +416,26 @@ async function loadCourse() {
// 确保数据结构完整,处理 null 值
const courseData = res.data;
// 确保 courseChapters 是数组
if (!courseData.courseChapters) {
courseData.courseChapters = [];
// 确保 chapters 是数组(章节列表)
if (!courseData.chapters) {
courseData.chapters = [];
}
// 确保 courseTags 是数组
if (!courseData.courseTags) {
courseData.courseTags = [];
}
// 确保每个章节的 nodes 是数组
courseData.courseChapters.forEach((chapterVO: ChapterVO) => {
if (!chapterVO.nodes) {
chapterVO.nodes = [];
// 确保每个章节的 chapters 是数组(节点列表)
courseData.chapters.forEach((chapterVO: CourseItemVO) => {
if (!chapterVO.chapters) {
chapterVO.chapters = [];
}
});
if (courseData.course.status === 1) {
if (courseData.status === 1) {
editMode.value = false;
}
// 保存原始数据
originalCourseVO.value = JSON.parse(JSON.stringify(courseData));
originalCourseItemVO.value = JSON.parse(JSON.stringify(courseData));
// 设置当前编辑数据
currentCourseVO.value = JSON.parse(JSON.stringify(courseData));
console.log(currentCourseVO.value);
currentCourseItemVO.value = JSON.parse(JSON.stringify(courseData));
console.log(currentCourseItemVO.value);
}
} catch (error) {
console.error('加载课程失败:', error);
@@ -459,21 +445,19 @@ async function loadCourse() {
// 添加章节
function addChapter() {
const newChapterVO: ChapterVO = {
chapter: {
chapterID: currentCourseVO.value.course.courseID,
name: '',
orderNum: currentCourseVO.value.courseChapters.length
},
nodes: []
const newChapterVO: CourseItemVO = {
chapterID: currentCourseItemVO.value.courseID,
name: '',
orderNum: currentCourseItemVO.value.chapters!.length,
chapters: []
};
currentCourseVO.value.courseChapters.push(newChapterVO);
activeChapters.value.push(currentCourseVO.value.courseChapters.length - 1);
currentCourseItemVO.value.chapters!.push(newChapterVO);
activeChapters.value.push(currentCourseItemVO.value.chapters!.length - 1);
}
// 删除章节
function removeChapter(index: number) {
currentCourseVO.value.courseChapters.splice(index, 1);
currentCourseItemVO.value.chapters!.splice(index, 1);
// 更新激活的章节索引
activeChapters.value = activeChapters.value
.filter(i => i !== index)
@@ -482,10 +466,10 @@ function removeChapter(index: number) {
// 添加节点
function addNode(chapterIndex: number) {
const nodeIndex = currentCourseVO.value.courseChapters[chapterIndex].nodes.length;
const newNode: NodeWithExtras = {
const nodeIndex = currentCourseItemVO.value.chapters![chapterIndex].chapters!.length;
const newNode: CourseItemVO & { loading?: boolean; articleOptions?: Resource[]; searchMethod?: (query: string) => void } = {
nodeID: '',
chapterID: currentCourseVO.value.courseChapters[chapterIndex].chapter.chapterID,
chapterID: currentCourseItemVO.value.chapters![chapterIndex].chapterID,
name: '',
nodeType: 0,
content: '',
@@ -496,35 +480,37 @@ function addNode(chapterIndex: number) {
articleOptions: [],
searchMethod: (query: string) => searchArticles(query, chapterIndex, nodeIndex)
};
currentCourseVO.value.courseChapters[chapterIndex].nodes.push(newNode);
currentCourseItemVO.value.chapters![chapterIndex].chapters!.push(newNode);
}
// 删除节点
function removeNode(chapterIndex: number, nodeIndex: number) {
currentCourseVO.value.courseChapters[chapterIndex].nodes.splice(nodeIndex, 1);
currentCourseItemVO.value.chapters![chapterIndex].chapters!.splice(nodeIndex, 1);
}
type NodeWithExtras = CourseItemVO & { loading?: boolean; articleOptions?: Resource[]; searchMethod?: (query: string) => void };
// 辅助函数:获取节点的 searchMethod
function getNodeSearchMethod(chapterIndex: number, nodeIndex: number) {
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
return node.searchMethod;
}
// 辅助函数:获取节点的 loading 状态
function getNodeLoading(chapterIndex: number, nodeIndex: number) {
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
return node.loading || false;
}
// 辅助函数:获取节点的 articleOptions
function getNodeArticleOptions(chapterIndex: number, nodeIndex: number) {
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
return node.articleOptions || [];
}
// 搜索文章
async function searchArticles(query: string, chapterIndex: number, nodeIndex: number) {
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
if (!query) {
node.articleOptions = [];
return;
@@ -556,13 +542,13 @@ function handleCoverUploadSuccess(files: SysFile[]) {
// 处理封面删除
function handleCoverRemove() {
currentCourseVO.value.course.coverImage = '';
currentCourseItemVO.value.coverImage = '';
}
// 处理节点文件上传成功
function handleNodeFileUploadSuccess(files: SysFile[], chapterIndex: number, nodeIndex: number) {
if (files && files.length > 0) {
currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex].videoUrl = files[0].filePath || '';
currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex].videoUrl = files[0].filePath || '';
}
}
@@ -576,16 +562,16 @@ async function handleSubmit() {
// 先创建/更新课程
let courseID = props.courseID;
if (courseID) {
const res = await courseApi.updateCourse(currentCourseVO.value);
const res = await courseApi.updateCourse(currentCourseItemVO.value);
if (!res.success) {
throw new Error('更新课程失败');
}
} else {
const res = await courseApi.createCourse(currentCourseVO.value);
if (!res.success || !res.data?.course.courseID) {
const res = await courseApi.createCourse(currentCourseItemVO.value);
if (!res.success || !res.data?.courseID) {
throw new Error('创建课程失败');
}
courseID = res.data.course.courseID;
courseID = res.data.courseID;
}
ElMessage.success(props.courseID ? '课程更新成功' : '课程创建成功');
emit('success');

View File

@@ -11,151 +11,142 @@
</div>
<!-- 课程详情 -->
<div v-else-if="courseVO" class="course-content">
<!-- 课程封面和基本信息 -->
<div class="course-header">
<div class="cover-section">
<img
:src="courseVO.course.coverImage? FILE_DOWNLOAD_URL + courseVO.course.coverImage : defaultCover"
:alt="courseVO.course.name"
class="cover-image"
/>
</div>
<div class="info-section">
<h1 class="course-title">{{ courseVO.course.name }}</h1>
<div class="course-meta">
<div class="meta-item">
<el-icon><User /></el-icon>
<span>授课老师{{ courseVO.course.teacher }}</span>
</div>
<div class="meta-item">
<el-icon><Clock /></el-icon>
<span>课程时长{{ formatDuration(courseVO.course.duration) }}</span>
</div>
<div class="meta-item">
<el-icon><View /></el-icon>
<span>{{ courseVO.course.viewCount || 0 }} 人浏览</span>
</div>
<div class="meta-item">
<el-icon><Reading /></el-icon>
<span>{{ courseVO.course.learnCount || 0 }} 人学习</span>
</div>
</div>
<div class="course-description">
<p>{{ courseVO.course.description }}</p>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
size="large"
@click="handleStartLearning"
:loading="enrolling"
>
<el-icon><VideoPlay /></el-icon>
{{ isEnrolled ? '继续学习' : '开始学习' }}
</el-button>
<el-button
size="large"
:icon="isCollected ? StarFilled : Star"
@click="handleCollect"
:type="isCollected ? 'success' : 'default'"
>
{{ isCollected ? '已收藏' : '收藏课程' }}
</el-button>
</div>
<!-- 学习进度 -->
<div v-if="learningProgress" class="learning-progress">
<div class="progress-header">
<span>学习进度</span>
<span class="progress-text">{{ learningProgress.progress }}%</span>
</div>
<el-progress
:percentage="learningProgress.progress"
:stroke-width="10"
:color="progressColor"
<div v-else-if="courseItemVO" class="course-content">
<!-- 课程信息看板 -->
<div class="course-info-panel">
<div class="panel-container">
<!-- 左侧课程封面 -->
<div class="course-cover">
<img
:src="courseItemVO.coverImage ? FILE_DOWNLOAD_URL + courseItemVO.coverImage : defaultCover"
:alt="courseItemVO.name"
@error="handleImageError"
/>
</div>
<!-- 右侧课程信息 -->
<div class="course-info">
<div class="info-content">
<!-- 课程标题 -->
<h1 class="course-title">{{ courseItemVO.name }}</h1>
<!-- 课程简介 -->
<p class="course-desc">{{ courseItemVO.description || '暂无简介' }}</p>
<!-- 课程元信息 -->
<div class="course-meta">
<div class="meta-item">
<el-avatar :size="24" :src="getTeacherAvatar()" />
<span>{{ courseItemVO.teacher || '课程讲师' }}</span>
</div>
<div class="meta-divider"></div>
<div class="meta-item">
<img src="@/assets/imgs/clock.svg" alt="time" />
<span>{{ formatDuration(courseItemVO.duration) }}</span>
</div>
<div class="meta-divider"></div>
<div class="meta-item">
<img src="@/assets/imgs/book-read.svg" alt="learning" />
<span>{{ courseItemVO.learnCount || 0 }}人学习</span>
</div>
</div>
</div>
<!-- 进度条区域 -->
<div v-if="learningProgress" class="progress-section">
<span class="progress-label">课程进度</span>
<div class="progress-bar-wrapper">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: learningProgress.progress + '%' }"></div>
</div>
<span class="progress-percent">{{ learningProgress.progress }}%</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
size="large"
@click="handleStartLearning"
:loading="enrolling"
>
{{ isEnrolled ? '继续学习' : '开始学习' }}
</el-button>
<el-button
size="large"
:plain="!isCollected"
@click="handleCollect"
>
<el-icon><Star /></el-icon>
收藏课程
</el-button>
</div>
</div>
</div>
</div>
<!-- 课程章节 -->
<el-card class="chapter-card" shadow="never">
<template #header>
<div class="card-header">
<span>课程章节</span>
<span class="chapter-count"> {{ courseVO.courseChapters.length }} </span>
</div>
</template>
<div class="chapter-section">
<!-- 标题 -->
<div class="section-title">
<div class="title-bar"></div>
<span class="title-text">课程目录</span>
</div>
<div v-if="courseVO.courseChapters.length === 0" class="empty-tip">
<!-- 章节列表 -->
<div v-if="!courseItemVO.chapters || courseItemVO.chapters.length === 0" class="empty-tip">
暂无章节内容
</div>
<el-collapse v-else v-model="activeChapters">
<el-collapse-item
v-for="(chapterVO, chapterIndex) in courseVO.courseChapters"
<div v-else class="chapter-list">
<div
v-for="(chapterItem, chapterIndex) in courseItemVO.chapters"
:key="chapterIndex"
:name="chapterIndex"
class="chapter-item"
>
<template #title>
<div class="chapter-title-bar">
<!-- 章节标题 -->
<div class="chapter-header" @click="toggleChapter(chapterIndex)">
<div class="chapter-title">
<img src="@/assets/imgs/arrow-down.svg" alt="arrow" class="chevron-icon" :class="{ 'expanded': activeChapters.includes(chapterIndex) }"/>
<span class="chapter-name">
<el-icon><DocumentCopy /></el-icon>
章节 {{ chapterIndex + 1 }}: {{ chapterVO.chapter.name }}
</span>
<span class="chapter-meta">
{{ chapterVO.nodes.length }} 个节点
{{ chapterIndex + 1 }} {{ chapterItem.name }}{{ getChapterNodes(chapterIndex).length }}小节
</span>
</div>
</template>
</div>
<!-- 节点列表 -->
<div v-if="chapterVO.nodes.length === 0" class="empty-tip">
暂无学习节点
</div>
<div v-else class="node-list">
<div v-show="activeChapters.includes(chapterIndex)" class="node-list">
<div v-if="getChapterNodes(chapterIndex).length === 0" class="empty-tip">
暂无学习节点
</div>
<div
v-for="(node, nodeIndex) in chapterVO.nodes"
v-else
v-for="(node, nodeIndex) in getChapterNodes(chapterIndex)"
:key="nodeIndex"
class="node-item"
:class="{ 'completed': isNodeCompleted(chapterIndex, nodeIndex) }"
@click="handleNodeClick(chapterIndex, nodeIndex)"
>
<div class="node-info">
<el-icon class="node-icon">
<Document v-if="node.nodeType === 0" />
<Edit v-else-if="node.nodeType === 1" />
<Upload v-else />
</el-icon>
<span class="node-name">{{ node.name }}</span>
<el-tag
v-if="node.isRequired === 1"
type="danger"
size="small"
effect="plain"
>
必修
</el-tag>
<div class="node-left">
<span class="node-number">{{ nodeIndex + 1 }}</span>
<div class="node-info">
<img v-if="node.nodeType === 2" src="@/assets/imgs/video.svg" alt="video" class="node-icon" />
<img v-else src="@/assets/imgs/article.svg" alt="article" class="node-icon" />
<span class="node-name">{{ node.name }}</span>
</div>
</div>
<div class="node-meta">
<span v-if="node.duration" class="node-duration">
<el-icon><Clock /></el-icon>
{{ node.duration }} 分钟
<div class="node-right">
<span class="node-status">
{{ getNodeStatusText(node, chapterIndex, nodeIndex) }}
</span>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</div>
</div>
<!-- 收藏按钮底部浮动 -->
<div class="floating-collect">
@@ -182,24 +173,14 @@ import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import {
ArrowLeft,
User,
Clock,
View,
Reading,
VideoPlay,
Star,
StarFilled,
DocumentCopy,
Document,
Edit,
Upload,
ArrowRight
StarFilled
} from '@element-plus/icons-vue';
import { courseApi } from '@/apis/study';
import { learningRecordApi } from '@/apis/study';
import { userCollectionApi } from '@/apis/usercenter';
import { useStore } from 'vuex';
import type { CourseVO, LearningRecord } from '@/types';
import type { CourseItemVO, LearningRecord } from '@/types';
import { CollectionType } from '@/types';
import { FILE_DOWNLOAD_URL } from '@/config';
@@ -231,20 +212,20 @@ const userInfo = computed(() => store.getters['auth/user']);
const loading = ref(false);
const enrolling = ref(false);
const courseVO = ref<CourseVO | null>(null);
const courseItemVO = ref<CourseItemVO | null>(null);
const isCollected = ref(false);
const isEnrolled = ref(false);
const learningProgress = ref<LearningRecord | null>(null);
const activeChapters = ref<number[]>([0]); // 默认展开第一章
const defaultCover = new URL('@/assets/imgs/article-default.png', import.meta.url).href;
// 进度条颜色
const progressColor = computed(() => {
const progress = learningProgress.value?.progress || 0;
if (progress >= 80) return '#67c23a';
if (progress >= 50) return '#409eff';
return '#e6a23c';
});
// 辅助函数:获取章节的节点列表
function getChapterNodes(chapterIndex: number): CourseItemVO[] {
if (!courseItemVO.value) return [];
const chapter = courseItemVO.value.chapters?.[chapterIndex];
if (!chapter?.chapterID) return [];
return courseItemVO.value.chapterNodes?.[chapter.chapterID] || [];
}
watch(() => props.courseId, (newId) => {
if (newId) {
@@ -263,20 +244,15 @@ async function loadCourseDetail() {
const res = await courseApi.getCourseById(props.courseId);
if (res.success && res.data) {
courseVO.value = res.data;
courseItemVO.value = res.data;
// 确保数据结构完整
if (!courseVO.value.courseChapters) {
courseVO.value.courseChapters = [];
if (!courseItemVO.value.chapters) {
courseItemVO.value.chapters = [];
}
if (!courseVO.value.courseTags) {
courseVO.value.courseTags = [];
if (!courseItemVO.value.chapterNodes) {
courseItemVO.value.chapterNodes = {};
}
courseVO.value.courseChapters.forEach((chapter) => {
if (!chapter.nodes) {
chapter.nodes = [];
}
});
} else {
ElMessage.error('加载课程失败');
}
@@ -371,7 +347,7 @@ async function handleStartLearning() {
return;
}
if (!courseVO.value || courseVO.value.courseChapters.length === 0) {
if (!courseItemVO.value || !courseItemVO.value.chapters || courseItemVO.value.chapters.length === 0) {
ElMessage.warning('课程暂无内容');
return;
}
@@ -418,17 +394,66 @@ function handleBack() {
emit('back');
}
// 格式化时长
// 切换章节展开/收起
function toggleChapter(chapterIndex: number) {
const index = activeChapters.value.indexOf(chapterIndex);
if (index > -1) {
activeChapters.value.splice(index, 1);
} else {
activeChapters.value.push(chapterIndex);
}
}
// 判断节点是否完成
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function isNodeCompleted(chapterIndex: number, nodeIndex: number): boolean {
// TODO: 实际应该从学习记录中判断
return false;
}
// 获取节点状态文本
function getNodeStatusText(node: any, chapterIndex: number, nodeIndex: number): string {
const isCompleted = isNodeCompleted(chapterIndex, nodeIndex);
if (isCompleted) {
if (node.nodeType === 0) {
// 文章类型,显示字数
return '已学完';
} else if (node.nodeType === 2 && node.duration) {
// 视频类型,显示时长
return `${node.duration}分钟|已学完`;
}
return '已学完';
} else {
if (node.nodeType === 0) {
// 文章类型
return '200字';
} else if (node.nodeType === 2 && node.duration) {
// 视频类型,显示时长和学习进度
return `${node.duration}分钟`;
}
return '未学习';
}
}
// 获取讲师头像
function getTeacherAvatar(): string {
return 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png';
}
// 图片加载失败处理
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
target.src = defaultCover;
}
function formatDuration(minutes?: number): string {
if (!minutes) return '0分';
if (!minutes) return '0小时0分';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}小时${mins}分钟`;
}
return `${mins}分钟`;
return `${hours}小时${mins}`;
}
</script>
@@ -463,90 +488,208 @@ function formatDuration(minutes?: number): string {
padding: 24px;
}
.course-header {
background: #fff;
border-radius: 8px;
padding: 24px;
// 课程信息看板
.course-info-panel {
margin-bottom: 24px;
display: flex;
gap: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
@media (max-width: 768px) {
flex-direction: column;
}
}
.cover-section {
flex-shrink: 0;
.cover-image {
width: 320px;
height: 240px;
object-fit: cover;
border-radius: 8px;
@media (max-width: 768px) {
width: 100%;
height: auto;
}
.panel-container {
display: flex;
gap: 40px;
padding: 40px 60px;
background: #FFFFFF;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
}
.info-section {
.course-cover {
width: 478px;
height: 237px;
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.course-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.course-title {
font-size: 28px;
font-weight: 600;
color: #303133;
margin: 0;
line-height: 1.4;
}
.course-meta {
display: flex;
flex-wrap: wrap;
gap: 24px;
gap: 20px;
.meta-item {
.info-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.course-title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 20px;
line-height: 1.4;
color: #141F38;
margin: 0;
}
.course-desc {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: rgba(0, 0, 0, 0.3);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-meta {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
font-size: 14px;
gap: 7px;
.el-icon {
color: #909399;
.meta-item {
display: flex;
align-items: center;
gap: 7px;
img {
width: 18px;
height: 18px;
}
span {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: #4E5969;
}
}
.meta-divider {
width: 1px;
height: 14px;
background: #F1F5F9;
}
}
}
.course-description {
color: #606266;
font-size: 14px;
line-height: 1.8;
.progress-section {
display: flex;
align-items: center;
gap: 14px;
p {
margin: 0;
.progress-label {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 14px;
line-height: 1.71;
color: #86909C;
}
.progress-bar-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 9px;
}
.progress-bar {
flex: 1;
height: 6px;
background: #EAEAEA;
border-radius: 27px;
overflow: hidden;
.progress-fill {
height: 100%;
background: #10A5A1;
border-radius: 27px;
transition: width 0.3s ease;
}
}
.progress-percent {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 14px;
line-height: 1.71;
color: #86909C;
text-align: right;
}
}
.action-buttons {
display: flex;
gap: 16px;
margin-top: 8px;
align-items: center;
gap: 27px;
:deep(.el-button) {
height: 42px;
border-radius: 8px;
font-family: 'PingFang SC';
font-weight: 500;
font-size: 16px;
padding: 8px 24px;
&.el-button--primary {
width: 180px;
background: #C62828;
border-color: #C62828;
&:hover {
background: #d32f2f;
border-color: #d32f2f;
}
}
&.el-button--default {
width: 125px;
border-color: #86909C;
color: #86909C;
img {
width: 20px;
height: 20px;
margin-right: 4px;
}
.el-icon {
margin-right: 4px;
}
&.is-plain {
background: #FFFFFF;
&:hover {
background: #f5f7fa;
}
}
&:not(.is-plain) {
background: #C62828;
border-color: #C62828;
color: #FFFFFF;
&:hover {
background: #d32f2f;
border-color: #d32f2f;
}
}
}
}
}
.learning-progress {
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
.progress-header {
display: flex;
justify-content: space-between;
@@ -560,105 +703,170 @@ function formatDuration(minutes?: number): string {
}
}
.chapter-card {
:deep(.el-card__header) {
background: #fafafa;
// 课程章节区域
.chapter-section {
background: #FFFFFF;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.section-title {
display: flex;
align-items: center;
gap: 4px;
height: 25px;
margin-bottom: 24px;
.title-bar {
width: 3px;
height: 14px;
background: #C62828;
border-radius: 2px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
.title-text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 16px;
line-height: 1.5;
color: #1E293B;
}
}
.chapter-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.chapter-item {
.chapter-header {
background: #F6F7F8;
border-radius: 6px;
padding: 18px 16px;
cursor: pointer;
transition: background 0.3s;
.chapter-count {
font-size: 14px;
color: #909399;
font-weight: normal;
&:hover {
background: #eef0f2;
}
}
.chapter-title {
display: flex;
align-items: center;
gap: 4px;
.chevron-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
transition: transform 0.3s;
transform: rotate(-90deg);
&.expanded {
transform: rotate(0deg);
}
}
.chapter-name {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 16px;
line-height: 1.5;
color: #000000;
}
}
}
.chapter-title-bar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-right: 20px;
.chapter-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
}
.chapter-meta {
font-size: 14px;
color: #909399;
}
}
.node-list {
padding: 8px 0;
display: flex;
flex-direction: column;
}
.node-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
margin: 8px 0;
background: #fafafa;
border-radius: 6px;
padding: 20px 30px;
border-bottom: 1px solid #F1F5F9;
cursor: pointer;
transition: all 0.3s;
transition: background 0.3s;
&:hover {
background: #f0f2f5;
transform: translateX(4px);
background: #fafbfc;
}
.node-info {
.node-left {
display: flex;
align-items: center;
gap: 12px;
gap: 16px;
flex: 1;
.node-icon {
color: #409eff;
font-size: 18px;
}
.node-name {
.node-number {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
color: #303133;
line-height: 1.57;
color: #C62828;
flex-shrink: 0;
}
}
.node-meta {
display: flex;
align-items: center;
gap: 12px;
.node-duration {
.node-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #909399;
}
.arrow-icon {
color: #c0c4cc;
transition: transform 0.3s;
gap: 6px;
.node-icon {
flex-shrink: 0;
stroke: currentColor;
}
.node-name {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: #C62828;
}
}
}
&:hover .arrow-icon {
transform: translateX(4px);
.node-right {
.node-status {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.57;
color: #C62828;
text-align: right;
}
}
// 已完成的节点样式
&.completed {
.node-left {
.node-number {
color: #4E5969;
}
.node-info {
.node-icon {
stroke: currentColor;
}
.node-name {
color: #4E5969;
}
}
}
.node-right {
.node-status {
color: #4E5969;
}
}
}
}

View File

@@ -6,9 +6,9 @@
<el-button @click="handleBack" :icon="ArrowLeft" text>
{{ backButtonText }}
</el-button>
<span class="course-name">{{ courseVO?.course.name }}</span>
<span class="course-name">{{ courseItemVO?.name }}</span>
</div>
<div class="header-right">
<span class="progress-info">
学习进度{{ currentProgress }}%
@@ -26,38 +26,25 @@
<div class="chapter-sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="sidebar-header">
<span class="sidebar-title">课程目录</span>
<el-button
text
:icon="Fold"
@click="toggleSidebar"
/>
<el-button text :icon="Fold" @click="toggleSidebar" />
</div>
<div v-if="!sidebarCollapsed" class="chapter-list">
<el-collapse v-model="activeChapters">
<el-collapse-item
v-for="(chapterVO, chapterIndex) in courseVO?.courseChapters || []"
:key="chapterIndex"
:name="chapterIndex"
>
<el-collapse-item v-for="(chapterItem, chapterIndex) in courseItemVO?.chapters || []" :key="chapterIndex"
:name="chapterIndex">
<template #title>
<div class="chapter-item-title">
<span>{{ chapterIndex + 1 }}. {{ chapterVO.chapter.name }}</span>
<span class="chapter-count">{{ chapterVO.nodes.length }}</span>
<span>{{ chapterIndex + 1 }}. {{ chapterItem.name }}</span>
<span class="chapter-count">{{ getChapterNodes(chapterIndex).length }}</span>
</div>
</template>
<div class="node-items">
<div
v-for="(node, nodeIndex) in chapterVO.nodes"
:key="nodeIndex"
class="node-item-bar"
:class="{
active: currentChapterIndex === chapterIndex && currentNodeIndex === nodeIndex,
completed: isNodeCompleted(chapterIndex, nodeIndex)
}"
@click="selectNode(chapterIndex, nodeIndex)"
>
<div v-for="(node, nodeIndex) in getChapterNodes(chapterIndex)" :key="nodeIndex" class="node-item-bar" :class="{
active: currentChapterIndex === chapterIndex && currentNodeIndex === nodeIndex,
completed: isNodeCompleted(chapterIndex, nodeIndex)
}" @click="selectNode(chapterIndex, nodeIndex)">
<div class="node-item-content">
<el-icon v-if="isNodeCompleted(chapterIndex, nodeIndex)" class="check-icon">
<CircleCheck />
@@ -70,12 +57,7 @@
<span class="node-item-name">{{ node.name }}</span>
</div>
<div class="node-item-meta">
<el-tag
v-if="node.isRequired === 1"
type="danger"
size="small"
effect="plain"
>
<el-tag v-if="node.isRequired === 1" type="danger" size="small" effect="plain">
必修
</el-tag>
<span v-if="node.duration" class="node-duration">
@@ -90,12 +72,8 @@
</div>
<!-- 右侧学习内容区 -->
<div
ref="contentAreaRef"
class="content-area"
:class="{ expanded: sidebarCollapsed }"
@scroll="handleContentScroll"
>
<div ref="contentAreaRef" class="content-area" :class="{ expanded: sidebarCollapsed }"
@scroll="handleContentScroll">
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="8" animated />
@@ -110,7 +88,9 @@
必修
</el-tag>
<span v-if="currentNode.duration" class="duration">
<el-icon><Clock /></el-icon>
<el-icon>
<Clock />
</el-icon>
{{ currentNode.duration }} 分钟
</span>
</div>
@@ -118,11 +98,7 @@
<!-- 文章资源 -->
<div v-if="currentNode.nodeType === 0 && articleData" class="article-content">
<ArticleShowView
:as-dialog="false"
:article-data="articleData"
:category-list="[]"
/>
<ArticleShowView :as-dialog="false" :article-data="articleData" :category-list="[]" />
</div>
<!-- 富文本内容 -->
@@ -132,32 +108,26 @@
<!-- 文件内容 -->
<div v-else-if="currentNode.nodeType === 2" class="file-content">
<div v-if="isVideoFile(currentNode.videoUrl)" class="video-wrapper">
<video
:src="getFileUrl(currentNode.videoUrl)"
controls
class="video-player"
@timeupdate="handleVideoProgress"
@ended="handleVideoEnded"
>
<video :src="getFileUrl(currentNode.videoUrl)" controls class="video-player"
@timeupdate="handleVideoProgress" @ended="handleVideoEnded">
您的浏览器不支持视频播放
</video>
</div>
<div v-else-if="isAudioFile(currentNode.videoUrl)" class="audio-wrapper">
<audio
:src="getFileUrl(currentNode.videoUrl)"
controls
class="audio-player"
@timeupdate="handleAudioProgress"
@ended="handleAudioEnded"
>
<audio :src="getFileUrl(currentNode.videoUrl)" controls class="audio-player"
@timeupdate="handleAudioProgress" @ended="handleAudioEnded">
您的浏览器不支持音频播放
</audio>
</div>
<div v-else class="file-download">
<el-icon class="file-icon"><Document /></el-icon>
<el-icon class="file-icon">
<Document />
</el-icon>
<p>文件资源</p>
<el-button type="primary" @click="downloadFile(currentNode.videoUrl)">
<el-icon><Download /></el-icon>
<el-icon>
<Download />
</el-icon>
下载文件
</el-button>
</div>
@@ -165,30 +135,23 @@
<!-- 学习操作 -->
<div class="learning-actions">
<el-button
@click="markAsComplete"
:type="isCurrentNodeCompleted ? 'success' : 'primary'"
:disabled="isCurrentNodeCompleted"
>
<el-icon><CircleCheck /></el-icon>
<el-button @click="markAsComplete" :type="isCurrentNodeCompleted ? 'success' : 'primary'"
:disabled="isCurrentNodeCompleted">
<el-icon>
<CircleCheck />
</el-icon>
{{ isCurrentNodeCompleted ? '已完成' : '标记为完成' }}
</el-button>
<div class="navigation-buttons">
<el-button
@click="gotoPrevious"
:disabled="!hasPrevious"
:icon="ArrowLeft"
>
<el-button @click="gotoPrevious" :disabled="!hasPrevious" :icon="ArrowLeft">
上一节
</el-button>
<el-button
@click="gotoNext"
:disabled="!hasNext"
type="primary"
>
<el-button @click="gotoNext" :disabled="!hasNext" type="primary">
下一节
<el-icon><ArrowRight /></el-icon>
<el-icon>
<ArrowRight />
</el-icon>
</el-button>
</div>
</div>
@@ -201,6 +164,7 @@
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -225,7 +189,7 @@ import { learningRecordApi, learningHistoryApi } from '@/apis/study';
import { resourceApi } from '@/apis/resource';
import { useStore } from 'vuex';
import { FILE_DOWNLOAD_URL } from '@/config';
import type { CourseVO, LearningRecord, TbLearningHistory } from '@/types';
import type { CourseItemVO, LearningRecord, TbLearningHistory } from '@/types';
interface Props {
courseId: string;
@@ -237,7 +201,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
chapterIndex: 0,
nodeIndex: 0,
backButtonText: '返回课程详情'
backButtonText: '返回'
});
const emit = defineEmits<{
@@ -249,7 +213,7 @@ const userInfo = computed(() => store.getters['auth/user']);
const route = useRoute();
const loading = ref(false);
const courseVO = ref<CourseVO | null>(null);
const courseItemVO = ref<CourseItemVO | null>(null);
const currentChapterIndex = ref(0);
const currentNodeIndex = ref(0);
const sidebarCollapsed = ref(false);
@@ -274,32 +238,37 @@ const learningHistory = ref<TbLearningHistory | null>(null);
const historyStartTime = ref<number>(0);
const historyTimer = ref<number | null>(null);
// 辅助函数:获取章节的节点列表
function getChapterNodes(chapterIndex: number): CourseItemVO[] {
if (!courseItemVO.value) return [];
const chapter = courseItemVO.value.chapters?.[chapterIndex];
if (!chapter?.chapterID) return [];
return courseItemVO.value.chapterNodes?.[chapter.chapterID] || [];
}
// 当前节点
const currentNode = computed(() => {
if (!courseVO.value || !courseVO.value.courseChapters) return null;
const chapter = courseVO.value.courseChapters[currentChapterIndex.value];
if (!chapter || !chapter.nodes) return null;
return chapter.nodes[currentNodeIndex.value] || null;
const nodes = getChapterNodes(currentChapterIndex.value);
return nodes[currentNodeIndex.value] || null;
});
// 当前进度
const currentProgress = computed(() => {
if (!courseVO.value || !courseVO.value.courseChapters) return 0;
if (!courseItemVO.value || !courseItemVO.value.chapters) return 0;
let totalNodes = 0;
let completedCount = 0;
courseVO.value.courseChapters.forEach((chapter, chapterIdx) => {
chapter.nodes.forEach((node, nodeIdx) => {
courseItemVO.value.chapters.forEach((chapter, chapterIdx) => {
const nodes = getChapterNodes(chapterIdx);
nodes.forEach((node, nodeIdx) => {
totalNodes++;
if (isNodeCompleted(chapterIdx, nodeIdx)) {
completedCount++;
}
});
});
if (totalNodes === 0) return 0;
return Math.round((completedCount / totalNodes) * 100);
});
@@ -314,23 +283,21 @@ const hasPrevious = computed(() => {
// 是否有下一节
const hasNext = computed(() => {
if (!courseVO.value || !courseVO.value.courseChapters) return false;
const chapters = courseVO.value.courseChapters;
const currentChapter = chapters[currentChapterIndex.value];
if (!currentChapter) return false;
if (!courseItemVO.value || !courseItemVO.value.chapters) return false;
const chapters = courseItemVO.value.chapters;
const currentChapterNodes = getChapterNodes(currentChapterIndex.value);
// 不是当前章节的最后一个节点
if (currentNodeIndex.value < currentChapter.nodes.length - 1) {
if (currentNodeIndex.value < currentChapterNodes.length - 1) {
return true;
}
// 不是最后一章
if (currentChapterIndex.value < chapters.length - 1) {
return true;
}
return false;
});
@@ -357,10 +324,10 @@ watch(currentNode, async () => {
await saveHistoryRecord();
stopHistoryTimer();
}
if (currentNode.value) {
loadNodeContent();
// 为新节点创建学习历史记录
await createHistoryRecord();
}
@@ -373,7 +340,7 @@ onMounted(() => {
onBeforeUnmount(() => {
stopLearningTimer();
saveLearningProgress();
// 保存学习历史记录
if (learningHistory.value) {
saveHistoryRecord();
@@ -385,20 +352,18 @@ onBeforeUnmount(() => {
async function loadCourse() {
loading.value = true;
try {
const res = await courseApi.getCourseById(props.courseId);
const res = await courseApi.getCourseProgress(props.courseId);
if (res.success && res.data) {
courseVO.value = res.data;
courseItemVO.value = res.data;
// 确保数据结构完整
if (!courseVO.value.courseChapters) {
courseVO.value.courseChapters = [];
if (!courseItemVO.value.chapters) {
courseItemVO.value.chapters = [];
}
courseVO.value.courseChapters.forEach((chapter) => {
if (!chapter.nodes) {
chapter.nodes = [];
}
});
if (!courseItemVO.value.chapterNodes) {
courseItemVO.value.chapterNodes = {};
}
// 加载学习记录
await loadLearningRecord();
}
@@ -413,17 +378,17 @@ async function loadCourse() {
// 加载学习记录
async function loadLearningRecord() {
if (!userInfo.value?.id) return;
try {
const res = await learningRecordApi.getCourseLearningRecord({
userID: userInfo.value.id,
resourceType: 2, // 课程
courseID: props.courseId
});
if (res.success && res.dataList && res.dataList.length > 0) {
learningRecord.value = res.dataList[0];
// 从本地存储加载已完成的节点列表
const savedProgress = localStorage.getItem(`course_${props.courseId}_nodes`);
if (savedProgress) {
@@ -441,17 +406,22 @@ async function loadLearningRecord() {
// 创建学习记录
async function createLearningRecord() {
if (!userInfo.value?.id) return;
try {
const currentChapter = courseItemVO.value?.chapters?.[currentChapterIndex.value];
const currentNodeData = currentChapter?.chapterID ?
courseItemVO.value?.chapterNodes?.[currentChapter.chapterID]?.[currentNodeIndex.value] :
null;
const res = await learningRecordApi.createRecord({
userID: userInfo.value.id,
resourceType: 2, // 课程
courseID: props.courseId,
chapterID: courseVO.value?.courseChapters[currentChapterIndex.value].chapter.chapterID,
nodeID: courseVO.value?.courseChapters[currentChapterIndex.value].nodes[currentNodeIndex.value].nodeID,
chapterID: currentChapter?.chapterID,
nodeID: currentNodeData?.nodeID,
taskID: route.query.taskId as string
});
if (res.success && res.data) {
learningRecord.value = res.data;
console.log('学习记录创建成功');
@@ -464,7 +434,7 @@ async function createLearningRecord() {
// 加载节点内容
async function loadNodeContent() {
if (!currentNode.value) return;
// 如果是文章资源,加载文章内容
if (currentNode.value.nodeType === 0 && currentNode.value.resourceID) {
try {
@@ -481,7 +451,7 @@ async function loadNodeContent() {
} else {
articleData.value = null;
}
// 展开当前章节
if (!activeChapters.value.includes(currentChapterIndex.value)) {
activeChapters.value.push(currentChapterIndex.value);
@@ -494,20 +464,20 @@ async function selectNode(chapterIndex: number, nodeIndex: number) {
if (previousNodeKey.value && (hasScrolledToBottom.value || !hasScrollbar())) {
await markNodeComplete(previousNodeKey.value);
}
// 切换到新节点
currentChapterIndex.value = chapterIndex;
currentNodeIndex.value = nodeIndex;
// 重置滚动状态
hasScrolledToBottom.value = false;
previousNodeKey.value = `${chapterIndex}-${nodeIndex}`;
// 滚动到顶部
if (contentAreaRef.value) {
contentAreaRef.value.scrollTop = 0;
}
// 等待 DOM 更新后初始化视频监听
await nextTick();
initRichTextVideoListeners();
@@ -515,25 +485,25 @@ async function selectNode(chapterIndex: number, nodeIndex: number) {
// 上一节
function gotoPrevious() {
if (!hasPrevious.value || !courseVO.value) return;
if (!hasPrevious.value || !courseItemVO.value) return;
if (currentNodeIndex.value > 0) {
currentNodeIndex.value--;
} else {
// 跳到上一章的最后一节
currentChapterIndex.value--;
const prevChapter = courseVO.value.courseChapters[currentChapterIndex.value];
currentNodeIndex.value = prevChapter.nodes.length - 1;
const prevChapterNodes = getChapterNodes(currentChapterIndex.value);
currentNodeIndex.value = prevChapterNodes.length - 1;
}
}
// 下一节
function gotoNext() {
if (!hasNext.value || !courseVO.value) return;
const currentChapter = courseVO.value.courseChapters[currentChapterIndex.value];
if (currentNodeIndex.value < currentChapter.nodes.length - 1) {
if (!hasNext.value || !courseItemVO.value) return;
const currentChapterNodes = getChapterNodes(currentChapterIndex.value);
if (currentNodeIndex.value < currentChapterNodes.length - 1) {
currentNodeIndex.value++;
} else {
// 跳到下一章的第一节
@@ -545,12 +515,12 @@ function gotoNext() {
// 标记为完成
async function markAsComplete() {
if (!currentNode.value) return;
const nodeKey = `${currentChapterIndex.value}-${currentNodeIndex.value}`;
await markNodeComplete(nodeKey);
ElMessage.success('已标记为完成');
// 自动跳转到下一节
if (hasNext.value) {
setTimeout(() => {
@@ -568,7 +538,7 @@ function isNodeCompleted(chapterIndex: number, nodeIndex: number): boolean {
// 开始学习计时
function startLearningTimer() {
learningStartTime.value = Date.now();
// 每10秒保存一次学习进度如果未完成
learningTimer.value = window.setInterval(() => {
// 如果课程已完成,停止定时器
@@ -591,16 +561,16 @@ function stopLearningTimer() {
// 保存学习进度
async function saveLearningProgress() {
if (!userInfo.value?.id || !learningRecord.value) return;
// 如果课程已完成,不再保存进度
if (learningRecord.value.isComplete) {
console.log('课程已完成,跳过进度保存');
return;
}
const currentTime = Date.now();
const duration = Math.floor((currentTime - learningStartTime.value) / 1000); // 秒
try {
const updatedRecord = {
id: learningRecord.value.id,
@@ -611,14 +581,14 @@ async function saveLearningProgress() {
progress: currentProgress.value,
isComplete: currentProgress.value === 100
};
await learningRecordApi.updateRecord(updatedRecord);
// 更新本地记录
learningRecord.value.duration = updatedRecord.duration;
learningRecord.value.progress = updatedRecord.progress;
learningRecord.value.isComplete = updatedRecord.isComplete;
// 重置开始时间
learningStartTime.value = currentTime;
} catch (error) {
@@ -629,24 +599,24 @@ async function saveLearningProgress() {
// 标记节点完成
async function markNodeComplete(nodeKey: string) {
if (completedNodes.value.has(nodeKey)) return;
// 如果课程已完成,不再标记节点
if (learningRecord.value?.isComplete) {
console.log('课程已完成,跳过节点标记');
return;
}
completedNodes.value.add(nodeKey);
// 保存到本地存储
localStorage.setItem(
`course_${props.courseId}_nodes`,
JSON.stringify(Array.from(completedNodes.value))
);
// 更新学习进度
await saveLearningProgress();
// 如果全部完成,标记课程为完成
if (currentProgress.value === 100) {
await markCourseComplete();
@@ -656,7 +626,7 @@ async function markNodeComplete(nodeKey: string) {
// 标记课程完成
async function markCourseComplete() {
if (!userInfo.value?.id || !learningRecord.value) return;
try {
await learningRecordApi.markComplete({
id: learningRecord.value.id,
@@ -667,7 +637,7 @@ async function markCourseComplete() {
progress: 100,
isComplete: true
});
ElMessage.success('恭喜你完成了整个课程!');
} catch (error) {
console.error('标记课程完成失败:', error);
@@ -686,7 +656,7 @@ function handleContentScroll(event: Event) {
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
// 判断是否滚动到底部留10px容差
if (scrollHeight - scrollTop - clientHeight < 10) {
if (!hasScrolledToBottom.value) {
@@ -703,7 +673,7 @@ function handleVideoProgress(event: Event) {
// 可以在这里记录视频播放进度
const video = event.target as HTMLVideoElement;
const progress = (video.currentTime / video.duration) * 100;
// 如果播放超过80%,自动标记为完成
if (progress > 80 && !isCurrentNodeCompleted.value) {
// markAsComplete();
@@ -727,30 +697,30 @@ function initRichTextVideoListeners() {
'.content-area .rich-text-content',
'.rich-text-content'
];
let richTextContent: Element | null = null;
for (const selector of selectors) {
richTextContent = document.querySelector(selector);
if (richTextContent) {
break;
}
}
if (!richTextContent) {
return;
}
const videos = richTextContent.querySelectorAll('video');
if (videos.length === 0) {
return;
}
// 初始化视频数量和完成状态
totalRichTextVideos.value = videos.length;
completedRichTextVideos.value.clear();
// 监听所有视频的播放结束事件
videos.forEach((video, index) => {
const videoElement = video as HTMLVideoElement;
@@ -766,9 +736,9 @@ function initRichTextVideoListeners() {
function handleRichTextVideoEnded(videoIndex: number) {
// 标记该视频已完成
completedRichTextVideos.value.add(videoIndex);
const completedCount = completedRichTextVideos.value.size;
// 检查是否所有视频都已完成
if (completedCount >= totalRichTextVideos.value) {
if (!isCurrentNodeCompleted.value) {
@@ -782,7 +752,7 @@ function handleRichTextVideoEnded(videoIndex: number) {
function handleAudioProgress(event: Event) {
const audio = event.target as HTMLAudioElement;
const progress = (audio.currentTime / audio.duration) * 100;
if (progress > 80 && !isCurrentNodeCompleted.value) {
// markAsComplete();
}
@@ -831,13 +801,13 @@ function toggleSidebar() {
// 创建学习历史记录
async function createHistoryRecord() {
if (!userInfo.value?.id || !courseVO.value || !currentNode.value) return;
if (!userInfo.value?.id || !courseItemVO.value || !currentNode.value) return;
try {
const chapterVO = courseVO.value.courseChapters[currentChapterIndex.value];
const chapter = chapterVO?.chapter;
const chapterItem = courseItemVO.value.chapters![currentChapterIndex.value];
const chapter = chapterItem;
const node = currentNode.value;
const res = await learningHistoryApi.recordCourseLearn(
userInfo.value.id,
props.courseId,
@@ -845,11 +815,11 @@ async function createHistoryRecord() {
node.nodeID,
0 // 初始时长为0
);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log('✅ 课程学习历史记录创建成功:', learningHistory.value);
// 开始计时
startHistoryTimer();
}
@@ -861,7 +831,7 @@ async function createHistoryRecord() {
// 开始学习历史计时
function startHistoryTimer() {
historyStartTime.value = Date.now();
// 每30秒保存一次学习历史
historyTimer.value = window.setInterval(() => {
saveHistoryRecord();
@@ -879,28 +849,28 @@ function stopHistoryTimer() {
// 保存学习历史记录
async function saveHistoryRecord() {
if (!userInfo.value?.id || !learningHistory.value) return;
const currentTime = Date.now();
const duration = Math.floor((currentTime - historyStartTime.value) / 1000); // 秒
// 如果时长太短小于1秒不保存
if (duration < 1) return;
try {
const updatedHistory: TbLearningHistory = {
...learningHistory.value,
duration: (learningHistory.value.duration || 0) + duration,
endTime: new Date().toISOString()
};
// 调用API更新学习历史
const res = await learningHistoryApi.recordLearningHistory(updatedHistory);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log(`💾 课程学习历史已保存 - 累计时长: ${learningHistory.value.duration}秒 - 节点: ${currentNode.value?.name}`);
}
// 重置开始时间
historyStartTime.value = currentTime;
} catch (error) {
@@ -911,13 +881,13 @@ async function saveHistoryRecord() {
function handleBack() {
stopLearningTimer();
saveLearningProgress();
// 保存学习历史记录
if (learningHistory.value) {
saveHistoryRecord();
}
stopHistoryTimer();
emit('back');
}
</script>
@@ -941,19 +911,19 @@ function handleBack() {
top: 0;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
.header-left {
display: flex;
align-items: center;
gap: 16px;
.course-name {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.header-right {
.progress-info {
font-size: 14px;
@@ -974,7 +944,7 @@ function handleBack() {
left: 16px;
top: 16px;
z-index: 10;
:deep(.el-button) {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
@@ -986,26 +956,26 @@ function handleBack() {
border-right: 1px solid #e4e7ed;
overflow-y: auto;
transition: width 0.3s;
&.collapsed {
width: 0;
overflow: hidden;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e4e7ed;
.sidebar-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.chapter-list {
padding: 8px;
}
@@ -1019,7 +989,7 @@ function handleBack() {
padding-right: 20px;
font-size: 14px;
font-weight: 500;
.chapter-count {
font-size: 12px;
color: #909399;
@@ -1042,48 +1012,48 @@ function handleBack() {
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
color: #409eff;
}
&.completed {
.check-icon {
color: #67c23a;
}
}
.node-item-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
.node-type-icon {
font-size: 16px;
color: #909399;
}
.check-icon {
font-size: 16px;
}
.node-item-name {
font-size: 13px;
color: #606266;
}
}
.node-item-meta {
display: flex;
align-items: center;
gap: 8px;
.node-duration {
font-size: 12px;
color: #909399;
@@ -1095,7 +1065,7 @@ function handleBack() {
flex: 1;
overflow-y: auto;
background: #fff;
&.expanded {
margin-left: 0;
}
@@ -1115,19 +1085,19 @@ function handleBack() {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e4e7ed;
.node-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0 0 12px 0;
}
.node-meta {
display: flex;
align-items: center;
gap: 12px;
.duration {
display: flex;
align-items: center;
@@ -1150,7 +1120,7 @@ function handleBack() {
max-width: 100%;
height: auto;
}
:deep(video) {
max-width: 100%;
height: auto;
@@ -1159,7 +1129,7 @@ function handleBack() {
.file-content {
margin-bottom: 24px;
.video-wrapper,
.audio-wrapper {
display: flex;
@@ -1167,17 +1137,17 @@ function handleBack() {
padding: 24px;
background: #000;
border-radius: 8px;
.video-player {
max-width: 100%;
max-height: 500px;
}
.audio-player {
width: 100%;
}
}
.file-download {
display: flex;
flex-direction: column;
@@ -1186,13 +1156,13 @@ function handleBack() {
padding: 60px;
background: #f5f7fa;
border-radius: 8px;
.file-icon {
font-size: 64px;
color: #909399;
margin-bottom: 16px;
}
p {
margin-bottom: 16px;
color: #606266;
@@ -1206,7 +1176,7 @@ function handleBack() {
align-items: center;
padding-top: 24px;
border-top: 1px solid #e4e7ed;
.navigation-buttons {
display: flex;
gap: 12px;
@@ -1221,4 +1191,3 @@ function handleBack() {
min-height: 400px;
}
</style>

View File

@@ -150,7 +150,7 @@ onMounted(() => {
async function loadCourses() {
loading.value = true;
try {
const res = await courseApi.getCoursePage({page: currentPage.value, size: pageSize.value}, searchForm);
const res = await courseApi.getCoursePage({pageNumber: currentPage.value, pageSize: pageSize.value}, searchForm);
if (res.success && res.pageDomain) {
courseList.value = res.pageDomain.dataList || [];
total.value = res.pageDomain.pageParam.totalElements || 0;

View File

@@ -2,7 +2,15 @@
<div class="home-view">
<!-- 轮播横幅区域 -->
<div class="banner-section">
<!-- 加载状态 -->
<div v-if="loading" class="banner-loading">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
<!-- 轮播图 -->
<Carousel
v-else-if="banners.length > 0"
:items="banners"
:interval="5000"
:active-icon="dangIcon"
@@ -12,6 +20,11 @@
<BannerCard :banner="item" />
</template>
</Carousel>
<!-- 空状态 -->
<div v-else class="banner-empty">
<p>暂无轮播内容</p>
</div>
</div>
<!-- 热门资源推荐 -->
@@ -57,18 +70,44 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted } from 'vue';
import { BannerCard, LearningProgress } from '@/views/public/';
import { HotArticleCard, IdeologicalArticleCard } from '@/views/public/article';
import { Carousel } from '@/components/base';
import { ArrowRight } from '@element-plus/icons-vue';
import { bannerApi } from '@/apis/resource/banner';
import { ElMessage } from 'element-plus';
import type { Banner } from '@/types';
import dangIcon from '@/assets/imgs/dang.svg';
// 模拟轮播数据,实际应该从接口获取
const banners = ref([
{ id: 1, imageUrl: '', linkType: 1, linkID: '', linkUrl: '' },
{ id: 2, imageUrl: '', linkType: 1, linkID: '', linkUrl: '' },
]);
// 轮播数据
const banners = ref<Banner[]>([]);
const loading = ref(false);
// 加载轮播图数据
async function loadBanners() {
try {
loading.value = true;
const result = await bannerApi.getHomeBannerList();
if (result.code === 200 && result.dataList) {
// 只显示启用状态的banner按排序号排序
banners.value = result.dataList
.filter((banner: Banner) => banner.status === 1)
.sort((a: Banner, b: Banner) => (a.orderNum || 0) - (b.orderNum || 0));
}
} catch (error) {
console.error('加载轮播图失败:', error);
ElMessage.error('加载轮播图失败');
} finally {
loading.value = false;
}
}
// 组件挂载时加载数据
onMounted(() => {
loadBanners();
});
</script>
<style lang="scss" scoped>
@@ -81,6 +120,37 @@ const banners = ref([
.banner-section {
width: 100%;
height: 30vh;
position: relative;
}
.banner-loading,
.banner-empty {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f7fa;
p {
margin-top: 16px;
color: #909399;
font-size: 14px;
}
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e4e7ed;
border-top-color: #E7000B;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.section {

View File

@@ -15,7 +15,7 @@
@category-change="handleCategoryChange"
/>
<ResourceList
v-if="!showArticle"
v-show="!showArticle"
ref="resourceListRef"
:tagID="currentCategoryId"
:search-keyword="searchKeyword"

View File

@@ -1,10 +1,9 @@
<template>
<div class="resource-article">
<ArticleShowView
<ArticleShow
v-if="articleData"
:as-dialog="false"
:article-data="articleData"
:category-list="[]"
:show-back-button="true"
back-button-text="返回列表"
@back="handleBack"
@@ -24,7 +23,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ArticleShowView } from '@/views/public/article';
import { ArticleShow } from '@/views/public/article';
import { ResouceCollect, ResouceBottom } from '@/views/user/resource-center/components';
import { resourceApi } from '@/apis/resource';
import { ElMessage } from 'element-plus';

View File

@@ -3,16 +3,17 @@
v-if="courseId"
:course-id="courseId"
:show-back-button="true"
back-button-text="返回课程列表"
back-button-text="返回"
@back="handleBack"
@start-learning="handleStartLearning"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { CourseDetail } from '@/views/public/course/components';
import { courseApi } from '@/apis/study';
defineOptions({
name: 'CourseDetailView'
@@ -27,6 +28,10 @@ const courseId = computed(() => route.query.courseId as string || '');
function handleBack() {
router.back();
}
onMounted(() => {
// 调用接口更新浏览记录
courseApi.incrementViewCount(courseId.value);
});
// 开始学习课程
function handleStartLearning(courseId: string, chapterIndex: number, nodeIndex: number) {