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
+49
View File
@@ -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}]|$)`;
}