视图路径修改

This commit is contained in:
2025-10-27 17:29:25 +08:00
parent 5fa4e1cd42
commit 0033ac10ec
69 changed files with 162 additions and 1199 deletions

View File

@@ -0,0 +1,150 @@
<template>
<div class="resource-center-view">
<CenterHead
title="资源中心"
:category-name="currentCategoryName"
:show-article-mode="showArticle"
/>
<div class="search-wrapper">
<Search @search="handleSearch" />
</div>
<div class="content-wrapper">
<div class="content-container">
<ResourceSideBar
:activeTagID="currentCategoryId"
@category-change="handleCategoryChange"
/>
<ResourceList
v-if="!showArticle"
ref="resourceListRef"
:tagID="currentCategoryId"
:search-keyword="searchKeyword"
@resource-click="handleResourceClick"
@list-updated="handleListUpdated"
/>
<ResourceArticle
v-if="showArticle"
:resource-id="currentResourceId"
:tagID="currentCategoryId"
:resource-list="resourceList"
@resource-change="handleResourceChange"
@navigate="handleArticleNavigate"
@back-to-list="backToList"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ResourceSideBar, ResourceList, ResourceArticle } from './components';
import { Search, CenterHead } from '@/components/base';
import type { Resource, Tag } from '@/types/resource';
const showArticle = ref(false);
const currentCategoryId = ref('party_history');
const currentCategoryName = ref('党史学习');
const currentResourceId = ref('');
const searchKeyword = ref('');
const resourceListRef = ref();
const resourceList = ref<Resource[]>([]);
function handleCategoryChange(category: Tag) {
currentCategoryId.value = category.tagID || category.id || '';
currentCategoryName.value = category.name || '';
searchKeyword.value = '';
showArticle.value = false;
}
function handleSearch(keyword: string) {
searchKeyword.value = keyword;
showArticle.value = false;
}
function handleListUpdated(list: Resource[]) {
resourceList.value = list;
}
function handleResourceClick(resource: Resource) {
currentResourceId.value = resource.resourceID || '';
showArticle.value = true;
}
function handleResourceChange(resourceId: string) {
currentResourceId.value = resourceId;
// ArticleShowView 会自动重新加载
}
function backToList() {
showArticle.value = false;
currentResourceId.value = '';
}
// 文章内前后切换时,靠近列表头部或尾部触发列表翻页
async function handleArticleNavigate(direction: 'prev' | 'next', resourceId: string) {
const list = resourceListRef.value?.getResources?.() || [];
const index = list.findIndex((r: any) => r.resourceID === resourceId);
if (index === -1) return;
const nearHead = index <= 2;
const nearTail = index >= list.length - 3;
if (nearHead && direction === 'prev') {
await resourceListRef.value?.loadPrevPage?.();
} else if (nearTail && direction === 'next') {
await resourceListRef.value?.loadNextPage?.();
}
}
</script>
<style scoped lang="scss">
.resource-center-view {
background: #F9F9F9;
}
.search-wrapper {
width: 100%;
display: flex;
justify-content: center;
padding: 20px 0;
:deep(.resource-search) {
width: 1200px;
height: 60px;
padding: 0;
.search-box {
height: 60px;
input {
font-size: 20px;
padding: 0 100px 0 40px;
}
.search-button {
width: 72px;
height: 60px;
img {
width: 24px;
height: 24px;
}
}
}
}
}
.content-wrapper {
width: 100%;
display: flex;
justify-content: center;
padding-bottom: 60px;
}
.content-container {
width: 1200px;
display: flex;
align-items: flex-start;
gap: 24px;
height: 100%;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="resource-bottom">
<div class="separator"></div>
<div class="nav-link" :class="{ disabled: !prevResource }" @click="handleNavigate('prev')">
<span class="nav-label">上一篇</span>
<span class="nav-title">{{ prevResource?.title || '没有了' }}</span>
</div>
<div class="nav-link" :class="{ disabled: !nextResource }" @click="handleNavigate('next')">
<span class="nav-label">下一篇</span>
<span class="nav-title">{{ nextResource?.title || '没有了' }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Resource } from '@/types/resource';
interface Props {
prevResource?: Resource | null;
nextResource?: Resource | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
navigate: [direction: 'prev' | 'next', resource: Resource];
}>();
function handleNavigate(direction: 'prev' | 'next') {
const resource = direction === 'prev' ? props.prevResource : props.nextResource;
if (resource) {
emit('navigate', direction, resource);
}
}
</script>
<style lang="scss" scoped>
.resource-bottom {
margin-top: 80px;
padding-top: 20px;
}
.separator {
width: 100%;
height: 1px;
background: #E9E9E9;
margin-bottom: 20px;
}
.nav-link {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 16px;
line-height: 24px;
color: #334155;
margin-bottom: 14px;
cursor: pointer;
transition: color 0.3s;
&:hover:not(.disabled) {
color: #C62828;
.nav-title {
text-decoration: underline;
}
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
&:last-child {
margin-bottom: 0;
}
}
.nav-label {
color: inherit;
}
.nav-title {
color: inherit;
transition: text-decoration 0.3s;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="resource-collect-btn">
<button
class="collect-button"
:class="{ collected: isCollected }"
@click="handleCollect"
>
<img src="@/assets/imgs/star-icon.svg" alt="collect" />
<span>{{ isCollected ? '已收藏' : '收藏' }}</span>
</button>
</div>
</template>
<script setup lang="ts">
interface Props {
isCollected: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
collect: [type: number];
}>();
function handleCollect() {
if(props.isCollected) {
// 已收藏,取消收藏
emit('collect', -1);
} else {
// 未收藏,收藏
emit('collect', 1);
}
}
</script>
<style lang="scss" scoped>
.resource-collect-btn {
display: flex;
justify-content: center;
margin: 80px 0;
}
.collect-button {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 20px;
background: #FFFFFF;
border: 1px solid #979797;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
img {
width: 20px;
height: 20px;
transition: filter 0.3s;
}
span {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 24px;
color: #979797;
transition: color 0.3s;
}
&:hover {
border-color: #C62828;
span {
color: #C62828;
}
img {
filter: brightness(0) saturate(100%) invert(17%) sepia(85%) saturate(3207%) hue-rotate(349deg) brightness(92%) contrast(92%);
}
}
&.collected {
border-color: #C62828;
background: #FFF5F5;
span {
color: #C62828;
}
img {
filter: brightness(0) saturate(100%) invert(17%) sepia(85%) saturate(3207%) hue-rotate(349deg) brightness(92%) contrast(92%);
}
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="resource-article">
<ArticleShowView
v-if="articleData"
:as-dialog="false"
:article-data="articleData"
:category-list="[]"
:show-back-button="true"
back-button-text="返回列表"
@back="handleBack"
/>
<div v-else class="loading">加载中...</div>
<ResouceCollect
:is-collected="isCollected"
@collect="handleCollect"
/>
<ResouceBottom
:prev-resource="prevResource"
:next-resource="nextResource"
@navigate="handleNavigate"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ArticleShowView } from '@/views/public/article';
import { ResouceCollect, ResouceBottom } from '@/views/user/resource-center/components';
import { resourceApi } from '@/apis/resource';
import { ElMessage } from 'element-plus';
import type { Resource } from '@/types/resource';
import { CollectionType, type UserCollection } from '@/types';
interface Props {
resourceId?: string;
tagID?: string;
resourceList?: Resource[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
'resource-change': [resourceId: string];
'back-to-list': [];
'navigate': [direction: 'prev' | 'next', resourceId: string];
}>();
const articleData = ref<any>(null);
const isCollected = ref(false);
const prevResource = ref<Resource | null>(null);
const nextResource = ref<Resource | null>(null);
watch(() => props.resourceId, (newId) => {
if (newId) {
// 进入加载前先置空,避免子组件读取到 null 字段
articleData.value = null;
loadArticle(newId);
updateAdjacentResources(newId);
}
}, { immediate: true });
watch(() => props.resourceList, () => {
if (props.resourceId) {
updateAdjacentResources(props.resourceId);
}
}, { deep: true });
async function loadArticle(resourceId: string) {
try {
const res = await resourceApi.getResourceById(resourceId);
if (res.success && res.data) {
const resourceVO = res.data;
articleData.value = {
...resourceVO.resource,
tags: resourceVO.tags || []
};
} else {
ElMessage.error('加载文章失败');
}
} catch (error) {
console.error('加载文章失败:', error);
ElMessage.error('加载文章失败');
}
}
function updateAdjacentResources(currentResourceId: string) {
if (!props.resourceList || props.resourceList.length === 0) {
prevResource.value = null;
nextResource.value = null;
return;
}
const currentIndex = props.resourceList.findIndex(r =>
String(r.resourceID) === String(currentResourceId)
);
if (currentIndex !== -1) {
prevResource.value = currentIndex > 0 ? props.resourceList[currentIndex - 1] : null;
nextResource.value = currentIndex < props.resourceList.length - 1 ? props.resourceList[currentIndex + 1] : null;
} else {
prevResource.value = null;
nextResource.value = null;
}
}
async function handleCollect(type: number) {
try {
const resourceID = articleData.value?.resourceID;
if (!resourceID) return;
let collect: UserCollection = {
collectionType: CollectionType.RESOURCE,
collectionID: resourceID,
collectionValue: type
}
const res = await resourceApi.resourceCollect(collect);
if (res.success) {
if (type === 1) {
isCollected.value = true;
ElMessage.success('收藏成功');
} else if (type === -1) {
isCollected.value = false;
ElMessage.success('已取消收藏');
}
} else {
ElMessage.error(type === 1 ? '收藏失败' : '取消收藏失败');
}
} catch (error) {
console.error('操作失败:', error);
ElMessage.error('操作失败');
}
}
function handleNavigate(direction: 'prev' | 'next', resource: Resource) {
const resourceId = resource.resourceID;
if (resourceId) {
emit('resource-change', resourceId);
emit('navigate', direction, resourceId);
}
}
// 返回列表
function handleBack() {
emit('back-to-list');
}
</script>
<style lang="scss" scoped>
.resource-article {
flex: 1;
background: #FFFFFF;
border-radius: 10px;
padding: 40px 60px;
}
.loading {
text-align: center;
padding: 40px;
color: #909399;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,286 @@
<template>
<div class="resource-list">
<div class="list-container" ref="listContainerRef">
<div
v-for="resource in resources"
:key="resource.resourceID "
class="resource-item"
@click="handleResourceClick(resource)"
>
<div class="resource-cover">
<img
:src="resource.coverImage ? (FILE_DOWNLOAD_URL + resource.coverImage) : defaultArticleImg"
alt="cover"
/>
</div>
<div class="resource-info">
<h3 class="resource-title">{{ resource.title }}</h3>
<div class="resource-collect">
<img src="@/assets/imgs/star-icon.svg" alt="collect" />
<span>{{ (resource.collectCount || 0) > 0 ? '已收藏' : '收藏' }}</span>
</div>
<p class="resource-summary">{{ resource.summary || '暂无简介' }}</p>
</div>
</div>
<div v-if="loading" class="loading-more">加载中...</div>
<div v-if="resources.length === 0 && !loading" class="empty">暂无数据</div>
</div>
<div v-if="total > 0" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, jumper"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, nextTick } from 'vue';
import { resourceApi } from '@/apis/resource';
import { FILE_DOWNLOAD_URL } from '@/config';
import type { Resource, ResourceSearchParams } from '@/types/resource';
import type { PageParam } from '@/types';
import defaultArticleImg from '@/assets/imgs/article-default.png';
interface Props {
tagID?: string;
searchKeyword?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'resource-click': [resource: Resource];
'list-updated': [resources: Resource[]];
}>();
const resources = ref<Resource[]>([]);
const loading = ref(false);
const total = ref(0);
const currentPage = ref(1);
const pageSize = 10;
const listContainerRef = ref<HTMLElement>();
onMounted(() => {
loadResources();
});
watch(() => [props.tagID, props.searchKeyword], () => {
currentPage.value = 1;
loadResources();
}, { deep: true });
async function loadResources() {
if (loading.value) return;
loading.value = true;
try {
const filter: ResourceSearchParams = {
tagID: props.tagID,
keyword: props.searchKeyword,
// status: 1 // 只加载已发布的
};
const pageParam: PageParam = {
pageNumber: currentPage.value,
pageSize: pageSize
};
const res = await resourceApi.getResourcePage(pageParam, filter);
if (res.success && res.dataList) {
resources.value = res.dataList;
total.value = res.pageDomain?.pageParam.totalElements || 0;
// 通知父组件列表已更新
emit('list-updated', res.dataList);
// 重置滚动位置到顶部
await nextTick();
if (listContainerRef.value) {
listContainerRef.value.scrollTop = 0;
}
}
} catch (error) {
console.error('加载资源列表失败:', error);
} finally {
loading.value = false;
}
}
function handlePageChange(page: number) {
currentPage.value = page;
loadResources();
}
function getResources() {
return resources.value;
}
function getPageInfo() {
return { currentPage: currentPage.value, total: total.value };
}
async function loadNextPage() {
const totalPages = Math.ceil(total.value / pageSize);
if (currentPage.value < totalPages) {
currentPage.value++;
await loadResources();
}
}
async function loadPrevPage() {
if (currentPage.value > 1) {
currentPage.value--;
await loadResources();
}
}
function handleResourceClick(resource: Resource) {
emit('resource-click', resource);
}
defineExpose({
loadResources,
getResources,
getPageInfo,
loadNextPage,
loadPrevPage
});
</script>
<style lang="scss" scoped>
.resource-list {
flex: 1;
align-self: stretch;
background: #FFFFFF;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.list-container {
flex: 1;
padding: 30px;
display: flex;
flex-direction: column;
gap: 30px;
min-height: 400px;
}
.resource-item {
display: flex;
gap: 20px;
padding-bottom: 30px;
border-bottom: 1px solid #EEEEEE;
cursor: pointer;
transition: all 0.3s;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:hover {
.resource-title {
color: #C62828;
}
}
}
.resource-cover {
width: 202px;
height: 123px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.default-cover {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
}
.resource-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.resource-title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 28px;
line-height: 28px;
color: #C62828;
margin: 0;
transition: color 0.3s;
}
.resource-collect {
display: flex;
align-items: center;
gap: 4px;
img {
width: 20px;
height: 20px;
}
span {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 24px;
color: #979797;
}
}
.resource-summary {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 16px;
line-height: 24px;
color: #334155;
margin: 8px 0 0 0;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.loading-more,
.empty {
text-align: center;
padding: 20px;
font-family: 'PingFang SC';
font-size: 14px;
color: #979797;
}
.pagination-wrapper {
display: flex;
justify-content: center;
padding: 30px 0;
background: #FFFFFF;
border-top: 1px solid #EEEEEE;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="resource-sidebar">
<div class="sidebar-content">
<div
v-for="category in categories"
:key="category.tagID || category.id"
class="sidebar-item"
:class="{ active: (category.tagID || category.id) === activeTagID }"
@click="handleCategoryClick(category)"
>
<span class="category-name">{{ category.name }}</span>
<div v-if="(category.tagID || category.id) === activeTagID" class="active-overlay"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { resourceTagApi } from '@/apis/resource';
import type { Tag, TagType } from '@/types/resource';
interface Props {
activeTagID?: string;
}
withDefaults(defineProps<Props>(), {
activeTagID: 'party_history'
});
const emit = defineEmits<{
'category-change': [category: Tag]; // 改为Tag类型
}>();
const categories = ref<Tag[]>([]); // 改为使用Tag类型tagType=1表示文章分类
onMounted(async () => {
await loadCategories();
});
async function loadCategories() {
try {
// 使用新的标签API获取文章分类标签tagType=1
const res = await resourceTagApi.getTagsByType(1); // 1 = 文章分类标签
if (res.success && res.dataList) {
categories.value = res.dataList;
}
} catch (error) {
console.error('加载分类失败:', error);
}
}
function handleCategoryClick(category: Tag) {
emit('category-change', category);
}
</script>
<style lang="scss" scoped>
.resource-sidebar {
width: 180px;
background: #FFFFFF;
border-radius: 10px;
padding: 20px 0;
}
.sidebar-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.sidebar-item {
position: relative;
height: 54px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
.category-name {
color: #C62828;
}
}
&.active {
.category-name {
color: #FFFFFF;
position: relative;
z-index: 2;
}
.active-overlay {
position: absolute;
top: 5px;
left: 10px;
right: 10px;
bottom: 5px;
background: #C62828;
border-radius: 8px;
z-index: 1;
}
}
}
.category-name {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 16px;
line-height: 24px;
color: #334155;
transition: color 0.3s;
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as ResourceSideBar } from './ResourceSideBar.vue';
export { default as ResourceList } from './ResourceList.vue';
export { default as ResourceArticle } from './ResourceArticle.vue';
export { default as ResouceCollect } from './ResouceCollect.vue';
export { default as ResouceBottom } from './ResouceBottom.vue';