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

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