feat: expand portal entry flows and dynamic recommendation routing

This commit is contained in:
2026-02-06 21:02:17 +08:00
parent f3e10256a8
commit 87a987963e
7 changed files with 542 additions and 174 deletions

View File

@@ -76,6 +76,12 @@ const logout = () => {
active-class="text-primary-600"
>专题</router-link
>
<router-link
:to="tenantRoute('/channel')"
class="text-muted font-medium hover:text-primary-600"
active-class="text-primary-600"
>频道</router-link
>
</div>
<!-- Center-Right: Global Search -->

View File

@@ -7,6 +7,32 @@ import LayoutCreator from "../layout/LayoutCreator.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
component: LayoutMain,
children: [
{
path: "",
component: () => import("../views/HomeView.vue"),
},
{
path: "contents/:id",
component: () => import("../views/content/DetailView.vue"),
},
{
path: "explore",
component: () => import("../views/ExploreView.vue"),
},
{
path: "topics",
component: () => import("../views/TopicsView.vue"),
},
{
path: "creator/apply",
component: () => import("../views/creator/ApplyView.vue"),
},
],
},
{
path: "/t/:tenantCode",
component: LayoutMain,
@@ -16,6 +42,11 @@ const router = createRouter({
name: "home",
component: () => import("../views/HomeView.vue"),
},
{
path: "channel",
name: "tenant-channel",
component: () => import("../views/tenant/HomeView.vue"),
},
{
path: "contents/:id",
name: "content-detail",

View File

@@ -1,7 +1,8 @@
<script setup>
import { ref, watch, onMounted } from "vue";
import { onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { contentApi } from "../api/content";
import { commonApi } from "../api/common";
import { tenantPath } from "../utils/tenant";
const route = useRoute();
@@ -12,6 +13,29 @@ const sort = ref("latest");
const keyword = ref("");
const contents = ref([]);
const page = ref(1);
const initializing = ref(true);
const STATIC_GENRES = [
"全部",
"京剧",
"昆曲",
"越剧",
"黄梅戏",
"豫剧",
"评剧",
"秦腔",
"河北梆子",
];
const filters = reactive({
genres: [...STATIC_GENRES],
prices: [
{ label: "全部", value: "all" },
{ label: "免费", value: "free" },
{ label: "付费", value: "paid" },
{ label: "会员专享", value: "member" },
],
});
const fetchContents = async (append = false) => {
const params = {
@@ -41,33 +65,59 @@ const loadMore = () => {
};
watch([selectedGenre, selectedPrice, sort], () => {
if (initializing.value) return;
page.value = 1;
fetchContents();
});
onMounted(() => {
fetchContents();
});
const fetchGenres = async () => {
try {
const res = await commonApi.getOptions();
const dynamicGenres = Array.from(
new Set(
(res?.content_genre || [])
.map((opt) => opt?.value)
.filter((value) => typeof value === "string" && value),
),
);
const filters = {
genres: [
"全部",
"京剧",
"昆曲",
"越剧",
"黄梅戏",
"豫剧",
"评剧",
"秦腔",
"河北梆子",
],
prices: [
{ label: "全部", value: "all" },
{ label: "免费", value: "free" },
{ label: "付费", value: "paid" },
{ label: "会员专享", value: "member" },
],
filters.genres = dynamicGenres.length
? ["全部", ...dynamicGenres]
: [...STATIC_GENRES];
} catch (e) {
console.error("Failed to fetch dynamic genres, using fallback", e);
filters.genres = [...STATIC_GENRES];
}
// 1. Sync from query if present
if (route.query.genre && filters.genres.includes(route.query.genre)) {
selectedGenre.value = route.query.genre;
}
// 2. Guard: if selected genre is no longer valid, reset to "全部"
else if (!filters.genres.includes(selectedGenre.value)) {
selectedGenre.value = "全部";
}
};
watch(
() => route.query.genre,
(newGenre) => {
if (newGenre && filters.genres.includes(newGenre)) {
selectedGenre.value = newGenre;
} else if (!newGenre) {
selectedGenre.value = "全部";
}
},
);
onMounted(async () => {
await fetchGenres();
// fetchContents handled by watcher or initial call if not triggered
if (initializing.value) {
initializing.value = false;
fetchContents();
}
});
</script>
<template>

View File

@@ -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"

View File

@@ -1,11 +1,14 @@
<script setup>
import { ref } from "vue";
import { useRoute } from "vue-router";
import { ref, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { tenantPath } from "../utils/tenant";
import { contentApi } from "../api/content";
const route = useRoute();
const router = useRouter();
const tenantRoute = (path) => tenantPath(path, route);
const topics = ref([
const STATIC_TOPICS = [
{
id: 1,
title: "程派艺术赏析:幽咽婉转后的风骨",
@@ -56,7 +59,59 @@ const topics = ref([
date: "2024-12-05",
count: 15,
},
]);
];
const topics = ref([...STATIC_TOPICS]);
const KNOWN_GENRES = [
"京剧",
"昆曲",
"越剧",
"黄梅戏",
"豫剧",
"评剧",
"秦腔",
"河北梆子",
];
const findGenre = (topic) => {
const text = (topic.title + topic.tag).toLowerCase();
return KNOWN_GENRES.find((g) => text.includes(g));
};
const navigateToTopic = (topic) => {
const genre = findGenre(topic);
const target = tenantRoute("/explore");
if (genre) {
router.push({ path: target, query: { genre } });
} else {
// Fallback: navigate to explore without specific genre if no match found
router.push(target);
}
};
onMounted(async () => {
try {
const res = await contentApi.listTopics();
if (res && Array.isArray(res) && res.length > 0) {
topics.value = res.map((item, index) => ({
id: item.id || index + 100, // Safe ID
title: item.title || "未命名专题",
desc: item.desc || `探索${item.title || "精彩内容"}的更多详情。`,
cover:
item.cover ||
"https://images.unsplash.com/photo-1514306191717-452ec28c7f31?auto=format&fit=crop&w=800",
tag: item.tag || "精选",
date: item.date || new Date().toISOString().split("T")[0],
count: item.count || 0,
}));
}
} catch (err) {
console.warn("Failed to load topics, using fallback:", err);
// Keep STATIC_TOPICS
}
});
</script>
<template>
@@ -68,8 +123,9 @@ const topics = ref([
<!-- 1. Hero Topic -->
<div
v-if="topics.length > 0"
class="relative w-full h-[400px] rounded-2xl overflow-hidden mb-12 group cursor-pointer shadow-lg"
@click="$router.push(tenantRoute(`/explore?topic=${topics[0].id}`))"
@click="navigateToTopic(topics[0])"
>
<img
:src="topics[0].cover"
@@ -112,13 +168,16 @@ const topics = ref([
</div>
<!-- 2. Masonry-like Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
v-if="topics.length > 1"
>
<div
v-for="(topic, idx) in topics.slice(1)"
:key="topic.id"
class="group bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-xl transition-all cursor-pointer flex flex-col"
:class="{ 'lg:col-span-2 flex-row': idx === 0 }"
@click="$router.push(tenantRoute(`/explore?topic=${topic.id}`))"
@click="navigateToTopic(topic)"
>
<!-- Cover -->
<div

View File

@@ -1,14 +1,25 @@
<script setup>
import { useToast } from "primevue/usetoast";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { tenantApi } from "../../api/tenant";
import { useRoute, useRouter } from "vue-router";
import { contentApi } from "../../api/content";
import { tenantApi } from "../../api/tenant";
import { tenantPath } from "../../utils/tenant";
const route = useRoute();
const router = useRouter();
const tenantRoute = (path) => tenantPath(path, route);
const toast = useToast();
const tenantID = ref(0);
const resolveTenantIDFromRoute = () => {
const raw = route.params.id;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
};
const showFollowActions = computed(() => tenantID.value > 0);
const currentTab = ref("home");
const isFollowing = ref(false);
const tenant = ref({});
@@ -25,46 +36,57 @@ const hasMore = ref(false);
const sortOption = ref("latest");
const selectedTopic = ref("");
const limit = 10;
const querySyncReady = ref(false);
const normalizeGenreQuery = (value) => {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value) && value.length > 0) {
return typeof value[0] === "string" ? value[0] : "";
}
return "";
};
const fetchData = async (isLoadMore = false) => {
if (!isLoadMore) loading.value = true;
try {
const id = route.params.id;
const effectiveTenantID = tenantID.value || resolveTenantIDFromRoute();
const query = {
tenant_id: id,
sort: sortOption.value,
page: page.value,
limit: limit,
keyword: searchKeyword.value,
};
if (effectiveTenantID > 0) {
query.tenant_id = effectiveTenantID;
}
if (selectedTopic.value) {
query.genre = selectedTopic.value;
}
const reqs = [contentApi.list(query)];
// Only fetch tenant info & featured on first load
// Only fetch featured on first load
if (!isLoadMore && page.value === 1) {
reqs.push(tenantApi.get(id));
reqs.push(
contentApi.list({
tenant_id: id,
is_pinned: true,
genre: selectedTopic.value || "",
}),
);
const pinnedQuery = {
is_pinned: true,
genre: selectedTopic.value || "",
};
if (effectiveTenantID > 0) {
pinnedQuery.tenant_id = effectiveTenantID;
}
reqs.push(contentApi.list(pinnedQuery));
}
const results = await Promise.all(reqs);
const c = results[0]; // Content List
if (!isLoadMore && page.value === 1) {
const t = results[1];
const f = results[2];
tenant.value = t || {};
isFollowing.value = t?.is_following || false;
const f = results[1];
if (f && f.items && f.items.length > 0) {
if (f?.items?.length > 0) {
featuredContent.value = f.items[0];
} else {
featuredContent.value = null;
@@ -92,12 +114,34 @@ const fetchData = async (isLoadMore = false) => {
}
};
onMounted(() => fetchData());
onMounted(async () => {
selectedTopic.value = normalizeGenreQuery(route.query.genre);
querySyncReady.value = true;
try {
topics.value = (await contentApi.listTopics()) || [];
const topicsRes = await contentApi.listTopics();
topics.value = topicsRes || [];
tenantID.value = resolveTenantIDFromRoute();
tenant.value = {
id: tenantID.value,
name: route.params.tenantCode,
bio: "",
cover: "",
avatar: "",
stats: {
followers: 0,
contents: 0,
likes: 0,
},
};
isFollowing.value = false;
fetchData();
} catch (e) {
console.error(e);
fetchData();
}
});
@@ -119,13 +163,25 @@ const loadMore = () => {
const toggleFollow = async () => {
if (followLoading.value) return;
const effectiveTenantID = tenantID.value || resolveTenantIDFromRoute();
if (!effectiveTenantID) {
toast.add({
severity: "warn",
summary: "暂不可用",
detail: "当前频道暂不支持关注操作",
life: 2000,
});
return;
}
followLoading.value = true;
try {
if (isFollowing.value) {
await tenantApi.unfollow(route.params.id);
await tenantApi.unfollow(effectiveTenantID);
isFollowing.value = false;
} else {
await tenantApi.follow(route.params.id);
await tenantApi.follow(effectiveTenantID);
isFollowing.value = true;
toast.add({
severity: "success",
@@ -156,9 +212,43 @@ const sortLabel = computed(() =>
sortOption.value === "hot" ? "最热" : "最新",
);
watch([sortOption, selectedTopic], () => {
applyFilters();
const currentTopicBreadcrumb = computed(() => {
const fromQuery = normalizeGenreQuery(route.query.genre);
if (fromQuery) return fromQuery;
if (selectedTopic.value) return selectedTopic.value;
return "全部专题";
});
watch(
() => route.query.genre,
(newGenre) => {
const normalized = normalizeGenreQuery(newGenre);
if (normalized !== selectedTopic.value) {
selectedTopic.value = normalized;
}
},
);
watch(
[sortOption, selectedTopic],
([nextSort, nextTopic], [prevSort, prevTopic]) => {
if (!querySyncReady.value) return;
if (nextTopic !== prevTopic) {
const nextQuery = { ...route.query };
if (nextTopic) {
nextQuery.genre = nextTopic;
} else {
delete nextQuery.genre;
}
router.replace({ query: nextQuery });
}
if (nextSort !== prevSort || nextTopic !== prevTopic) {
applyFilters();
}
},
);
</script>
<template>
@@ -229,6 +319,7 @@ watch([sortOption, selectedTopic], () => {
<div class="flex flex-col items-end gap-5 pb-2">
<div class="flex gap-3">
<button
v-if="showFollowActions"
@click="toggleFollow"
:disabled="followLoading"
class="h-11 w-32 rounded-full font-bold text-base transition-all flex items-center justify-center gap-2 backdrop-blur-md"
@@ -310,7 +401,16 @@ watch([sortOption, selectedTopic], () => {
<div
class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-14"
>
<div class="flex gap-8 h-full">
<div class="flex items-center gap-4 h-full">
<div
class="hidden md:flex items-center text-xs text-slate-500 bg-slate-100 rounded-full px-3 py-1"
>
<span>频道</span>
<i class="pi pi-angle-right text-[10px] mx-1.5"></i>
<span class="font-semibold text-slate-700">{{
currentTopicBreadcrumb
}}</span>
</div>
<button
v-for="tab in tabs"
:key="tab.value"
@@ -324,6 +424,12 @@ watch([sortOption, selectedTopic], () => {
>
{{ tab.label }}
</button>
<router-link
:to="tenantRoute('/topics')"
class="text-xs px-3 py-1.5 rounded-full border border-slate-200 text-slate-600 hover:border-primary-200 hover:text-primary-600 transition-colors"
>
去专题
</router-link>
</div>
<!-- In-Tenant Search -->