feat: hashtag 列表显示按共现计算的相关 tag

- 新增 /api/hashtag/related:从作品描述实时解析 hashtag,按「同一作品里一起出现」计算相关 tag
- 打分取子话题(候选多与目标同现)与上位话题(目标多与热门候选同现)两者较大值,
  过滤 cos、二次元等万金油 tag 与平台活动、纯数字 tag
- 播放页侧栏的 hashtag 列表顶部显示相关 tag,点击在侧栏内预览(不打断当前播放),
  在预览里点作品才切换到该 tag 的列表

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 01:18:04 +08:00
co-authored by Claude Opus 5.5
parent b84fcec77b
commit 2391abdeea
7 changed files with 279 additions and 7 deletions
+10
View File
@@ -471,6 +471,14 @@ export default function AwemeDetailClient({
return `/search?${params.toString()}`;
}, [listKind, contextQuery]);
const hashtagName = useMemo(
() =>
listKind === "hashtag"
? new URLSearchParams(contextQuery).get("hashtag")
: null,
[listKind, contextQuery],
);
const listVisible = panel.open && activeTab === "list";
const worksVisible = panel.open && activeTab === "works";
@@ -976,11 +984,13 @@ export default function AwemeDetailClient({
count: listContext.total,
content: (
<ContextListTab
key={contextQuery}
active={panel.open && activeTab === "list"}
context={listContext}
list={contextList}
currentId={data.aweme_id}
contextQuery={contextQuery}
hashtagName={hashtagName}
searchPageHref={searchPageHref}
onSelect={handleWorkSelect}
/>
@@ -1,7 +1,9 @@
import { Hash, Search } from "lucide-react";
import { ArrowLeft, Hash, Search } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import type { PlaylistState } from "../hooks/usePlaylist";
import { memo, useState } from "react";
import { playbackContextQuery } from "@/lib/playback-context";
import { type PlaylistState, usePlaylist } from "../hooks/usePlaylist";
import { useRelatedHashtags } from "../hooks/useRelatedHashtags";
import type { PlaybackContextInfo } from "../types.ts";
import { WorksGrid } from "./WorksGrid";
@@ -11,23 +13,62 @@ interface ContextListTabProps {
list: PlaylistState;
currentId: string;
contextQuery: string;
/** hashtag 上下文时的 tag 名(用来取相关 tag) */
hashtagName: string | null;
/** 对应的搜索结果页链接(搜索上下文才有) */
searchPageHref: string | null;
onSelect: () => void;
}
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致。
// hashtag 列表上方显示相关 tag(按共现计算);点相关 tag 只在侧栏里预览它的作品,不打断当前播放,
// 在预览里点作品才切换到那个 tag 的列表。
export const ContextListTab = memo(function ContextListTab({
active,
context,
list,
currentId,
contextQuery,
hashtagName,
searchPageHref,
onSelect,
}: ContextListTabProps) {
// 正在预览的相关 tag(null 表示显示当前列表本身)
const [browsing, setBrowsing] = useState<string | null>(null);
const viewTag = browsing ?? hashtagName;
const related = useRelatedHashtags(viewTag, active);
const browseQuery = browsing
? playbackContextQuery({ kind: "hashtag", name: browsing })
: "";
const browseList = usePlaylist(browseQuery, active && Boolean(browsing));
const pickTag = (name: string) => {
setBrowsing(
name.toLowerCase() === hashtagName?.toLowerCase() ? null : name,
);
};
const Icon = context.kind === "hashtag" ? Hash : Search;
const header = (
const header = browsing ? (
<>
<button
type="button"
onClick={() => setBrowsing(null)}
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-white/80 hover:bg-white/20 cursor-pointer"
aria-label={`返回 ${context.label}`}
title={`返回 ${context.label}`}
>
<ArrowLeft size={16} />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">#{browsing}</div>
<div className="text-xs text-white/50">
{related ? `共 ${related.count} 个 · ` : ""}点作品切换到这个 tag
</div>
</div>
</>
) : (
<>
<Icon size={18} className="shrink-0 text-rose-300" />
<div className="min-w-0 flex-1">
@@ -47,14 +88,34 @@ export const ContextListTab = memo(function ContextListTab({
</>
);
const relatedRow =
viewTag && related && related.related.length > 0 ? (
<div className="flex items-center gap-1.5 overflow-x-auto px-3 py-2 border-b border-white/10 comment-scroll">
<span className="shrink-0 text-xs text-white/40">相关</span>
{related.related.map((tag) => (
<button
key={tag.name}
type="button"
onClick={() => pickTag(tag.name)}
className="shrink-0 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:border-white/30 hover:text-white cursor-pointer"
title={`一起出现在 ${tag.together} 个作品里,#${tag.name} 共 ${tag.count} 个作品`}
>
#{tag.name}
<span className="ml-1 text-white/40">{tag.together}</span>
</button>
))}
</div>
) : null;
return (
<WorksGrid
active={active}
list={list}
list={browsing ? browseList : list}
currentId={currentId}
hrefQuery={contextQuery}
hrefQuery={browsing ? browseQuery : contextQuery}
onSelect={onSelect}
header={header}
subheader={relatedRow}
/>
);
});
@@ -16,6 +16,8 @@ interface WorksGridProps {
singleHint?: string;
/** 网格上方的标题栏 */
header: ReactNode;
/** 标题栏下方的附加内容(如相关 tag) */
subheader?: ReactNode;
}
function formatCount(n: number): string {
@@ -46,6 +48,7 @@ export const WorksGrid = memo(function WorksGrid({
onSelect,
singleHint,
header,
subheader,
}: WorksGridProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const currentRef = useRef<HTMLAnchorElement>(null);
@@ -74,6 +77,7 @@ export const WorksGrid = memo(function WorksGrid({
<div className="flex items-center gap-3 px-3 py-3 border-b border-white/10 shrink-0">
{header}
</div>
{subheader}
<div ref={scrollRef} className="comment-scroll flex-1 overflow-auto p-2">
{list.loading && !list.items && (
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import type { RelatedHashtagsResult } from "@/lib/hashtag-related.server";
const cache = new Map<string, RelatedHashtagsResult>();
/** 取某个 hashtag 的相关 tag(按共现计算);name 为空或 enabled 为 false 时不请求。 */
export function useRelatedHashtags(
name: string | null,
enabled: boolean,
): RelatedHashtagsResult | null {
const key = name?.toLowerCase() ?? "";
const [result, setResult] = useState<RelatedHashtagsResult | null>(
() => cache.get(key) ?? null,
);
useEffect(() => {
if (!key || !enabled) return;
const cached = cache.get(key);
setResult(cached ?? null);
if (cached) return;
let cancelled = false;
fetch(`/api/hashtag/related?name=${encodeURIComponent(key)}`)
.then((res) => (res.ok ? res.json() : null))
.then((data: RelatedHashtagsResult | null) => {
if (!data) return;
cache.set(key, data);
if (!cancelled) setResult(data);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [key, enabled]);
return result?.name === key ? result : null;
}