视图修改、接口修改
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @description 轮播图相关API
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
|
||||
import { api } from '@/apis/index';
|
||||
import type { Banner, Resource, ResultDomain } from '@/types';
|
||||
|
||||
/**
|
||||
* 轮播图API服务
|
||||
*/
|
||||
export const bannerApi = {
|
||||
/**
|
||||
* 获取轮播组件数据
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getBannerList(): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/homepage/banner/list');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 点击轮播跳转新闻详情
|
||||
* @param bannerID Banner ID
|
||||
* @returns Promise<ResultDomain<Resource>>
|
||||
*/
|
||||
async getBannerNewsDetail(bannerID: string): Promise<ResultDomain<Resource>> {
|
||||
const response = await api.get<Resource>(`/homepage/banner/click/${bannerID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃轮播列表
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getActiveBanners(): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/homepage/banner/active');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
// 重新导出各个子模块
|
||||
export { bannerApi } from './banner';
|
||||
export { recommendApi } from './recommend';
|
||||
export { newsApi } from './news';
|
||||
export { menuApi } from './menu';
|
||||
|
||||
124
schoolNewsWeb/src/apis/resource/banner.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @description Banner 管理 API 接口
|
||||
* @filename banner-manage.ts
|
||||
* @author yslg
|
||||
* @copyright xyzh
|
||||
* @since 2025-10-28
|
||||
*/
|
||||
|
||||
import { api } from '@/apis';
|
||||
import type { ResultDomain, Banner, PageParam } from '@/types';
|
||||
|
||||
/**
|
||||
* Banner 管理 API 服务
|
||||
*/
|
||||
export const bannerApi = {
|
||||
/**
|
||||
* 获取横幅列表
|
||||
* @param filter 筛选条件
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getBannerList(filter?: Partial<Banner>): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/news/banners/list', filter);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取横幅分页列表
|
||||
* @param pageParam 分页参数
|
||||
* @param filter 筛选条件
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getBannerPage(pageParam: PageParam, filter?: Partial<Banner>): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.post<Banner>('/news/banners/banner/page', {
|
||||
pageParam,
|
||||
filter,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建横幅
|
||||
* @param banner 横幅信息
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async createBanner(banner: Banner): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.post<Banner>('/news/banners/banner', banner);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新横幅
|
||||
* @param banner 横幅信息
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async updateBanner(banner: Banner): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.put<Banner>('/news/banners/banner', banner);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除横幅
|
||||
* @param banner 横幅信息(包含 bannerID)
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteBanner(banner: Banner): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.delete<boolean>('/news/banners/banner', {
|
||||
data: banner
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据 ID 删除横幅
|
||||
* @param bannerID 横幅ID
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteBannerById(bannerID: string): Promise<ResultDomain<boolean>> {
|
||||
return this.deleteBanner({ id: bannerID });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新横幅状态
|
||||
* @param bannerID 横幅ID
|
||||
* @param status 状态值(0禁用 1启用)
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async updateBannerStatus(bannerID: string, status: number): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.put<Banner>('/news/banners/banner/status', {
|
||||
id: bannerID,
|
||||
status,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 启用横幅
|
||||
* @param bannerID 横幅ID
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async enableBanner(bannerID: string): Promise<ResultDomain<Banner>> {
|
||||
return this.updateBannerStatus(bannerID, 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 禁用横幅
|
||||
* @param bannerID 横幅ID
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async disableBanner(bannerID: string): Promise<ResultDomain<Banner>> {
|
||||
return this.updateBannerStatus(bannerID, 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取首页横幅列表
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getHomeBannerList(): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/news/banners/home');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default bannerApi;
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
|
||||
export * from './resourceCategory';
|
||||
export * from './resourceTag';
|
||||
export * from './resource';
|
||||
export * from './resource';
|
||||
export { bannerApi} from './banner';
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* @description 资源分类API接口
|
||||
* @filename resourceCategory.ts
|
||||
* @author yslg
|
||||
* @copyright xyzh
|
||||
* @since 2025-10-15
|
||||
*
|
||||
* ⚠️ 注意:此API已废弃!
|
||||
*
|
||||
* 从2025-10-27起,资源分类功能已迁移到标签系统(Tag)中。
|
||||
*
|
||||
* 迁移说明:
|
||||
* - 原 tb_resource_category 表已废弃
|
||||
* - 改为使用 tb_tag 表的 tag_type=1 表示文章分类标签
|
||||
* - 请使用 resourceTagApi.getTagsByType(1) 替代本 API 的方法
|
||||
*
|
||||
* API 迁移对照:
|
||||
* - getCategoryList() → resourceTagApi.getTagsByType(1)
|
||||
* - getCategoryById(id) → resourceTagApi.getTagById(id)
|
||||
* - createCategory(category) → resourceTagApi.createTag({...category, tagType: 1})
|
||||
* - updateCategory(category) → resourceTagApi.updateTag(category)
|
||||
* - deleteCategory(id) → resourceTagApi.deleteTag(id)
|
||||
*
|
||||
* @deprecated 请使用 resourceTagApi 代替
|
||||
*/
|
||||
|
||||
import { api } from '@/apis';
|
||||
import type { ResultDomain, ResourceCategory } from '@/types';
|
||||
|
||||
/**
|
||||
* 资源分类API服务
|
||||
* @deprecated 已废弃,请使用 resourceTagApi.getTagsByType(1) 获取文章分类标签
|
||||
*/
|
||||
export const resourceCategoryApi = {
|
||||
/**
|
||||
* 获取分类列表
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getCategoryList(): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>('/news/categorys/list');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据ID获取分类详情
|
||||
* @param tagID 分类ID
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getCategoryById(tagID: string): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>(`/news/categorys/category/${tagID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建分类
|
||||
* @param category 分类信息
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async createCategory(category: ResourceCategory): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.post<ResourceCategory>('/news/categorys/category', category);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分类
|
||||
* @param category 分类信息
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async updateCategory(category: ResourceCategory): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.put<ResourceCategory>('/news/categorys/category', category);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
* @param tagID 分类ID
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteCategory(tagID: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.delete<boolean>(`/news/categorys/category/${tagID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分类状态
|
||||
* @param tagID 分类ID
|
||||
* @param status 状态值
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async updateCategoryStatus(tagID: string, status: number): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.put<ResourceCategory>(`/news/categorys/category/${tagID}/status`, null, {
|
||||
params: { status }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取分类树
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getCategoryTree(): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>('/news/categorys/tree');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取子分类
|
||||
* @param parentID 父分类ID
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getChildCategories(parentID: string): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>(`/news/categorys/category/${parentID}/children`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export default resourceCategoryApi;
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '@/apis/index';
|
||||
import type { Course, CourseChapter, ResultDomain,CourseVO,PageRequest, PageParam, CourseNode } from '@/types';
|
||||
import type { Course, CourseChapter, ResultDomain, CourseItemVO, PageParam, CourseNode } from '@/types';
|
||||
|
||||
/**
|
||||
* 课程API服务
|
||||
@@ -38,30 +38,40 @@ export const courseApi = {
|
||||
/**
|
||||
* 根据ID获取课程详情
|
||||
* @param courseID 课程ID
|
||||
* @returns Promise<ResultDomain<Course>>
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async getCourseById(courseID: string): Promise<ResultDomain<CourseVO>> {
|
||||
const response = await api.get<CourseVO>(`${this.prefixCourse}/${courseID}`);
|
||||
async getCourseById(courseID: string): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.get<CourseItemVO>(`${this.prefixCourse}/${courseID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取课程学习进度
|
||||
* @param courseID 课程ID
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async getCourseProgress(courseID: string): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.get<CourseItemVO>(`${this.prefixCourse}/${courseID}/progress`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建课程
|
||||
* @param course 课程数据
|
||||
* @returns Promise<ResultDomain<Course>>
|
||||
* @param courseItemVO 课程数据
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async createCourse(course: CourseVO): Promise<ResultDomain<CourseVO>> {
|
||||
const response = await api.post<CourseVO>(`${this.prefixCourse}/course`, course);
|
||||
async createCourse(courseItemVO: CourseItemVO): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.post<CourseItemVO>(`${this.prefixCourse}/course`, courseItemVO);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新课程
|
||||
* @param course 课程数据
|
||||
* @returns Promise<ResultDomain<Course>>
|
||||
* @param courseItemVO 课程数据
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async updateCourse(courseVO: CourseVO): Promise<ResultDomain<CourseVO>> {
|
||||
const response = await api.put<CourseVO>(`${this.prefixCourse}/course`, courseVO);
|
||||
async updateCourse(courseItemVO: CourseItemVO): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.put<CourseItemVO>(`${this.prefixCourse}/course`, courseItemVO);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const learningTaskApi = {
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteTask(taskID: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.delete<boolean>(`${this.learningTaskPrefix}/task/${taskID}`);
|
||||
const response = await api.delete<boolean>(`${this.learningTaskPrefix}/task`, {taskID});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
4
schoolNewsWeb/src/assets/imgs/article.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3333 14.6666H2.66667C2.29848 14.6666 2 14.3681 2 13.9999V1.99992C2 1.63173 2.29848 1.33325 2.66667 1.33325H13.3333C13.7015 1.33325 14 1.63173 14 1.99992V13.9999C14 14.3681 13.7015 14.6666 13.3333 14.6666ZM12.6667 13.3333V2.66659H3.33333V13.3333H12.6667ZM4.66667 3.99992H7.33333V6.66658H4.66667V3.99992ZM4.66667 7.99992H11.3333V9.33325H4.66667V7.99992ZM4.66667 10.6666H11.3333V11.9999H4.66667V10.6666ZM8.66667 4.66658H11.3333V5.99992H8.66667V4.66658Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 590 B |
4
schoolNewsWeb/src/assets/imgs/book-read.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.50049 2.99505C1.50049 2.58357 1.84197 2.25 2.24434 2.25H15.7566C16.1675 2.25 16.5005 2.58371 16.5005 2.99505V15.0049C16.5005 15.4164 16.159 15.75 15.7566 15.75H2.24434C1.83353 15.75 1.50049 15.4163 1.50049 15.0049V2.99505ZM8.25049 3.75H3.00049V14.25H8.25049V3.75ZM9.75049 3.75V14.25H15.0005V3.75H9.75049ZM10.5005 5.25H14.2505V6.75H10.5005V5.25ZM10.5005 7.5H14.2505V9H10.5005V7.5Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 519 B |
4
schoolNewsWeb/src/assets/imgs/clock.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 16.5C4.85786 16.5 1.5 13.1421 1.5 9C1.5 4.85786 4.85786 1.5 9 1.5C13.1421 1.5 16.5 4.85786 16.5 9C16.5 13.1421 13.1421 16.5 9 16.5ZM9 15C12.3137 15 15 12.3137 15 9C15 5.68629 12.3137 3 9 3C5.68629 3 3 5.68629 3 9C3 12.3137 5.68629 15 9 15ZM9.75 9H12.75V10.5H8.25V5.25H9.75V9Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 415 B |
5
schoolNewsWeb/src/assets/imgs/edit.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 2H3.33333C2.97971 2 2.64057 2.14048 2.39052 2.39052C2.14048 2.64057 2 2.97971 2 3.33333V12.6667C2 13.0203 2.14048 13.3594 2.39052 13.6095C2.64057 13.8595 2.97971 14 3.33333 14H12.6667C13.0203 14 13.3594 13.8595 13.6095 13.6095C13.8595 13.3594 14 13.0203 14 12.6667V8" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.25 1.75015C12.5152 1.48493 12.8749 1.33594 13.25 1.33594C13.6251 1.33594 13.9848 1.48493 14.25 1.75015C14.5152 2.01537 14.6642 2.37508 14.6642 2.75015C14.6642 3.12522 14.5152 3.48493 14.25 3.75015L8.24136 9.75948C8.08305 9.91765 7.88749 10.0334 7.67269 10.0962L5.75735 10.6562C5.69999 10.6729 5.63918 10.6739 5.58129 10.6591C5.52341 10.6442 5.47057 10.6141 5.42832 10.5719C5.38607 10.5296 5.35595 10.4768 5.34112 10.4189C5.32629 10.361 5.32729 10.3002 5.34402 10.2428L5.90402 8.32748C5.96704 8.11285 6.08304 7.91752 6.24136 7.75948L12.25 1.75015Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
9
schoolNewsWeb/src/assets/imgs/eye.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="38" height="32" viewBox="0 0 38 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="path-1-inside-1_906_1872" fill="white">
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H30C34.4183 0 38 3.58172 38 8V24C38 28.4183 34.4183 32 30 32H8C3.58172 32 0 28.4183 0 24V8Z"/>
|
||||
</mask>
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H30C34.4183 0 38 3.58172 38 8V24C38 28.4183 34.4183 32 30 32H8C3.58172 32 0 28.4183 0 24V8Z" fill="white"/>
|
||||
<path d="M8 0V1H30V0V-1H8V0ZM38 8H37V24H38H39V8H38ZM30 32V31H8V32V33H30V32ZM0 24H1V8H0H-1V24H0ZM8 32V31C4.13401 31 1 27.866 1 24H0H-1C-1 28.9706 3.02944 33 8 33V32ZM38 24H37C37 27.866 33.866 31 30 31V32V33C34.9706 33 39 28.9706 39 24H38ZM30 0V1C33.866 1 37 4.13401 37 8H38H39C39 3.02944 34.9706 -1 30 -1V0ZM8 0V-1C3.02944 -1 -1 3.02944 -1 8H0H1C1 4.13401 4.13401 1 8 1V0Z" fill="black" fill-opacity="0.1" mask="url(#path-1-inside-1_906_1872)"/>
|
||||
<path d="M19.0002 10C22.5949 10 25.5856 12.5865 26.2126 16C25.5856 19.4135 22.5949 22 19.0002 22C15.4054 22 12.4147 19.4135 11.7877 16C12.4147 12.5865 15.4054 10 19.0002 10ZM19.0002 20.6667C21.8239 20.6667 24.2402 18.7013 24.8518 16C24.2402 13.2987 21.8239 11.3333 19.0002 11.3333C16.1764 11.3333 13.7601 13.2987 13.1485 16C13.7601 18.7013 16.1764 20.6667 19.0002 20.6667ZM19.0002 19C17.3433 19 16.0001 17.6569 16.0001 16C16.0001 14.3431 17.3433 13 19.0002 13C20.657 13 22.0002 14.3431 22.0002 16C22.0002 17.6569 20.657 19 19.0002 19ZM19.0002 17.6667C19.9206 17.6667 20.6668 16.9205 20.6668 16C20.6668 15.0795 19.9206 14.3333 19.0002 14.3333C18.0797 14.3333 17.3335 15.0795 17.3335 16C17.3335 16.9205 18.0797 17.6667 19.0002 17.6667Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
schoolNewsWeb/src/assets/imgs/plus.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.3335 8H12.6668" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.3335V12.6668" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 341 B |
5
schoolNewsWeb/src/assets/imgs/time-line.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="381" height="6" viewBox="0 0 381 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="381" height="6" rx="3" fill="#EAEAEA"/>
|
||||
<rect width="238.979" height="6" rx="3" fill="#EAEAEA"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 221 B |
8
schoolNewsWeb/src/assets/imgs/trashbin.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.66663 7.3335V11.3335" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.33337 7.3335V11.3335" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.6667 4V13.3333C12.6667 13.687 12.5262 14.0261 12.2762 14.2761C12.0261 14.5262 11.687 14.6667 11.3334 14.6667H4.66671C4.31309 14.6667 3.97395 14.5262 3.7239 14.2761C3.47385 14.0261 3.33337 13.687 3.33337 13.3333V4" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 4H14" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.33337 4.00016V2.66683C5.33337 2.31321 5.47385 1.97407 5.7239 1.72402C5.97395 1.47397 6.31309 1.3335 6.66671 1.3335H9.33337C9.687 1.3335 10.0261 1.47397 10.2762 1.72402C10.5262 1.97407 10.6667 2.31321 10.6667 2.66683V4.00016" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
schoolNewsWeb/src/assets/imgs/video.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3333 14.6666H2.66667C2.29848 14.6666 2 14.3681 2 13.9999V1.99992C2 1.63173 2.29848 1.33325 2.66667 1.33325H13.3333C13.7015 1.33325 14 1.63173 14 1.99992V13.9999C14 14.3681 13.7015 14.6666 13.3333 14.6666ZM12.6667 13.3333V2.66659H3.33333V13.3333H12.6667ZM4.66667 3.99992H7.33333V6.66658H4.66667V3.99992ZM4.66667 7.99992H11.3333V9.33325H4.66667V7.99992ZM4.66667 10.6666H11.3333V11.9999H4.66667V10.6666ZM8.66667 4.66658H11.3333V5.99992H8.66667V4.66658Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 590 B |
@@ -8,7 +8,12 @@
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<div class="menu-item-inner">
|
||||
<i class="menu-icon" :class="menu.icon || 'icon-folder'"></i>
|
||||
<img
|
||||
v-if="menu.icon"
|
||||
:src="String(PUBLIC_IMG_PATH + '/' + menu.icon)"
|
||||
class="tab-icon"
|
||||
:alt="String(menu.name || '')"
|
||||
/>
|
||||
<span class="menu-title">{{ menu.name }}</span>
|
||||
<img
|
||||
src="@/assets/imgs/arrow-down.svg"
|
||||
@@ -44,7 +49,12 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="menu-item-inner">
|
||||
<i class="menu-icon" :class="menu.icon || 'icon-file'"></i>
|
||||
<img
|
||||
v-if="menu.icon"
|
||||
:src="String(PUBLIC_IMG_PATH + '/' + menu.icon)"
|
||||
class="tab-icon"
|
||||
:alt="String(menu.name || '')"
|
||||
/>
|
||||
<span class="menu-title">{{ menu.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +67,7 @@ import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
|
||||
import { PUBLIC_IMG_PATH} from '@/config'
|
||||
// 递归组件需要声明名称(Vue 3.5+)
|
||||
defineOptions({
|
||||
name: 'MenuItem'
|
||||
@@ -153,15 +163,12 @@ function handleClick() {
|
||||
line-height: 1.43;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
.tab-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
height: 16px;
|
||||
margin-right: 12px;
|
||||
|
||||
.collapsed & {
|
||||
margin-right: 0;
|
||||
}
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
@@ -211,12 +218,4 @@ function handleClick() {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
/* 图标字体类(简单实现,实际项目中使用图标库) */
|
||||
.icon-folder::before { content: "📁"; }
|
||||
.icon-file::before { content: "📄"; }
|
||||
.icon-dashboard::before { content: "📊"; }
|
||||
.icon-user::before { content: "👤"; }
|
||||
.icon-news::before { content: "📰"; }
|
||||
.icon-settings::before { content: "⚙️"; }
|
||||
</style>
|
||||
|
||||
@@ -179,13 +179,10 @@ function handleMenuClick(menu: SysMenu) {
|
||||
flex: 1;
|
||||
background: #F9FAFB;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
|
||||
// 使用 margin 而不是 padding,避免影响滚动高度计算
|
||||
// margin 不计入元素尺寸,所以不会导致滚动条
|
||||
> * {
|
||||
margin: 20px;
|
||||
}
|
||||
/* min-width: 0; */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
height: 100vh;
|
||||
|
||||
// 美化滚动条
|
||||
&::-webkit-scrollbar {
|
||||
@@ -215,11 +212,8 @@ function handleMenuClick(menu: SysMenu) {
|
||||
background: #F9FAFB;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
|
||||
// 使用 margin 而不是 padding,避免影响滚动高度计算
|
||||
> * {
|
||||
margin: 20px;
|
||||
}
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
// 美化滚动条
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface Resource extends BaseDTO {
|
||||
* Banner实体
|
||||
*/
|
||||
export interface Banner extends BaseDTO {
|
||||
bannerID?: string;
|
||||
/** Banner标题 */
|
||||
title?: string;
|
||||
/** Banner图片URL */
|
||||
@@ -138,10 +139,8 @@ export interface Tag extends BaseDTO {
|
||||
color?: string;
|
||||
/** 标签类型(1-文章分类标签 2-课程分类标签 3-学习任务分类标签) */
|
||||
tagType?: number;
|
||||
/** 排序号 */
|
||||
orderNum?: number;
|
||||
/** 状态(0禁用 1启用) */
|
||||
status?: number;
|
||||
/** 使用计数(被标记的资源数量) */
|
||||
usageCount?: number;
|
||||
/** 创建者 */
|
||||
creator?: string;
|
||||
/** 更新者 */
|
||||
|
||||
@@ -118,15 +118,72 @@ export interface CourseNode extends BaseDTO {
|
||||
updater?: string;
|
||||
}
|
||||
|
||||
export interface ChapterVO extends BaseDTO {
|
||||
chapter: CourseChapter;
|
||||
nodes: CourseNode[];
|
||||
}
|
||||
/**
|
||||
* 课程项目VO - 统一的课程视图对象
|
||||
* 包含课程、章节、学习节点、学习记录的字段
|
||||
*/
|
||||
export interface CourseItemVO extends BaseDTO {
|
||||
// ========== 课程基本信息 ==========
|
||||
/** 课程ID */
|
||||
courseID?: string;
|
||||
/** 课程名称 */
|
||||
name?: string;
|
||||
/** 课程封面图片 */
|
||||
coverImage?: string;
|
||||
/** 课程描述 */
|
||||
description?: string;
|
||||
/** 课程时长(分钟) */
|
||||
duration?: number;
|
||||
/** 授课老师 */
|
||||
teacher?: string;
|
||||
/** 课程状态(0未上线 1已上线 2已下架) */
|
||||
status?: number;
|
||||
/** 浏览次数 */
|
||||
viewCount?: number;
|
||||
/** 学习人数 */
|
||||
learnCount?: number;
|
||||
/** 课程创建时间 */
|
||||
createTime?: string;
|
||||
|
||||
export interface CourseVO extends BaseDTO {
|
||||
course: Course;
|
||||
courseChapters: ChapterVO[];
|
||||
courseTags: CourseTag[];
|
||||
// ========== 学习记录信息 ==========
|
||||
/** 学习记录ID */
|
||||
recordID?: string;
|
||||
/** 学习进度(0-100) */
|
||||
progress?: number;
|
||||
/** 是否完成 */
|
||||
isComplete?: boolean;
|
||||
/** 学习时长(秒) */
|
||||
learningDuration?: number;
|
||||
/** 最后学习时间 */
|
||||
lastLearnTime?: string;
|
||||
/** 完成时间 */
|
||||
completeTime?: string;
|
||||
|
||||
// ========== 章节节点信息 ==========
|
||||
/** 章节ID(当此对象代表章节或节点时使用) */
|
||||
chapterID?: string;
|
||||
/** 节点ID(当此对象代表节点时使用) */
|
||||
nodeID?: string;
|
||||
/** 父级ID(章节的父章节ID) */
|
||||
parentID?: string;
|
||||
/** 节点类型(1视频 2文档 3音频 4图片 5链接) */
|
||||
nodeType?: number;
|
||||
/** 节点内容(富文本内容) */
|
||||
content?: string;
|
||||
/** 视频URL */
|
||||
videoUrl?: string;
|
||||
/** 资源ID */
|
||||
resourceID?: string;
|
||||
/** 排序号 */
|
||||
orderNum?: number;
|
||||
/** 是否必修(1必修 0选修) */
|
||||
isRequired?: number;
|
||||
|
||||
// ========== 层级结构 ==========
|
||||
/** 章节列表(课程的章节列表,或章节的节点列表) */
|
||||
chapters?: CourseItemVO[];
|
||||
/** 章节节点映射(key: chapterID, value: 该章节下的节点列表) */
|
||||
chapterNodes?: Record<string, CourseItemVO[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { RouteRecordRaw, RouteLocationNormalized } from 'vue-router';
|
||||
export function getParentChildrenRoutes(route: RouteLocationNormalized): RouteRecordRaw[] {
|
||||
// 判断是否有父节点(至少需要2个匹配的路由)
|
||||
if (route.matched.length < 2) {
|
||||
console.log('没有父节点,route.matched 长度不足');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -12,7 +11,6 @@ export function getParentChildrenRoutes(route: RouteLocationNormalized): RouteRe
|
||||
|
||||
// 检查父路由是否有子路由
|
||||
if (!parentRoute?.children || parentRoute.children.length === 0) {
|
||||
console.log('父路由没有子路由');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
172
schoolNewsWeb/src/views/admin/AdminLayout.vue
Normal 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>
|
||||
1
schoolNewsWeb/src/views/admin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AdminLayout } from './AdminLayout.vue';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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点、12点、18点执行)<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点、12点、18点执行)<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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
// 加载分类列表(使用标签API,tagType=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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
332
schoolNewsWeb/src/views/public/article/components/ArticleAdd.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ArticleAdd } from './ArticleAdd.vue';
|
||||
export { default as ArticleShow } from './ArticleShow.vue';
|
||||
|
||||
@@ -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';
|
||||
@@ -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%;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@category-change="handleCategoryChange"
|
||||
/>
|
||||
<ResourceList
|
||||
v-if="!showArticle"
|
||||
v-show="!showArticle"
|
||||
ref="resourceListRef"
|
||||
:tagID="currentCategoryId"
|
||||
:search-keyword="searchKeyword"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||