feat: expand portal entry flows and dynamic recommendation routing
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<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";
|
||||
@@ -18,16 +19,129 @@ 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 {
|
||||
const [bannerRes, trendingRes, creatorsRes, feedRes] = await Promise.all([
|
||||
// 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) {
|
||||
@@ -38,7 +152,7 @@ const fetchData = async () => {
|
||||
recommendedCreators.value = creatorsRes.items || [];
|
||||
|
||||
contents.value = feedRes.items || [];
|
||||
hasMore.value = feedRes.total > contents.value.length;
|
||||
hasMore.value = (feedRes.total || 0) > contents.value.length;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@@ -50,23 +164,25 @@ 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 = [
|
||||
contentApi.list({ page: 1, limit: 10, keyword: searchKeyword.value }),
|
||||
];
|
||||
const promises = [fetchFeed()];
|
||||
if (searchKeyword.value) {
|
||||
promises.push(tenantApi.list({ keyword: searchKeyword.value, limit: 5 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const contentRes = results[0];
|
||||
|
||||
contents.value = contentRes.items || [];
|
||||
hasMore.value = contentRes.total > contents.value.length;
|
||||
|
||||
if (results[1]) {
|
||||
matchedCreators.value = results[1].items || [];
|
||||
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;
|
||||
}
|
||||
@@ -74,18 +190,13 @@ const handleSearch = async () => {
|
||||
|
||||
const loadMore = async () => {
|
||||
page.value++;
|
||||
const res = await contentApi.list({
|
||||
page: page.value,
|
||||
limit: 10,
|
||||
keyword: searchKeyword.value,
|
||||
});
|
||||
if (res.items) {
|
||||
contents.value.push(...res.items);
|
||||
hasMore.value = res.total > contents.value.length;
|
||||
}
|
||||
await fetchFeed(true);
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
onMounted(async () => {
|
||||
await loadGenres();
|
||||
await fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -168,17 +279,35 @@ onMounted(fetchData);
|
||||
>
|
||||
<div class="flex items-center gap-8">
|
||||
<button
|
||||
class="text-lg font-bold text-primary-600 border-b-2 border-primary-600 -mb-4.5 pb-4 px-2"
|
||||
@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
|
||||
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors"
|
||||
@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
|
||||
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors"
|
||||
@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>
|
||||
@@ -201,34 +330,17 @@ onMounted(fetchData);
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-900 text-white text-sm font-medium"
|
||||
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',
|
||||
]"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
京剧
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
昆曲
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
越剧
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
名家名段
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
戏曲教学
|
||||
{{ genre.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,7 +360,7 @@ onMounted(fetchData);
|
||||
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(`/t/${creator.id}`)"
|
||||
@click="$router.push(getCreatorPath(creator))"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
@@ -397,12 +509,12 @@ onMounted(fetchData);
|
||||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
|
||||
"
|
||||
class="w-10 h-10 rounded-full cursor-pointer"
|
||||
@click="$router.push(`/t/${creator.id}`)"
|
||||
@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(`/t/${creator.id}`)"
|
||||
@click="$router.push(getCreatorPath(creator))"
|
||||
>
|
||||
{{ creator.name }}
|
||||
</div>
|
||||
@@ -410,11 +522,13 @@ onMounted(fetchData);
|
||||
粉丝 {{ creator.stats?.followers || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<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"
|
||||
>
|
||||
关注
|
||||
</button>
|
||||
频道
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-if="recommendedCreators.length === 0"
|
||||
|
||||
Reference in New Issue
Block a user