feat: 播放页按来源列表切换,新增作者/搜索/hashtag 侧栏

- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条
  在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序
- 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出
- 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换
- 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表
- 从搜索结果进入或点 hashtag 时自动打开对应列表
- 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos)
- 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致
- 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 00:50:09 +08:00
co-authored by Claude Opus 5.5
parent 182ca9cf05
commit b84fcec77b
28 changed files with 1592 additions and 282 deletions
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { json } from "@/lib/json";
import { parsePlaybackContext } from "@/lib/playback-context";
import { contextItems } from "@/lib/playback-context.server";
// 按播放上下文的顺序列出作品,供播放页侧栏的网格使用。
// 参数与 /aweme/<id> 上的上下文参数相同:?ctx=search&q=…、?ctx=hashtag&hashtag=…、?ctx=author&author=… 等。
export async function GET(req: Request) {
const ctx = parsePlaybackContext(new URL(req.url).searchParams);
if (!ctx) {
return NextResponse.json({ error: "Invalid context" }, { status: 400 });
}
try {
return json(await contextItems(ctx));
} catch (err) {
console.error("Playlist error:", err);
return NextResponse.json({ error: "Playlist failed" }, { status: 500 });
}
}
export const dynamic = "force-dynamic";
+7 -90
View File
@@ -2,26 +2,10 @@ import { NextResponse } from "next/server";
import { json } from "@/lib/json";
import { getFileUrl } from "@/lib/minio";
import { prisma } from "@/lib/prisma";
import {
buildSnippet,
highlightAll,
parseSearchQuery,
termToPgPattern,
} from "@/lib/search";
import { buildSnippet, highlightAll, parseSearchQuery } from "@/lib/search";
import { searchRows } from "@/lib/search-query";
import { isVideoContentTag } from "@/lib/video-tags";
type SearchRow = {
awemeId: string;
type: "video" | "image";
score: number;
inDesc: boolean;
inTranscript: boolean;
rawDesc: string;
rawTranscript: string;
contentTags: string[];
total: number;
};
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim();
@@ -43,79 +27,12 @@ export async function GET(req: Request) {
return json({ results: [], total: 0, page, limit, terms });
}
// 关键词已在 parseSearchQuery 里转为小写,这里转成正则后与 lower() 后的文本匹配
const patterns = terms.map(termToPgPattern);
const excludePatterns = excludes.map(termToPgPattern);
try {
// 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写),
// 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。
// 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。
// 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。
const rows = await prisma.$queryRaw<SearchRow[]>`
WITH params AS (
SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes
),
docs AS (
SELECT
v.aweme_id AS "awemeId",
'video'::text AS type,
v."desc" AS "rawDesc",
COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript",
v.created_at AS created,
v.manual_content_tags AS "contentTags"
FROM "Video" v
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
WHERE ${contentTag}::text IS NULL
OR v.manual_content_tags @> ARRAY[${contentTag}]::text[]
UNION ALL
SELECT
ip.aweme_id,
'image'::text,
ip."desc",
'',
ip.created_at,
ARRAY[]::text[]
FROM "ImagePost" ip
WHERE ${contentTag}::text IS NULL
),
lowered AS (
SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t
FROM docs
),
matched AS (
SELECT
l.*,
COALESCE((
SELECT SUM(
4 * LEAST(regexp_count(l.d, term), 3)
+ LEAST(regexp_count(l.t, term), 10)
)
FROM unnest(p.terms) term
), 0)::float8 AS score,
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc",
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript"
FROM lowered l, params p
WHERE NOT EXISTS (
SELECT 1 FROM unnest(p.terms) term
WHERE l.d !~ term AND l.t !~ term
)
AND NOT EXISTS (
SELECT 1 FROM unnest(p.excludes) x
WHERE l.d ~ x OR l.t ~ x
)
)
SELECT
"awemeId", type, score, "inDesc", "inTranscript",
"rawDesc", "rawTranscript", "contentTags",
(COUNT(*) OVER ())::int AS total
FROM matched
ORDER BY score DESC, created DESC, "awemeId"
OFFSET ${offset}
LIMIT ${limit};
`;
const rows = await searchRows(
{ terms, excludes, contentTag },
offset,
limit,
);
const videoIds = rows
.filter((r) => r.type === "video")