- 播放上下文:作品链接带上来源(搜索、标签、作者、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>
50 lines
1.9 KiB
TypeScript
50 lines
1.9 KiB
TypeScript
// 从作品描述里解析抖音 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}]|$)`;
|
||
}
|