Files
quyun-v2/frontend/portal/src/views/HomeView.vue

605 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { commonApi } from "../api/common";
import { contentApi } from "../api/content";
import { tenantApi } from "../api/tenant";
import { tenantPath } from "../utils/tenant";
const route = useRoute();
const tenantRoute = (path) => tenantPath(path, route);
const contents = ref([]);
const bannerItems = ref([]);
const trendingItems = ref([]);
const recommendedCreators = ref([]);
const matchedCreators = ref([]);
const searchKeyword = ref("");
const loading = ref(true);
const page = ref(1);
const hasMore = ref(false);
const activeBannerIndex = ref(0);
const activeTab = ref("recommend");
const activeGenre = ref("");
const fallbackGenres = [
{ label: "全部", value: "" },
{ label: "京剧", value: "京剧" },
{ label: "昆曲", value: "昆曲" },
{ label: "越剧", value: "越剧" },
{ label: "名家名段", value: "名家名段" },
{ label: "戏曲教学", value: "戏曲教学" },
];
const genres = ref(fallbackGenres);
const getFeedSort = () => {
if (activeTab.value === "hot") return "hot";
return "latest";
};
const loadGenres = async () => {
try {
const res = await commonApi.getOptions();
const dynamicGenres = (res?.content_genre || [])
.filter((opt) => opt?.value)
.map((opt) => ({ label: opt.value, value: opt.value }));
genres.value = dynamicGenres.length
? [{ label: "全部", value: "" }, ...dynamicGenres]
: fallbackGenres;
if (!genres.value.some((item) => item.value === activeGenre.value)) {
activeGenre.value = "";
}
} catch {
genres.value = fallbackGenres;
if (!genres.value.some((item) => item.value === activeGenre.value)) {
activeGenre.value = "";
}
}
};
const getCreatorPath = (creator) => {
const code =
creator.tenant_code || creator.tenantCode || creator.code || creator.id;
return `/t/${code}`;
};
const getCreatorChannelPath = (creator) => {
const code = creator.tenant_code || creator.tenantCode || creator.code;
if (!code) return "";
const id = creator.id;
if (!Number.isInteger(id) || id <= 0) return "";
return `/t/${code}/channel`;
};
const getFeedParams = () => {
const params = {
page: page.value,
limit: 10,
sort: getFeedSort(),
keyword: searchKeyword.value,
};
if (activeGenre.value) {
params.genre = activeGenre.value;
}
return params;
};
const fetchFeed = async (isLoadMore = false) => {
if (!isLoadMore) {
loading.value = true;
page.value = 1;
}
try {
const res = await contentApi.list(getFeedParams());
if (isLoadMore) {
if (res.items) contents.value.push(...res.items);
} else {
contents.value = res.items || [];
}
hasMore.value = (res.total || 0) > contents.value.length;
} catch (e) {
console.error("Feed load error:", e);
} finally {
loading.value = false;
}
};
const switchTab = (tab) => {
if (activeTab.value === tab) return;
activeTab.value = tab;
fetchFeed();
};
const switchGenre = (genreValue) => {
if (activeGenre.value === genreValue) return;
activeGenre.value = genreValue;
fetchFeed();
};
const fetchData = async () => {
loading.value = true;
try {
// Resilient loading using Promise.allSettled
const results = await Promise.allSettled([
contentApi.list({ is_pinned: true, limit: 5 }),
contentApi.list({ sort: "hot", limit: 3 }),
tenantApi.list({ limit: 5 }),
contentApi.list({ page: 1, limit: 10, sort: "latest" }),
]);
const getVal = (res, def = { items: [] }) =>
res.status === "fulfilled" ? res.value : def;
const bannerRes = getVal(results[0]);
const trendingRes = getVal(results[1]);
const creatorsRes = getVal(results[2]);
const feedRes = getVal(results[3]);
if (bannerRes.items && bannerRes.items.length > 0) {
bannerItems.value = bannerRes.items;
} else if (feedRes.items && feedRes.items.length > 0) {
bannerItems.value = feedRes.items.slice(0, 5);
}
trendingItems.value = trendingRes.items || [];
recommendedCreators.value = creatorsRes.items || [];
contents.value = feedRes.items || [];
hasMore.value = (feedRes.total || 0) > contents.value.length;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const handleSearch = async () => {
page.value = 1;
loading.value = true;
matchedCreators.value = [];
// Search resets tab context to 'recommend' (latest) effectively
// but we can just invoke fetchFeed with current tab if we want to respect it.
// Requirement says: "Feed requests respect current tab mode + keyword"
// So we do NOT reset activeTab here.
try {
const promises = [fetchFeed()];
if (searchKeyword.value) {
promises.push(
tenantApi
.list({ keyword: searchKeyword.value, limit: 5 })
.then((res) => {
matchedCreators.value = res.items || [];
})
.catch((e) => console.error(e)),
);
}
await Promise.all(promises);
} finally {
loading.value = false;
}
};
const loadMore = async () => {
page.value++;
await fetchFeed(true);
};
onMounted(async () => {
await loadGenres();
await fetchData();
});
</script>
<template>
<div class="mx-auto max-w-screen-xl py-8">
<!-- Hero Banner -->
<div
class="relative w-full h-[400px] rounded-2xl overflow-hidden bg-slate-900 mb-8 group"
v-if="bannerItems.length > 0"
>
<div
v-for="(item, index) in bannerItems"
:key="item.id"
class="absolute inset-0 transition-opacity duration-700"
:class="{
'opacity-100 z-10': activeBannerIndex === index,
'opacity-0 z-0': activeBannerIndex !== index,
}"
>
<img
:src="item.cover"
class="w-full h-full object-cover opacity-80"
alt="Banner"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"
></div>
<div class="absolute bottom-0 left-0 p-10 max-w-2xl text-white">
<div
class="inline-block px-3 py-1 bg-red-600 text-white text-xs font-bold rounded mb-3"
>
置顶推荐
</div>
<h2
class="text-4xl font-bold mb-4 leading-tight cursor-pointer hover:underline"
@click="$router.push(tenantRoute(`/contents/${item.id}`))"
>
{{ item.title }}
</h2>
<p class="text-lg text-slate-200 line-clamp-2">
{{ item.description || item.title }}
</p>
</div>
</div>
<!-- Arrows -->
<button
@click="
activeBannerIndex =
(activeBannerIndex - 1 + bannerItems.length) % bannerItems.length
"
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"
>
<i class="pi pi-chevron-left text-xl"></i>
</button>
<button
@click="
activeBannerIndex = (activeBannerIndex + 1) % bannerItems.length
"
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"
>
<i class="pi pi-chevron-right text-xl"></i>
</button>
<!-- Indicators -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-20">
<span
v-for="(item, index) in bannerItems"
:key="index"
class="w-2 h-2 rounded-full cursor-pointer transition-colors"
:class="activeBannerIndex === index ? 'bg-white' : 'bg-white/50'"
@click="activeBannerIndex = index"
></span>
</div>
</div>
<!-- Filter Bar -->
<div class="mb-8">
<div
class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6 border-b border-slate-200 pb-4"
>
<div class="flex items-center gap-8">
<button
@click="switchTab('recommend')"
class="text-lg -mb-4.5 pb-4 px-2 transition-colors"
:class="[
activeTab === 'recommend'
? 'font-bold text-primary-600 border-b-2 border-primary-600'
: 'font-medium text-slate-500 hover:text-slate-800',
]"
>
推荐
</button>
<button
@click="switchTab('latest')"
class="text-lg -mb-4.5 pb-4 px-2 transition-colors"
:class="[
activeTab === 'latest'
? 'font-bold text-primary-600 border-b-2 border-primary-600'
: 'font-medium text-slate-500 hover:text-slate-800',
]"
>
最新
</button>
<button
@click="switchTab('hot')"
class="text-lg -mb-4.5 pb-4 px-2 transition-colors"
:class="[
activeTab === 'hot'
? 'font-bold text-primary-600 border-b-2 border-primary-600'
: 'font-medium text-slate-500 hover:text-slate-800',
]"
>
热门
</button>
</div>
<!-- Global Search -->
<div class="relative">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
type="text"
v-model="searchKeyword"
@keyup.enter="handleSearch"
placeholder="搜索全站内容..."
class="h-10 pl-10 pr-4 rounded-full border border-slate-200 bg-slate-50 text-sm focus:bg-white focus:border-primary-500 focus:outline-none w-full md:w-64 transition-all"
/>
</div>
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-3">
<button
v-for="genre in genres"
:key="genre.label"
@click="switchGenre(genre.value)"
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors"
:class="[
activeGenre === genre.value
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200',
]"
>
{{ genre.label }}
</button>
</div>
</div>
<!-- Main Layout: Grid 9:3 -->
<div class="grid grid-cols-12 gap-8">
<!-- Main Feed (Left 9) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-6">
<!-- Matched Creators (Search Result) -->
<div
v-if="searchKeyword && matchedCreators.length > 0"
class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6"
>
<h3 class="font-bold text-slate-900 mb-4">相关频道</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
v-for="creator in matchedCreators"
:key="creator.id"
class="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors cursor-pointer border border-transparent hover:border-slate-200"
@click="$router.push(getCreatorPath(creator))"
>
<img
:src="
creator.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
"
class="w-12 h-12 rounded-full border border-slate-100"
/>
<div class="flex-1 min-w-0">
<div class="font-bold text-slate-900 truncate">
{{ creator.name }}
</div>
<div class="text-xs text-slate-500 truncate">
{{ creator.bio || "暂无简介" }}
</div>
</div>
</div>
</div>
</div>
<router-link
v-for="item in contents"
:key="item.id"
:to="tenantRoute(`/contents/${item.id}`)"
class="block bg-white rounded-2xl shadow-sm border border-slate-100 p-6 hover:shadow-xl hover:border-primary-100 transition-all duration-300 group cursor-pointer active:scale-[0.99]"
>
<div class="flex gap-8">
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex items-center gap-3 mb-3">
<span
v-if="item.price === 0"
class="px-2 py-0.5 rounded text-xs font-bold bg-green-500 text-white"
>免费</span
>
<span
v-else
class="px-2 py-0.5 rounded text-xs font-bold bg-red-600 text-white"
>付费</span
>
<span
class="text-xs font-bold text-slate-700 bg-slate-50 border border-slate-100 px-2 py-0.5 rounded-full"
>{{ item.genre }}</span
>
</div>
<h3
class="text-xl font-bold text-slate-900 mb-3 leading-snug group-hover:text-primary-600 transition-colors"
>
{{ item.title }}
</h3>
<p
class="text-base text-slate-500 line-clamp-2 mb-6 leading-relaxed"
>
{{ item.description || item.title }}
</p>
<div class="mt-auto flex items-center justify-between">
<div class="flex items-center gap-3 text-sm text-slate-500">
<img
:src="
item.author_avatar ||
'https://api.dicebear.com/7.x/avataaars/svg?seed=' +
item.author_id
"
class="w-7 h-7 rounded-full ring-2 ring-white"
/>
<span class="font-medium text-slate-700">{{
item.author_name || "Unknown"
}}</span>
<span class="text-slate-300">|</span>
<span>{{ item.create_time || "刚刚" }}</span>
</div>
<div class="flex items-center gap-4">
<span class="text-sm text-slate-400 font-medium"
><i class="pi pi-eye mr-1"></i> {{ item.views }}</span
>
<span class="text-xl font-bold text-red-600"
>¥ {{ item.price }}</span
>
</div>
</div>
</div>
<div
v-if="item.cover"
class="w-[280px] h-[157px] flex-shrink-0 rounded-xl overflow-hidden relative bg-slate-100 hidden sm:block shadow-inner"
>
<img
:src="item.cover"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div
class="absolute inset-0 bg-black/10 group-hover:bg-black/0 transition-colors"
></div>
<div class="absolute inset-0 flex items-center justify-center">
<i
class="pi pi-play-circle text-5xl text-white opacity-0 group-hover:opacity-100 transition-all scale-75 group-hover:scale-100 drop-shadow-lg"
></i>
</div>
</div>
</div>
</router-link>
<!-- Load More -->
<div class="pt-4 text-center" v-if="hasMore">
<button
@click="loadMore"
:disabled="loading"
class="px-8 py-3 bg-white border border-slate-200 rounded-full text-slate-600 hover:bg-slate-50 hover:text-primary-600 font-medium transition-all shadow-sm disabled:opacity-50"
>
<span v-if="loading">加载中...</span>
<span v-else>点击加载更多内容</span>
</button>
</div>
</div>
<!-- Sidebar (Right 3) -->
<div class="hidden lg:block lg:col-span-4 xl:col-span-3 space-y-6">
<!-- Announcement -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
<h3 class="font-bold text-slate-900 mb-4 flex items-center gap-2">
<i class="pi pi-megaphone text-orange-500"></i> 公告
</h3>
<ul class="space-y-3 text-sm text-slate-600">
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">
关于调整创作者收益结算周期的通知
</li>
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">
国粹传承戏曲短视频大赛开启
</li>
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">
平台系统维护升级公告 (12.30)
</li>
</ul>
</div>
<!-- Recommended Tenants -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
<h3 class="font-bold text-slate-900 mb-4">推荐名家</h3>
<div class="space-y-4">
<div
v-for="creator in recommendedCreators"
:key="creator.id"
class="flex items-center gap-3"
>
<img
:src="
creator.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
"
class="w-10 h-10 rounded-full cursor-pointer"
@click="$router.push(getCreatorPath(creator))"
/>
<div class="flex-1 min-w-0">
<div
class="font-bold text-slate-900 text-sm truncate hover:text-primary-600 cursor-pointer"
@click="$router.push(getCreatorPath(creator))"
>
{{ creator.name }}
</div>
<div class="text-xs text-slate-500 truncate">
粉丝 {{ creator.stats?.followers || 0 }}
</div>
</div>
<router-link
v-if="getCreatorChannelPath(creator)"
:to="getCreatorChannelPath(creator)"
class="px-3 py-1 bg-primary-50 text-primary-600 text-xs font-bold rounded-full hover:bg-primary-100"
>
频道
</router-link>
</div>
<div
v-if="recommendedCreators.length === 0"
class="text-center text-slate-400 text-sm"
>
暂无推荐
</div>
</div>
</div>
<!-- Trending List -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
<h3 class="font-bold text-slate-900 mb-4 flex items-center gap-2">
<i class="pi pi-chart-line text-red-500"></i> 本周热门
</h3>
<ul class="space-y-4">
<li
v-for="(item, index) in trendingItems"
:key="item.id"
class="flex gap-3 items-start"
>
<span
class="font-bold italic text-lg w-4"
:class="
index === 0
? 'text-red-500'
: index === 1
? 'text-orange-500'
: 'text-yellow-500'
"
>{{ index + 1 }}</span
>
<div class="flex-1">
<h4
@click="$router.push(tenantRoute(`/contents/${item.id}`))"
class="text-sm font-medium text-slate-800 line-clamp-2 hover:text-primary-600 cursor-pointer"
>
{{ item.title }}
</h4>
<span class="text-xs text-slate-400 mt-1 block"
>{{ item.views }} 阅读</span
>
</div>
</li>
<div
v-if="trendingItems.length === 0"
class="text-center text-slate-400 text-sm"
>
暂无热门
</div>
</ul>
</div>
<!-- Ad / Promo -->
<div class="rounded-xl overflow-hidden shadow-sm">
<img
src="https://images.unsplash.com/photo-1557683316-973673baf926?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60"
class="w-full h-40 object-cover"
/>
<div class="bg-white p-3 flex justify-between items-center">
<span
class="text-xs text-slate-400 border border-slate-200 px-1 rounded"
>广告</span
>
<span class="text-sm font-medium text-slate-700"
>戏曲周边商城上线啦</span
>
</div>
</div>
</div>
</div>
</div>
</template>