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:
@@ -0,0 +1,49 @@
|
||||
// 从作品描述里解析抖音 hashtag(#xxx)。
|
||||
// 前端渲染可点击的 hashtag 与后端按 hashtag 精确筛选作品共用这里的边界规则,
|
||||
// 保证「点了 #丰川祥子 看到的列表」不会混进 #丰川祥子cos。
|
||||
|
||||
// hashtag 在遇到空白、下一个 #、或这些标点时结束
|
||||
const STOP_CHARS =
|
||||
"#,。!?、;:,.!?;:()()【】\\[\\]《》<>\"'“”‘’…~~|/\\\\@";
|
||||
const MAX_HASHTAG_LENGTH = 50;
|
||||
|
||||
const HASHTAG_RE = new RegExp(`#([^\\s${STOP_CHARS}]+)`, "gu");
|
||||
|
||||
export type DescPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "hashtag"; text: string; name: string };
|
||||
|
||||
/** 把描述切成普通文字与 hashtag 片段。 */
|
||||
export function parseDescHashtags(desc: string): DescPart[] {
|
||||
const parts: DescPart[] = [];
|
||||
let cursor = 0;
|
||||
for (const m of desc.matchAll(HASHTAG_RE)) {
|
||||
const start = m.index ?? 0;
|
||||
if (start > cursor) {
|
||||
parts.push({ type: "text", text: desc.slice(cursor, start) });
|
||||
}
|
||||
parts.push({ type: "hashtag", text: m[0], name: m[1] });
|
||||
cursor = start + m[0].length;
|
||||
}
|
||||
if (cursor < desc.length) {
|
||||
parts.push({ type: "text", text: desc.slice(cursor) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 规范化 hashtag 名(去掉开头的 #、首尾空白,限制长度);不合法时返回 null。 */
|
||||
export function normalizeHashtag(raw: string): string | null {
|
||||
const name = raw.trim().replace(/^#+/, "").slice(0, MAX_HASHTAG_LENGTH);
|
||||
if (!name) return null;
|
||||
if (new RegExp(`[\\s${STOP_CHARS}]`, "u").test(name)) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配 lower() 后描述文本的 Postgres 正则(ARE):#名字 之后必须是结束符或文本末尾。
|
||||
* 名字里的正则特殊字符会被转义。
|
||||
*/
|
||||
export function hashtagToPgPattern(name: string): string {
|
||||
const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return `#${escaped}([\\s${STOP_CHARS}]|$)`;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { hashtagToPgPattern } from "@/lib/hashtag";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import {
|
||||
awemeHref,
|
||||
type PlaybackContext,
|
||||
type PlaylistItem,
|
||||
playbackContextQuery,
|
||||
} from "@/lib/playback-context";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parseSearchQuery } from "@/lib/search";
|
||||
import { searchIds } from "@/lib/search-query";
|
||||
|
||||
export type NeighborLink = { aweme_id: string; href: string };
|
||||
|
||||
export type PlaybackNeighbors = {
|
||||
prev: NeighborLink | null;
|
||||
next: NeighborLink | null;
|
||||
};
|
||||
|
||||
export type PlaybackContextInfo = {
|
||||
kind: PlaybackContext["kind"];
|
||||
/** 顶部提示里的说明,如「搜索「百姓」」 */
|
||||
label: string;
|
||||
/** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */
|
||||
listTitle: string;
|
||||
/** 从 1 开始 */
|
||||
position: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ResolvedPlayback = {
|
||||
neighbors: PlaybackNeighbors;
|
||||
/** 当前生效的上下文(URL 里的上下文不包含当前作品时为 null,退回全部作品) */
|
||||
context: PlaybackContextInfo | null;
|
||||
/** URL 里原本的上下文查询串,侧栏里跳转时原样带上 */
|
||||
contextQuery: string;
|
||||
};
|
||||
|
||||
/** 侧栏一次最多列出的作品数(搜索词很宽泛时避免一次塞几千张封面) */
|
||||
export const PLAYLIST_LIMIT = 500;
|
||||
|
||||
// 全部作品 / 某作者的作品 / 带某个 hashtag 的作品:视频与图文合并,按发布时间倒序(与首页、作者页一致)
|
||||
async function timelineIds(filter: Prisma.Sql = Prisma.empty) {
|
||||
const rows = await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT aweme_id AS id FROM (
|
||||
SELECT aweme_id, created_at, "authorId", "desc" FROM "Video"
|
||||
UNION ALL
|
||||
SELECT aweme_id, created_at, "authorId", "desc" FROM "ImagePost"
|
||||
) works
|
||||
${filter}
|
||||
ORDER BY created_at DESC, aweme_id DESC
|
||||
`;
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// 数据量小(全库约两千个作品),直接取完整的有序 ID 列表再定位当前作品,
|
||||
// 比为每种上下文分别写游标查询简单得多。数据量大了再换成游标查询,前端不用动。
|
||||
export async function contextIds(
|
||||
ctx: PlaybackContext | null,
|
||||
): Promise<string[]> {
|
||||
if (!ctx) return timelineIds();
|
||||
switch (ctx.kind) {
|
||||
case "author":
|
||||
return timelineIds(Prisma.sql`WHERE "authorId" = ${ctx.secUid}`);
|
||||
case "hashtag":
|
||||
return timelineIds(
|
||||
Prisma.sql`WHERE lower("desc") ~ ${hashtagToPgPattern(ctx.name)}`,
|
||||
);
|
||||
case "tag":
|
||||
return searchIds({ terms: [], excludes: [], contentTag: ctx.tag });
|
||||
case "search": {
|
||||
const { terms, excludes } = parseSearchQuery(ctx.q);
|
||||
if (terms.length === 0 && !ctx.tag) return [];
|
||||
return searchIds({ terms, excludes, contentTag: ctx.tag });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function contextLabels(
|
||||
ctx: PlaybackContext,
|
||||
): Promise<{ label: string; listTitle: string }> {
|
||||
switch (ctx.kind) {
|
||||
case "search":
|
||||
if (!ctx.q) return { label: `标签「${ctx.tag}」`, listTitle: "搜索结果" };
|
||||
return {
|
||||
label: ctx.tag ? `搜索「${ctx.q}」· ${ctx.tag}` : `搜索「${ctx.q}」`,
|
||||
listTitle: "搜索结果",
|
||||
};
|
||||
case "tag":
|
||||
return { label: `标签「${ctx.tag}」`, listTitle: ctx.tag };
|
||||
case "hashtag":
|
||||
return { label: `#${ctx.name}`, listTitle: `#${ctx.name}` };
|
||||
case "author": {
|
||||
const author = await prisma.author.findUnique({
|
||||
where: { sec_uid: ctx.secUid },
|
||||
select: { nickname: true },
|
||||
});
|
||||
const label = `${author?.nickname ?? "作者"} 的作品`;
|
||||
return { label, listTitle: label };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function neighborsAt(
|
||||
ids: string[],
|
||||
index: number,
|
||||
query: string,
|
||||
): PlaybackNeighbors {
|
||||
const link = (id: string | undefined) =>
|
||||
id ? { aweme_id: id, href: awemeHref(id, query) } : null;
|
||||
return { prev: link(ids[index - 1]), next: link(ids[index + 1]) };
|
||||
}
|
||||
|
||||
export async function resolvePlayback(
|
||||
awemeId: string,
|
||||
ctx: PlaybackContext | null,
|
||||
): Promise<ResolvedPlayback> {
|
||||
const contextQuery = playbackContextQuery(ctx);
|
||||
|
||||
if (ctx) {
|
||||
const [ids, labels] = await Promise.all([
|
||||
contextIds(ctx),
|
||||
contextLabels(ctx),
|
||||
]);
|
||||
const index = ids.indexOf(awemeId);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
neighbors: neighborsAt(ids, index, contextQuery),
|
||||
context: {
|
||||
kind: ctx.kind,
|
||||
...labels,
|
||||
position: index + 1,
|
||||
total: ids.length,
|
||||
},
|
||||
contextQuery,
|
||||
};
|
||||
}
|
||||
// 当前作品不在这个列表里(例如在作者模式里切到了不在搜索结果中的作品),
|
||||
// 退回全部作品的顺序;继续往下切时不再带上原来的上下文。
|
||||
}
|
||||
|
||||
const ids = await timelineIds();
|
||||
const index = ids.indexOf(awemeId);
|
||||
return {
|
||||
neighbors:
|
||||
index === -1 ? { prev: null, next: null } : neighborsAt(ids, index, ""),
|
||||
context: null,
|
||||
contextQuery,
|
||||
};
|
||||
}
|
||||
|
||||
/** 按上下文顺序列出作品(供侧栏网格使用),最多 PLAYLIST_LIMIT 个。 */
|
||||
export async function contextItems(
|
||||
ctx: PlaybackContext,
|
||||
): Promise<{ items: PlaylistItem[]; total: number }> {
|
||||
const allIds = await contextIds(ctx);
|
||||
const ids = allIds.slice(0, PLAYLIST_LIMIT);
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: { aweme_id: { in: ids } },
|
||||
select: { aweme_id: true, desc: true, cover_url: true, digg_count: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: { aweme_id: { in: ids } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
desc: true,
|
||||
digg_count: true,
|
||||
images: { orderBy: { order: "asc" }, take: 1, select: { url: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const byId = new Map<string, PlaylistItem>();
|
||||
for (const v of videos) {
|
||||
byId.set(v.aweme_id, {
|
||||
aweme_id: v.aweme_id,
|
||||
type: "video",
|
||||
desc: v.desc,
|
||||
cover_url: v.cover_url ? getFileUrl(v.cover_url) : null,
|
||||
likes: Number(v.digg_count),
|
||||
});
|
||||
}
|
||||
for (const p of posts) {
|
||||
byId.set(p.aweme_id, {
|
||||
aweme_id: p.aweme_id,
|
||||
type: "image",
|
||||
desc: p.desc,
|
||||
cover_url: p.images[0] ? getFileUrl(p.images[0].url) : null,
|
||||
likes: Number(p.digg_count),
|
||||
});
|
||||
}
|
||||
|
||||
const items = ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((item): item is PlaylistItem => Boolean(item));
|
||||
return { items, total: allIds.length };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 播放上下文:播放页「上一条 / 下一条」在哪个列表里移动。
|
||||
// 它放在 /aweme/<id> 的查询参数里,刷新、新标签页打开、后退都不会丢:
|
||||
// (无) 全部作品,按发布时间倒序(和首页一致)
|
||||
// ?ctx=tag&tag=美女 某个标签
|
||||
// ?ctx=author&author=<secUid> 某个作者的作品
|
||||
// ?ctx=search&q=百姓&tag=… 某次搜索的结果
|
||||
// ?ctx=hashtag&hashtag=丰川祥子 描述里带某个 hashtag 的作品
|
||||
// 这个文件只放纯函数,客户端组件也可以引用;查数据库的部分在 playback-context.server.ts。
|
||||
import { normalizeHashtag } from "@/lib/hashtag";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
|
||||
export type PlaybackContext =
|
||||
| { kind: "search"; q: string; tag: string | null }
|
||||
| { kind: "tag"; tag: string }
|
||||
| { kind: "hashtag"; name: string }
|
||||
| { kind: "author"; secUid: string };
|
||||
|
||||
/** 播放页侧栏网格里的一项(/api/playlist 的返回) */
|
||||
export type PlaylistItem = {
|
||||
aweme_id: string;
|
||||
type: "video" | "image";
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
likes: number;
|
||||
};
|
||||
|
||||
export type PlaylistResponse = { items: PlaylistItem[]; total: number };
|
||||
|
||||
type ParamSource =
|
||||
| URLSearchParams
|
||||
| Record<string, string | string[] | undefined>;
|
||||
|
||||
function readParam(source: ParamSource, key: string): string {
|
||||
const value =
|
||||
source instanceof URLSearchParams ? source.get(key) : source[key];
|
||||
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function parsePlaybackContext(
|
||||
source: ParamSource,
|
||||
): PlaybackContext | null {
|
||||
const kind = readParam(source, "ctx");
|
||||
const tag = readParam(source, "tag");
|
||||
const validTag = tag && isVideoContentTag(tag) ? tag : null;
|
||||
|
||||
if (kind === "search") {
|
||||
const q = readParam(source, "q");
|
||||
if (!q && !validTag) return null;
|
||||
return { kind: "search", q, tag: validTag };
|
||||
}
|
||||
if (kind === "tag" && validTag) return { kind: "tag", tag: validTag };
|
||||
if (kind === "hashtag") {
|
||||
const name = normalizeHashtag(readParam(source, "hashtag"));
|
||||
if (name) return { kind: "hashtag", name };
|
||||
}
|
||||
if (kind === "author") {
|
||||
const secUid = readParam(source, "author");
|
||||
if (secUid) return { kind: "author", secUid };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 生成带上下文的查询串(含开头的 ?);没有上下文时返回空串。 */
|
||||
export function playbackContextQuery(ctx: PlaybackContext | null): string {
|
||||
if (!ctx) return "";
|
||||
const params = new URLSearchParams({ ctx: ctx.kind });
|
||||
if (ctx.kind === "search") {
|
||||
if (ctx.q) params.set("q", ctx.q);
|
||||
if (ctx.tag) params.set("tag", ctx.tag);
|
||||
} else if (ctx.kind === "tag") {
|
||||
params.set("tag", ctx.tag);
|
||||
} else if (ctx.kind === "hashtag") {
|
||||
params.set("hashtag", ctx.name);
|
||||
} else {
|
||||
params.set("author", ctx.secUid);
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function awemeHref(awemeId: string, query = ""): string {
|
||||
return `/aweme/${awemeId}${query}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 搜索的 SQL 部分:搜索接口(分页取结果)和播放页(取完整的有序 ID 列表算上一条/下一条)
|
||||
// 共用同一段匹配与排序逻辑,保证两边顺序完全一致。
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { termToPgPattern } from "@/lib/search";
|
||||
|
||||
export type SearchInput = {
|
||||
/** 已转小写的关键词(parseSearchQuery 的结果) */
|
||||
terms: string[];
|
||||
excludes: string[];
|
||||
contentTag: string | null;
|
||||
};
|
||||
|
||||
export type SearchRow = {
|
||||
awemeId: string;
|
||||
type: "video" | "image";
|
||||
score: number;
|
||||
inDesc: boolean;
|
||||
inTranscript: boolean;
|
||||
rawDesc: string;
|
||||
rawTranscript: string;
|
||||
contentTags: string[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
// 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写),
|
||||
// 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。
|
||||
// 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。
|
||||
// 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。
|
||||
function matchedCte({ terms, excludes, contentTag }: SearchInput) {
|
||||
const patterns = terms.map(termToPgPattern);
|
||||
const excludePatterns = excludes.map(termToPgPattern);
|
||||
return Prisma.sql`
|
||||
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
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const ORDER_BY = Prisma.sql`ORDER BY score DESC, created DESC, "awemeId"`;
|
||||
|
||||
export function searchRows(input: SearchInput, offset: number, limit: number) {
|
||||
return prisma.$queryRaw<SearchRow[]>`
|
||||
${matchedCte(input)}
|
||||
SELECT
|
||||
"awemeId", type, score, "inDesc", "inTranscript",
|
||||
"rawDesc", "rawTranscript", "contentTags",
|
||||
(COUNT(*) OVER ())::int AS total
|
||||
FROM matched
|
||||
${ORDER_BY}
|
||||
OFFSET ${offset}
|
||||
LIMIT ${limit};
|
||||
`;
|
||||
}
|
||||
|
||||
/** 按搜索结果顺序返回全部命中作品的 ID。 */
|
||||
export async function searchIds(input: SearchInput): Promise<string[]> {
|
||||
const rows = await prisma.$queryRaw<{ awemeId: string }[]>`
|
||||
${matchedCte(input)}
|
||||
SELECT "awemeId" FROM matched
|
||||
${ORDER_BY};
|
||||
`;
|
||||
return rows.map((r) => r.awemeId);
|
||||
}
|
||||
Reference in New Issue
Block a user