feat: expand portal entry flows and dynamic recommendation routing
This commit is contained in:
118
docs/plan.md
118
docs/plan.md
@@ -1,30 +1,30 @@
|
|||||||
# Implementation Plan: portal-senior-color-redesign
|
# Implementation Plan: portal-tenant-home-route-query-sync
|
||||||
|
|
||||||
**Branch**: `[feat/portal-senior-color-redesign]` | **Date**: 2026-02-06 | **Spec**: `N/A`
|
**Branch**: `[main]` | **Date**: 2026-02-06 | **Spec**: `N/A`
|
||||||
**Input**: User request to redesign Portal color system for minimal, senior-friendly UX and provide a branch for visual validation.
|
**Input**: User confirmed next step should be “connect route first, then implement query-sync” for `frontend/portal/src/views/tenant/HomeView.vue`.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Implement a senior-friendly, minimal color system for `frontend/portal` by introducing semantic design tokens (default + high-contrast), applying them to shared shells/components (layouts, navbar, footer), and adding PrimeVue-friendly global styling so visual changes are broad, consistent, and easy to validate by product stakeholders.
|
Expose `tenant/HomeView.vue` through an active router path, then implement full `genre` query synchronization for its topic filter so navigation into this page with `?genre=...` produces consistent filtering behavior (and optional URL state sync when in-page filters change), while preserving existing content loading and UX.
|
||||||
|
|
||||||
## Technical Context
|
## Technical Context
|
||||||
|
|
||||||
**Language/Version**: Vue 3 (ESM), JavaScript, CSS (Tailwind v4)
|
**Language/Version**: Vue 3 + JavaScript (Vite)
|
||||||
**Primary Dependencies**: Vite, TailwindCSS v4, PrimeVue 4 (`@primevue/themes/aura`), PrimeIcons
|
**Primary Dependencies**: Vue Router, PrimeVue, TailwindCSS
|
||||||
**Storage**: N/A
|
**Storage**: N/A (frontend behavior/routing only)
|
||||||
**Testing**: `npm -C frontend/portal run lint`, `npm -C frontend/portal run build`
|
**Testing**: `npm -C frontend/portal run lint`, `npm -C frontend/portal run build`, browser flow checks for tenant page + query sync; `go test ./...` for frontend-involved phase completion governance
|
||||||
**Target Platform**: Web browser (Portal tenant-facing frontend)
|
**Target Platform**: Web browser (`frontend/portal`)
|
||||||
**Project Type**: Web frontend module (`frontend/portal`)
|
**Project Type**: Web frontend module
|
||||||
**Performance Goals**: No regressions in initial render and interactivity; preserve current bundle behavior
|
**Performance Goals**: No notable regression in tenant page load/filter response
|
||||||
**Constraints**: Keep changes focused on styling/theme surfaces; no route/business-logic changes; avoid generated files; maintain readability and accessibility contrast goals
|
**Constraints**: Minimal incremental changes; no backend API contract changes; keep existing page styles/layout; avoid generated-file edits
|
||||||
**Scale/Scope**: Global portal style tokens + shared layout/component surfaces for meaningful visual review
|
**Scale/Scope**: `frontend/portal/src/router/index.js`, `frontend/portal/src/views/tenant/HomeView.vue` (optional minimal caller path adjustment if needed)
|
||||||
|
|
||||||
## Constitution Check
|
## Constitution Check
|
||||||
|
|
||||||
- Conforms to repository planning rule: complete plan defined before non-trivial implementation.
|
- Plan-first requirement satisfied before non-trivial implementation.
|
||||||
- Scope limited to `frontend/portal` styling/theme layers and shared UI shell.
|
- Scope constrained to route exposure + query-sync behavior for tenant page.
|
||||||
- No generated files are modified.
|
- No generated files involved.
|
||||||
- Verification includes frontend lint/build checks before handoff.
|
- Acceptance includes frontend page-flow validation and backend `go test ./...` evidence per repository rules.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -39,66 +39,68 @@ docs/
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
frontend/portal/
|
frontend/portal/
|
||||||
├── src/assets/main.css
|
├── src/router/index.js
|
||||||
├── src/main.js
|
├── src/views/tenant/HomeView.vue
|
||||||
├── src/layout/LayoutMain.vue
|
└── src/views/HomeView.vue (optional tiny entry-path adjustment only if needed)
|
||||||
├── src/layout/LayoutUser.vue
|
|
||||||
├── src/layout/LayoutCreator.vue
|
|
||||||
├── src/layout/LayoutAuth.vue
|
|
||||||
├── src/components/TopNavbar.vue
|
|
||||||
└── src/components/AppFooter.vue
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Structure Decision**: Apply color redesign through centralized tokens and shared shell/components to maximize consistency and minimize page-level edits.
|
**Structure Decision**: Activate existing `tenant/HomeView.vue` via router first, then add robust route-query synchronization inside that page to avoid broad refactor.
|
||||||
|
|
||||||
## Plan Phases
|
## Plan Phases
|
||||||
|
|
||||||
1. **Token Foundation**
|
1. **Phase 1 — Route Activation**
|
||||||
- Define semantic color tokens for senior-light and high-contrast modes.
|
- Add explicit route entry for tenant profile page to render `views/tenant/HomeView.vue`.
|
||||||
- Map tokens for Tailwind and global CSS usage.
|
- Ensure route path does not conflict with existing tenant homepage / creator routes.
|
||||||
2. **Shared Surface Application**
|
|
||||||
- Update shared layouts/navbar/footer from hardcoded slate/dark palette to semantic tokens.
|
2. **Phase 2 — Topic/Query Synchronization**
|
||||||
- Remove distracting decorative backgrounds and improve contrast/focus cues.
|
- Parse incoming `route.query.genre` and initialize selected topic filter.
|
||||||
3. **PrimeVue Alignment + Verification**
|
- Watch query changes to keep selected topic in sync.
|
||||||
- Add global PrimeVue color overrides for button/input/panel/readability consistency.
|
- Optionally sync selected topic back to URL query (replace) for deep-link consistency.
|
||||||
- Run lint/build verification and prepare branch handoff for visual QA.
|
- Keep sort/topic/search/loadMore behavior consistent with existing logic.
|
||||||
|
|
||||||
|
3. **Phase 3 — Entry Path and Validation**
|
||||||
|
- Ensure there is at least one practical navigation path to this newly connected route.
|
||||||
|
- Run diagnostics and command checks.
|
||||||
|
- Verify browser flows for route rendering and query-driven filtering.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
1. Create and verify a dedicated feature branch for this redesign.
|
1. Add tenant profile route in `router/index.js` to load `views/tenant/HomeView.vue`.
|
||||||
2. Rebuild `src/assets/main.css` with:
|
2. In `tenant/HomeView.vue`, add helper to normalize `route.query.genre` and initialize `selectedTopic` from it.
|
||||||
- semantic palette tokens,
|
3. Add watcher for route query changes (`genre`) to update in-page filter state.
|
||||||
- default + high-contrast variable sets,
|
4. Add safe guard/initialization flag to avoid duplicate or race-condition fetches.
|
||||||
- global base styles (body/link/focus),
|
5. Ensure existing data fetch (`query.genre`) continues to rely on selected topic string.
|
||||||
- PrimeVue global color overrides.
|
6. (If needed) Add minimal navigation entry in existing pages so new route is reachable through UI.
|
||||||
3. Ensure `src/main.js` imports and theme setup remain compatible after token changes.
|
7. Run `lsp_diagnostics` on changed files.
|
||||||
4. Refactor shared shells to semantic colors and reduced visual noise:
|
8. Run `npm -C frontend/portal run lint`.
|
||||||
- `LayoutMain.vue`, `LayoutUser.vue`, `LayoutCreator.vue`, `LayoutAuth.vue`.
|
9. Run `npm -C frontend/portal run build`.
|
||||||
5. Refactor shared navigation/footer surfaces to semantic tokens:
|
10. Run backend `go test ./...`.
|
||||||
- `TopNavbar.vue`, `AppFooter.vue`.
|
11. Execute browser verification for route access and `genre` prefilter behavior.
|
||||||
6. Run portal checks (`lint`, `build`) and resolve any regressions caused by this change set.
|
|
||||||
7. Provide branch name and reviewer instructions for visual validation.
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- Phase 2 depends on semantic token completion from Phase 1.
|
- Phase 2 depends on Phase 1 route activation.
|
||||||
- Phase 3 depends on Phase 2 to verify final visual consistency and avoid rework.
|
- Phase 3 depends on completed route/query behavior.
|
||||||
- Reviewer validation depends on successful lint/build completion.
|
- Browser acceptance depends on build/runtime availability.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- A new branch exists containing only portal color-system redesign changes.
|
- `tenant/HomeView.vue` is reachable via a real router path (not dead code).
|
||||||
- Portal shared shells/components use semantic color tokens instead of ad-hoc hardcoded palette where modified.
|
- Entering tenant page with `?genre=xxx` preselects topic filter and requests content with matching `genre` query.
|
||||||
- High-contrast mode token set is available for accessibility-forward validation.
|
- Query updates (navigation changes) keep topic filter state synchronized.
|
||||||
|
- Existing tenant page sort/topic/search/loadMore behaviors remain functional.
|
||||||
|
- No new 404 regressions introduced by route activation.
|
||||||
|
- `lsp_diagnostics` clean on changed files (or only known template-analysis false positives reported).
|
||||||
- `npm -C frontend/portal run lint` passes.
|
- `npm -C frontend/portal run lint` passes.
|
||||||
- `npm -C frontend/portal run build` passes.
|
- `npm -C frontend/portal run build` passes.
|
||||||
- User can run portal and visually compare revised palette/readability across login, home shell, user shell, and creator shell.
|
- backend `go test ./...` passes (or unrelated pre-existing failures explicitly reported).
|
||||||
|
- Browser page-flow checks pass for route + query-sync scenarios.
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
- Broad visual changes may impact component contrast unexpectedly in edge views not directly edited.
|
- Newly connected route may require clearer entry path from existing UI to be practically discoverable.
|
||||||
- PrimeVue internal styles can override utility classes; requires targeted global overrides.
|
- Query-sync watchers can accidentally trigger duplicate fetches without initialization guards.
|
||||||
- Overly aggressive recolor could reduce brand recognition; keep primary brand hue stable while improving accessibility.
|
- Topic list values and query genre may not always align exactly, requiring graceful fallback behavior.
|
||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ const logout = () => {
|
|||||||
active-class="text-primary-600"
|
active-class="text-primary-600"
|
||||||
>专题</router-link
|
>专题</router-link
|
||||||
>
|
>
|
||||||
|
<router-link
|
||||||
|
:to="tenantRoute('/channel')"
|
||||||
|
class="text-muted font-medium hover:text-primary-600"
|
||||||
|
active-class="text-primary-600"
|
||||||
|
>频道</router-link
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center-Right: Global Search -->
|
<!-- Center-Right: Global Search -->
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import LayoutCreator from "../layout/LayoutCreator.vue";
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
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",
|
path: "/t/:tenantCode",
|
||||||
component: LayoutMain,
|
component: LayoutMain,
|
||||||
@@ -16,6 +42,11 @@ const router = createRouter({
|
|||||||
name: "home",
|
name: "home",
|
||||||
component: () => import("../views/HomeView.vue"),
|
component: () => import("../views/HomeView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "channel",
|
||||||
|
name: "tenant-channel",
|
||||||
|
component: () => import("../views/tenant/HomeView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "contents/:id",
|
path: "contents/:id",
|
||||||
name: "content-detail",
|
name: "content-detail",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from "vue";
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { contentApi } from "../api/content";
|
import { contentApi } from "../api/content";
|
||||||
|
import { commonApi } from "../api/common";
|
||||||
import { tenantPath } from "../utils/tenant";
|
import { tenantPath } from "../utils/tenant";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -12,6 +13,29 @@ const sort = ref("latest");
|
|||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const contents = ref([]);
|
const contents = ref([]);
|
||||||
const page = ref(1);
|
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 fetchContents = async (append = false) => {
|
||||||
const params = {
|
const params = {
|
||||||
@@ -41,33 +65,59 @@ const loadMore = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
watch([selectedGenre, selectedPrice, sort], () => {
|
watch([selectedGenre, selectedPrice, sort], () => {
|
||||||
|
if (initializing.value) return;
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
fetchContents();
|
fetchContents();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
const fetchGenres = async () => {
|
||||||
fetchContents();
|
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 = {
|
filters.genres = dynamicGenres.length
|
||||||
genres: [
|
? ["全部", ...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;
|
||||||
],
|
}
|
||||||
prices: [
|
// 2. Guard: if selected genre is no longer valid, reset to "全部"
|
||||||
{ label: "全部", value: "all" },
|
else if (!filters.genres.includes(selectedGenre.value)) {
|
||||||
{ label: "免费", value: "free" },
|
selectedGenre.value = "全部";
|
||||||
{ label: "付费", value: "paid" },
|
}
|
||||||
{ label: "会员专享", value: "member" },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import { commonApi } from "../api/common";
|
||||||
import { contentApi } from "../api/content";
|
import { contentApi } from "../api/content";
|
||||||
import { tenantApi } from "../api/tenant";
|
import { tenantApi } from "../api/tenant";
|
||||||
import { tenantPath } from "../utils/tenant";
|
import { tenantPath } from "../utils/tenant";
|
||||||
@@ -18,16 +19,129 @@ const page = ref(1);
|
|||||||
const hasMore = ref(false);
|
const hasMore = ref(false);
|
||||||
const activeBannerIndex = ref(0);
|
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 () => {
|
const fetchData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
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({ is_pinned: true, limit: 5 }),
|
||||||
contentApi.list({ sort: "hot", limit: 3 }),
|
contentApi.list({ sort: "hot", limit: 3 }),
|
||||||
tenantApi.list({ limit: 5 }),
|
tenantApi.list({ limit: 5 }),
|
||||||
contentApi.list({ page: 1, limit: 10, sort: "latest" }),
|
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) {
|
if (bannerRes.items && bannerRes.items.length > 0) {
|
||||||
bannerItems.value = bannerRes.items;
|
bannerItems.value = bannerRes.items;
|
||||||
} else if (feedRes.items && feedRes.items.length > 0) {
|
} else if (feedRes.items && feedRes.items.length > 0) {
|
||||||
@@ -38,7 +152,7 @@ const fetchData = async () => {
|
|||||||
recommendedCreators.value = creatorsRes.items || [];
|
recommendedCreators.value = creatorsRes.items || [];
|
||||||
|
|
||||||
contents.value = feedRes.items || [];
|
contents.value = feedRes.items || [];
|
||||||
hasMore.value = feedRes.total > contents.value.length;
|
hasMore.value = (feedRes.total || 0) > contents.value.length;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -50,23 +164,25 @@ const handleSearch = async () => {
|
|||||||
page.value = 1;
|
page.value = 1;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
matchedCreators.value = [];
|
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 {
|
try {
|
||||||
const promises = [
|
const promises = [fetchFeed()];
|
||||||
contentApi.list({ page: 1, limit: 10, keyword: searchKeyword.value }),
|
|
||||||
];
|
|
||||||
if (searchKeyword.value) {
|
if (searchKeyword.value) {
|
||||||
promises.push(tenantApi.list({ keyword: searchKeyword.value, limit: 5 }));
|
promises.push(
|
||||||
}
|
tenantApi
|
||||||
|
.list({ keyword: searchKeyword.value, limit: 5 })
|
||||||
const results = await Promise.all(promises);
|
.then((res) => {
|
||||||
const contentRes = results[0];
|
matchedCreators.value = res.items || [];
|
||||||
|
})
|
||||||
contents.value = contentRes.items || [];
|
.catch((e) => console.error(e)),
|
||||||
hasMore.value = contentRes.total > contents.value.length;
|
);
|
||||||
|
|
||||||
if (results[1]) {
|
|
||||||
matchedCreators.value = results[1].items || [];
|
|
||||||
}
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -74,18 +190,13 @@ const handleSearch = async () => {
|
|||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
page.value++;
|
page.value++;
|
||||||
const res = await contentApi.list({
|
await fetchFeed(true);
|
||||||
page: page.value,
|
|
||||||
limit: 10,
|
|
||||||
keyword: searchKeyword.value,
|
|
||||||
});
|
|
||||||
if (res.items) {
|
|
||||||
contents.value.push(...res.items);
|
|
||||||
hasMore.value = res.total > contents.value.length;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(async () => {
|
||||||
|
await loadGenres();
|
||||||
|
await fetchData();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -168,17 +279,35 @@ onMounted(fetchData);
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<button
|
<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>
|
||||||
<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>
|
||||||
<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>
|
</button>
|
||||||
@@ -201,34 +330,17 @@ onMounted(fetchData);
|
|||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<button
|
<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',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
全部
|
{{ genre.label }}
|
||||||
</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"
|
|
||||||
>
|
|
||||||
戏曲教学
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +360,7 @@ onMounted(fetchData);
|
|||||||
v-for="creator in matchedCreators"
|
v-for="creator in matchedCreators"
|
||||||
:key="creator.id"
|
: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"
|
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
|
<img
|
||||||
:src="
|
:src="
|
||||||
@@ -397,12 +509,12 @@ onMounted(fetchData);
|
|||||||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
|
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
|
||||||
"
|
"
|
||||||
class="w-10 h-10 rounded-full cursor-pointer"
|
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="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
class="font-bold text-slate-900 text-sm truncate hover:text-primary-600 cursor-pointer"
|
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 }}
|
{{ creator.name }}
|
||||||
</div>
|
</div>
|
||||||
@@ -410,11 +522,13 @@ onMounted(fetchData);
|
|||||||
粉丝 {{ creator.stats?.followers || 0 }}
|
粉丝 {{ creator.stats?.followers || 0 }}
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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>
|
||||||
<div
|
<div
|
||||||
v-if="recommendedCreators.length === 0"
|
v-if="recommendedCreators.length === 0"
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { tenantPath } from "../utils/tenant";
|
import { tenantPath } from "../utils/tenant";
|
||||||
|
import { contentApi } from "../api/content";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const tenantRoute = (path) => tenantPath(path, route);
|
const tenantRoute = (path) => tenantPath(path, route);
|
||||||
const topics = ref([
|
|
||||||
|
const STATIC_TOPICS = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "程派艺术赏析:幽咽婉转后的风骨",
|
title: "程派艺术赏析:幽咽婉转后的风骨",
|
||||||
@@ -56,7 +59,59 @@ const topics = ref([
|
|||||||
date: "2024-12-05",
|
date: "2024-12-05",
|
||||||
count: 15,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -68,8 +123,9 @@ const topics = ref([
|
|||||||
|
|
||||||
<!-- 1. Hero Topic -->
|
<!-- 1. Hero Topic -->
|
||||||
<div
|
<div
|
||||||
|
v-if="topics.length > 0"
|
||||||
class="relative w-full h-[400px] rounded-2xl overflow-hidden mb-12 group cursor-pointer shadow-lg"
|
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
|
<img
|
||||||
:src="topics[0].cover"
|
:src="topics[0].cover"
|
||||||
@@ -112,13 +168,16 @@ const topics = ref([
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. Masonry-like Grid -->
|
<!-- 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
|
<div
|
||||||
v-for="(topic, idx) in topics.slice(1)"
|
v-for="(topic, idx) in topics.slice(1)"
|
||||||
:key="topic.id"
|
: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="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 }"
|
:class="{ 'lg:col-span-2 flex-row': idx === 0 }"
|
||||||
@click="$router.push(tenantRoute(`/explore?topic=${topic.id}`))"
|
@click="navigateToTopic(topic)"
|
||||||
>
|
>
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useToast } from "primevue/usetoast";
|
import { useToast } from "primevue/usetoast";
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { tenantApi } from "../../api/tenant";
|
|
||||||
import { contentApi } from "../../api/content";
|
import { contentApi } from "../../api/content";
|
||||||
|
import { tenantApi } from "../../api/tenant";
|
||||||
import { tenantPath } from "../../utils/tenant";
|
import { tenantPath } from "../../utils/tenant";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const tenantRoute = (path) => tenantPath(path, route);
|
const tenantRoute = (path) => tenantPath(path, route);
|
||||||
const toast = useToast();
|
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 currentTab = ref("home");
|
||||||
const isFollowing = ref(false);
|
const isFollowing = ref(false);
|
||||||
const tenant = ref({});
|
const tenant = ref({});
|
||||||
@@ -25,46 +36,57 @@ const hasMore = ref(false);
|
|||||||
const sortOption = ref("latest");
|
const sortOption = ref("latest");
|
||||||
const selectedTopic = ref("");
|
const selectedTopic = ref("");
|
||||||
const limit = 10;
|
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) => {
|
const fetchData = async (isLoadMore = false) => {
|
||||||
if (!isLoadMore) loading.value = true;
|
if (!isLoadMore) loading.value = true;
|
||||||
try {
|
try {
|
||||||
const id = route.params.id;
|
const effectiveTenantID = tenantID.value || resolveTenantIDFromRoute();
|
||||||
const query = {
|
const query = {
|
||||||
tenant_id: id,
|
|
||||||
sort: sortOption.value,
|
sort: sortOption.value,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
keyword: searchKeyword.value,
|
keyword: searchKeyword.value,
|
||||||
};
|
};
|
||||||
|
if (effectiveTenantID > 0) {
|
||||||
|
query.tenant_id = effectiveTenantID;
|
||||||
|
}
|
||||||
if (selectedTopic.value) {
|
if (selectedTopic.value) {
|
||||||
query.genre = selectedTopic.value;
|
query.genre = selectedTopic.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reqs = [contentApi.list(query)];
|
const reqs = [contentApi.list(query)];
|
||||||
|
|
||||||
// Only fetch tenant info & featured on first load
|
// Only fetch featured on first load
|
||||||
if (!isLoadMore && page.value === 1) {
|
if (!isLoadMore && page.value === 1) {
|
||||||
reqs.push(tenantApi.get(id));
|
const pinnedQuery = {
|
||||||
reqs.push(
|
is_pinned: true,
|
||||||
contentApi.list({
|
genre: selectedTopic.value || "",
|
||||||
tenant_id: id,
|
};
|
||||||
is_pinned: true,
|
if (effectiveTenantID > 0) {
|
||||||
genre: selectedTopic.value || "",
|
pinnedQuery.tenant_id = effectiveTenantID;
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
|
reqs.push(contentApi.list(pinnedQuery));
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(reqs);
|
const results = await Promise.all(reqs);
|
||||||
const c = results[0]; // Content List
|
const c = results[0]; // Content List
|
||||||
|
|
||||||
if (!isLoadMore && page.value === 1) {
|
if (!isLoadMore && page.value === 1) {
|
||||||
const t = results[1];
|
const f = results[1];
|
||||||
const f = results[2];
|
|
||||||
tenant.value = t || {};
|
|
||||||
isFollowing.value = t?.is_following || false;
|
|
||||||
|
|
||||||
if (f && f.items && f.items.length > 0) {
|
if (f?.items?.length > 0) {
|
||||||
featuredContent.value = f.items[0];
|
featuredContent.value = f.items[0];
|
||||||
} else {
|
} else {
|
||||||
featuredContent.value = null;
|
featuredContent.value = null;
|
||||||
@@ -92,12 +114,34 @@ const fetchData = async (isLoadMore = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => fetchData());
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
selectedTopic.value = normalizeGenreQuery(route.query.genre);
|
||||||
|
querySyncReady.value = true;
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
fetchData();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,13 +163,25 @@ const loadMore = () => {
|
|||||||
|
|
||||||
const toggleFollow = async () => {
|
const toggleFollow = async () => {
|
||||||
if (followLoading.value) return;
|
if (followLoading.value) return;
|
||||||
|
|
||||||
|
const effectiveTenantID = tenantID.value || resolveTenantIDFromRoute();
|
||||||
|
if (!effectiveTenantID) {
|
||||||
|
toast.add({
|
||||||
|
severity: "warn",
|
||||||
|
summary: "暂不可用",
|
||||||
|
detail: "当前频道暂不支持关注操作",
|
||||||
|
life: 2000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
followLoading.value = true;
|
followLoading.value = true;
|
||||||
try {
|
try {
|
||||||
if (isFollowing.value) {
|
if (isFollowing.value) {
|
||||||
await tenantApi.unfollow(route.params.id);
|
await tenantApi.unfollow(effectiveTenantID);
|
||||||
isFollowing.value = false;
|
isFollowing.value = false;
|
||||||
} else {
|
} else {
|
||||||
await tenantApi.follow(route.params.id);
|
await tenantApi.follow(effectiveTenantID);
|
||||||
isFollowing.value = true;
|
isFollowing.value = true;
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: "success",
|
severity: "success",
|
||||||
@@ -156,9 +212,43 @@ const sortLabel = computed(() =>
|
|||||||
sortOption.value === "hot" ? "最热" : "最新",
|
sortOption.value === "hot" ? "最热" : "最新",
|
||||||
);
|
);
|
||||||
|
|
||||||
watch([sortOption, selectedTopic], () => {
|
const currentTopicBreadcrumb = computed(() => {
|
||||||
applyFilters();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -229,6 +319,7 @@ watch([sortOption, selectedTopic], () => {
|
|||||||
<div class="flex flex-col items-end gap-5 pb-2">
|
<div class="flex flex-col items-end gap-5 pb-2">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
|
v-if="showFollowActions"
|
||||||
@click="toggleFollow"
|
@click="toggleFollow"
|
||||||
:disabled="followLoading"
|
: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"
|
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
|
<div
|
||||||
class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-14"
|
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
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.value"
|
:key="tab.value"
|
||||||
@@ -324,6 +424,12 @@ watch([sortOption, selectedTopic], () => {
|
|||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- In-Tenant Search -->
|
<!-- In-Tenant Search -->
|
||||||
|
|||||||
Reference in New Issue
Block a user