Compare commits
24
Commits
77bc2c5775
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2391abdeea | ||
|
|
b84fcec77b | ||
|
|
182ca9cf05 | ||
|
|
11d9d35002 | ||
|
|
bcf1e6e349 | ||
|
|
10f2520279 | ||
|
|
990e1b1804 | ||
|
|
d3896522ec | ||
|
|
6f0e38054b | ||
|
|
b9af979582 | ||
|
|
eac6ad2343 | ||
|
|
02d37f3953 | ||
|
|
206a561ee7 | ||
|
|
040273586b | ||
|
|
674c202264 | ||
|
|
63ed49e762 | ||
|
|
edd0a85439 | ||
|
|
823df2cbf6 | ||
|
|
0d18595b68 | ||
|
|
d1b4fb6be0 | ||
|
|
e4339a5b91 | ||
|
|
f94ef73518 | ||
|
|
5a5eba19e4 | ||
|
|
590b26c420 |
@@ -0,0 +1,18 @@
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/douyin_archive
|
||||
PORT=3000
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_BUCKET_NAME=home-page
|
||||
MINIO_PUBLIC_DOMAIN=https://your-public-storage.example.com
|
||||
|
||||
# Native DashScope ASR, not the OpenAI compatible-mode endpoint.
|
||||
DASHSCOPE_API_KEY=
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
DASHSCOPE_ASR_MODEL=qwen3-asr-flash-filetrans
|
||||
|
||||
FFMPEG_PATH=ffmpeg
|
||||
FFPROBE_PATH=ffprobe
|
||||
CHROMIUM_HEADLESS=true
|
||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"editor.tabSize": 2
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
Vendored
+17
-25
@@ -1,27 +1,19 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "tsc-check",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"-e",
|
||||
"require('typescript').transpile('const x: number = 1;')"
|
||||
],
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "tsc-check (one-off)",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"-e",
|
||||
"require('typescript').transpile('const x: number = 1;')"
|
||||
]
|
||||
}
|
||||
]
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "tsc-check",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": ["-e", "require('typescript').transpile('const x: number = 1;')"],
|
||||
"problemMatcher": ["$tsc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "tsc-check (one-off)",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": ["-e", "require('typescript').transpile('const x: number = 1;')"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
This project uses local system font stacks so production builds do not depend on fetching Google Fonts.
|
||||
|
||||
## Learn More
|
||||
|
||||
@@ -34,3 +34,79 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
## 视频文字转写(DashScope ASR)
|
||||
|
||||
仅使用 `qwen3-asr-flash-filetrans` 做文字识别,不使用通用模型理解、分类、总结或润色。新视频保存后入队;历史文字保留,后台可单条重转或批量补转。关闭页面不影响已入队任务。
|
||||
|
||||
处理流程:从 MinIO 将原视频流式下载到临时文件 → ffprobe 检查音轨及实际时长 → ffmpeg 提取整段 **16 kHz 单声道、64 kbps MP3** → 上传至 MinIO → 将 **音频 URL** 提交到 DashScope → 后台取回文字。没有分块、视频上传到模型或二次模型调用。无音轨直接保存空文字;ASR 空结果显示“未识别到文字”。
|
||||
|
||||
### 配置与启动
|
||||
|
||||
参考 `.env.example`。模型配置统一为:
|
||||
|
||||
```dotenv
|
||||
DASHSCOPE_API_KEY=你的百炼Key
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
DASHSCOPE_ASR_MODEL=qwen3-asr-flash-filetrans
|
||||
```
|
||||
|
||||
这是 DashScope **原生 API** 地址,不是 `/compatible-mode/v1`。Key 和端点地域必须匹配。也可使用北京业务空间域名的 `/api/v1` 地址。旧 `OPENAI_*`、`QWEN_*` 和转写模块的全局代理配置不再使用;已有 Omni 的百炼 Key 可用于 ASR,仍需账号开通相应模型权限。
|
||||
|
||||
需要 ffmpeg、ffprobe(可通过 `FFMPEG_PATH`、`FFPROBE_PATH` 指定)。`MINIO_ENDPOINT`/端口/SSL 必须是运行机器可鉴权读写的 S3 API;`MINIO_PUBLIC_DOMAIN` 必须让 DashScope 能下载所上传的音频。执行器不修改 Bucket 策略。音频存储于 `transcription-audio/<视频ID>/<时间戳>.mp3`;独立对象防止手动重试覆盖仍在使用的音频。可为该前缀配置不少于 7 天的生命周期,视频原文件不受影响。本地视频临时文件在成功或失败后都会清理。
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npm run dev
|
||||
# 另一个终端(开发与生产均需执行器)
|
||||
npm run stt:worker
|
||||
```
|
||||
|
||||
生产使用 `pm2 startOrReload pm2.config.cjs --env production --update-env`,配置同时包含 `DouyinArchive` 和 `DouyinArchive-ASR`。全局最多两个处理中任务,每 10 秒查一次供应商状态。任务保存在 PostgreSQL,不需要 Redis。未配置 Key 不阻止网站启动,转写会显示配置错误。
|
||||
|
||||
### 数据库迁移与发布顺序
|
||||
|
||||
1. 备份现有数据库,保存旧版本用于回退。
|
||||
2. 在停止服务前安装依赖、生成 Prisma Client 并运行 `npm run build`。
|
||||
3. 停止旧网站及旧后台执行器,运行 `npx prisma migrate deploy`。
|
||||
4. 用上述 PM2 命令启动新网站和 ASR 执行器,检查两者日志;确认转写与搜索后再完成发布验收。
|
||||
|
||||
迁移保留 `VideoTranscript` 的历史文字、关联和时间字段;删除 `speech_detected`、`language`、`audio_type`、`non_speech_summary`,以及部分部署存在的 `content_tags`。不自动补转或重转历史视频。新建 `TranscriptionTask` 保存状态、任务 ID、租约和重试信息。重转成功时原子替换文字,失败保留旧文字。
|
||||
|
||||
迁移会替换已知的旧全文检索触发器,并维护 `transcript_zh_tsv` 与 GIN 索引。**已有 `public.zhcfg` 中文分词配置会保留**;新库没有该配置时创建 `simple` 回退配置,它不提供中文分词。新部署若需要与旧库相同的中文搜索效果,先安装同样的中文分词扩展和 `zhcfg`;后续调整分词配置后应重新计算现有索引字段。不要在迁移后直接回滚到依赖旧字段的应用;需要回退时恢复数据库备份和旧应用版本。
|
||||
|
||||
### 接口和任务行为
|
||||
|
||||
- `POST /api/stt`,JSON `{ "awemeId": "视频ID" }`:首次转写或手动重转;处理中重复点击复用当前任务。
|
||||
- `GET /api/stt?awemeId=视频ID`:只读取 `{ status, error, transcript }`,不触发转写。`transcript` 为 `string[] | null`;`null` 表示没有完成记录,`[]` 表示完成但没有文字。
|
||||
- `POST /api/stt/batch`:一次将所有没有完成记录、且未在排队或处理中的视频加入数据库队列,失败记录可补转,历史完成记录不重转。
|
||||
- 状态为 `PENDING`(尚未入队)、`QUEUED`、`PROCESSING`、`SUCCEEDED`、`FAILED`。后台显示各状态数量、错误和重试按钮。
|
||||
|
||||
音频限制为 12 小时、2 GB,超限失败而非截断。执行器重启后复用已有供应商任务 ID;租约到期可恢复领取。提交前写入标记,提交阶段崩溃或超时无法确认任务 ID 时,标为失败并要求核对百炼任务后手动重试,避免盲目重复付费。查询和结果下载的网络错误/429/5xx 按 10 秒至 5 分钟退避重试;鉴权和明确的供应商失败不自动重提。超过 24 小时的任务标为失败。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
npm run test:stt
|
||||
# 必须是独立本地库 douyin_asr_test;此测试会重建该库的 public schema
|
||||
TEST_DATABASE_URL=postgresql://你的本地用户名@127.0.0.1:5432/douyin_asr_test npm run test:stt:integration
|
||||
npx tsc --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
部署后可用管理页测试短视频、超过 5 分钟的视频、无发言/无音轨视频。检查新归档自动入队、关页面继续处理、重启续查、失败保留旧文字及文字搜索。
|
||||
|
||||
也可对已归档的视频独立实测(会上传音频、调用付费 ASR;**不修改数据库里的原转写**):
|
||||
|
||||
```bash
|
||||
npx tsx scripts/stt-smoke.ts 7682010237578136858
|
||||
```
|
||||
|
||||
脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。
|
||||
|
||||
### 手动标签兼容
|
||||
|
||||
保留服务器版本的手动标签、首页批量打标签、详情页编辑及标签筛选;标签只来自 `Video.manual_content_tags`,不再回退到模型自动分类。转写成功、失败或重试都不会修改手动标签。后台转写列表支持手动标签筛选与批量添加/清空。
|
||||
|
||||
已有 `/admin/asr`、`/admin/asr/videos`、`/api/admin/asr/videos` 地址继续可用;`/api/asr` 与 `/api/stt` 一样,POST 入队、GET 只读。旧内存批处理和分块模型调用已移除。
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
|
||||
export default function AsrAdminPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<BackButton />
|
||||
|
||||
<div className="mt-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">ASR 管理中心</h1>
|
||||
<p className="text-gray-600 mt-2">管理和配置视频语音识别功能</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6">
|
||||
{/* 视频转写管理 */}
|
||||
<Link
|
||||
href="/admin/asr/videos"
|
||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
视频转写管理
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写
|
||||
</p>
|
||||
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
|
||||
进入管理
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 转写设置(预留) */}
|
||||
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
转写设置
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
配置转写 API、模型参数和其他高级选项
|
||||
</p>
|
||||
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转写统计(预留) */}
|
||||
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
转写统计
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
查看转写使用情况、成功率、语言分布等统计数据
|
||||
</p>
|
||||
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../stt/videos/page";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../asr/page";
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import {
|
||||
isActive,
|
||||
statusLabels,
|
||||
type TranscriptionState,
|
||||
type TranscriptionStatus,
|
||||
} from "@/lib/stt/types";
|
||||
|
||||
type Video = {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
cover_url: string;
|
||||
duration_ms: number;
|
||||
author: { nickname: string };
|
||||
transcription: TranscriptionState;
|
||||
content_tags: string[];
|
||||
};
|
||||
const filters: ("all" | TranscriptionStatus)[] = [
|
||||
"all",
|
||||
"PENDING",
|
||||
"QUEUED",
|
||||
"PROCESSING",
|
||||
"SUCCEEDED",
|
||||
"FAILED",
|
||||
];
|
||||
|
||||
export default function SttVideosPage() {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [tag, setTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const [savingTags, setSavingTags] = useState(false);
|
||||
const fetching = useRef(false);
|
||||
const fetchVideos = useCallback(async () => {
|
||||
if (fetching.current) return;
|
||||
fetching.current = true;
|
||||
try {
|
||||
const response = await fetch("/api/admin/stt/videos", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("读取转写列表失败");
|
||||
const data: { videos: Video[] } = await response.json();
|
||||
setVideos(data.videos);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "读取失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
fetching.current = false;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void fetchVideos();
|
||||
const timer = setInterval(() => {
|
||||
void fetchVideos();
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchVideos]);
|
||||
|
||||
async function handleTranscribe(awemeId: string) {
|
||||
setSubmitting((prev) => new Set(prev).add(awemeId));
|
||||
try {
|
||||
const response = await fetch("/api/stt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ awemeId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "任务入队失败");
|
||||
setVideos((prev) =>
|
||||
prev.map((video) =>
|
||||
video.aweme_id === awemeId
|
||||
? { ...video, transcription: data }
|
||||
: video,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setNotice(err instanceof Error ? err.message : "任务入队失败");
|
||||
} finally {
|
||||
setSubmitting((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(awemeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
async function handleBatch() {
|
||||
setBatchSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/stt/batch", { method: "POST" });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "批量入队失败");
|
||||
setNotice(`已加入 ${data.queued} 个转写任务;关闭页面后仍会继续处理。`);
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
setNotice(err instanceof Error ? err.message : "批量入队失败");
|
||||
} finally {
|
||||
setBatchSubmitting(false);
|
||||
}
|
||||
}
|
||||
async function saveTags(clear = false) {
|
||||
setSavingTags(true);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
awemeIds: [...selected],
|
||||
contentTags: clear ? [] : [tag],
|
||||
mode: clear ? "set" : "add",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存手动标签失败");
|
||||
setNotice(`已更新 ${selected.size} 个视频的手动标签`);
|
||||
setSelected(new Set());
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
setNotice(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSavingTags(false);
|
||||
}
|
||||
}
|
||||
const count = (status: TranscriptionStatus) =>
|
||||
videos.filter((v) => v.transcription.status === status).length;
|
||||
const eligible = videos.filter(
|
||||
(v) =>
|
||||
v.transcription.transcript === null && !isActive(v.transcription.status),
|
||||
).length;
|
||||
const shown = videos.filter(
|
||||
(v) =>
|
||||
(filter === "all" || v.transcription.status === filter) &&
|
||||
(!tagFilter || v.content_tags.includes(tagFilter)),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-4">
|
||||
<BackButton />
|
||||
<h1 className="text-2xl font-semibold text-gray-900">视频文字转写</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "}
|
||||
{count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<output className="block text-sm text-gray-700">{notice}</output>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((value) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
onClick={() => setFilter(value)}
|
||||
className={`rounded px-3 py-2 text-sm ${filter === value ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
|
||||
>
|
||||
{value === "all"
|
||||
? `全部 (${videos.length})`
|
||||
: `${statusLabels[value]} (${count(value)})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatch}
|
||||
disabled={batchSubmitting || !eligible}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
|
||||
<select
|
||||
aria-label="筛选手动标签"
|
||||
value={tagFilter}
|
||||
onChange={(e) => {
|
||||
setTagFilter(e.target.value);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className="rounded border p-2"
|
||||
>
|
||||
<option value="">全部手动标签</option>
|
||||
{VIDEO_CONTENT_TAGS.map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
<span>已选 {selected.size} 个(最多 200 个)</span>
|
||||
<select
|
||||
aria-label="批量添加的标签"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
className="rounded border p-2"
|
||||
>
|
||||
{VIDEO_CONTENT_TAGS.map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selected.size || savingTags}
|
||||
onClick={() => saveTags()}
|
||||
className="rounded border px-3 py-2 disabled:opacity-50"
|
||||
>
|
||||
添加手动标签
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selected.size || savingTags}
|
||||
onClick={() => saveTags(true)}
|
||||
className="rounded border px-3 py-2 disabled:opacity-50"
|
||||
>
|
||||
清空手动标签
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-100 text-gray-600">
|
||||
<tr>
|
||||
<th className="p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="选择当前列表前200个视频"
|
||||
checked={
|
||||
shown.length > 0 &&
|
||||
shown.slice(0, 200).every((v) => selected.has(v.aweme_id))
|
||||
}
|
||||
onChange={(e) =>
|
||||
setSelected(
|
||||
e.target.checked
|
||||
? new Set(shown.slice(0, 200).map((v) => v.aweme_id))
|
||||
: new Set(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3">视频</th>
|
||||
<th className="p-3">时长</th>
|
||||
<th className="p-3">状态与文字</th>
|
||||
<th className="p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((video) => {
|
||||
const state = video.transcription;
|
||||
const active =
|
||||
isActive(state.status) || submitting.has(video.aweme_id);
|
||||
return (
|
||||
<tr
|
||||
key={video.aweme_id}
|
||||
className="border-t border-gray-100 align-top"
|
||||
>
|
||||
<td className="p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择视频 ${video.aweme_id}`}
|
||||
checked={selected.has(video.aweme_id)}
|
||||
disabled={
|
||||
!selected.has(video.aweme_id) && selected.size >= 200
|
||||
}
|
||||
onChange={(e) =>
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (e.target.checked) next.add(video.aweme_id);
|
||||
else next.delete(video.aweme_id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex max-w-sm gap-3">
|
||||
{video.cover_url && (
|
||||
<Image
|
||||
src={video.cover_url}
|
||||
alt=""
|
||||
width={56}
|
||||
height={80}
|
||||
unoptimized
|
||||
className="h-20 w-14 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Link
|
||||
className="line-clamp-2 text-blue-700"
|
||||
href={`/aweme/${video.aweme_id}`}
|
||||
>
|
||||
{video.desc || "无描述"}
|
||||
</Link>
|
||||
<p className="mt-1 text-gray-500">
|
||||
@{video.author.nickname}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-blue-700">
|
||||
{video.content_tags.join("、")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-3 text-gray-700">
|
||||
{Math.floor(video.duration_ms / 1000)} 秒
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p
|
||||
className={
|
||||
state.status === "FAILED"
|
||||
? "text-red-700"
|
||||
: "text-gray-700"
|
||||
}
|
||||
>
|
||||
{statusLabels[state.status]}
|
||||
</p>
|
||||
{state.error && (
|
||||
<p className="mt-1 max-w-lg text-xs text-red-700">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
{state.transcript !== null && (
|
||||
<p className="mt-1 max-w-lg whitespace-pre-wrap text-gray-600">
|
||||
{state.transcript.join("\n") || "未识别到文字"}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => handleTranscribe(video.aweme_id)}
|
||||
className="rounded bg-gray-100 px-3 py-2 text-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{active
|
||||
? "处理中…"
|
||||
: state.status === "FAILED"
|
||||
? "重试"
|
||||
: state.transcript !== null
|
||||
? "重新转写"
|
||||
: "开始转写"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!shown.length && (
|
||||
<p className="p-8 text-center text-gray-500">
|
||||
{loading ? "加载中…" : "没有找到视频"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from "../../stt/videos/route";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { transcriptionState } from "@/lib/stt/types";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const videos = await prisma.video.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
transcriptionTask: true,
|
||||
transcript: {
|
||||
select: {
|
||||
id: true,
|
||||
transcript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const formattedVideos = videos.map((v) => ({
|
||||
aweme_id: v.aweme_id,
|
||||
desc: v.desc,
|
||||
cover_url: v.cover_url ? getFileUrl(v.cover_url) : "",
|
||||
duration_ms: v.duration_ms,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
content_tags: v.manual_content_tags,
|
||||
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
videos: formattedVideos,
|
||||
total: videos.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch videos:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch videos" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET, POST } from "../stt/route";
|
||||
@@ -0,0 +1,121 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type UpdateMode = "set" | "add";
|
||||
|
||||
function uniqueValidTags(tags: unknown[]) {
|
||||
const validTags: string[] = [];
|
||||
for (const tag of tags) {
|
||||
if (typeof tag !== "string" || !isVideoContentTag(tag)) {
|
||||
throw new Error("Invalid tag");
|
||||
}
|
||||
if (!validTags.includes(tag)) validTags.push(tag);
|
||||
}
|
||||
return validTags;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const raw = body as {
|
||||
awemeId?: unknown;
|
||||
awemeIds?: unknown;
|
||||
tag?: unknown;
|
||||
contentTags?: unknown;
|
||||
mode?: unknown;
|
||||
};
|
||||
const awemeIds = Array.isArray(raw.awemeIds)
|
||||
? raw.awemeIds
|
||||
: typeof raw.awemeId === "string"
|
||||
? [raw.awemeId]
|
||||
: [];
|
||||
const normalizedAwemeIds = [...new Set(awemeIds)]
|
||||
.filter(
|
||||
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
||||
)
|
||||
.map((id) => id.trim());
|
||||
|
||||
if (normalizedAwemeIds.length === 0 || normalizedAwemeIds.length > 200) {
|
||||
return NextResponse.json(
|
||||
{ error: "awemeIds must contain 1 to 200 ids" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const mode: UpdateMode = raw.mode === "add" ? "add" : "set";
|
||||
const rawTags = Array.isArray(raw.contentTags)
|
||||
? raw.contentTags
|
||||
: typeof raw.tag === "string"
|
||||
? [raw.tag]
|
||||
: [];
|
||||
|
||||
let contentTags: string[];
|
||||
try {
|
||||
contentTags = uniqueValidTags(rawTags);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (mode === "add" && contentTags.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "add mode requires at least one tag" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const videos = await prisma.video.findMany({
|
||||
where: { aweme_id: { in: normalizedAwemeIds } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
manual_content_tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (videos.length === 0) {
|
||||
return NextResponse.json({ error: "No videos found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates = videos.map((video) => {
|
||||
const baseTags = video.manual_content_tags;
|
||||
const manualTags =
|
||||
mode === "add"
|
||||
? [...new Set([...baseTags, ...contentTags])]
|
||||
: contentTags;
|
||||
return { awemeId: video.aweme_id, manualTags };
|
||||
});
|
||||
|
||||
await prisma.$transaction(
|
||||
updates.map((update) =>
|
||||
prisma.video.update({
|
||||
where: { aweme_id: update.awemeId },
|
||||
data: { manual_content_tags: update.manualTags },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
updated: updates.map((update) => ({
|
||||
aweme_id: update.awemeId,
|
||||
manual_content_tags: update.manualTags,
|
||||
content_tags: update.manualTags,
|
||||
})),
|
||||
missing: normalizedAwemeIds.filter(
|
||||
(id) => !videos.some((video) => video.aweme_id === id),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ secUid: string }> },
|
||||
) {
|
||||
const secUid = (await params).secUid;
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limitParam = searchParams.get("limit");
|
||||
const beforeParam = searchParams.get("before");
|
||||
|
||||
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||
const before = beforeParam ? new Date(beforeParam) : null;
|
||||
|
||||
// fetch chunk from both tables
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: {
|
||||
authorId: secUid,
|
||||
...(before ? { created_at: { lt: before } } : {}),
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: {
|
||||
authorId: secUid,
|
||||
...(before ? { created_at: { lt: before } } : {}),
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const merged: FeedItem[] = [
|
||||
...videos.map((v) => ({
|
||||
type: "video" as const,
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: getFileUrl(v.video_url),
|
||||
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||
.slice(0, limit);
|
||||
|
||||
const nextCursor =
|
||||
merged.length > 0
|
||||
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
||||
: null;
|
||||
const payload: FeedResponse = { items: merged, nextCursor };
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
@@ -12,8 +12,14 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// Find current item timestamp from either table
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
||||
prisma.imagePost.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: awemeId },
|
||||
select: { aweme_id: true, created_at: true },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: awemeId },
|
||||
select: { aweme_id: true, created_at: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const current = video ?? post;
|
||||
@@ -52,25 +58,61 @@ export async function GET(req: NextRequest) {
|
||||
]);
|
||||
|
||||
const pickPrev = (() => {
|
||||
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
||||
if (newerVideo) cands.push({ type: "video", aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
|
||||
if (newerPost) cands.push({ type: "image", aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
|
||||
const cands: {
|
||||
type: "video" | "image";
|
||||
aweme_id: string;
|
||||
created_at: Date;
|
||||
}[] = [];
|
||||
if (newerVideo)
|
||||
cands.push({
|
||||
type: "video",
|
||||
aweme_id: newerVideo.aweme_id,
|
||||
created_at: newerVideo.created_at as unknown as Date,
|
||||
});
|
||||
if (newerPost)
|
||||
cands.push({
|
||||
type: "image",
|
||||
aweme_id: newerPost.aweme_id,
|
||||
created_at: newerPost.created_at as unknown as Date,
|
||||
});
|
||||
if (cands.length === 0) return undefined;
|
||||
// nearest newer -> minimal created_at
|
||||
cands.sort((a, b) => +a.created_at - +b.created_at);
|
||||
const h = cands[0];
|
||||
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
||||
return {
|
||||
type: h.type,
|
||||
aweme_id: h.aweme_id,
|
||||
created_at: h.created_at.toISOString(),
|
||||
};
|
||||
})();
|
||||
|
||||
const pickNext = (() => {
|
||||
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
||||
if (olderVideo) cands.push({ type: "video", aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
|
||||
if (olderPost) cands.push({ type: "image", aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
|
||||
const cands: {
|
||||
type: "video" | "image";
|
||||
aweme_id: string;
|
||||
created_at: Date;
|
||||
}[] = [];
|
||||
if (olderVideo)
|
||||
cands.push({
|
||||
type: "video",
|
||||
aweme_id: olderVideo.aweme_id,
|
||||
created_at: olderVideo.created_at as unknown as Date,
|
||||
});
|
||||
if (olderPost)
|
||||
cands.push({
|
||||
type: "image",
|
||||
aweme_id: olderPost.aweme_id,
|
||||
created_at: olderPost.created_at as unknown as Date,
|
||||
});
|
||||
if (cands.length === 0) return undefined;
|
||||
// nearest older -> maximal created_at among older
|
||||
cands.sort((a, b) => +b.created_at - +a.created_at);
|
||||
const h = cands[0];
|
||||
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
||||
return {
|
||||
type: h.type,
|
||||
aweme_id: h.aweme_id,
|
||||
created_at: h.created_at.toISOString(),
|
||||
};
|
||||
})();
|
||||
|
||||
return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null });
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ awemeId: string }> },
|
||||
) {
|
||||
const awemeId = (await params).awemeId;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const skip = parseInt(searchParams.get("skip") || "0", 10);
|
||||
const take = parseInt(searchParams.get("take") || "20", 10);
|
||||
|
||||
// ranked 模式参数(均为可选,提供合理默认值)
|
||||
const seed =
|
||||
searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
|
||||
const snapshotIso = searchParams.get("snapshot");
|
||||
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
|
||||
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
|
||||
const wPop = parseFloat(searchParams.get("wPop") || "1"); // 热度权重
|
||||
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
|
||||
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
|
||||
|
||||
try {
|
||||
// 查找是视频还是图文
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: awemeId },
|
||||
select: { aweme_id: true },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: awemeId },
|
||||
select: { aweme_id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!video && !post) {
|
||||
return NextResponse.json({ error: "作品不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const where = video ? { videoId: awemeId } : { imagePostId: awemeId };
|
||||
|
||||
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
|
||||
const total = await prisma.comment.count({ where });
|
||||
|
||||
// 计算稳定随机扰动:基于 (cid, seed) 的 md5 -> 取前4字节 -> 映射到 [0,1)
|
||||
const jitterExpr = Prisma.sql`(
|
||||
(
|
||||
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 0)::double precision * 16777216.0 +
|
||||
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 1)::double precision * 65536.0 +
|
||||
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 2)::double precision * 256.0 +
|
||||
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 3)::double precision
|
||||
) / 4294967295.0
|
||||
)`;
|
||||
|
||||
const snapshotTs = snapshot.toISOString();
|
||||
|
||||
// popularity: ln(1 + digg_count)
|
||||
// time decay: exp(- age_hours / halfLifeHours),使用固定 snapshot 基准保证会话内稳定
|
||||
const scoreExpr = Prisma.sql`(
|
||||
${wPop} * ln(1 + c."digg_count"::numeric) +
|
||||
${wTime} * exp(- (extract(epoch from (${snapshotTs}::timestamptz - c."created_at")) / 3600.0) / ${halfLifeHours}) +
|
||||
${wJit} * ${jitterExpr}
|
||||
)`;
|
||||
|
||||
const whereField = (await video)
|
||||
? Prisma.sql`c."videoId"`
|
||||
: Prisma.sql`c."imagePostId"`;
|
||||
|
||||
const rows: Array<{
|
||||
cid: string;
|
||||
text: string;
|
||||
created_at: Date;
|
||||
digg_count: bigint;
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
}> = await prisma.$queryRaw(
|
||||
Prisma.sql`
|
||||
SELECT
|
||||
c."cid",
|
||||
c."text",
|
||||
c."created_at",
|
||||
c."digg_count",
|
||||
u."nickname",
|
||||
u."avatar_url"
|
||||
FROM "Comment" c
|
||||
JOIN "CommentUser" u ON u."id" = c."userId"
|
||||
WHERE ${whereField} = ${awemeId}
|
||||
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
|
||||
OFFSET ${skip}
|
||||
LIMIT ${take}
|
||||
`,
|
||||
);
|
||||
|
||||
// 批量查询每条评论的配图/贴纸
|
||||
const cids = rows.map((r) => r.cid);
|
||||
const images = cids.length
|
||||
? await prisma.commentImage.findMany({
|
||||
where: { commentId: { in: cids } },
|
||||
orderBy: { order: "asc" },
|
||||
select: {
|
||||
commentId: true,
|
||||
url: true,
|
||||
width: true,
|
||||
height: true,
|
||||
order: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const group = new Map<
|
||||
string,
|
||||
{
|
||||
url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
order: number;
|
||||
}[]
|
||||
>();
|
||||
for (const img of images) {
|
||||
const arr = group.get(img.commentId) || [];
|
||||
arr.push({
|
||||
url: img.url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
order: img.order,
|
||||
});
|
||||
group.set(img.commentId, arr);
|
||||
}
|
||||
const formattedComments = rows.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: Number(c.digg_count),
|
||||
user: {
|
||||
nickname: c.nickname,
|
||||
avatar_url: getFileUrl(c.avatar_url || "default_avatar.png"),
|
||||
},
|
||||
images: (group.get(c.cid) || [])
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((i) => ({
|
||||
url: getFileUrl(i.url),
|
||||
width: i.width ?? undefined,
|
||||
height: i.height ?? undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
comments: formattedComments,
|
||||
total,
|
||||
hasMore: skip + take < total,
|
||||
// 回传用于稳定排序的参数,前端可在后续请求中透传
|
||||
seed,
|
||||
snapshot: snapshotTs,
|
||||
weights: { wPop, wTime, wJit, halfLifeHours },
|
||||
mode: "ranked",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取评论失败:", error);
|
||||
return NextResponse.json({ error: "获取评论失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+70
-32
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Contract
|
||||
// Inputs: search params { before?: ISOString, limit?: number }
|
||||
@@ -8,57 +10,93 @@ import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limitParam = searchParams.get('limit');
|
||||
const beforeParam = searchParams.get('before');
|
||||
const limitParam = searchParams.get("limit");
|
||||
const beforeParam = searchParams.get("before");
|
||||
const rawTag = (searchParams.get("tag") || "").trim();
|
||||
const selectedTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
|
||||
|
||||
const limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60
|
||||
if (rawTag && !selectedTag) {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||
const before = beforeParam ? new Date(beforeParam) : null;
|
||||
const videoWhere = {
|
||||
...(before ? { created_at: { lt: before } } : {}),
|
||||
...(selectedTag
|
||||
? {
|
||||
manual_content_tags: { has: selectedTag },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
// fetch chunk from both tables
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: 'desc' },
|
||||
where: videoWhere,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: limit,
|
||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
||||
include: {
|
||||
author: true,
|
||||
},
|
||||
}),
|
||||
selectedTag
|
||||
? Promise.resolve([])
|
||||
: prisma.imagePost.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
author: true,
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const merged: FeedItem[] = [
|
||||
...videos.map((v) => ({
|
||||
type: 'video' as const,
|
||||
type: "video" as const,
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: v.video_url,
|
||||
cover_url: v.cover_url ?? null,
|
||||
video_url: getFileUrl(v.video_url),
|
||||
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null },
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
content_tags: v.manual_content_tags,
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: 'image' as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: p.images?.[0]?.url ?? null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null },
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
...posts.map((p) => {
|
||||
const coverPath = p.images?.[0]?.url;
|
||||
return {
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
};
|
||||
}),
|
||||
]
|
||||
.sort((a, b) => +new Date(b.created_at as any) - +new Date(a.created_at as any))
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||
.slice(0, limit);
|
||||
|
||||
const nextCursor = merged.length > 0 ? new Date(merged[merged.length - 1].created_at as any).toISOString() : null;
|
||||
const nextCursor =
|
||||
merged.length > 0
|
||||
? new Date(merged[merged.length - 1].created_at).toISOString()
|
||||
: null;
|
||||
const payload: FeedResponse = { items: merged, nextCursor };
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// index.js
|
||||
import { chromium } from "playwright-extra";
|
||||
import stealth from "puppeteer-extra-plugin-stealth";
|
||||
|
||||
chromium.use(stealth());
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://bot.sannysoft.com/"); // 常用自测页
|
||||
console.log("Title:", await page.title());
|
||||
await page.screenshot({ path: "stealth.png", fullPage: true });
|
||||
setTimeout(async () => {
|
||||
await browser.close();
|
||||
}, 1000_000);
|
||||
})();
|
||||
+106
-45
@@ -1,79 +1,140 @@
|
||||
import { chromium, type BrowserContext } from 'playwright'
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// A simple singleton manager for a persistent Chromium context with ref-counting.
|
||||
// Prevents concurrent tasks from closing the shared window prematurely.
|
||||
import { type Browser, type BrowserContext } from "playwright";
|
||||
import { chromium } from "playwright-extra"; // A simple singleton manager for a persistent Chromium context with ref-counting. // Prevents concurrent tasks from closing the shared window prematurely.
|
||||
import stealth from "puppeteer-extra-plugin-stealth";
|
||||
chromium.use(stealth());
|
||||
|
||||
let contextPromise: Promise<BrowserContext> | null = null
|
||||
let context: BrowserContext | null = null
|
||||
let refCount = 0
|
||||
let idleCloseTimer: NodeJS.Timeout | null = null
|
||||
let contextPromise: Promise<BrowserContext> | null = null;
|
||||
let context: BrowserContext | null = null;
|
||||
let refCount = 0;
|
||||
let idleCloseTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const USER_DATA_DIR = 'chrome-profile/douyin'
|
||||
const USER_DATA_DIR = process.env.USER_DATA_DIR ?? "chrome-profile/douyin";
|
||||
|
||||
async function launchContext(): Promise<BrowserContext> {
|
||||
const ctx = await chromium.launchPersistentContext(
|
||||
USER_DATA_DIR,
|
||||
{
|
||||
headless: Boolean(process.env.CHROMIUM_HEADLESS ?? 'false'),
|
||||
viewport: {
|
||||
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
||||
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080)
|
||||
}
|
||||
}
|
||||
)
|
||||
const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, {
|
||||
headless: process.env.CHROMIUM_HEADLESS === "true",
|
||||
viewport: {
|
||||
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
||||
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
|
||||
},
|
||||
});
|
||||
// When the context is closed externally, reset manager state
|
||||
ctx.on('close', () => {
|
||||
context = null
|
||||
contextPromise = null
|
||||
refCount = 0
|
||||
ctx.on("close", () => {
|
||||
context = null;
|
||||
contextPromise = null;
|
||||
refCount = 0;
|
||||
if (idleCloseTimer) {
|
||||
clearTimeout(idleCloseTimer)
|
||||
idleCloseTimer = null
|
||||
clearTimeout(idleCloseTimer);
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
})
|
||||
return ctx
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function acquireBrowserContext(): Promise<BrowserContext> {
|
||||
// Cancel any pending idle close if a new consumer arrives
|
||||
if (idleCloseTimer) {
|
||||
clearTimeout(idleCloseTimer)
|
||||
idleCloseTimer = null
|
||||
clearTimeout(idleCloseTimer);
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
|
||||
if (context) {
|
||||
refCount += 1
|
||||
return context
|
||||
refCount += 1;
|
||||
return context;
|
||||
}
|
||||
|
||||
if (!contextPromise) {
|
||||
contextPromise = launchContext()
|
||||
contextPromise = launchContext();
|
||||
}
|
||||
context = await contextPromise
|
||||
refCount += 1
|
||||
return context
|
||||
context = await contextPromise;
|
||||
refCount += 1;
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise<void> {
|
||||
const idleMillis = options?.idleMillis ?? 15_000
|
||||
refCount = Math.max(0, refCount - 1)
|
||||
export async function releaseBrowserContext(options?: {
|
||||
idleMillis?: number;
|
||||
}): Promise<void> {
|
||||
const idleMillis = options?.idleMillis ?? 15_000;
|
||||
refCount = Math.max(0, refCount - 1);
|
||||
|
||||
if (refCount > 0 || !context) return
|
||||
if (refCount > 0 || !context) return;
|
||||
|
||||
// Delay the close to allow bursty workloads to reuse the context
|
||||
if (idleCloseTimer) {
|
||||
clearTimeout(idleCloseTimer)
|
||||
idleCloseTimer = null
|
||||
clearTimeout(idleCloseTimer);
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
idleCloseTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (context && refCount === 0) {
|
||||
await context.close()
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
context = null
|
||||
contextPromise = null
|
||||
idleCloseTimer = null
|
||||
context = null;
|
||||
contextPromise = null;
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
}, idleMillis)
|
||||
}, idleMillis);
|
||||
}
|
||||
|
||||
// --- Isolated context support for per-scrape independence ---
|
||||
|
||||
const isolatedMap = new WeakMap<BrowserContext, Browser>();
|
||||
|
||||
async function getSharedStorageState(): Promise<any | undefined> {
|
||||
try {
|
||||
const shared = await acquireBrowserContext();
|
||||
const state = await shared.storageState();
|
||||
// Do not force-close immediately; keep ref-counting behavior
|
||||
await releaseBrowserContext();
|
||||
return state;
|
||||
} catch {
|
||||
// If shared context not available, proceed without storageState
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand-new, independent Browser + Context for a single scrape.
|
||||
* Attempts to import cookies/localStorage from the shared persistent profile
|
||||
* so you remain logged-in, but isolates network events, cache and listeners.
|
||||
*/
|
||||
export async function acquireIsolatedContext(): Promise<BrowserContext> {
|
||||
const storageState = await getSharedStorageState();
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.CHROMIUM_HEADLESS === "true",
|
||||
});
|
||||
const ctx = await browser.newContext({
|
||||
storageState,
|
||||
viewport: {
|
||||
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
||||
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
|
||||
},
|
||||
});
|
||||
isolatedMap.set(ctx, browser);
|
||||
ctx.on("close", () => {
|
||||
const b = isolatedMap.get(ctx);
|
||||
if (b) {
|
||||
b.close().catch(() => {});
|
||||
isolatedMap.delete(ctx);
|
||||
}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function releaseIsolatedContext(
|
||||
ctx: BrowserContext | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!ctx) return;
|
||||
try {
|
||||
await ctx.close();
|
||||
} finally {
|
||||
const b = isolatedMap.get(ctx);
|
||||
if (b) {
|
||||
await b.close().catch(() => {});
|
||||
isolatedMap.delete(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+460
-224
@@ -1,246 +1,482 @@
|
||||
export const runtime = "nodejs";
|
||||
// src/scrapeDouyin.ts
|
||||
import { BrowserContext, Page, chromium, type Response } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils';
|
||||
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/api/fetcher/network';
|
||||
import { pickBestPlayAddr, extractFirstFrame } from '@/app/api/fetcher/media';
|
||||
import { handleImagePost } from '@/app/api/fetcher/uploader';
|
||||
import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist';
|
||||
import chalk from 'chalk';
|
||||
import { acquireBrowserContext, releaseBrowserContext } from '@/app/api/fetcher/browser';
|
||||
import { BrowserContext, Page, type Response } from "playwright";
|
||||
import { chromium } from "playwright-extra";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { uploadFile, generateUniqueFileName } from "@/lib/minio";
|
||||
import { createCamelCompatibleProxy } from "@/app/api/fetcher/utils";
|
||||
import {
|
||||
waitForFirstResponse,
|
||||
waitForResponseWithTimeout,
|
||||
safeJson,
|
||||
downloadBinary,
|
||||
collectResponsesWithinTime,
|
||||
} from "@/app/api/fetcher/network";
|
||||
import { pickBestPlayAddr } from "@/app/api/fetcher/media";
|
||||
import { handleImagePost } from "@/app/api/fetcher/uploader";
|
||||
import { saveToDB, saveImagePostToDB } from "@/app/api/fetcher/persist";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
acquireIsolatedContext,
|
||||
releaseIsolatedContext,
|
||||
} from "@/app/api/fetcher/browser";
|
||||
import { extractFirstFrame } from "@/app/api/media";
|
||||
import { enqueueTranscription } from "@/lib/stt/tasks";
|
||||
|
||||
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
||||
const DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
|
||||
const COMMENT_PATH = "/aweme/v1/web/comment/list/";
|
||||
const POST_PATH = "/aweme/v1/web/aweme/post/";
|
||||
|
||||
async function readPostMem(context: BrowserContext, page: Page) {
|
||||
const md = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
||||
// return {aweme: { detail: {} } };
|
||||
}).catch(() => null);
|
||||
/**
|
||||
* 滚动页面并收集评论
|
||||
* @param context 浏览器上下文
|
||||
* @param page 页面对象
|
||||
* @param durationMs 持续时间(毫秒)
|
||||
* @returns 收集到的所有评论响应
|
||||
*/
|
||||
async function scrollAndCollectComments(
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
durationMs: number = 10_000,
|
||||
): Promise<Response[]> {
|
||||
console.log(
|
||||
chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`),
|
||||
);
|
||||
|
||||
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
||||
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
||||
// 启动评论响应收集器
|
||||
const commentResponsesPromise = collectResponsesWithinTime(
|
||||
context,
|
||||
(r: Response) =>
|
||||
r.url().includes(COMMENT_PATH) &&
|
||||
r.status() === 200 &&
|
||||
r.request().frame()?.page() === page,
|
||||
durationMs,
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
aweme_mem.author = aweme_mem.authorInfo
|
||||
const comments = md.comment ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment) : null;
|
||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||
// 在指定时间内持续滚动页面
|
||||
const startTime = Date.now();
|
||||
const scrollInterval = 500;
|
||||
let scrollCount = 0;
|
||||
const selector = "div[data-e2e='comment-list']";
|
||||
|
||||
return { aweme, comments }
|
||||
// 1) 等元素出现并可见
|
||||
await page.waitForSelector(selector, { state: "visible", timeout: 5000 });
|
||||
|
||||
// 2) 确保滚动到可见区域
|
||||
const list = page.locator(selector);
|
||||
await list.scrollIntoViewIfNeeded();
|
||||
|
||||
// 3) 执行 hover(推荐用 locator 的 hover)
|
||||
list.hover({ timeout: 5000 }).catch(() => {});
|
||||
while (Date.now() - startTime < durationMs - 500) {
|
||||
// 留 500ms 缓冲
|
||||
try {
|
||||
list.hover({ timeout: 2000 }).catch(() => {});
|
||||
// 使用 Playwright 的 mouse.wheel 方法滚动
|
||||
// 每次滚动一大段距离
|
||||
// await list.hover();
|
||||
const scrollAmount = 1500;
|
||||
await page.mouse.wheel(0, scrollAmount);
|
||||
|
||||
scrollCount++;
|
||||
console.log(chalk.gray(` ↓ 第 ${scrollCount} 次滚动`));
|
||||
|
||||
// 等待一段时间,让评论加载
|
||||
await page.waitForTimeout(scrollInterval);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待收集器完成
|
||||
const commentResponses = await commentResponsesPromise;
|
||||
console.log(
|
||||
chalk.green(
|
||||
`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`,
|
||||
),
|
||||
);
|
||||
|
||||
return commentResponses;
|
||||
}
|
||||
|
||||
async function readPostMem(context: BrowserContext, page: Page) {
|
||||
const md = await page
|
||||
.evaluate(() => {
|
||||
const captured = (window as any).__pace_captured__ as Array<unknown[]>;
|
||||
let data = captured.find(
|
||||
(item) => typeof item[1] === "string" && item[1].includes(`"awemeId":`),
|
||||
)?.[1] as string | undefined;
|
||||
if (!data) return null;
|
||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ""));
|
||||
// return {aweme: { detail: {} } };
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
// await new Promise((res) => setTimeout(res, 1000000));
|
||||
|
||||
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
||||
if (!aweme_mem) throw new Error("页面内存数据中未找到作品详情");
|
||||
|
||||
// @ts-ignore
|
||||
aweme_mem.author = aweme_mem.authorInfo;
|
||||
// @ts-ignore
|
||||
aweme_mem.statistics = aweme_mem.stats;
|
||||
|
||||
const comments = md.comment
|
||||
? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment)
|
||||
: null;
|
||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||
|
||||
return { aweme, comments };
|
||||
}
|
||||
|
||||
export class ScrapeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number = 500,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ScrapeError';
|
||||
}
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number = 500,
|
||||
public code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ScrapeError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrapeDouyin(url: string) {
|
||||
console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...'));
|
||||
console.log(chalk.blue("🚀 启动共享 Chromium 浏览器..."));
|
||||
let context: BrowserContext | null = await acquireIsolatedContext();
|
||||
const page = await context.newPage();
|
||||
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
||||
|
||||
const context = await acquireBrowserContext();
|
||||
const page = await context.newPage();
|
||||
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
||||
await page.addInitScript(() => {
|
||||
// 建一个全局容器存捕获的数据
|
||||
(window as any).__pace_captured__ = [];
|
||||
|
||||
await page.addInitScript(() => {
|
||||
// 建一个全局容器存捕获的数据
|
||||
(window as any).__pace_captured__ = [];
|
||||
|
||||
// 用 Proxy 包装一个数组,拦截 push
|
||||
const captured = (window as any).__pace_captured__;
|
||||
const proxyArr = new Proxy([] as any[], {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'push') {
|
||||
return (...items: any[]) => {
|
||||
try { captured.push(...items); } catch { }
|
||||
return Array.prototype.push.apply(target, items);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
set(target, prop, value, receiver) {
|
||||
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
|
||||
if (prop === 'length') return Reflect.set(target, prop, value, receiver);
|
||||
return Reflect.set(target, prop, value, receiver);
|
||||
}
|
||||
});
|
||||
|
||||
(self as any).__pace_f = proxyArr;
|
||||
(window as any).__pace_f = proxyArr;
|
||||
// 用 Proxy 包装一个数组,拦截 push
|
||||
const captured = (window as any).__pace_captured__;
|
||||
const proxyArr = new Proxy([] as any[], {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "push") {
|
||||
return (...items: any[]) => {
|
||||
try {
|
||||
captured.push(...items);
|
||||
} catch {}
|
||||
return Array.prototype.push.apply(target, items);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
set(target, prop, value, receiver) {
|
||||
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
|
||||
if (prop === "length")
|
||||
return Reflect.set(target, prop, value, receiver);
|
||||
return Reflect.set(target, prop, value, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// 先注册“先到先得”的监听,再导航,避免漏包
|
||||
const firstTypePromise = waitForFirstResponse(context, [
|
||||
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 },
|
||||
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 },
|
||||
], 40_000);
|
||||
(self as any).__pace_f = proxyArr;
|
||||
(window as any).__pace_f = proxyArr;
|
||||
});
|
||||
|
||||
// 评论只做短时“有就用、没有不等”的监听
|
||||
const commentPromise = waitForResponseWithTimeout(
|
||||
context, (r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200, 40_000
|
||||
).catch(() => null);
|
||||
try {
|
||||
// 先注册“先到先得”的监听,再导航,避免漏包
|
||||
const firstTypePromise = waitForFirstResponse(
|
||||
context,
|
||||
[
|
||||
{
|
||||
key: "detail",
|
||||
test: (r: Response) =>
|
||||
r.url().includes(DETAIL_PATH) &&
|
||||
r.status() === 200 &&
|
||||
r.request().frame()?.page() === page,
|
||||
},
|
||||
{
|
||||
key: "post",
|
||||
test: (r: Response) =>
|
||||
r.url().includes(POST_PATH) &&
|
||||
r.status() === 200 &&
|
||||
r.request().frame()?.page() === page,
|
||||
},
|
||||
],
|
||||
10_000,
|
||||
);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
||||
|
||||
// 查找页面中是否存在 "视频不存在" 的提示
|
||||
const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false);
|
||||
if (isNotFound) {
|
||||
console.error(chalk.red('✗ 视频不存在或已被删除'));
|
||||
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND');
|
||||
}
|
||||
|
||||
try {
|
||||
// 优先尝试从内存读取图文数据
|
||||
let { aweme, comments } = await readPostMem(context, page);
|
||||
console.log(chalk.green('✓ 从内存读取图文数据成功'));
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
if (!comments) {
|
||||
console.warn(chalk.yellow('⚠ 从内存读取评论数据失败,尝试从网络请求获取评论数据'));
|
||||
|
||||
const commentRes = await commentPromise;
|
||||
comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
|
||||
if (!comments) {
|
||||
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
|
||||
comments = { comments: [], total: 0, status_code: 0 };
|
||||
} else {
|
||||
console.log(chalk.green('✓ 从网络请求获取评论数据成功'));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.green('✓ 从内存读取评论数据成功'));
|
||||
}
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
||||
return { type: "image", ...saved };
|
||||
} catch {
|
||||
|
||||
}
|
||||
|
||||
const commentRes = await commentPromise;
|
||||
const firstType = await firstTypePromise;
|
||||
|
||||
if (!firstType) {
|
||||
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据'));
|
||||
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA');
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType.key === 'post' ? '图文' : '视频')}`));
|
||||
|
||||
let comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
|
||||
if (!comments) {
|
||||
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
|
||||
comments = { comments: [], total: 0, status_code: 0 };
|
||||
}
|
||||
|
||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
||||
if (firstType.key === 'post') {
|
||||
// 图文作品
|
||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
||||
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE');
|
||||
|
||||
const currentURL = page.url();
|
||||
const target_aweme_id = currentURL.split('/').at(-1);
|
||||
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
||||
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
|
||||
if (!aweme) {
|
||||
throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND');
|
||||
}
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON
|
||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
||||
return { type: "image", ...saved };
|
||||
} else if (firstType.key === 'detail') {
|
||||
// 视频作品
|
||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
||||
|
||||
// 找到比特率最高的 url
|
||||
const bestPlayAddr = pickBestPlayAddr(
|
||||
detail?.aweme_detail?.video.bit_rate
|
||||
);
|
||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
||||
|
||||
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
||||
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`));
|
||||
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
||||
console.log(chalk.cyan(`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`));
|
||||
}
|
||||
|
||||
// 下载视频并上传至 MinIO,获取外链
|
||||
let uploadedUrl: string | undefined;
|
||||
let coverUrl: string | undefined;
|
||||
if (bestVUrl && detail?.aweme_detail) {
|
||||
console.log(chalk.blue('⬇️ 正在下载视频...'));
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
||||
const awemeId = detail.aweme_detail.aweme_id;
|
||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
||||
|
||||
console.log(chalk.blue('⬆️ 正在上传视频到 MinIO...'));
|
||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
console.log(chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`));
|
||||
|
||||
// 提取首帧作为封面并上传
|
||||
try {
|
||||
console.log(chalk.blue('🖼️ 正在提取视频封面...'));
|
||||
const cover = await extractFirstFrame(buffer);
|
||||
if (cover) {
|
||||
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
|
||||
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
|
||||
console.log(chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`));
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined);
|
||||
console.log(chalk.green.bold('✓ 视频作品保存成功'));
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE');
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果是我们自定义的错误,直接抛出
|
||||
if (error instanceof ScrapeError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 处理其他类型的错误
|
||||
const errMsg = (error as Error)?.message || String(error);
|
||||
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
||||
|
||||
// 根据错误类型返回不同的状态码
|
||||
if (errMsg.includes('timeout') || errMsg.includes('超时')) {
|
||||
throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT');
|
||||
}
|
||||
if (errMsg.includes('页面内存数据中未找到作品详情')) {
|
||||
throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED');
|
||||
}
|
||||
if (errMsg.includes('net::')) {
|
||||
throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR');
|
||||
}
|
||||
|
||||
// 默认服务器错误
|
||||
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR');
|
||||
} finally {
|
||||
console.log(chalk.gray('🧹 清理资源...'));
|
||||
try { await page.close({ runBeforeUnload: true }); } catch {}
|
||||
// 仅释放共享上下文的引用,不直接关闭窗口
|
||||
await releaseBrowserContext();
|
||||
await prisma.$disconnect();
|
||||
console.log(chalk.gray('✓ 资源清理完成'));
|
||||
// 查找页面中是否存在 "视频不存在" 的提示
|
||||
const isNotFound = await page
|
||||
.locator("text=视频不存在")
|
||||
.count()
|
||||
.then((count) => count > 0)
|
||||
.catch(() => false);
|
||||
if (isNotFound) {
|
||||
console.error(chalk.red("✗ 视频不存在或已被删除"));
|
||||
throw new ScrapeError("视频不存在或已被删除", 404, "VIDEO_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
// 等待作品类型判定
|
||||
const firstType = await firstTypePromise;
|
||||
|
||||
// 尝试从内存读取图文数据(如果是图文作品)
|
||||
let memoryData: {
|
||||
aweme: any;
|
||||
comments: DouyinCommentResponse | null;
|
||||
} | null = null;
|
||||
try {
|
||||
memoryData = await readPostMem(context, page);
|
||||
console.log(chalk.green("✓ 从内存读取图文数据成功"));
|
||||
} catch {
|
||||
// 内存读取失败,稍后通过网络获取
|
||||
}
|
||||
|
||||
if (!firstType && !memoryData) {
|
||||
console.error(chalk.red("✗ 既无法从内存读取数据,也无法从网络获得数据"));
|
||||
throw new ScrapeError(
|
||||
"无法获取作品数据,可能是网络问题或作品已下架",
|
||||
404,
|
||||
"NO_DATA",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`📡 检测到作品类型: ${chalk.bold(firstType?.key === "post" || memoryData ? "图文" : "视频")}`,
|
||||
),
|
||||
);
|
||||
|
||||
let allComments: DouyinComment[] = [];
|
||||
try {
|
||||
// 开始滚动并收集评论
|
||||
const commentResponses = await scrollAndCollectComments(context, page);
|
||||
|
||||
// 解析所有收集到的评论响应
|
||||
for (const commentRes of commentResponses) {
|
||||
try {
|
||||
const commentData = await safeJson<DouyinCommentResponse>(commentRes);
|
||||
if (commentData?.comments?.length) {
|
||||
allComments.push(...commentData.comments);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
// 去重评论(根据 cid)
|
||||
const uniqueComments = Array.from(
|
||||
new Map(allComments.map((c) => [c.cid, c])).values(),
|
||||
);
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length})`,
|
||||
),
|
||||
);
|
||||
|
||||
// 如果从内存读取到了评论,合并进来作为兜底
|
||||
let comments: DouyinCommentResponse;
|
||||
if (memoryData?.comments?.comments?.length) {
|
||||
console.log(
|
||||
chalk.blue(
|
||||
`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`,
|
||||
),
|
||||
);
|
||||
const memComments = memoryData.comments.comments;
|
||||
const mergedMap = new Map(uniqueComments.map((c) => [c.cid, c]));
|
||||
for (const c of memComments) {
|
||||
if (!mergedMap.has(c.cid)) {
|
||||
mergedMap.set(c.cid, c);
|
||||
}
|
||||
}
|
||||
comments = {
|
||||
comments: Array.from(mergedMap.values()),
|
||||
total: mergedMap.size,
|
||||
status_code: 0,
|
||||
};
|
||||
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
|
||||
} else {
|
||||
comments = {
|
||||
comments: uniqueComments,
|
||||
total: uniqueComments.length,
|
||||
status_code: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
||||
// 优先处理内存数据(图文)
|
||||
if (memoryData) {
|
||||
const aweme = memoryData.aweme;
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
||||
console.log(chalk.green.bold("✓ 图文作品保存成功"));
|
||||
return { type: "image", ...saved };
|
||||
} else if (firstType?.key === "post") {
|
||||
// 图文作品(网络)
|
||||
const postJson = await safeJson<DouyinPostListResponse>(
|
||||
firstType.response,
|
||||
);
|
||||
if (!postJson?.aweme_list?.length)
|
||||
throw new ScrapeError("图文作品响应为空", 404, "EMPTY_POST_RESPONSE");
|
||||
|
||||
const currentURL = page.url();
|
||||
const target_aweme_id = currentURL.split("/").at(-1);
|
||||
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
||||
let aweme = awemeList.find(
|
||||
(pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id,
|
||||
);
|
||||
if (!aweme) {
|
||||
throw new ScrapeError(
|
||||
"无法找到目标作品,可能已被删除",
|
||||
404,
|
||||
"POST_NOT_FOUND",
|
||||
);
|
||||
}
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(
|
||||
context,
|
||||
aweme,
|
||||
comments,
|
||||
uploads,
|
||||
postJson,
|
||||
); // 传递完整 JSON
|
||||
console.log(chalk.green.bold("✓ 图文作品保存成功"));
|
||||
return { type: "image", ...saved };
|
||||
} else if (firstType?.key === "detail") {
|
||||
// 视频作品
|
||||
const detail = (await safeJson<DouyinVideoDetailResponse>(
|
||||
firstType.response,
|
||||
))!;
|
||||
|
||||
// 找到比特率最高的 url
|
||||
const bestPlayAddr = pickBestPlayAddr(
|
||||
detail?.aweme_detail?.video.bit_rate,
|
||||
);
|
||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
||||
|
||||
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
||||
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || "N/A")} FPS`));
|
||||
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 下载视频并上传至 MinIO,获取外链
|
||||
let uploadedUrl: string | undefined;
|
||||
let coverUrl: string | undefined;
|
||||
if (bestVUrl && detail?.aweme_detail) {
|
||||
console.log(chalk.blue("⬇️ 正在下载视频..."));
|
||||
const { buffer, contentType, ext } = await downloadBinary(
|
||||
context,
|
||||
bestVUrl,
|
||||
);
|
||||
const awemeId = detail.aweme_detail.aweme_id;
|
||||
const fileName = generateUniqueFileName(
|
||||
`${awemeId}.${ext}`,
|
||||
"douyin/videos",
|
||||
);
|
||||
|
||||
console.log(chalk.blue("⬆️ 正在上传视频到 MinIO..."));
|
||||
uploadedUrl = await uploadFile(buffer, fileName, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
console.log(
|
||||
chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`),
|
||||
);
|
||||
|
||||
// 提取首帧作为封面并上传
|
||||
try {
|
||||
console.log(chalk.blue("🖼️ 正在提取视频封面..."));
|
||||
const cover = await extractFirstFrame(buffer);
|
||||
if (cover) {
|
||||
const coverName = generateUniqueFileName(
|
||||
`${awemeId}.jpg`,
|
||||
"douyin/covers",
|
||||
);
|
||||
coverUrl = await uploadFile(cover.buffer, coverName, {
|
||||
"Content-Type": cover.contentType,
|
||||
});
|
||||
console.log(
|
||||
chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await saveToDB(
|
||||
context,
|
||||
detail,
|
||||
comments,
|
||||
uploadedUrl,
|
||||
bestPlayAddr?.width,
|
||||
bestPlayAddr?.height,
|
||||
coverUrl,
|
||||
fps ?? undefined,
|
||||
);
|
||||
console.log(chalk.green.bold("✓ 视频作品保存成功"));
|
||||
try {
|
||||
await enqueueTranscription(detail.aweme_detail.aweme_id);
|
||||
} catch {
|
||||
console.error("视频已保存,但转写任务入队失败,可在后台批量补转");
|
||||
}
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new ScrapeError(
|
||||
"无法判定作品类型,接口响应异常",
|
||||
500,
|
||||
"UNKNOWN_TYPE",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果是我们自定义的错误,直接抛出
|
||||
if (error instanceof ScrapeError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 处理其他类型的错误
|
||||
const errMsg = (error as Error)?.message || String(error);
|
||||
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
||||
|
||||
// 根据错误类型返回不同的状态码
|
||||
if (errMsg.includes("timeout") || errMsg.includes("超时")) {
|
||||
throw new ScrapeError("请求超时,请稍后重试", 408, "TIMEOUT");
|
||||
}
|
||||
if (errMsg.includes("页面内存数据中未找到作品详情")) {
|
||||
throw new ScrapeError("作品数据加载失败", 404, "DATA_NOT_LOADED");
|
||||
}
|
||||
if (errMsg.includes("net::")) {
|
||||
throw new ScrapeError("网络连接失败", 503, "NETWORK_ERROR");
|
||||
}
|
||||
|
||||
// 默认服务器错误
|
||||
throw new ScrapeError(
|
||||
errMsg || "爬取过程中发生未知错误",
|
||||
500,
|
||||
"UNKNOWN_ERROR",
|
||||
);
|
||||
} finally {
|
||||
console.log(chalk.gray("🧹 清理资源..."));
|
||||
try {
|
||||
await page.close({ runBeforeUnload: true });
|
||||
} catch {}
|
||||
// 关闭本次任务的隔离上下文与浏览器
|
||||
await releaseIsolatedContext(context);
|
||||
await prisma.$disconnect();
|
||||
console.log(chalk.gray("✓ 资源清理完成"));
|
||||
}
|
||||
}
|
||||
|
||||
+10
-51
@@ -1,57 +1,16 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
|
||||
if (!variants?.length) return null;
|
||||
if (!variants?.length) return null;
|
||||
|
||||
const best = variants.reduce((best, cur) => {
|
||||
const b1 = best?.bit_rate ?? -1;
|
||||
const b2 = cur?.bit_rate ?? -1;
|
||||
return b2 > b1 ? cur : best;
|
||||
});
|
||||
const best = variants.reduce((best, cur) => {
|
||||
const b1 = best?.bit_rate ?? -1;
|
||||
const b2 = cur?.bit_rate ?? -1;
|
||||
return b2 > b1 ? cur : best;
|
||||
});
|
||||
|
||||
return best?.play_addr ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
||||
*/
|
||||
export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const tmpDir = os.tmpdir();
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||
const outPath = path.join(tmpDir, `${base}.jpg`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-ss', '0',
|
||||
'-i', inPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '2',
|
||||
'-f', 'image2',
|
||||
'-y',
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
const img = await fs.readFile(outPath);
|
||||
return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' };
|
||||
} catch (e: any) {
|
||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
||||
console.warn('系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。');
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
try { await fs.unlink(inPath); } catch { }
|
||||
try { await fs.unlink(outPath); } catch { }
|
||||
}
|
||||
return best?.play_addr ?? null;
|
||||
}
|
||||
|
||||
+141
-96
@@ -1,16 +1,18 @@
|
||||
import type { BrowserContext, Response } from 'playwright';
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import type { BrowserContext, Response } from "playwright";
|
||||
|
||||
export async function safeJson<T>(res: Response): Promise<T | null> {
|
||||
const ctype = res.headers()['content-type'] || '';
|
||||
if (ctype.includes('application/json')) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
const t = await res.text();
|
||||
try {
|
||||
return JSON.parse(t) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const ctype = res.headers()["content-type"] || "";
|
||||
if (ctype.includes("application/json")) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
const t = await res.text();
|
||||
try {
|
||||
return JSON.parse(t) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,30 +21,31 @@ export async function safeJson<T>(res: Response): Promise<T | null> {
|
||||
* - referrer 使用链接本身
|
||||
*/
|
||||
export async function downloadBinary(
|
||||
context: BrowserContext,
|
||||
url: string
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||
console.log('下载:', url);
|
||||
console.log("下载:", url);
|
||||
|
||||
const headers = {
|
||||
referer: url,
|
||||
} as Record<string, string>;
|
||||
const headers = {
|
||||
referer: "https://www.douyin.com/",
|
||||
} as Record<string, string>;
|
||||
|
||||
const res = await context.request.get(url, {
|
||||
headers,
|
||||
maxRedirects: 3,
|
||||
timeout: 240_000,
|
||||
failOnStatusCode: true,
|
||||
});
|
||||
const res = await context.request.get(url, {
|
||||
headers,
|
||||
maxRedirects: 3,
|
||||
timeout: 240_000,
|
||||
failOnStatusCode: true,
|
||||
});
|
||||
|
||||
if (!res.ok()) {
|
||||
throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`);
|
||||
}
|
||||
if (!res.ok()) {
|
||||
throw new Error(`下载内容失败: ${res.status()} ${res.statusText()}`);
|
||||
}
|
||||
|
||||
const buffer = await res.body();
|
||||
const contentType = res.headers()['content-type'] || 'application/octet-stream';
|
||||
const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin';
|
||||
return { buffer, contentType, ext };
|
||||
const buffer = await res.body();
|
||||
const contentType =
|
||||
res.headers()["content-type"] || "application/octet-stream";
|
||||
const ext = (contentType.split("/")[1] || "bin").split(";")[0] || "bin";
|
||||
return { buffer, contentType, ext };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,81 +53,123 @@ export async function downloadBinary(
|
||||
* - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。
|
||||
*/
|
||||
export function waitForFirstResponse(
|
||||
context: BrowserContext,
|
||||
candidates: { key: string; test: (r: Response) => boolean }[],
|
||||
timeoutMs = 20_000
|
||||
context: BrowserContext,
|
||||
candidates: { key: string; test: (r: Response) => boolean }[],
|
||||
timeoutMs = 20_000,
|
||||
): Promise<{ key: string; response: Response } | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const handler = (res: Response) => {
|
||||
if (resolved) return;
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (c.test(res)) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({ key: c.key, response: res });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore predicate errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
context.off('response', handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
context.on('response', handler);
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
}, timeoutMs);
|
||||
const handler = (res: Response) => {
|
||||
if (resolved) return;
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
if (c.test(res)) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve({ key: c.key, response: res });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore predicate errors
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
context.off("response", handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
context.on("response", handler);
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待符合条件的单个 Response,带短超时;用于评论等“可有可无”的数据。
|
||||
* 在指定时间内持续收集所有符合条件的 Response
|
||||
* 用于评论等需要滚动加载的数据
|
||||
*/
|
||||
export function collectResponsesWithinTime(
|
||||
context: BrowserContext,
|
||||
predicate: (r: Response) => boolean,
|
||||
durationMs: number,
|
||||
): Promise<Response[]> {
|
||||
return new Promise((resolve) => {
|
||||
const collected: Response[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const handler = (res: Response) => {
|
||||
try {
|
||||
if (predicate(res)) {
|
||||
// 使用 URL 去重,避免重复收集同一个请求
|
||||
const url = res.url();
|
||||
if (!seenUrls.has(url)) {
|
||||
seenUrls.add(url);
|
||||
collected.push(res);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore predicate errors
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
context.off("response", handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
context.on("response", handler);
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(collected);
|
||||
}, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待符合条件的单个 Response,带短超时;用于评论等"可有可无"的数据。
|
||||
*/
|
||||
export function waitForResponseWithTimeout(
|
||||
context: BrowserContext,
|
||||
predicate: (r: Response) => boolean,
|
||||
timeoutMs = 5_000
|
||||
context: BrowserContext,
|
||||
predicate: (r: Response) => boolean,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const handler = (res: Response) => {
|
||||
try {
|
||||
if (predicate(res)) {
|
||||
cleanup();
|
||||
resolve(res);
|
||||
}
|
||||
} catch {
|
||||
// ignore predicate errors
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
context.off('response', handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
context.on('response', handler);
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs);
|
||||
const handler = (res: Response) => {
|
||||
try {
|
||||
if (predicate(res)) {
|
||||
cleanup();
|
||||
resolve(res);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore predicate errors
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
context.off("response", handler);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
context.on("response", handler);
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("timeout"));
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+399
-245
@@ -1,273 +1,427 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadAvatarFromUrl } from './uploader';
|
||||
import { firstUrl } from './utils';
|
||||
import type { BrowserContext } from "playwright";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { uploadAvatarFromUrl, uploadImageFromUrl } from "./uploader";
|
||||
import { firstUrl } from "./utils";
|
||||
|
||||
export async function saveToDB(
|
||||
context: BrowserContext,
|
||||
detailResp: DouyinVideoDetailResponse,
|
||||
commentResp: DouyinCommentResponse,
|
||||
videoUrl?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
coverUrl?: string,
|
||||
fps?: number
|
||||
context: BrowserContext,
|
||||
detailResp: DouyinVideoDetailResponse,
|
||||
commentResp: DouyinCommentResponse,
|
||||
videoUrl?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
coverUrl?: string,
|
||||
fps?: number,
|
||||
) {
|
||||
if (!detailResp?.aweme_detail) throw new Error('视频详情为空');
|
||||
const d = detailResp.aweme_detail;
|
||||
if (!detailResp?.aweme_detail) throw new Error("视频详情为空");
|
||||
const d = detailResp.aweme_detail;
|
||||
|
||||
// 1) Upsert Author
|
||||
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: d.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: d.author.sec_uid,
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt(d.author.follower_count || 0),
|
||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||
unique_id: d.author.unique_id ?? null,
|
||||
short_id: d.author.short_id ?? null,
|
||||
},
|
||||
update: {
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt(d.author.follower_count || 0),
|
||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||
unique_id: d.author.unique_id ?? null,
|
||||
short_id: d.author.short_id ?? null,
|
||||
// 1) Upsert Author
|
||||
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(
|
||||
context,
|
||||
authorAvatarSrc,
|
||||
`authors/${d.author.sec_uid}`,
|
||||
);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: d.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: d.author.sec_uid,
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt(d.author.follower_count || 0),
|
||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||
unique_id: d.author.unique_id ?? null,
|
||||
short_id: d.author.short_id ?? null,
|
||||
},
|
||||
update: {
|
||||
uid: d.author.uid,
|
||||
nickname: d.author.nickname,
|
||||
signature: d.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt(d.author.follower_count || 0),
|
||||
total_favorited: BigInt(d.author.total_favorited || 0),
|
||||
unique_id: d.author.unique_id ?? null,
|
||||
short_id: d.author.short_id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// 2) Upsert Video
|
||||
const video = await prisma.video.upsert({
|
||||
where: { aweme_id: d.aweme_id },
|
||||
create: {
|
||||
aweme_id: d.aweme_id,
|
||||
desc: d.desc,
|
||||
preview_title: d.preview_title ?? null,
|
||||
duration_ms: d.duration,
|
||||
created_at: new Date((d.create_time || 0) * 1000),
|
||||
share_url: d.share_url,
|
||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||
share_count: BigInt(d.statistics?.share_count || 0),
|
||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: d.tags?.map((t) => t.tag_name) ?? [],
|
||||
video_url: videoUrl ?? "",
|
||||
width: width ?? null,
|
||||
height: height ?? null,
|
||||
cover_url: coverUrl ?? null,
|
||||
fps: fps ?? null,
|
||||
raw_json: detailResp as any, // 保存完整接口 JSON
|
||||
},
|
||||
update: {
|
||||
desc: d.desc,
|
||||
preview_title: d.preview_title ?? null,
|
||||
duration_ms: d.duration,
|
||||
created_at: new Date((d.create_time || 0) * 1000),
|
||||
share_url: d.share_url,
|
||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||
share_count: BigInt(d.statistics?.share_count || 0),
|
||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
...(videoUrl ? { video_url: videoUrl } : {}),
|
||||
...(width ? { width } : {}),
|
||||
...(height ? { height } : {}),
|
||||
...(coverUrl ? { cover_url: coverUrl } : {}),
|
||||
...(fps ? { fps } : {}),
|
||||
raw_json: detailResp as any, // 更新完整接口 JSON
|
||||
},
|
||||
});
|
||||
|
||||
// 3) Upsert Comments + CommentUser
|
||||
const comments = commentResp?.comments ?? [];
|
||||
for (const c of comments) {
|
||||
const origAvatar: string | null =
|
||||
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||
const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
|
||||
const uploadedAvatar = await uploadAvatarFromUrl(
|
||||
context,
|
||||
origAvatar ?? undefined,
|
||||
nameHint,
|
||||
);
|
||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||
const finalAvatarKey = finalAvatar ?? "";
|
||||
const cu = await prisma.commentUser.upsert({
|
||||
where: {
|
||||
nickname_avatar_url: {
|
||||
nickname: c.user?.nickname || "未知用户",
|
||||
avatar_url: finalAvatarKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
nickname: c.user?.nickname || "未知用户",
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
update: {
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// 2) Upsert Video
|
||||
const video = await prisma.video.upsert({
|
||||
where: { aweme_id: d.aweme_id },
|
||||
create: {
|
||||
aweme_id: d.aweme_id,
|
||||
desc: d.desc,
|
||||
preview_title: d.preview_title ?? null,
|
||||
duration_ms: d.duration,
|
||||
created_at: new Date((d.create_time || 0) * 1000),
|
||||
share_url: d.share_url,
|
||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||
share_count: BigInt(d.statistics?.share_count || 0),
|
||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: (d.tags?.map(t => t.tag_name) ?? []),
|
||||
video_url: videoUrl ?? '',
|
||||
width: width ?? null,
|
||||
height: height ?? null,
|
||||
cover_url: coverUrl ?? null,
|
||||
fps: fps ?? null,
|
||||
raw_json: detailResp as any, // 保存完整接口 JSON
|
||||
},
|
||||
update: {
|
||||
desc: d.desc,
|
||||
preview_title: d.preview_title ?? null,
|
||||
duration_ms: d.duration,
|
||||
created_at: new Date((d.create_time || 0) * 1000),
|
||||
share_url: d.share_url,
|
||||
digg_count: BigInt(d.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(d.statistics?.comment_count || 0),
|
||||
share_count: BigInt(d.statistics?.share_count || 0),
|
||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
...(videoUrl ? { video_url: videoUrl } : {}),
|
||||
...(width ? { width } : {}),
|
||||
...(height ? { height } : {}),
|
||||
...(coverUrl ? { cover_url: coverUrl } : {}),
|
||||
...(fps ? { fps } : {}),
|
||||
raw_json: detailResp as any, // 更新完整接口 JSON
|
||||
},
|
||||
const savedComment = await prisma.comment.upsert({
|
||||
where: { cid: c.cid },
|
||||
create: {
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
videoId: video.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
update: {
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
videoId: video.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 3) Upsert Comments + CommentUser
|
||||
const comments = commentResp?.comments ?? [];
|
||||
for (const c of comments) {
|
||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||
const finalAvatarKey = finalAvatar ?? '';
|
||||
const cu = await prisma.commentUser.upsert({
|
||||
where: {
|
||||
nickname_avatar_url: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatarKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
update: {
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
// 处理评论贴纸/配图上传与入库
|
||||
try {
|
||||
const sources: {
|
||||
url?: string | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}[] = [];
|
||||
// 贴纸(当作第一张)
|
||||
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
||||
if (stickerUrl) {
|
||||
sources.push({
|
||||
url: stickerUrl,
|
||||
width: c.sticker?.animate_url?.width,
|
||||
height: c.sticker?.animate_url?.height,
|
||||
});
|
||||
}
|
||||
// 配图列表
|
||||
const imgs = c.image_list || [];
|
||||
for (const it of imgs) {
|
||||
const u = firstUrl(it?.origin_url?.url_list);
|
||||
if (u)
|
||||
sources.push({
|
||||
url: u,
|
||||
width: it?.origin_url?.width as any,
|
||||
height: it?.origin_url?.height as any,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.comment.upsert({
|
||||
where: { cid: c.cid },
|
||||
create: {
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
videoId: video.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
update: {
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
videoId: video.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i];
|
||||
const uploaded = await uploadImageFromUrl(
|
||||
context,
|
||||
s.url ?? undefined,
|
||||
`comments/${c.cid}/${i}`,
|
||||
);
|
||||
if (!uploaded) continue;
|
||||
await prisma.commentImage.upsert({
|
||||
where: { commentId_order: { commentId: savedComment.cid, order: i } },
|
||||
create: {
|
||||
commentId: savedComment.cid,
|
||||
order: i,
|
||||
url: uploaded,
|
||||
width: typeof s.width === "number" ? s.width : null,
|
||||
height: typeof s.height === "number" ? s.height : null,
|
||||
},
|
||||
update: {
|
||||
url: uploaded,
|
||||
width: typeof s.width === "number" ? s.width : null,
|
||||
height: typeof s.height === "number" ? s.height : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length };
|
||||
return {
|
||||
aweme_id: video.aweme_id,
|
||||
author_sec_uid: author.sec_uid,
|
||||
comment_count: comments.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveImagePostToDB(
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme,
|
||||
commentResp: DouyinCommentResponse,
|
||||
uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string },
|
||||
rawJson?: any
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme,
|
||||
commentResp: DouyinCommentResponse,
|
||||
uploads: {
|
||||
images: {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
video?: string;
|
||||
duration?: number;
|
||||
}[];
|
||||
musicUrl?: string;
|
||||
},
|
||||
rawJson?: any,
|
||||
) {
|
||||
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
||||
if (!aweme?.author?.sec_uid) throw new Error("作者 sec_uid 缺失");
|
||||
|
||||
// Upsert Author(与视频一致)
|
||||
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: aweme.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: aweme.author.sec_uid,
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||
unique_id: (aweme.author as any).unique_id ?? null,
|
||||
short_id: (aweme.author as any).short_id ?? null,
|
||||
},
|
||||
update: {
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||
unique_id: (aweme.author as any).unique_id ?? null,
|
||||
short_id: (aweme.author as any).short_id ?? null,
|
||||
// Upsert Author(与视频一致)
|
||||
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
||||
const authorAvatarUploaded = await uploadAvatarFromUrl(
|
||||
context,
|
||||
authorAvatarSrc,
|
||||
`authors/${aweme.author.sec_uid}`,
|
||||
);
|
||||
const author = await prisma.author.upsert({
|
||||
where: { sec_uid: aweme.author.sec_uid },
|
||||
create: {
|
||||
sec_uid: aweme.author.sec_uid,
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||
unique_id: (aweme.author as any).unique_id ?? null,
|
||||
short_id: (aweme.author as any).short_id ?? null,
|
||||
},
|
||||
update: {
|
||||
uid: aweme.author.uid,
|
||||
nickname: aweme.author.nickname,
|
||||
signature: aweme.author.signature ?? null,
|
||||
avatar_url: authorAvatarUploaded ?? null,
|
||||
follower_count: BigInt((aweme.author as any).follower_count || 0),
|
||||
total_favorited: BigInt((aweme.author as any).total_favorited || 0),
|
||||
unique_id: (aweme.author as any).unique_id ?? null,
|
||||
short_id: (aweme.author as any).short_id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImagePost
|
||||
const imagePost = await prisma.imagePost.upsert({
|
||||
where: { aweme_id: aweme.aweme_id },
|
||||
create: {
|
||||
aweme_id: aweme.aweme_id,
|
||||
desc: aweme.desc,
|
||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||
share_url: aweme.share_url || "",
|
||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
|
||||
music_url: uploads.musicUrl ?? null,
|
||||
raw_json: rawJson ?? null, // 保存完整接口 JSON
|
||||
},
|
||||
update: {
|
||||
desc: aweme.desc,
|
||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||
share_url: aweme.share_url,
|
||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
|
||||
music_url: uploads.musicUrl ?? undefined,
|
||||
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImageFiles(按顺序)
|
||||
for (let i = 0; i < uploads.images.length; i++) {
|
||||
const { url, width, height, video, duration } = uploads.images[i];
|
||||
const durationMs =
|
||||
typeof duration === "number" && Number.isFinite(duration)
|
||||
? Math.max(1, Math.round(duration))
|
||||
: null;
|
||||
await prisma.imageFile.upsert({
|
||||
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
||||
create: {
|
||||
postId: imagePost.aweme_id,
|
||||
order: i,
|
||||
url,
|
||||
width: typeof width === "number" ? width : null,
|
||||
height: typeof height === "number" ? height : null,
|
||||
animated: video || null,
|
||||
duration: durationMs,
|
||||
},
|
||||
update: {
|
||||
url,
|
||||
width: typeof width === "number" ? width : null,
|
||||
height: typeof height === "number" ? height : null,
|
||||
animated: video || null,
|
||||
duration: durationMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 评论入库:关联到 ImagePost
|
||||
const comments = commentResp?.comments ?? [];
|
||||
for (const c of comments) {
|
||||
const origAvatar: string | null =
|
||||
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||
const nameHint = `comment-users/${(c.user?.nickname || "unknown").replace(/\s+/g, "_")}-${c.cid}`;
|
||||
const uploadedAvatar = await uploadAvatarFromUrl(
|
||||
context,
|
||||
origAvatar ?? undefined,
|
||||
nameHint,
|
||||
);
|
||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||
const finalAvatarKey = finalAvatar ?? "";
|
||||
const cu = await prisma.commentUser.upsert({
|
||||
where: {
|
||||
nickname_avatar_url: {
|
||||
nickname: c.user?.nickname || "未知用户",
|
||||
avatar_url: finalAvatarKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
nickname: c.user?.nickname || "未知用户",
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
update: {
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImagePost
|
||||
const imagePost = await prisma.imagePost.upsert({
|
||||
where: { aweme_id: aweme.aweme_id },
|
||||
create: {
|
||||
aweme_id: aweme.aweme_id,
|
||||
desc: aweme.desc,
|
||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||
share_url: aweme.share_url || '',
|
||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
||||
music_url: uploads.musicUrl ?? null,
|
||||
raw_json: rawJson ?? null, // 保存完整接口 JSON
|
||||
},
|
||||
update: {
|
||||
desc: aweme.desc,
|
||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||
share_url: aweme.share_url,
|
||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||
authorId: author.sec_uid,
|
||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
||||
music_url: uploads.musicUrl ?? undefined,
|
||||
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
|
||||
},
|
||||
const savedComment = await prisma.comment.upsert({
|
||||
where: { cid: c.cid },
|
||||
create: {
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
imagePostId: imagePost.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
update: {
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
imagePostId: imagePost.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImageFiles(按顺序)
|
||||
for (let i = 0; i < uploads.images.length; i++) {
|
||||
const { url, width, height, video } = uploads.images[i];
|
||||
await prisma.imageFile.upsert({
|
||||
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
||||
create: {
|
||||
postId: imagePost.aweme_id,
|
||||
order: i,
|
||||
url,
|
||||
width: typeof width === 'number' ? width : null,
|
||||
height: typeof height === 'number' ? height : null,
|
||||
animated: video || null,
|
||||
},
|
||||
update: {
|
||||
url,
|
||||
width: typeof width === 'number' ? width : null,
|
||||
height: typeof height === 'number' ? height : null,
|
||||
animated: video || null,
|
||||
},
|
||||
// 处理评论贴纸/配图上传与入库
|
||||
try {
|
||||
const sources: {
|
||||
url?: string | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}[] = [];
|
||||
// 贴纸(当作第一张)
|
||||
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
||||
if (stickerUrl) {
|
||||
sources.push({
|
||||
url: stickerUrl,
|
||||
width: c.sticker?.animate_url?.width,
|
||||
height: c.sticker?.animate_url?.height,
|
||||
});
|
||||
}
|
||||
// 配图列表
|
||||
const imgs = c.image_list || [];
|
||||
for (const it of imgs) {
|
||||
const u = firstUrl(it?.origin_url?.url_list);
|
||||
if (u)
|
||||
sources.push({
|
||||
url: u,
|
||||
width: it?.origin_url?.width as any,
|
||||
height: it?.origin_url?.height as any,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i];
|
||||
const uploaded = await uploadImageFromUrl(
|
||||
context,
|
||||
s.url ?? undefined,
|
||||
`comments/${c.cid}/${i}`,
|
||||
);
|
||||
if (!uploaded) continue;
|
||||
await prisma.commentImage.upsert({
|
||||
where: { commentId_order: { commentId: savedComment.cid, order: i } },
|
||||
create: {
|
||||
commentId: savedComment.cid,
|
||||
order: i,
|
||||
url: uploaded,
|
||||
width: typeof s.width === "number" ? s.width : null,
|
||||
height: typeof s.height === "number" ? s.height : null,
|
||||
},
|
||||
update: {
|
||||
url: uploaded,
|
||||
width: typeof s.width === "number" ? s.width : null,
|
||||
height: typeof s.height === "number" ? s.height : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// 评论入库:关联到 ImagePost
|
||||
const comments = commentResp?.comments ?? [];
|
||||
for (const c of comments) {
|
||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
||||
const finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||
const finalAvatarKey = finalAvatar ?? '';
|
||||
const cu = await prisma.commentUser.upsert({
|
||||
where: {
|
||||
nickname_avatar_url: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatarKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
nickname: c.user?.nickname || '未知用户',
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
update: {
|
||||
avatar_url: finalAvatar ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.comment.upsert({
|
||||
where: { cid: c.cid },
|
||||
create: {
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
imagePostId: imagePost.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
update: {
|
||||
text: c.text,
|
||||
digg_count: BigInt(c.digg_count || 0),
|
||||
created_at: new Date((c.create_time || 0) * 1000),
|
||||
imagePostId: imagePost.aweme_id,
|
||||
userId: cu.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { aweme_id: imagePost.aweme_id, author_sec_uid: author.sec_uid, image_count: uploads.images.length, comment_count: comments.length };
|
||||
return {
|
||||
aweme_id: imagePost.aweme_id,
|
||||
author_sec_uid: author.sec_uid,
|
||||
image_count: uploads.images.length,
|
||||
comment_count: comments.length,
|
||||
};
|
||||
}
|
||||
|
||||
+44
-42
@@ -1,49 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scrapeDouyin, ScrapeError } from '.';
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { scrapeDouyin, ScrapeError } from ".";
|
||||
|
||||
async function handleDouyinScrape(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const videoUrl = searchParams.get('url');
|
||||
const { searchParams } = new URL(req.url);
|
||||
const videoUrl = searchParams.get("url");
|
||||
|
||||
if (!videoUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少视频URL', code: 'MISSING_URL' },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (!videoUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "缺少视频URL", code: "MISSING_URL" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用爬虫函数
|
||||
const result = await scrapeDouyin(videoUrl);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
// 处理自定义的 ScrapeError
|
||||
if (error instanceof ScrapeError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
},
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用爬虫函数
|
||||
const result = await scrapeDouyin(videoUrl);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
// 处理自定义的 ScrapeError
|
||||
if (error instanceof ScrapeError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理未知错误
|
||||
console.error('未捕获的错误:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '服务器内部错误',
|
||||
code: 'INTERNAL_ERROR'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
// 处理未知错误
|
||||
console.error("未捕获的错误:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "服务器内部错误",
|
||||
code: "INTERNAL_ERROR",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handleDouyinScrape
|
||||
export const GET = handleDouyinScrape;
|
||||
|
||||
Vendored
+41
-25
@@ -12,6 +12,22 @@ interface DouyinComment {
|
||||
digg_count: number; // 点赞数
|
||||
create_time: number; // 创建时间(时间戳)
|
||||
user: DouyinUser; // 评论用户
|
||||
sticker?: {
|
||||
id: number;
|
||||
animate_url: {
|
||||
width: number;
|
||||
height: number;
|
||||
url_list: string[];
|
||||
};
|
||||
};
|
||||
|
||||
image_list?: {
|
||||
origin_url: {
|
||||
width: number;
|
||||
height: number;
|
||||
url_list: string[];
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
/** 用户信息(精简版) */
|
||||
@@ -29,35 +45,35 @@ interface DouyinVideoDetailResponse {
|
||||
}
|
||||
/** 作者信息(精简版) */
|
||||
interface DouyinAuthor {
|
||||
uid: string; // 用户ID
|
||||
sec_uid: string; // 安全UID
|
||||
nickname: string; // 用户昵称
|
||||
signature: string; // 个性签名
|
||||
uid: string; // 用户ID
|
||||
sec_uid: string; // 安全UID
|
||||
nickname: string; // 用户昵称
|
||||
signature: string; // 个性签名
|
||||
avatar_thumb: {
|
||||
url_list: string[]; // 头像URL(可取第一个)
|
||||
url_list: string[]; // 头像URL(可取第一个)
|
||||
};
|
||||
follower_count: number; // 粉丝数
|
||||
total_favorited: number; // 获赞总数
|
||||
unique_id: string; // 抖音号
|
||||
short_id: string; // 短ID
|
||||
follower_count: number; // 粉丝数
|
||||
total_favorited: number; // 获赞总数
|
||||
unique_id: string; // 抖音号
|
||||
short_id: string; // 短ID
|
||||
}
|
||||
/** 视频详情 */
|
||||
interface DouyinVideoDetail {
|
||||
aweme_id: string; // 视频ID
|
||||
desc: string; // 视频描述
|
||||
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
||||
duration: number; // 视频时长(毫秒)
|
||||
create_time: number; // 创建时间(时间戳)
|
||||
share_url: string; // 视频分享链接
|
||||
aweme_id: string; // 视频ID
|
||||
desc: string; // 视频描述
|
||||
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
||||
duration: number; // 视频时长(毫秒)
|
||||
create_time: number; // 创建时间(时间戳)
|
||||
share_url: string; // 视频分享链接
|
||||
|
||||
statistics: {
|
||||
digg_count: number; // 点赞数
|
||||
comment_count: number; // 评论数
|
||||
share_count: number; // 分享数
|
||||
collect_count: number; // 收藏数
|
||||
digg_count: number; // 点赞数
|
||||
comment_count: number; // 评论数
|
||||
share_count: number; // 分享数
|
||||
collect_count: number; // 收藏数
|
||||
};
|
||||
|
||||
author: DouyinAuthor; // 作者信息
|
||||
author: DouyinAuthor; // 作者信息
|
||||
video: VideoPlayBasic;
|
||||
tags: VideoTagBasic[];
|
||||
}
|
||||
@@ -72,9 +88,9 @@ interface VideoPlayBasic {
|
||||
|
||||
/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */
|
||||
interface PlayVariant {
|
||||
format: string; // mp4 等
|
||||
format: string; // mp4 等
|
||||
FPS: number;
|
||||
bit_rate: number; // bit_rate.bit_rate
|
||||
bit_rate: number; // bit_rate.bit_rate
|
||||
|
||||
/** 直连播放地址(最关键) */
|
||||
play_addr: {
|
||||
@@ -85,7 +101,7 @@ interface PlayVariant {
|
||||
data_size: number;
|
||||
FPS: number;
|
||||
is_bytevc1: number; // 0 or 1
|
||||
is_h265: number; // 0 or 1
|
||||
is_h265: number; // 0 or 1
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,7 +132,7 @@ interface DouyinImageAweme {
|
||||
};
|
||||
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
||||
images: DouyinImageInfo[]; // 图片列表
|
||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||
video_tag?: VideoTagBasic[]; // 标签
|
||||
}
|
||||
|
||||
@@ -127,7 +143,7 @@ interface DouyinImageInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
video: {
|
||||
play_addr: { src: string }[]
|
||||
play_addr: { src: string }[];
|
||||
} | null; // 如果是动图,会有 video 信息
|
||||
}
|
||||
|
||||
|
||||
+158
-60
@@ -1,77 +1,175 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { downloadBinary } from './network';
|
||||
import { pickFirstUrl } from './utils';
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import type { BrowserContext } from "playwright";
|
||||
import { uploadFile, generateUniqueFileName } from "@/lib/minio";
|
||||
import { downloadBinary } from "./network";
|
||||
import { pickFirstUrl } from "./utils";
|
||||
import { getVideoDuration } from "@/app/api/media";
|
||||
|
||||
/**
|
||||
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||
*/
|
||||
export async function uploadAvatarFromUrl(
|
||||
context: BrowserContext,
|
||||
srcUrl?: string | null,
|
||||
nameHint?: string
|
||||
context: BrowserContext,
|
||||
srcUrl?: string | null,
|
||||
nameHint?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!srcUrl) return undefined;
|
||||
try {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||
const safeExt = ext || 'jpg';
|
||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
||||
const fileName = generateUniqueFileName(baseName, 'douyin/avatars');
|
||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
return uploaded;
|
||||
} catch (e) {
|
||||
console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e);
|
||||
return srcUrl || undefined;
|
||||
}
|
||||
if (!srcUrl) return undefined;
|
||||
try {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||
const safeExt = ext || "jpg";
|
||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
||||
const fileName = generateUniqueFileName(baseName, "douyin/avatars");
|
||||
const uploaded = await uploadFile(buffer, fileName, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
return uploaded;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[avatar] 上传失败,使用原始链接:",
|
||||
(e as Error)?.message || e,
|
||||
);
|
||||
return srcUrl || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载任意图片并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||
*/
|
||||
export async function uploadImageFromUrl(
|
||||
context: BrowserContext,
|
||||
srcUrl?: string | null,
|
||||
nameHint?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!srcUrl) return undefined;
|
||||
try {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||
const safeExt = ext || "jpg";
|
||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
|
||||
const fileName = generateUniqueFileName(baseName, "douyin/comment-images");
|
||||
const uploaded = await uploadFile(buffer, fileName, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
return uploaded;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[image] 上传失败,使用原始链接:",
|
||||
(e as Error)?.message || e,
|
||||
);
|
||||
return srcUrl || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载图文作品的图片和音乐并上传到 MinIO */
|
||||
export async function handleImagePost(
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme
|
||||
): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> {
|
||||
const awemeId = aweme.aweme_id;
|
||||
const uploadedImages: { url: string; width?: number; height?: number, video?: string }[] = [];
|
||||
context: BrowserContext,
|
||||
aweme: DouyinImageAweme,
|
||||
): Promise<{
|
||||
images: {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
video?: string;
|
||||
duration?: number;
|
||||
}[];
|
||||
musicUrl?: string;
|
||||
}> {
|
||||
const awemeId = aweme.aweme_id;
|
||||
const uploadedImages: {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
video?: string;
|
||||
duration?: number;
|
||||
}[] = [];
|
||||
|
||||
// 下载图片(顺序保持)
|
||||
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
||||
const img = aweme.images[i];
|
||||
const url = pickFirstUrl(img?.url_list);
|
||||
if (!url) continue;
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
||||
const safeExt = ext || 'jpg';
|
||||
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images');
|
||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
// 下载图片(顺序保持)
|
||||
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
||||
const img = aweme.images[i];
|
||||
const url = pickFirstUrl(img?.url_list);
|
||||
if (!url) continue;
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
||||
const safeExt = ext || "jpg";
|
||||
const fileName = generateUniqueFileName(
|
||||
`${awemeId}/${i}.${safeExt}`,
|
||||
"douyin/images",
|
||||
);
|
||||
const uploaded = await uploadFile(buffer, fileName, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
|
||||
if (img.video?.play_addr) {
|
||||
// 如果是动图,下载 video 并上传
|
||||
const videoUrl = img.video.play_addr[0]?.src;
|
||||
if (videoUrl) {
|
||||
try {
|
||||
const { buffer: videoBuffer, contentType: videoContentType, ext: videoExt } = await downloadBinary(context, videoUrl);
|
||||
const safeVideoExt = videoExt || 'mp4';
|
||||
const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images');
|
||||
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': videoContentType });
|
||||
// 将动图的 video URL 也存储起来
|
||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height, video: uploadedVideo });
|
||||
} catch (e) {
|
||||
console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
|
||||
if (img.video?.play_addr) {
|
||||
// 如果是动图,下载 video 并上传
|
||||
const videoUrl = img.video.play_addr[0]?.src;
|
||||
if (videoUrl) {
|
||||
try {
|
||||
const {
|
||||
buffer: videoBuffer,
|
||||
contentType: videoContentType,
|
||||
ext: videoExt,
|
||||
} = await downloadBinary(context, videoUrl);
|
||||
const safeVideoExt = videoExt || "mp4";
|
||||
const videoFileName = generateUniqueFileName(
|
||||
`${awemeId}/${i}_animated.${safeVideoExt}`,
|
||||
"douyin/images",
|
||||
);
|
||||
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, {
|
||||
"Content-Type": videoContentType,
|
||||
});
|
||||
|
||||
// 获取动图时长
|
||||
const duration = await getVideoDuration(videoBuffer);
|
||||
|
||||
// 将动图的 video URL 和 duration 也存储起来
|
||||
uploadedImages.push({
|
||||
url: uploaded,
|
||||
width: img?.width,
|
||||
height: img?.height,
|
||||
video: uploadedVideo,
|
||||
duration: duration ?? undefined,
|
||||
});
|
||||
|
||||
if (duration) {
|
||||
console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[image] 动图视频上传失败,跳过:`,
|
||||
(e as Error)?.message || e,
|
||||
);
|
||||
uploadedImages.push({
|
||||
url: uploaded,
|
||||
width: img?.width,
|
||||
height: img?.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploadedImages.push({
|
||||
url: uploaded,
|
||||
width: img?.width,
|
||||
height: img?.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 下载音乐(可选)
|
||||
let musicUrl: string | undefined;
|
||||
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
||||
if (audioSrc) {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, audioSrc);
|
||||
const safeExt = ext || 'mp3';
|
||||
const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios');
|
||||
musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
}
|
||||
// 下载音乐(可选)
|
||||
let musicUrl: string | undefined;
|
||||
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
||||
if (audioSrc) {
|
||||
const { buffer, contentType, ext } = await downloadBinary(
|
||||
context,
|
||||
audioSrc,
|
||||
);
|
||||
const safeExt = ext || "mp3";
|
||||
const fileName = generateUniqueFileName(
|
||||
`${awemeId}.${safeExt}`,
|
||||
"douyin/audios",
|
||||
);
|
||||
musicUrl = await uploadFile(buffer, fileName, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
}
|
||||
|
||||
return { images: uploadedImages, musicUrl };
|
||||
return { images: uploadedImages, musicUrl };
|
||||
}
|
||||
|
||||
+39
-36
@@ -1,9 +1,11 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function toCamelCaseKey(key: string): string {
|
||||
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
||||
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function toSnakeCaseKey(key: string): string {
|
||||
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11,52 +13,53 @@ export function toSnakeCaseKey(key: string): string {
|
||||
* 访问顺序:原名 -> camelCase -> snake_case
|
||||
*/
|
||||
export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
||||
const seen = new WeakMap<object, any>();
|
||||
const seen = new WeakMap<object, any>();
|
||||
|
||||
const wrap = (value: any): any => {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return seen.get(value);
|
||||
const proxied = new Proxy(value, handler);
|
||||
seen.set(value, proxied);
|
||||
return proxied;
|
||||
};
|
||||
const wrap = (value: any): any => {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (seen.has(value)) return seen.get(value);
|
||||
const proxied = new Proxy(value, handler);
|
||||
seen.set(value, proxied);
|
||||
return proxied;
|
||||
};
|
||||
|
||||
const handler: ProxyHandler<any> = {
|
||||
get(target, prop, receiver) {
|
||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||
if (typeof prop !== 'string') {
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
}
|
||||
const handler: ProxyHandler<any> = {
|
||||
get(target, prop, receiver) {
|
||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||
if (typeof prop !== "string") {
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
}
|
||||
|
||||
const primary = prop;
|
||||
const primary = prop;
|
||||
|
||||
if (primary in target) return wrap(Reflect.get(target, primary, receiver));
|
||||
if (primary in target)
|
||||
return wrap(Reflect.get(target, primary, receiver));
|
||||
|
||||
const camel = toCamelCaseKey(primary);
|
||||
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
||||
const camel = toCamelCaseKey(primary);
|
||||
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
||||
|
||||
const snake = toSnakeCaseKey(primary);
|
||||
if (snake in target) return wrap(Reflect.get(target, snake, receiver));
|
||||
const snake = toSnakeCaseKey(primary);
|
||||
if (snake in target) return wrap(Reflect.get(target, snake, receiver));
|
||||
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
},
|
||||
has(target, prop) {
|
||||
if (typeof prop !== 'string') return prop in target;
|
||||
const primary = prop === 'auther' ? 'autherInfo' : prop;
|
||||
return (
|
||||
primary in target ||
|
||||
toCamelCaseKey(primary) in target ||
|
||||
toSnakeCaseKey(primary) in target
|
||||
);
|
||||
}
|
||||
};
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
},
|
||||
has(target, prop) {
|
||||
if (typeof prop !== "string") return prop in target;
|
||||
const primary = prop === "auther" ? "autherInfo" : prop;
|
||||
return (
|
||||
primary in target ||
|
||||
toCamelCaseKey(primary) in target ||
|
||||
toSnakeCaseKey(primary) in target
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return wrap(root);
|
||||
return wrap(root);
|
||||
}
|
||||
|
||||
/** 选择首个可用 URL */
|
||||
export function pickFirstUrl(list?: string[]) {
|
||||
return Array.isArray(list) && list.length ? list[0] : undefined;
|
||||
return Array.isArray(list) && list.length ? list[0] : undefined;
|
||||
}
|
||||
|
||||
// 别名,兼容旧命名
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { normalizeHashtag } from "@/lib/hashtag";
|
||||
import { relatedHashtags } from "@/lib/hashtag-related.server";
|
||||
import { json } from "@/lib/json";
|
||||
|
||||
// 相关 hashtag(按共现计算):GET /api/hashtag/related?name=丰川祥子
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const name = normalizeHashtag(searchParams.get("name") || "");
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Invalid hashtag" }, { status: 400 });
|
||||
}
|
||||
const limit = Math.min(
|
||||
30,
|
||||
Math.max(1, Math.floor(Number(searchParams.get("limit")) || 12)),
|
||||
);
|
||||
try {
|
||||
return json(await relatedHashtags(name, limit));
|
||||
} catch (err) {
|
||||
console.error("Related hashtags error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Related hashtags failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -0,0 +1,224 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
||||
*/
|
||||
export async function extractFirstFrame(
|
||||
videoBuffer: Buffer,
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||
const tmpDir = os.tmpdir();
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||
const outPath = path.join(tmpDir, `${base}.jpg`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
"0",
|
||||
"-i",
|
||||
inPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-f",
|
||||
"image2",
|
||||
"-y",
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
const img = await fs.readFile(outPath);
|
||||
return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e &&
|
||||
(e.code === "ENOENT" ||
|
||||
/not found|is not recognized/i.test(String(e.message)))
|
||||
) {
|
||||
console.warn(
|
||||
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(inPath);
|
||||
} catch {}
|
||||
try {
|
||||
await fs.unlink(outPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffprobe 获取视频时长(毫秒)
|
||||
*/
|
||||
export async function getVideoDuration(
|
||||
videoBuffer: Buffer,
|
||||
): Promise<number | null> {
|
||||
const ffprobeCmd = process.env.FFPROBE_PATH || "ffprobe";
|
||||
const tmpDir = os.tmpdir();
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
inPath,
|
||||
];
|
||||
const { stdout } = await execFileAsync(ffprobeCmd, args, {
|
||||
windowsHide: true,
|
||||
});
|
||||
const durationSeconds = parseFloat(stdout.trim());
|
||||
if (isNaN(durationSeconds)) return null;
|
||||
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e &&
|
||||
(e.code === "ENOENT" ||
|
||||
/not found|is not recognized/i.test(String(e.message)))
|
||||
) {
|
||||
console.warn(
|
||||
"系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
console.warn(`获取视频时长失败: ${e?.message || e}`);
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(inPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 将视频 Buffer 或本地文件转换为音频,返回音频 Buffer
|
||||
*
|
||||
* 默认输出 mp3,可通过 opts.format 指定 'mp3' | 'aac' | 'wav'
|
||||
* - mp3: 使用 libmp3lame,16 kHz 单声道,contentType 为 'audio/mpeg'
|
||||
* - aac: 输出 m4a 容器(AAC 编码),contentType 为 'audio/mp4'
|
||||
* - wav: PCM 16-bit,44100Hz,双声道,contentType 为 'audio/wav'
|
||||
*
|
||||
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
|
||||
*/
|
||||
export async function extractAudio(
|
||||
videoBuffer: Buffer | string,
|
||||
opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||
const tmpDir = os.tmpdir();
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||
|
||||
const inputPath = typeof videoBuffer === "string" ? videoBuffer : inPath;
|
||||
const format = opts?.format ?? "mp3";
|
||||
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
|
||||
|
||||
// 根据目标格式设置输出路径、MIME 与编码参数
|
||||
let outPath = "";
|
||||
let contentType = "";
|
||||
let ext = "";
|
||||
let codecArgs: string[] = [];
|
||||
|
||||
if (format === "mp3") {
|
||||
ext = "mp3";
|
||||
contentType = "audio/mpeg";
|
||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||
codecArgs = ["-c:a", "libmp3lame", "-b:a", `${bitrate}k`];
|
||||
} else if (format === "aac") {
|
||||
// 使用 m4a 容器更通用
|
||||
ext = "m4a";
|
||||
contentType = "audio/mp4";
|
||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||
codecArgs = [
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
`${bitrate}k`,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
];
|
||||
} else {
|
||||
// wav
|
||||
ext = "wav";
|
||||
contentType = "audio/wav";
|
||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||
codecArgs = [
|
||||
"-f",
|
||||
"wav",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-ac",
|
||||
"2",
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof videoBuffer !== "string") await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-vn", // 丢弃视频流
|
||||
...codecArgs,
|
||||
"-y",
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, {
|
||||
windowsHide: true,
|
||||
timeout: 60 * 60 * 1000,
|
||||
});
|
||||
const audio = await fs.readFile(outPath);
|
||||
return { buffer: audio, contentType, ext };
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e &&
|
||||
(e.code === "ENOENT" ||
|
||||
/not found|is not recognized/i.test(String(e.message)))
|
||||
) {
|
||||
console.warn(
|
||||
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
||||
console.warn(`提取音频失败: ${e?.message || e}`);
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(inPath);
|
||||
} catch {}
|
||||
try {
|
||||
if (outPath) await fs.unlink(outPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { json } from "@/lib/json";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { buildSnippet, highlightAll, parseSearchQuery } from "@/lib/search";
|
||||
import { searchRows } from "@/lib/search-query";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = (searchParams.get("q") || "").trim();
|
||||
const rawTag = (searchParams.get("tag") || "").trim();
|
||||
const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
|
||||
const page = Math.max(1, Math.floor(Number(searchParams.get("page")) || 1));
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Math.floor(Number(searchParams.get("limit")) || 20)),
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
if (rawTag && !contentTag) {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { terms, excludes } = parseSearchQuery(q);
|
||||
if (terms.length === 0 && !contentTag) {
|
||||
return json({ results: [], total: 0, page, limit, terms });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await searchRows(
|
||||
{ terms, excludes, contentTag },
|
||||
offset,
|
||||
limit,
|
||||
);
|
||||
|
||||
const videoIds = rows
|
||||
.filter((r) => r.type === "video")
|
||||
.map((r) => r.awemeId);
|
||||
const imagePostIds = rows
|
||||
.filter((r) => r.type === "image")
|
||||
.map((r) => r.awemeId);
|
||||
|
||||
const [videos, imagePosts] = await Promise.all([
|
||||
videoIds.length > 0
|
||||
? prisma.video.findMany({
|
||||
where: { aweme_id: { in: videoIds } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
desc: true,
|
||||
cover_url: true,
|
||||
video_url: true,
|
||||
duration_ms: true,
|
||||
author: true,
|
||||
},
|
||||
})
|
||||
: [],
|
||||
imagePostIds.length > 0
|
||||
? prisma.imagePost.findMany({
|
||||
where: { aweme_id: { in: imagePostIds } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
desc: true,
|
||||
author: true,
|
||||
images: {
|
||||
orderBy: { order: "asc" },
|
||||
take: 1,
|
||||
select: { url: true, width: true, height: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const videoMap = new Map(
|
||||
videos.map((v) => [
|
||||
v.aweme_id,
|
||||
{
|
||||
...v,
|
||||
cover_url: getFileUrl(v.cover_url || ""),
|
||||
video_url: getFileUrl(v.video_url || ""),
|
||||
author: {
|
||||
...v.author,
|
||||
avatar_url: getFileUrl(v.author.avatar_url || ""),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const imagePostMap = new Map(
|
||||
imagePosts.map((ip) => [
|
||||
ip.aweme_id,
|
||||
{
|
||||
...ip,
|
||||
author: {
|
||||
...ip.author,
|
||||
avatar_url: getFileUrl(ip.author.avatar_url || ""),
|
||||
},
|
||||
cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return json({
|
||||
results: rows.map((r) => {
|
||||
const matchedIn: ("desc" | "transcript")[] = [];
|
||||
if (r.inDesc) matchedIn.push("desc");
|
||||
if (r.inTranscript) matchedIn.push("transcript");
|
||||
return {
|
||||
id: `${r.type}:${r.awemeId}`,
|
||||
awemeId: r.awemeId,
|
||||
type: r.type,
|
||||
score: r.score,
|
||||
matchedIn,
|
||||
contentTags: r.contentTags,
|
||||
descParts: highlightAll(r.rawDesc, terms),
|
||||
// 转写命中时给出命中片段;只按标签筛选时给出转写开头作为预览
|
||||
snippet:
|
||||
r.inTranscript || (terms.length === 0 && r.rawTranscript)
|
||||
? buildSnippet(r.rawTranscript, terms)
|
||||
: null,
|
||||
video: r.type === "video" ? videoMap.get(r.awemeId) : undefined,
|
||||
imagePost:
|
||||
r.type === "image" ? imagePostMap.get(r.awemeId) : undefined,
|
||||
};
|
||||
}),
|
||||
total: rows[0]?.total ?? 0,
|
||||
page,
|
||||
limit,
|
||||
terms,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Search error:", err);
|
||||
return NextResponse.json({ error: "Search failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAsrConfig } from "@/lib/stt/dashscope";
|
||||
import { enqueueMissingTranscriptions } from "@/lib/stt/tasks";
|
||||
export const runtime = "nodejs";
|
||||
export async function POST() {
|
||||
try {
|
||||
getAsrConfig();
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(
|
||||
{ queued: await enqueueMissingTranscriptions() },
|
||||
{ status: 202 },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "批量任务入队失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { enqueueTranscription, readTranscription } from "@/lib/stt/tasks";
|
||||
import { isActive } from "@/lib/stt/types";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
const validId = (id: unknown): id is string =>
|
||||
typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
||||
export async function GET(req: NextRequest) {
|
||||
const id = req.nextUrl.searchParams.get("awemeId");
|
||||
if (!validId(id))
|
||||
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
|
||||
try {
|
||||
const state = await readTranscription(id);
|
||||
return state
|
||||
? NextResponse.json(state)
|
||||
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "读取转写状态失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!validId(body?.awemeId))
|
||||
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
|
||||
try {
|
||||
const state = await enqueueTranscription(body.awemeId, true);
|
||||
return state
|
||||
? NextResponse.json(state, { status: isActive(state.status) ? 202 : 200 })
|
||||
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "转写任务入队失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { playbackContextQuery } from "@/lib/playback-context";
|
||||
import FeedMasonry from "@/app/components/FeedMasonry";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import { FeedItem } from "@/app/types/feed";
|
||||
import { notFound } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
export default async function AuthorPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ secUid: string }>;
|
||||
}) {
|
||||
const secUid = (await params).secUid;
|
||||
|
||||
const author = await prisma.author.findUnique({
|
||||
where: { sec_uid: secUid },
|
||||
});
|
||||
|
||||
if (!author) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Initial feed fetch
|
||||
const limit = 24;
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: { authorId: secUid },
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: { authorId: secUid },
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const initialItems: FeedItem[] = [
|
||||
...videos.map((v) => ({
|
||||
type: "video" as const,
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: getFileUrl(v.video_url),
|
||||
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||
.slice(0, limit);
|
||||
|
||||
const initialCursor =
|
||||
initialItems.length > 0
|
||||
? new Date(
|
||||
initialItems[initialItems.length - 1].created_at as any,
|
||||
).toISOString()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
|
||||
<div className="sticky top-0 z-10 p-4 bg-white/80 dark:bg-black/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800 flex items-center gap-4">
|
||||
<BackButton />
|
||||
<h1 className="text-lg font-bold truncate">{author.nickname}</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Author Profile Header */}
|
||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12">
|
||||
<div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0">
|
||||
<Image
|
||||
src={getFileUrl(author.avatar_url || "default-avatar.png")}
|
||||
alt={author.nickname}
|
||||
fill
|
||||
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-center md:text-left space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{author.nickname}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
抖音号:{author.unique_id || author.short_id || "未知"}
|
||||
</p>
|
||||
<a
|
||||
href={`https://www.douyin.com/user/${encodeURIComponent(secUid)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 mt-3 text-sm text-gray-600 hover:text-black dark:text-gray-400 dark:hover:text-white underline underline-offset-4"
|
||||
>
|
||||
打开抖音主页
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{author.signature && (
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-w-2xl">
|
||||
{author.signature}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center md:justify-start gap-6 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-bold text-lg">
|
||||
{Number(author.total_favorited).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-gray-500">获赞</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-bold text-lg">
|
||||
{Number(author.follower_count).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-gray-500">粉丝</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-800 pt-8">
|
||||
<h3 className="text-lg font-semibold mb-6">作品</h3>
|
||||
<FeedMasonry
|
||||
initialItems={initialItems}
|
||||
initialCursor={initialCursor}
|
||||
fetchUrl={`/api/author/${secUid}/feed`}
|
||||
linkQuery={playbackContextQuery({ kind: "author", secUid })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+833
-118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||
import type { User } from "../types.ts";
|
||||
import { WorksGrid } from "./WorksGrid";
|
||||
|
||||
interface AuthorWorksTabProps {
|
||||
/** 侧栏打开且停在「TA的作品」标签页 */
|
||||
active: boolean;
|
||||
author: User;
|
||||
works: PlaylistState;
|
||||
currentId: string;
|
||||
/** 跳转时原样带上的播放上下文查询串 */
|
||||
contextQuery: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
// 侧栏「TA的作品」:作者在库里的全部作品。点作品就地切换;点作者名字才去独立的作者页。
|
||||
export const AuthorWorksTab = memo(function AuthorWorksTab({
|
||||
active,
|
||||
author,
|
||||
works,
|
||||
currentId,
|
||||
contextQuery,
|
||||
onSelect,
|
||||
}: AuthorWorksTabProps) {
|
||||
const header = author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${author.sec_uid}`}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5"
|
||||
title="打开作者主页"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: 头像来自对象存储,不走 next/image */}
|
||||
<img
|
||||
src={author.avatar_url ?? ""}
|
||||
alt=""
|
||||
className="h-9 w-9 shrink-0 rounded-full bg-white/10 object-cover"
|
||||
/>
|
||||
<span className="truncate font-semibold text-white group-hover:underline">
|
||||
@{author.nickname}
|
||||
</span>
|
||||
<ChevronRight size={16} className="shrink-0 text-white/60" />
|
||||
<span className="ml-auto shrink-0 text-xs text-white/40">作者主页</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-semibold text-white">
|
||||
@{author.nickname}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorksGrid
|
||||
active={active}
|
||||
list={works}
|
||||
currentId={currentId}
|
||||
hrefQuery={contextQuery}
|
||||
onSelect={onSelect}
|
||||
singleHint="库里只存了这位作者的这一个作品"
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { forwardRef } from "react";
|
||||
import { forwardRef, memo } from "react";
|
||||
|
||||
interface BackgroundCanvasProps {}
|
||||
|
||||
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
className="fixed inset-0 w-full h-full -z-10"
|
||||
style={{ filter: "blur(40px)" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
export const BackgroundCanvas = memo(
|
||||
forwardRef<HTMLCanvasElement>((_props, ref) => {
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
className="pointer-events-none fixed inset-0 w-full h-full -z-10 scale-110"
|
||||
style={{ filter: "blur(40px)" }}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
BackgroundCanvas.displayName = "BackgroundCanvas";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ThumbsUp } from "lucide-react";
|
||||
import type { Comment, User } from "../types";
|
||||
import { formatRelativeTime, formatAbsoluteUTC } from "../utils";
|
||||
import { ThumbsUp, X } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import type { Comment, User } from "../types.ts";
|
||||
import { formatAbsoluteUTC, formatRelativeTime } from "../utils";
|
||||
import { CommentText } from "./CommentText";
|
||||
|
||||
interface CommentListProps {
|
||||
@@ -9,18 +10,35 @@ interface CommentListProps {
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
||||
export const CommentList = memo(function CommentList({
|
||||
author,
|
||||
createdAt,
|
||||
comments,
|
||||
}: CommentListProps) {
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center gap-4 mb-5">
|
||||
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
||||
{author.avatar_url ? (
|
||||
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={author.avatar_url}
|
||||
alt="avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
|
||||
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}>
|
||||
<div className="font-medium text-white/95 text-sm sm:text-base">
|
||||
{author.nickname}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-white/50"
|
||||
title={formatAbsoluteUTC(createdAt)}
|
||||
>
|
||||
发布于 {formatRelativeTime(createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,17 +49,50 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
||||
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
|
||||
{c.user.avatar_url ? (
|
||||
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={c.user.avatar_url}
|
||||
alt="avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
|
||||
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span>
|
||||
<span className="font-medium text-white/95 text-sm">
|
||||
{c.user.nickname}
|
||||
</span>
|
||||
<span className="text-xs text-white/50">
|
||||
{formatRelativeTime(c.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
|
||||
<CommentText text={c.text} />
|
||||
</p>
|
||||
{c.images && c.images.length > 0 ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||
{c.images.map((img, idx) => (
|
||||
<button
|
||||
key={`${c.cid}-img-${idx}`}
|
||||
type="button"
|
||||
onClick={() => setPreviewImage(img.url)}
|
||||
className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
title="点击预览大图"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt="comment-image"
|
||||
width={img.width}
|
||||
height={img.height}
|
||||
className="w-full h-auto"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
|
||||
<ThumbsUp size={14} />
|
||||
<span>{c.digg_count}</span>
|
||||
@@ -49,8 +100,34 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
|
||||
{comments.length === 0 ? (
|
||||
<li className="text-sm text-white/60">暂无评论</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
{/* 图片预览灯箱 */}
|
||||
{previewImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/15 text-white border border-white/20 hover:bg-white/25 transition-colors flex items-center justify-center"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
aria-label="关闭预览"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="preview"
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,71 +1,196 @@
|
||||
import { X } from "lucide-react";
|
||||
import type { Comment, User } from "../types";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Comment, User } from "../types.ts";
|
||||
import { CommentList } from "./CommentList";
|
||||
|
||||
interface CommentPanelProps {
|
||||
/** 侧栏打开且停在「评论」标签页 */
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
author: User;
|
||||
createdAt: string | Date;
|
||||
comments: Comment[];
|
||||
mounted: boolean;
|
||||
awemeId: string;
|
||||
}
|
||||
|
||||
export function CommentPanel({ open, onClose, author, createdAt, comments, mounted }: CommentPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 横屏评论面板:并排分栏 */}
|
||||
<aside
|
||||
className={`
|
||||
hidden landscape:flex
|
||||
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
|
||||
relative h-full overflow-hidden
|
||||
${mounted ? "transition-[width] duration-200 ease-out" : ""}
|
||||
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||
<button
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
|
||||
onClick={onClose}
|
||||
aria-label="关闭评论"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
|
||||
</div>
|
||||
interface CommentsApiResponse {
|
||||
comments?: Comment[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
mode?: string;
|
||||
seed?: string | number;
|
||||
snapshot?: string | number;
|
||||
}
|
||||
|
||||
<div className="p-3 overflow-auto">
|
||||
<CommentList author={author} createdAt={createdAt} comments={comments} />
|
||||
</div>
|
||||
</aside>
|
||||
// 侧栏「评论」标签页的内容(外壳与标签切换在 SidePanel 里)
|
||||
export const CommentPanel = memo(function CommentPanel({
|
||||
open,
|
||||
author,
|
||||
createdAt,
|
||||
awemeId,
|
||||
}: CommentPanelProps) {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [hasOpened, setHasOpened] = useState(open);
|
||||
|
||||
{/* 竖屏评论面板:bottom sheet */}
|
||||
<aside
|
||||
className={`
|
||||
landscape:hidden
|
||||
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
|
||||
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
|
||||
${mounted ? "transition-transform duration-200 ease-out" : ""}
|
||||
border-t border-white/10
|
||||
${open ? "translate-y-0" : "translate-y-full"}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||
<div className="text-white font-semibold">评论 {comments.length > 0 ? `(${comments.length})` : ""}</div>
|
||||
<button
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
|
||||
onClick={onClose}
|
||||
aria-label="关闭评论"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const commentsCountRef = useRef(0);
|
||||
const hasMoreRef = useRef(true);
|
||||
const rankParamsRef = useRef<{ seed: string; snapshot: string } | null>(null);
|
||||
|
||||
<div className="p-3 overflow-auto">
|
||||
<CommentList author={author} createdAt={createdAt} comments={comments} />
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
useEffect(() => {
|
||||
if (open) setHasOpened(true);
|
||||
}, [open]);
|
||||
|
||||
const loadComments = useCallback(
|
||||
async (reset = false): Promise<void> => {
|
||||
if (loadingRef.current && !reset) return;
|
||||
if (!reset && !hasMoreRef.current) return;
|
||||
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const skip = reset ? 0 : commentsCountRef.current;
|
||||
const query = new URLSearchParams({
|
||||
skip: String(skip),
|
||||
take: String(20),
|
||||
mode: "ranked",
|
||||
});
|
||||
const currentRank = reset ? null : rankParamsRef.current;
|
||||
if (currentRank) {
|
||||
query.set("seed", currentRank.seed);
|
||||
query.set("snapshot", currentRank.snapshot);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/comments/${awemeId}?${query.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const data = (await response.json()) as CommentsApiResponse;
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const incoming = Array.isArray(data.comments) ? data.comments : [];
|
||||
|
||||
setComments((prev) => {
|
||||
const source = reset ? incoming : [...prev, ...incoming];
|
||||
const seen = new Set<string>();
|
||||
const deduped = source.filter((c) => {
|
||||
if (seen.has(c.cid)) return false;
|
||||
seen.add(c.cid);
|
||||
return true;
|
||||
});
|
||||
commentsCountRef.current = deduped.length;
|
||||
return deduped;
|
||||
});
|
||||
|
||||
const nextHasMore = Boolean(data.hasMore);
|
||||
hasMoreRef.current = nextHasMore;
|
||||
setHasMore(nextHasMore);
|
||||
|
||||
if (data.mode === "ranked" && data.seed && data.snapshot) {
|
||||
if (reset || !rankParamsRef.current) {
|
||||
rankParamsRef.current = {
|
||||
seed: String(data.seed),
|
||||
snapshot: String(data.snapshot),
|
||||
};
|
||||
}
|
||||
} else if (reset) {
|
||||
rankParamsRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("加载评论失败:", error);
|
||||
} finally {
|
||||
if (abortControllerRef.current === controller) {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[awemeId],
|
||||
);
|
||||
}
|
||||
|
||||
// 切换作品时清理旧评论状态与未完成请求
|
||||
useEffect(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
loadingRef.current = false;
|
||||
commentsCountRef.current = 0;
|
||||
hasMoreRef.current = true;
|
||||
rankParamsRef.current = null;
|
||||
setComments([]);
|
||||
setHasMore(true);
|
||||
setLoading(false);
|
||||
|
||||
if (open) {
|
||||
void loadComments(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [awemeId, open, loadComments]);
|
||||
|
||||
// Intersection Observer 监听底部触发分页加载(单容器)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const rootEl = scrollRef.current;
|
||||
const targetEl = sentinelRef.current;
|
||||
if (!rootEl || !targetEl) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (
|
||||
entry?.isIntersecting &&
|
||||
!loadingRef.current &&
|
||||
hasMoreRef.current
|
||||
) {
|
||||
void loadComments(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: rootEl,
|
||||
rootMargin: "0px 0px 200px 0px",
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
io.observe(targetEl);
|
||||
return () => io.disconnect();
|
||||
}, [open, hasMore, loadComments]);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
|
||||
{(open || hasOpened) && (
|
||||
<>
|
||||
<CommentList
|
||||
author={author}
|
||||
createdAt={createdAt}
|
||||
comments={comments}
|
||||
/>
|
||||
|
||||
{hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
|
||||
|
||||
{loading && (
|
||||
<div className="py-4 text-center text-white/60 text-sm">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && comments.length > 0 && (
|
||||
<div className="py-4 text-center text-white/40 text-sm">
|
||||
没有更多评论了
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) {
|
||||
// 如果图片加载失败,显示原始文本
|
||||
e.currentTarget.style.display = "none";
|
||||
const textNode = document.createTextNode(`[${part.name}]`);
|
||||
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
|
||||
e.currentTarget.parentNode?.insertBefore(
|
||||
textNode,
|
||||
e.currentTarget,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ChevronUp, ListVideo, X } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ContextChipProps {
|
||||
label: string;
|
||||
position: number;
|
||||
total: number;
|
||||
/** 提供时整个标签可点:展开 / 打开侧栏里的完整列表 */
|
||||
onExpand?: () => void;
|
||||
expandLabel?: string;
|
||||
onExit: () => void;
|
||||
exitLabel: string;
|
||||
}
|
||||
|
||||
// 顶部的模式提示:说明上下切换当前在哪个列表里移动,避免「为什么跳到这里了」的困惑
|
||||
export const ContextChip = memo(function ContextChip({
|
||||
label,
|
||||
position,
|
||||
total,
|
||||
onExpand,
|
||||
expandLabel,
|
||||
onExit,
|
||||
exitLabel,
|
||||
}: ContextChipProps) {
|
||||
const content = (
|
||||
<>
|
||||
<ListVideo size={14} className="shrink-0 text-rose-300" />
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-white/60">
|
||||
{position}/{total}
|
||||
</span>
|
||||
{onExpand && <ChevronUp size={14} className="shrink-0 text-white/70" />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute left-1/2 top-14 z-40 flex max-w-[calc(100%-1.5rem)] sm:top-3 sm:max-w-[calc(100%-14rem)] -translate-x-1/2 items-center rounded-full border border-white/20 bg-black/60 text-xs font-medium text-white shadow-lg backdrop-blur">
|
||||
{onExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1 cursor-pointer"
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
className="mr-1 grid h-6 w-6 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/15 hover:text-white cursor-pointer"
|
||||
aria-label={exitLabel}
|
||||
title={exitLabel}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ArrowLeft, Hash, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
interface ContextListTabProps {
|
||||
active: boolean;
|
||||
context: PlaybackContextInfo;
|
||||
list: PlaylistState;
|
||||
currentId: string;
|
||||
contextQuery: string;
|
||||
/** hashtag 上下文时的 tag 名(用来取相关 tag) */
|
||||
hashtagName: string | null;
|
||||
/** 对应的搜索结果页链接(搜索上下文才有) */
|
||||
searchPageHref: string | null;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
// 侧栏的「搜索结果 / #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 = 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">
|
||||
<div className="truncate font-semibold text-white">{context.label}</div>
|
||||
<div className="text-xs text-white/50">
|
||||
第 {context.position} 个 · 共 {context.total} 个
|
||||
</div>
|
||||
</div>
|
||||
{searchPageHref && (
|
||||
<Link
|
||||
href={searchPageHref}
|
||||
className="shrink-0 text-xs text-white/50 hover:text-white hover:underline"
|
||||
>
|
||||
在搜索页查看
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
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={browsing ? browseList : list}
|
||||
currentId={currentId}
|
||||
hrefQuery={browsing ? browseQuery : contextQuery}
|
||||
onSelect={onSelect}
|
||||
header={header}
|
||||
subheader={relatedRow}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,40 +1,489 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ImageData } from "../types";
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
|
||||
import { releaseMediaElement } from "../utils";
|
||||
|
||||
interface ImageCarouselProps {
|
||||
images: ImageData["images"];
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
playbackRate?: number;
|
||||
segmentProgressRef: RefObject<number>;
|
||||
mediaSyncToken: number;
|
||||
loopMode: LoopMode;
|
||||
hasNextNeighbor: boolean;
|
||||
rotation: number;
|
||||
objectFit: ObjectFit;
|
||||
failedAnimatedMap: Record<string, boolean>;
|
||||
onTogglePlay: () => void;
|
||||
onAnimatedDuration: (imageId: string, durationMs: number) => void;
|
||||
onMediaReady: (imageId: string) => void;
|
||||
onAnimatedFailed: (imageId: string) => void;
|
||||
onAnimatedProgress: (imageId: string, progress: number) => void;
|
||||
onAnimatedEnded: (imageId: string) => void;
|
||||
}
|
||||
|
||||
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
||||
({ images, currentIndex, onTogglePlay }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer"
|
||||
onClick={onTogglePlay}
|
||||
>{
|
||||
img.animated ? <video src={img.animated} autoPlay muted playsInline className="max-w-full max-h-full object-contain" /> : <img
|
||||
src={img.url}
|
||||
alt={`image-${i + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{
|
||||
width: img.width ? `${img.width}px` : undefined,
|
||||
height: img.height ? `${img.height}px` : undefined,
|
||||
}}
|
||||
/>
|
||||
export const ImageCarousel = memo(
|
||||
forwardRef<HTMLDivElement, ImageCarouselProps>(
|
||||
(
|
||||
{
|
||||
images,
|
||||
currentIndex,
|
||||
isPlaying,
|
||||
playbackRate = 1,
|
||||
segmentProgressRef,
|
||||
mediaSyncToken,
|
||||
loopMode,
|
||||
hasNextNeighbor,
|
||||
rotation,
|
||||
objectFit,
|
||||
failedAnimatedMap,
|
||||
onTogglePlay,
|
||||
onAnimatedDuration,
|
||||
onMediaReady,
|
||||
onAnimatedFailed,
|
||||
onAnimatedProgress,
|
||||
onAnimatedEnded,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
||||
const videoRefCallbacks = useRef<
|
||||
Map<string, (el: HTMLVideoElement | null) => void>
|
||||
>(new Map());
|
||||
const activeVideoIdRef = useRef<string | null>(null);
|
||||
const lastMediaSyncTokenRef = useRef(mediaSyncToken);
|
||||
const playbackRateRef = useRef(playbackRate);
|
||||
playbackRateRef.current = playbackRate;
|
||||
const pendingSeekRef = useRef<{
|
||||
imageId: string;
|
||||
progress: number;
|
||||
} | null>(null);
|
||||
|
||||
const shouldLoopCurrentVideo =
|
||||
loopMode === "single" ||
|
||||
(images.length === 1 &&
|
||||
(loopMode !== "sequential" || !hasNextNeighbor));
|
||||
|
||||
const seekVideoToProgress = useCallback(
|
||||
(videoEl: HTMLVideoElement, progress: number): boolean => {
|
||||
const duration = videoEl.duration;
|
||||
if (!Number.isFinite(duration) || duration <= 0) return false;
|
||||
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress));
|
||||
const maxTime = Math.max(0, duration - 0.05);
|
||||
const targetTime = Math.min(maxTime, duration * clampedProgress);
|
||||
|
||||
if (Math.abs(videoEl.currentTime - targetTime) > 0.08) {
|
||||
try {
|
||||
videoEl.currentTime = targetTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const tryPlayVideo = useCallback(
|
||||
(videoEl: HTMLVideoElement, imageId: string): void => {
|
||||
videoEl.play().catch((err: unknown) => {
|
||||
const errorName = err instanceof DOMException ? err.name : "";
|
||||
if (
|
||||
errorName === "NotAllowedError" ||
|
||||
errorName === "NotSupportedError"
|
||||
) {
|
||||
onAnimatedFailed(imageId);
|
||||
}
|
||||
});
|
||||
},
|
||||
[onAnimatedFailed],
|
||||
);
|
||||
|
||||
// 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个 <video>
|
||||
const visibleIndices = useMemo((): number[] => {
|
||||
const indices: number[] = [];
|
||||
if (currentIndex > 0) indices.push(currentIndex - 1);
|
||||
if (currentIndex >= 0 && currentIndex < images.length) {
|
||||
indices.push(currentIndex);
|
||||
}
|
||||
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
|
||||
return indices;
|
||||
}, [currentIndex, images.length]);
|
||||
|
||||
// 组件卸载时释放所有残留的 <video> 硬件解码缓冲
|
||||
useEffect(() => {
|
||||
const map = videoRefs.current;
|
||||
const callbacks = videoRefCallbacks.current;
|
||||
return () => {
|
||||
map.forEach((videoEl) => {
|
||||
releaseMediaElement(videoEl);
|
||||
});
|
||||
map.clear();
|
||||
callbacks.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 管理当前动图的播放、暂停与切换
|
||||
useEffect(() => {
|
||||
const currentImage = images[currentIndex];
|
||||
const isAnimated =
|
||||
Boolean(currentImage?.animated) &&
|
||||
!failedAnimatedMap[currentImage?.id ?? ""];
|
||||
|
||||
if (!currentImage || !isAnimated) {
|
||||
activeVideoIdRef.current = null;
|
||||
videoRefs.current.forEach((videoEl) => {
|
||||
videoEl.pause();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const videoEl = videoRefs.current.get(currentImage.id);
|
||||
|
||||
videoRefs.current.forEach((itemVideoEl, imageId) => {
|
||||
if (imageId !== currentImage.id || !isPlaying) {
|
||||
itemVideoEl.pause();
|
||||
}
|
||||
});
|
||||
|
||||
if (!videoEl) return;
|
||||
|
||||
videoEl.loop = shouldLoopCurrentVideo;
|
||||
videoEl.playbackRate = playbackRate;
|
||||
|
||||
if (activeVideoIdRef.current !== currentImage.id) {
|
||||
activeVideoIdRef.current = currentImage.id;
|
||||
const initialProgress = segmentProgressRef.current;
|
||||
if (initialProgress > 0.01) {
|
||||
const applied = seekVideoToProgress(videoEl, initialProgress);
|
||||
if (!applied) {
|
||||
pendingSeekRef.current = {
|
||||
imageId: currentImage.id,
|
||||
progress: initialProgress,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
pendingSeekRef.current = null;
|
||||
if (videoEl.currentTime > 0.05) {
|
||||
try {
|
||||
videoEl.currentTime = 0;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
isPlaying &&
|
||||
(videoEl.ended ||
|
||||
(Number.isFinite(videoEl.duration) &&
|
||||
videoEl.duration > 0 &&
|
||||
videoEl.currentTime >= videoEl.duration - 0.05))
|
||||
) {
|
||||
try {
|
||||
videoEl.currentTime = 0;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
tryPlayVideo(videoEl, currentImage.id);
|
||||
} else {
|
||||
videoEl.pause();
|
||||
}
|
||||
}, [
|
||||
currentIndex,
|
||||
failedAnimatedMap,
|
||||
images,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
seekVideoToProgress,
|
||||
segmentProgressRef,
|
||||
shouldLoopCurrentVideo,
|
||||
tryPlayVideo,
|
||||
]);
|
||||
|
||||
// 响应用户主动 Seek 或外部强制同步
|
||||
useEffect(() => {
|
||||
if (lastMediaSyncTokenRef.current === mediaSyncToken) return;
|
||||
lastMediaSyncTokenRef.current = mediaSyncToken;
|
||||
|
||||
const currentImage = images[currentIndex];
|
||||
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetProgress = segmentProgressRef.current;
|
||||
const videoEl = videoRefs.current.get(currentImage.id);
|
||||
if (!videoEl) {
|
||||
pendingSeekRef.current = {
|
||||
imageId: currentImage.id,
|
||||
progress: targetProgress,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const applied = seekVideoToProgress(videoEl, targetProgress);
|
||||
if (applied) {
|
||||
pendingSeekRef.current = null;
|
||||
} else {
|
||||
pendingSeekRef.current = {
|
||||
imageId: currentImage.id,
|
||||
progress: targetProgress,
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
tryPlayVideo(videoEl, currentImage.id);
|
||||
}
|
||||
}, [
|
||||
currentIndex,
|
||||
failedAnimatedMap,
|
||||
images,
|
||||
isPlaying,
|
||||
mediaSyncToken,
|
||||
seekVideoToProgress,
|
||||
segmentProgressRef,
|
||||
tryPlayVideo,
|
||||
]);
|
||||
|
||||
// 使用 rAF 跟踪当前动图真实播放进度,只有动图实际走帧时进度条才前进
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
const currentImage = images[currentIndex];
|
||||
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
const imageId = currentImage.id;
|
||||
|
||||
const updateLoop = (): void => {
|
||||
const videoEl = videoRefs.current.get(imageId);
|
||||
if (videoEl) {
|
||||
const duration = videoEl.duration;
|
||||
if (Number.isFinite(duration) && duration > 0 && !videoEl.paused) {
|
||||
const progress = Math.max(
|
||||
0,
|
||||
Math.min(1, videoEl.currentTime / duration),
|
||||
);
|
||||
onAnimatedProgress(imageId, progress);
|
||||
}
|
||||
}
|
||||
rafId = window.requestAnimationFrame(updateLoop);
|
||||
};
|
||||
|
||||
rafId = window.requestAnimationFrame(updateLoop);
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
currentIndex,
|
||||
failedAnimatedMap,
|
||||
images,
|
||||
isPlaying,
|
||||
onAnimatedProgress,
|
||||
]);
|
||||
|
||||
const getVideoRefCallback = useCallback(
|
||||
(imageId: string): ((el: HTMLVideoElement | null) => void) => {
|
||||
const existing = videoRefCallbacks.current.get(imageId);
|
||||
if (existing) return existing;
|
||||
|
||||
const callback = (el: HTMLVideoElement | null): void => {
|
||||
if (el) {
|
||||
el.playbackRate = playbackRateRef.current;
|
||||
videoRefs.current.set(imageId, el);
|
||||
if (Number.isFinite(el.duration) && el.duration > 0) {
|
||||
onAnimatedDuration(imageId, Math.round(el.duration * 1000));
|
||||
}
|
||||
} else {
|
||||
const prevEl = videoRefs.current.get(imageId);
|
||||
if (prevEl) {
|
||||
releaseMediaElement(prevEl);
|
||||
videoRefs.current.delete(imageId);
|
||||
}
|
||||
videoRefCallbacks.current.delete(imageId);
|
||||
}
|
||||
};
|
||||
|
||||
videoRefCallbacks.current.set(imageId, callback);
|
||||
return callback;
|
||||
},
|
||||
[onAnimatedDuration],
|
||||
);
|
||||
|
||||
const handleImgRef = useCallback(
|
||||
(el: HTMLImageElement | null, imageId: string): void => {
|
||||
if (el?.complete) {
|
||||
onMediaReady(imageId);
|
||||
}
|
||||
},
|
||||
[onMediaReady],
|
||||
);
|
||||
|
||||
const mediaStyle = useMemo(
|
||||
() => ({
|
||||
transform:
|
||||
rotation % 180 === 0
|
||||
? `rotate(${rotation}deg)`
|
||||
: `rotate(${rotation}deg) scale(0.85)`,
|
||||
objectFit,
|
||||
}),
|
||||
[objectFit, rotation],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative h-full w-full overflow-hidden">
|
||||
<div className="relative h-full w-full">
|
||||
{visibleIndices.map((i) => {
|
||||
const img = images[i];
|
||||
if (!img) return null;
|
||||
const isCurrent = i === currentIndex;
|
||||
const canShowAnimated =
|
||||
isCurrent &&
|
||||
Boolean(img.animated) &&
|
||||
!failedAnimatedMap[img.id];
|
||||
const animatedSrc = canShowAnimated ? img.animated : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
data-active={isCurrent ? "true" : undefined}
|
||||
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
||||
style={{
|
||||
transform: `translateX(${(i - currentIndex) * 100}%)`,
|
||||
transition: "transform 240ms ease-out",
|
||||
zIndex: isCurrent ? 1 : 0,
|
||||
}}
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{animatedSrc ? (
|
||||
<video
|
||||
ref={getVideoRefCallback(img.id)}
|
||||
src={animatedSrc}
|
||||
poster={img.url}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={isPlaying}
|
||||
loop={shouldLoopCurrentVideo}
|
||||
preload="auto"
|
||||
controls={false}
|
||||
disablePictureInPicture
|
||||
className="max-w-full max-h-full"
|
||||
style={{
|
||||
width: img.width ? `${img.width}px` : undefined,
|
||||
height: img.height ? `${img.height}px` : undefined,
|
||||
...mediaStyle,
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
e.currentTarget.playbackRate = playbackRateRef.current;
|
||||
const duration = e.currentTarget.duration;
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
onAnimatedDuration(
|
||||
img.id,
|
||||
Math.round(duration * 1000),
|
||||
);
|
||||
if (
|
||||
pendingSeekRef.current &&
|
||||
pendingSeekRef.current.imageId === img.id
|
||||
) {
|
||||
const applied = seekVideoToProgress(
|
||||
e.currentTarget,
|
||||
pendingSeekRef.current.progress,
|
||||
);
|
||||
if (applied) {
|
||||
pendingSeekRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onLoadedData={() => {
|
||||
onMediaReady(img.id);
|
||||
}}
|
||||
onCanPlay={(e) => {
|
||||
onMediaReady(img.id);
|
||||
if (
|
||||
pendingSeekRef.current &&
|
||||
pendingSeekRef.current.imageId === img.id
|
||||
) {
|
||||
const applied = seekVideoToProgress(
|
||||
e.currentTarget,
|
||||
pendingSeekRef.current.progress,
|
||||
);
|
||||
if (applied) {
|
||||
pendingSeekRef.current = null;
|
||||
}
|
||||
}
|
||||
if (isCurrent && isPlaying && e.currentTarget.paused) {
|
||||
tryPlayVideo(e.currentTarget, img.id);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const duration = e.currentTarget.duration;
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
onAnimatedProgress(
|
||||
img.id,
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
e.currentTarget.currentTime / duration,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onEnded={(e) => {
|
||||
if (shouldLoopCurrentVideo) {
|
||||
try {
|
||||
e.currentTarget.currentTime = 0;
|
||||
} catch {}
|
||||
if (isPlaying) {
|
||||
tryPlayVideo(e.currentTarget, img.id);
|
||||
}
|
||||
onAnimatedEnded(img.id);
|
||||
return;
|
||||
}
|
||||
e.currentTarget.pause();
|
||||
onAnimatedEnded(img.id);
|
||||
}}
|
||||
onError={() => {
|
||||
onAnimatedFailed(img.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
ref={(el) => handleImgRef(el, img.id)}
|
||||
src={img.url}
|
||||
alt={`image-${i + 1}`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
onLoad={() => onMediaReady(img.id)}
|
||||
onError={() => onMediaReady(img.id)}
|
||||
className="max-w-full max-h-full"
|
||||
style={{
|
||||
width: img.width ? `${img.width}px` : undefined,
|
||||
height: img.height ? `${img.height}px` : undefined,
|
||||
...mediaStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
ImageCarousel.displayName = "ImageCarousel";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ImageNavigationButtonsProps {
|
||||
onPrev: () => void;
|
||||
@@ -7,7 +8,7 @@ interface ImageNavigationButtonsProps {
|
||||
totalImages: number;
|
||||
}
|
||||
|
||||
export function ImageNavigationButtons({
|
||||
export const ImageNavigationButtons = memo(function ImageNavigationButtons({
|
||||
onPrev,
|
||||
onNext,
|
||||
currentIndex,
|
||||
@@ -20,13 +21,14 @@ export function ImageNavigationButtons({
|
||||
<>
|
||||
{/* 左侧按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
absolute left-4 top-7/16 -translate-y-1/2 z-20
|
||||
absolute left-4 top-6/17 -translate-y-1/2 z-20
|
||||
w-12 h-12 rounded-full
|
||||
bg-black/40 backdrop-blur-sm border border-white/20
|
||||
bg-black/55 border border-white/20
|
||||
flex items-center justify-center
|
||||
text-white transition-all
|
||||
${hasPrev ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||
text-white transition-opacity
|
||||
${hasPrev ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||
`}
|
||||
onClick={onPrev}
|
||||
disabled={!hasPrev}
|
||||
@@ -37,13 +39,14 @@ export function ImageNavigationButtons({
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
absolute right-4 top-7/16 -translate-y-1/2 z-20
|
||||
absolute right-4 top-6/17 -translate-y-1/2 z-20
|
||||
w-12 h-12 rounded-full
|
||||
bg-black/40 backdrop-blur-sm border border-white/20
|
||||
bg-black/55 border border-white/20
|
||||
flex items-center justify-center
|
||||
text-white transition-all
|
||||
${hasNext ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||
text-white transition-opacity
|
||||
${hasNext ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||
`}
|
||||
onClick={onNext}
|
||||
disabled={!hasNext}
|
||||
@@ -53,4 +56,4 @@ export function ImageNavigationButtons({
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Download,
|
||||
FileText,
|
||||
Maximize,
|
||||
Maximize2,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import type { LoopMode, ObjectFit, User } from "../types";
|
||||
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils";
|
||||
import Link from "next/link";
|
||||
import { memo, type ReactNode, type RefObject, useMemo } from "react";
|
||||
import { parseDescHashtags } from "@/lib/hashtag";
|
||||
import type { AbLoop, LoopMode, ObjectFit, User } from "../types.ts";
|
||||
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
|
||||
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { SegmentedProgressBar } from "./SegmentedProgressBar";
|
||||
|
||||
@@ -34,12 +39,17 @@ interface MediaControlsProps {
|
||||
desc: string;
|
||||
// 视频专用
|
||||
videoRef?: RefObject<HTMLVideoElement | null>;
|
||||
abLoop?: AbLoop;
|
||||
onClearAbLoop?: () => void;
|
||||
// 图文专用
|
||||
currentIndex?: number;
|
||||
totalSegments?: number;
|
||||
segmentProgress?: number;
|
||||
musicUrl?: string | null;
|
||||
audioRef?: RefObject<HTMLAudioElement | null>;
|
||||
// 转录相关
|
||||
hasTranscript?: boolean;
|
||||
onShowTranscript?: () => void;
|
||||
// 回调
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (ratio: number) => void;
|
||||
@@ -50,9 +60,16 @@ interface MediaControlsProps {
|
||||
onLoopModeChange: (mode: LoopMode) => void;
|
||||
onDownload: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
|
||||
onAuthorClick?: () => void;
|
||||
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
|
||||
onHashtagClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
export function MediaControls({
|
||||
const CONTROL_BTN_CLASS =
|
||||
"w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 cursor-pointer hover:bg-white/25 transition-colors";
|
||||
|
||||
export const MediaControls = memo(function MediaControls({
|
||||
isVideo,
|
||||
isPlaying,
|
||||
progress,
|
||||
@@ -66,11 +83,15 @@ export function MediaControls({
|
||||
createdAt,
|
||||
desc,
|
||||
videoRef,
|
||||
abLoop,
|
||||
onClearAbLoop,
|
||||
currentIndex = 0,
|
||||
totalSegments = 0,
|
||||
segmentProgress = 0,
|
||||
musicUrl,
|
||||
audioRef,
|
||||
hasTranscript = false,
|
||||
onShowTranscript,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
onVolumeChange,
|
||||
@@ -80,14 +101,93 @@ export function MediaControls({
|
||||
onLoopModeChange,
|
||||
onDownload,
|
||||
onToggleFullscreen,
|
||||
onAuthorClick,
|
||||
onHashtagClick,
|
||||
}: MediaControlsProps) {
|
||||
const descParts = useMemo(() => parseDescHashtags(desc || ""), [desc]);
|
||||
const normalizedLoopMode =
|
||||
isVideo && loopMode === "single" ? "loop" : loopMode;
|
||||
const loopLabel = isVideo
|
||||
? normalizedLoopMode === "loop"
|
||||
? "循环播放"
|
||||
: "顺序播放"
|
||||
: loopMode === "single"
|
||||
? "单页循环"
|
||||
: loopMode === "loop"
|
||||
? "图文循环"
|
||||
: "顺序播放";
|
||||
const handleLoopModeToggle = (): void => {
|
||||
if (isVideo) {
|
||||
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
|
||||
return;
|
||||
}
|
||||
|
||||
onLoopModeChange(
|
||||
loopMode === "loop"
|
||||
? "single"
|
||||
: loopMode === "single"
|
||||
? "sequential"
|
||||
: "loop",
|
||||
);
|
||||
};
|
||||
const renderLoopIcon = (): ReactNode => {
|
||||
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
|
||||
if (normalizedLoopMode === "loop") return <Repeat size={18} />;
|
||||
return <ArrowDownUp size={18} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
|
||||
<div
|
||||
data-no-swipe
|
||||
className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5"
|
||||
>
|
||||
{/* 描述行 */}
|
||||
<div className="pointer-events-none flex items-center gap-2.5 mb-1">
|
||||
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
|
||||
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
|
||||
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
|
||||
{author.sec_uid && onAuthorClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAuthorClick}
|
||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity cursor-pointer"
|
||||
title="查看TA的作品"
|
||||
>
|
||||
<img
|
||||
src={author.avatar_url ?? ""}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
|
||||
{author.nickname}
|
||||
</span>
|
||||
</button>
|
||||
) : author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${author.sec_uid}`}
|
||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={author.avatar_url ?? ""}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
|
||||
{author.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
src={author.avatar_url ?? ""}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow">
|
||||
{author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[13px] leading-tight text-white/95 drop-shadow">
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
||||
title={formatAbsoluteUTC(createdAt)}
|
||||
@@ -99,7 +199,23 @@ export function MediaControls({
|
||||
{desc ? (
|
||||
<div className="pointer-events-none">
|
||||
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
|
||||
{desc}
|
||||
{descParts.map((part, i) =>
|
||||
part.type === "hashtag" && onHashtagClick ? (
|
||||
<button
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onHashtagClick(part.name)}
|
||||
className="pointer-events-auto font-semibold text-white hover:underline cursor-pointer"
|
||||
title={`查看所有带 #${part.name} 的作品`}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
) : (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
|
||||
<span key={i}>{part.text}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -113,7 +229,12 @@ export function MediaControls({
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
) : (
|
||||
<ProgressBar progress={progress} onSeek={onSeek} />
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
onSeek={onSeek}
|
||||
abLoop={isVideo ? abLoop : undefined}
|
||||
duration={isVideo ? (videoRef?.current?.duration ?? 0) : 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 控制按钮行 - 响应式布局 */}
|
||||
@@ -121,7 +242,8 @@ export function MediaControls({
|
||||
{/* 左侧:播放控制 + 时间/进度 */}
|
||||
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
|
||||
<button
|
||||
className="w-[34px] h-[34px] shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
type="button"
|
||||
className={`${CONTROL_BTN_CLASS} shrink-0`}
|
||||
onClick={onTogglePlay}
|
||||
aria-label={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
@@ -130,22 +252,23 @@ export function MediaControls({
|
||||
|
||||
{/* 播放进度显示 - 所有设备都显示 */}
|
||||
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
|
||||
{isVideo ? (
|
||||
(() => {
|
||||
const v = videoRef?.current;
|
||||
const current = v?.currentTime ?? 0;
|
||||
const total = v?.duration ?? 0;
|
||||
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--";
|
||||
})()
|
||||
) : (
|
||||
`${currentIndex + 1} / ${totalSegments}`
|
||||
)}
|
||||
{isVideo
|
||||
? (() => {
|
||||
const v = videoRef?.current;
|
||||
const current = v?.currentTime ?? 0;
|
||||
const total = v?.duration ?? 0;
|
||||
return total > 0
|
||||
? `${formatTime(current)} / ${formatTime(total)}`
|
||||
: "--:-- / --:--";
|
||||
})()
|
||||
: `${currentIndex + 1} / ${totalSegments}`}
|
||||
</div>
|
||||
|
||||
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
|
||||
{isVideo && (
|
||||
<button
|
||||
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer shrink-0"
|
||||
type="button"
|
||||
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 cursor-pointer shrink-0 hover:bg-white/25 transition-colors"
|
||||
onClick={() => {
|
||||
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
||||
const i = steps.indexOf(rate);
|
||||
@@ -157,13 +280,35 @@ export function MediaControls({
|
||||
{rate}x
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AB 循环状态提示与快捷清除 */}
|
||||
{isVideo && abLoop && (abLoop.a !== null || abLoop.b !== null) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAbLoop}
|
||||
className="h-[30px] px-2.5 rounded-full text-[11px] font-mono bg-amber-400/20 text-amber-300 border border-amber-400/50 cursor-pointer shrink-0 hover:bg-amber-400/30 transition-colors inline-flex items-center gap-1"
|
||||
title="点击清除 AB 循环 (快捷键 \)"
|
||||
aria-label="清除 AB 循环"
|
||||
>
|
||||
<span>
|
||||
AB {formatTime(abLoop.a ?? 0)}-
|
||||
{abLoop.b !== null
|
||||
? formatTime(abLoop.b)
|
||||
: videoRef?.current?.duration
|
||||
? formatTime(videoRef.current.duration)
|
||||
: "末"}
|
||||
</span>
|
||||
<span className="text-amber-200/80 text-xs leading-none">×</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:音量控制 - 中等屏幕以上显示 */}
|
||||
<div className="hidden md:inline-flex items-center gap-2 shrink-0">
|
||||
{/* 旋转按钮 - 小屏幕以上显示 */}
|
||||
<button
|
||||
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
type="button"
|
||||
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={() => onRotationChange((rotation + 270) % 360)}
|
||||
aria-label="向左旋转 90 度"
|
||||
title="向左旋转 90 度"
|
||||
@@ -171,7 +316,8 @@ export function MediaControls({
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
type="button"
|
||||
className={CONTROL_BTN_CLASS}
|
||||
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||
>
|
||||
@@ -188,7 +334,8 @@ export function MediaControls({
|
||||
aria-label="音量"
|
||||
/>
|
||||
<button
|
||||
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
type="button"
|
||||
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={() => onRotationChange((rotation + 90) % 360)}
|
||||
aria-label="向右旋转 90 度"
|
||||
title="向右旋转 90 度"
|
||||
@@ -201,7 +348,8 @@ export function MediaControls({
|
||||
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
|
||||
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
|
||||
<button
|
||||
className="md:hidden w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
type="button"
|
||||
className={`md:hidden ${CONTROL_BTN_CLASS}`}
|
||||
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||
>
|
||||
@@ -210,27 +358,53 @@ export function MediaControls({
|
||||
|
||||
{/* 循环模式 - 中等屏幕以上显示 */}
|
||||
<button
|
||||
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
|
||||
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
||||
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
||||
type="button"
|
||||
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={handleLoopModeToggle}
|
||||
aria-label={loopLabel}
|
||||
title={loopLabel}
|
||||
>
|
||||
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
|
||||
{renderLoopIcon()}
|
||||
</button>
|
||||
|
||||
{/* 适配模式 - 小屏幕以上显示 */}
|
||||
<button
|
||||
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
|
||||
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
||||
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
||||
type="button"
|
||||
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={() =>
|
||||
onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
|
||||
}
|
||||
aria-label={
|
||||
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
||||
}
|
||||
title={
|
||||
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
||||
}
|
||||
>
|
||||
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
||||
{objectFit === "contain" ? (
|
||||
<Maximize2 size={18} />
|
||||
) : (
|
||||
<Minimize size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
|
||||
{isVideo && hasTranscript && onShowTranscript && (
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={onShowTranscript}
|
||||
aria-label="显示转录文本"
|
||||
title="显示转录文本"
|
||||
>
|
||||
<FileText size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 下载 - 中等屏幕以上显示 */}
|
||||
<button
|
||||
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
type="button"
|
||||
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={onDownload}
|
||||
aria-label={isVideo ? "下载视频" : "下载当前图片"}
|
||||
title={isVideo ? "下载视频" : "下载当前图片"}
|
||||
@@ -238,9 +412,74 @@ export function MediaControls({
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
{/* 更多菜单 - 只在有按钮被折叠时显示 */}
|
||||
<div className="sm:block md:hidden">
|
||||
<MoreMenu>
|
||||
<div className="sm:hidden">
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
objectFit === "contain" ? (
|
||||
<Maximize2 size={18} />
|
||||
) : (
|
||||
<Minimize size={18} />
|
||||
)
|
||||
}
|
||||
label={objectFit === "contain" ? "填充模式" : "适应模式"}
|
||||
onClick={() =>
|
||||
onObjectFitChange(
|
||||
objectFit === "contain" ? "cover" : "contain",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={renderLoopIcon()}
|
||||
label={loopLabel}
|
||||
onClick={handleLoopModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isVideo && hasTranscript && onShowTranscript && (
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={<FileText size={18} />}
|
||||
label="显示转录"
|
||||
onClick={onShowTranscript}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={<Download size={18} />}
|
||||
label={isVideo ? "下载视频" : "下载图片"}
|
||||
onClick={onDownload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isVideo && (
|
||||
<div className="md:hidden">
|
||||
<MoreMenuItem
|
||||
icon={<span className="text-sm font-mono">{rate}x</span>}
|
||||
label="播放速度"
|
||||
onClick={() => {
|
||||
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
||||
const i = steps.indexOf(rate);
|
||||
const next = steps[(i + 1) % steps.length];
|
||||
onRateChange(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</MoreMenu>
|
||||
</div>
|
||||
|
||||
{/* 全屏 - 所有设备都显示 */}
|
||||
<button
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
type="button"
|
||||
className={CONTROL_BTN_CLASS}
|
||||
onClick={onToggleFullscreen}
|
||||
aria-label="切换全屏"
|
||||
>
|
||||
@@ -250,7 +489,9 @@ export function MediaControls({
|
||||
</div>
|
||||
|
||||
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
||||
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null}
|
||||
{!isVideo && musicUrl ? (
|
||||
<audio ref={audioRef} src={musicUrl} loop preload="metadata" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { MoreVertical, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface MoreMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MoreMenu({ children }: MoreMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
buttonRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 更多按钮 */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="更多选项"
|
||||
title="更多选项"
|
||||
>
|
||||
{isOpen ? <X size={18} /> : <MoreVertical size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 弹出菜单 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
style={{ minWidth: "200px" }}
|
||||
>
|
||||
<div className="p-2 flex flex-col gap-1">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreMenuItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function MoreMenuItem({ icon, label, onClick }: MoreMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-white/90 hover:bg-white/10 rounded-lg transition-colors text-left w-full"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<span className="text-sm">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,72 @@
|
||||
import { ChevronDown, ChevronUp, MessageSquareText } from "lucide-react";
|
||||
import type { Neighbors } from "../types";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageSquareText,
|
||||
ThumbsUp,
|
||||
} from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import type { Neighbors } from "../types.ts";
|
||||
|
||||
interface NavigationButtonsProps {
|
||||
neighbors: Neighbors;
|
||||
commentsCount: number;
|
||||
likesCount: number;
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onToggleComments: () => void;
|
||||
}
|
||||
|
||||
export function NavigationButtons({
|
||||
export const NavigationButtons = memo(function NavigationButtons({
|
||||
neighbors,
|
||||
commentsCount,
|
||||
likesCount,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onToggleComments,
|
||||
}: NavigationButtonsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 评论开关(右侧中部) */}
|
||||
<button
|
||||
className="z-10 grid place-items-center w-[54px] h-[54px] rounded-full absolute right-4 top-2/3 -translate-y-1/2 cursor-pointer"
|
||||
onClick={onToggleComments}
|
||||
aria-label="切换评论"
|
||||
>
|
||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||
<MessageSquareText size={40} className="" />
|
||||
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2"
|
||||
>
|
||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||
<ThumbsUp size={40} className="" />
|
||||
|
||||
{commentsCount > 0 ? <span className="text-[18px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
|
||||
<span className="text-[12px] font-semibold text-white/90 drop-shadow">暂无评论</span>
|
||||
}
|
||||
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
|
||||
{likesCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/* 评论开关(右侧中部) */}
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2 cursor-pointer"
|
||||
onClick={onToggleComments}
|
||||
aria-label="切换评论"
|
||||
>
|
||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||
<MessageSquareText size={40} className="" />
|
||||
|
||||
</div>
|
||||
</button>
|
||||
{commentsCount > 0 ? (
|
||||
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
|
||||
{commentsCount}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12px] font-semibold text-white/90 drop-shadow">
|
||||
暂无评论
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 上下切换按钮(右侧胶囊形状) */}
|
||||
<div className="absolute right-4 top-6/11 -translate-y-1/2 z-10">
|
||||
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden">
|
||||
<div className="absolute right-4 top-5/11 -translate-y-1/2 z-10">
|
||||
<div className="flex flex-col rounded-full bg-black/55 border border-white/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors"
|
||||
onClick={onNavigatePrev}
|
||||
disabled={!neighbors.prev}
|
||||
@@ -46,6 +75,7 @@ export function NavigationButtons({
|
||||
<ChevronUp size={24} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors border-t border-white/20"
|
||||
onClick={onNavigateNext}
|
||||
disabled={!neighbors.next}
|
||||
@@ -57,4 +87,4 @@ export function NavigationButtons({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,18 +1,120 @@
|
||||
import { memo } from "react";
|
||||
import type { AbLoop } from "../types.ts";
|
||||
|
||||
interface ProgressBarProps {
|
||||
progress: number;
|
||||
onSeek: (ratio: number) => void;
|
||||
abLoop?: AbLoop;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
|
||||
export const ProgressBar = memo(function ProgressBar({
|
||||
progress,
|
||||
onSeek,
|
||||
abLoop,
|
||||
duration = 0,
|
||||
}: ProgressBarProps) {
|
||||
const seekFromClientX = (clientX: number, element: HTMLElement): void => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
onSeek((clientX - rect.left) / rect.width);
|
||||
};
|
||||
|
||||
const hasAbLoop = Boolean(
|
||||
abLoop && (abLoop.a !== null || abLoop.b !== null) && duration > 0,
|
||||
);
|
||||
const startRatio =
|
||||
hasAbLoop && abLoop
|
||||
? Math.max(0, Math.min(1, (abLoop.a ?? 0) / duration))
|
||||
: 0;
|
||||
const endRatio =
|
||||
hasAbLoop && abLoop
|
||||
? Math.max(startRatio, Math.min(1, (abLoop.b ?? duration) / duration))
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
||||
className="relative h-1.5 cursor-pointer touch-none select-none"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onSeek((e.clientX - rect.left) / rect.width);
|
||||
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
seekFromClientX(e.clientX, e.currentTarget);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
||||
seekFromClientX(e.clientX, e.currentTarget);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} />
|
||||
{/* 轨道主体 */}
|
||||
<div className="relative h-full w-full rounded-full bg-white/25 overflow-hidden">
|
||||
{/* AB 循环区间底色高亮 */}
|
||||
{hasAbLoop && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-amber-400/40"
|
||||
style={{
|
||||
left: `${startRatio * 100}%`,
|
||||
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 播放进度填充 */}
|
||||
<div
|
||||
className={`origin-left h-full will-change-transform ${
|
||||
hasAbLoop ? "bg-amber-300" : "bg-white"
|
||||
}`}
|
||||
style={{ transform: `scaleX(${progress || 0})` }}
|
||||
/>
|
||||
|
||||
{/* AB 循环区间顶部与底部亮边 */}
|
||||
{hasAbLoop && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 bottom-0 border-y border-amber-300/90 bg-amber-300/20"
|
||||
style={{
|
||||
left: `${startRatio * 100}%`,
|
||||
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* A / B 端点指示标记 */}
|
||||
{hasAbLoop && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
|
||||
style={{ left: `${startRatio * 100}%` }}
|
||||
>
|
||||
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
|
||||
A
|
||||
</span>
|
||||
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
|
||||
style={{ left: `${endRatio * 100}%` }}
|
||||
>
|
||||
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
|
||||
B
|
||||
</span>
|
||||
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo } from "react";
|
||||
|
||||
interface SegmentedProgressBarProps {
|
||||
totalSegments: number;
|
||||
currentIndex: number;
|
||||
@@ -5,18 +7,64 @@ interface SegmentedProgressBarProps {
|
||||
onSeek: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export function SegmentedProgressBar({
|
||||
const SegmentBarItem = memo(function SegmentBarItem({
|
||||
index,
|
||||
fill,
|
||||
}: {
|
||||
index: number;
|
||||
fill: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`第 ${index + 1} 段`}
|
||||
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full origin-left bg-white will-change-transform"
|
||||
style={{
|
||||
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const SegmentedProgressBar = memo(function SegmentedProgressBar({
|
||||
totalSegments,
|
||||
currentIndex,
|
||||
segmentProgress,
|
||||
onSeek,
|
||||
}: SegmentedProgressBarProps) {
|
||||
const seekFromClientX = (clientX: number, element: HTMLElement): void => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
onSeek((clientX - rect.left) / rect.width);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-1.5 cursor-pointer"
|
||||
className="relative h-1.5 cursor-pointer touch-none"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
onSeek((e.clientX - rect.left) / rect.width);
|
||||
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
seekFromClientX(e.clientX, e.currentTarget);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
||||
seekFromClientX(e.clientX, e.currentTarget);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1.5 h-full">
|
||||
@@ -24,17 +72,9 @@ export function SegmentedProgressBar({
|
||||
let fill = 0;
|
||||
if (i < currentIndex) fill = 1;
|
||||
else if (i === currentIndex) fill = segmentProgress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
aria-label={`第 ${i + 1} 段`}
|
||||
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
|
||||
>
|
||||
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${fill})` }} />
|
||||
</div>
|
||||
);
|
||||
return <SegmentBarItem key={`seg-${i}`} index={i} fill={fill} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { X } from "lucide-react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import type { SidePanelTab } from "../hooks/useSidePanelState";
|
||||
|
||||
export type SidePanelTabConfig = {
|
||||
key: SidePanelTab;
|
||||
label: string;
|
||||
count: number;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
interface SidePanelProps {
|
||||
open: boolean;
|
||||
mounted: boolean;
|
||||
tab: SidePanelTab;
|
||||
tabs: SidePanelTabConfig[];
|
||||
onTabChange: (tab: SidePanelTab) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 右侧(竖屏为底部抽屉)的侧栏外壳:评论 / TA的作品 / 搜索结果(或 hashtag)几个标签页
|
||||
export const SidePanel = memo(function SidePanel({
|
||||
open,
|
||||
mounted,
|
||||
tab,
|
||||
tabs,
|
||||
onTabChange,
|
||||
onClose,
|
||||
}: SidePanelProps) {
|
||||
const visibility = open
|
||||
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
|
||||
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0";
|
||||
// 竖屏时作品网格不需要那么高,给视频多留点位置
|
||||
const portraitHeight =
|
||||
tab === "comments" ? "h-[min(80vh,88dvh)]" : "h-[min(62vh,64dvh)]";
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
|
||||
fixed inset-x-0 bottom-0 w-full ${portraitHeight} border-t border-white/10 rounded-t-2xl
|
||||
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0 landscape:rounded-none
|
||||
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
|
||||
${visibility}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-3 border-b border-white/10 shrink-0">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-5 overflow-x-auto"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => onTabChange(t.key)}
|
||||
className={`relative shrink-0 max-w-[10em] truncate py-3 text-[15px] transition-colors cursor-pointer ${
|
||||
tab === t.key
|
||||
? "font-semibold text-white"
|
||||
: "text-white/55 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{t.count > 0 && (
|
||||
<span className="ml-1 text-xs font-normal text-white/50">
|
||||
{t.count}
|
||||
</span>
|
||||
)}
|
||||
{tab === t.key && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-label="关闭侧栏"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tabs.map((t) => (
|
||||
<div
|
||||
key={t.key}
|
||||
className={tab === t.key ? "flex min-h-0 flex-1 flex-col" : "hidden"}
|
||||
>
|
||||
{t.content}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Check, CheckCheck, Copy, X } from "lucide-react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import type { VideoTranscript } from "../types";
|
||||
|
||||
interface TranscriptPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
transcript: VideoTranscript | null;
|
||||
}
|
||||
|
||||
export const TranscriptPanel = memo(function TranscriptPanel({
|
||||
open,
|
||||
onClose,
|
||||
transcript,
|
||||
}: TranscriptPanelProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copyAllTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current);
|
||||
if (copyAllTimerRef.current !== null)
|
||||
clearTimeout(copyAllTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleCopy = (text: string, index: number): void => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIndex(index);
|
||||
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setCopiedIndex(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyAll = (): void => {
|
||||
if (!transcript?.transcript) return;
|
||||
const allText = transcript.transcript.join("\n");
|
||||
void navigator.clipboard.writeText(allText).then(() => {
|
||||
setCopiedAll(true);
|
||||
if (copyAllTimerRef.current !== null)
|
||||
clearTimeout(copyAllTimerRef.current);
|
||||
copyAllTimerRef.current = setTimeout(() => setCopiedAll(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (!transcript?.transcript?.length) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 border border-white/10 rounded-2xl shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={20} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center text-white/60">
|
||||
<p>{transcript ? "未识别到文字" : "该视频尚未转写"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 border border-white/10 rounded-2xl shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyAll}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600/20 text-blue-300 rounded-lg hover:bg-blue-600/30 transition-colors"
|
||||
>
|
||||
{copiedAll ? (
|
||||
<>
|
||||
<CheckCheck size={16} />
|
||||
已复制全部
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={16} />
|
||||
复制全部
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={20} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{transcript.transcript.map((text, index) => (
|
||||
<div
|
||||
key={`transcript-${index}`}
|
||||
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleCopy(text, index)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-blue-400">
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/90 leading-relaxed break-words">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(text, index);
|
||||
}}
|
||||
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all"
|
||||
aria-label="复制"
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check size={16} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={16} className="text-white/60" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50">
|
||||
共 {transcript.transcript.length} 段转录内容
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ObjectFit } from "../types";
|
||||
import { forwardRef, memo } from "react";
|
||||
import type { ObjectFit } from "../types.ts";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoUrl: string;
|
||||
@@ -9,33 +9,33 @@ interface VideoPlayerProps {
|
||||
onTogglePlay: () => void;
|
||||
}
|
||||
|
||||
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={videoUrl}
|
||||
className={[
|
||||
// 旋转 0/180:充满容器盒子;
|
||||
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
|
||||
rotation % 180 === 0
|
||||
? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer"
|
||||
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer",
|
||||
].join(" ")}
|
||||
style={{
|
||||
transform:
|
||||
export const VideoPlayer = memo(
|
||||
forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={videoUrl}
|
||||
className={
|
||||
rotation % 180 === 0
|
||||
? `rotate(${rotation}deg)`
|
||||
: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
objectFit,
|
||||
}}
|
||||
playsInline
|
||||
loop={loop}
|
||||
onClick={onTogglePlay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer"
|
||||
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer"
|
||||
}
|
||||
style={{
|
||||
transform:
|
||||
rotation % 180 === 0
|
||||
? `rotate(${rotation}deg)`
|
||||
: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
objectFit,
|
||||
}}
|
||||
playsInline
|
||||
loop={loop}
|
||||
onClick={onTogglePlay}
|
||||
/>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
VideoPlayer.displayName = "VideoPlayer";
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Tag, X } from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||
|
||||
type TagUpdateResponse = {
|
||||
updated?: {
|
||||
aweme_id: string;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type VideoTagEditorProps = {
|
||||
awemeId: string;
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
export const VideoTagEditor = memo(function VideoTagEditor({
|
||||
awemeId,
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: VideoTagEditorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftTags, setDraftTags] = useState<string[]>(tags);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDraftTags(tags);
|
||||
}, [open, tags]);
|
||||
|
||||
const toggleTag = (tag: string): void => {
|
||||
setDraftTags((current) =>
|
||||
current.includes(tag)
|
||||
? current.filter((item) => item !== tag)
|
||||
: [...current, tag],
|
||||
);
|
||||
};
|
||||
|
||||
const saveTags = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
awemeId,
|
||||
contentTags: draftTags,
|
||||
mode: "set",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存标签失败");
|
||||
const data: TagUpdateResponse = await response.json();
|
||||
const updated = data.updated?.find((item) => item.aweme_id === awemeId);
|
||||
onTagsChange(updated?.content_tags ?? draftTags);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "保存标签失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-no-swipe
|
||||
className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/55 px-3 text-sm font-medium text-white shadow-lg transition-colors hover:bg-black/70"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Tag size={16} />
|
||||
{tags.length > 0 ? tags.join("、") : "打标签"}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/95 p-3 text-white shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">手动标签</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-full p-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="关闭标签编辑"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VIDEO_CONTENT_TAGS.map((tag) => {
|
||||
const selected = draftTags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-blue-400 bg-blue-500/25 text-blue-100"
|
||||
: "border-white/10 bg-white/5 text-white/70 hover:border-white/25 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{selected && <Check size={13} />}
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-3 text-xs text-rose-300">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftTags([])}
|
||||
className="rounded-full px-3 py-1.5 text-xs text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveTags}
|
||||
disabled={saving}
|
||||
className="rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Heart, Images } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo, type ReactNode, useEffect, useRef } from "react";
|
||||
import { awemeHref } from "@/lib/playback-context";
|
||||
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||
|
||||
interface WorksGridProps {
|
||||
/** 所在标签页当前可见 */
|
||||
active: boolean;
|
||||
list: PlaylistState;
|
||||
currentId: string;
|
||||
/** 点击作品跳转时带上的播放上下文查询串 */
|
||||
hrefQuery: string;
|
||||
onSelect: () => void;
|
||||
/** 列表只有当前这一个作品时的提示 */
|
||||
singleHint?: string;
|
||||
/** 网格上方的标题栏 */
|
||||
header: ReactNode;
|
||||
/** 标题栏下方的附加内容(如相关 tag) */
|
||||
subheader?: ReactNode;
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 10000) return `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}万`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function PlayingBars() {
|
||||
return (
|
||||
<span className="flex h-4 items-end gap-0.5" aria-hidden>
|
||||
{[60, 100, 40].map((height, i) => (
|
||||
<span
|
||||
key={height}
|
||||
className="w-1 rounded-sm bg-white animate-pulse"
|
||||
style={{ height: `${height}%`, animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 侧栏里的作品封面网格(TA的作品 / 搜索结果 / hashtag 共用):当前作品标「播放中」,点作品就地切换
|
||||
export const WorksGrid = memo(function WorksGrid({
|
||||
active,
|
||||
list,
|
||||
currentId,
|
||||
hrefQuery,
|
||||
onSelect,
|
||||
singleHint,
|
||||
header,
|
||||
subheader,
|
||||
}: WorksGridProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const currentRef = useRef<HTMLAnchorElement>(null);
|
||||
const items = list.items ?? [];
|
||||
|
||||
// 让「播放中」的作品保持在可见区域(切换作品或列表加载完成时重新定位)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: currentId / items.length 只用来触发重新定位
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = scrollRef.current;
|
||||
const el = currentRef.current;
|
||||
if (!container || !el) return;
|
||||
const top = el.offsetTop - container.offsetTop;
|
||||
const bottom = top + el.offsetHeight;
|
||||
if (
|
||||
top < container.scrollTop ||
|
||||
bottom > container.scrollTop + container.clientHeight
|
||||
) {
|
||||
container.scrollTop =
|
||||
top - (container.clientHeight - el.offsetHeight) / 2;
|
||||
}
|
||||
}, [active, currentId, items.length]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<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 && (
|
||||
<div className="py-10 text-center text-sm text-white/60">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
{list.error && !list.items && (
|
||||
<div className="py-10 text-center text-sm text-red-300/80">
|
||||
{list.error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{items.map((item) => {
|
||||
const isCurrent = item.aweme_id === currentId;
|
||||
return (
|
||||
<Link
|
||||
key={item.aweme_id}
|
||||
ref={isCurrent ? currentRef : undefined}
|
||||
href={awemeHref(item.aweme_id, hrefQuery)}
|
||||
scroll={false}
|
||||
onClick={onSelect}
|
||||
title={item.desc}
|
||||
aria-current={isCurrent ? "true" : undefined}
|
||||
className={`relative block aspect-[3/4] overflow-hidden rounded-md bg-white/5 ${
|
||||
isCurrent ? "ring-2 ring-rose-400" : "hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
{item.cover_url ? (
|
||||
// biome-ignore lint/performance/noImgElement: 封面来自对象存储,不走 next/image
|
||||
<img
|
||||
src={item.cover_url}
|
||||
alt={item.desc?.slice(0, 20) || ""}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
{item.type === "image" && (
|
||||
<span className="absolute right-1 top-1 inline-flex items-center gap-0.5 rounded bg-black/55 px-1 py-0.5 text-[10px] text-white/90">
|
||||
<Images size={10} />
|
||||
图文
|
||||
</span>
|
||||
)}
|
||||
{isCurrent ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/55 text-xs text-white">
|
||||
<PlayingBars />
|
||||
播放中
|
||||
</div>
|
||||
) : (
|
||||
<span className="absolute bottom-1 left-1 inline-flex items-center gap-0.5 text-[11px] text-white/90 drop-shadow">
|
||||
<Heart size={11} />
|
||||
{formatCount(item.likes)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{list.items && items.length <= 1 && singleHint && (
|
||||
<div className="py-6 text-center text-xs text-white/40">
|
||||
{singleHint}
|
||||
</div>
|
||||
)}
|
||||
{list.items && list.total > items.length && (
|
||||
<div className="py-6 text-center text-xs text-white/40">
|
||||
只列出前 {items.length} 个,共 {list.total} 个
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,216 @@
|
||||
export const emojiList = [
|
||||
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子"
|
||||
]
|
||||
"微笑",
|
||||
"色",
|
||||
"发呆",
|
||||
"酷拽",
|
||||
"抠鼻",
|
||||
"流泪",
|
||||
"捂脸",
|
||||
"发怒",
|
||||
"呲牙",
|
||||
"尬笑",
|
||||
"害羞",
|
||||
"调皮",
|
||||
"舔屏",
|
||||
"看",
|
||||
"爱心",
|
||||
"比心",
|
||||
"赞",
|
||||
"鼓掌",
|
||||
"感谢",
|
||||
"抱抱你",
|
||||
"玫瑰",
|
||||
"尴尬流汗",
|
||||
"戳手手",
|
||||
"星星眼",
|
||||
"杀马特",
|
||||
"黄脸干杯",
|
||||
"抱紧自己",
|
||||
"拜拜",
|
||||
"热化了",
|
||||
"黄脸祈祷",
|
||||
"懵",
|
||||
"举手",
|
||||
"加功德",
|
||||
"摊手",
|
||||
"无语流汗",
|
||||
"续火花吧",
|
||||
"点火",
|
||||
"哭哭",
|
||||
"吐舌小狗",
|
||||
"送花",
|
||||
"爱心手",
|
||||
"贴贴",
|
||||
"灵机一动",
|
||||
"耶",
|
||||
"打脸",
|
||||
"大笑",
|
||||
"机智",
|
||||
"送心",
|
||||
"666",
|
||||
"闭嘴",
|
||||
"来看我",
|
||||
"一起加油",
|
||||
"哈欠",
|
||||
"震惊",
|
||||
"晕",
|
||||
"衰",
|
||||
"困",
|
||||
"疑问",
|
||||
"泣不成声",
|
||||
"小鼓掌",
|
||||
"大金牙",
|
||||
"偷笑",
|
||||
"石化",
|
||||
"思考",
|
||||
"吐血",
|
||||
"可怜",
|
||||
"嘘",
|
||||
"撇嘴",
|
||||
"笑哭",
|
||||
"奸笑",
|
||||
"得意",
|
||||
"憨笑",
|
||||
"坏笑",
|
||||
"抓狂",
|
||||
"泪奔",
|
||||
"钱",
|
||||
"恐惧",
|
||||
"愉快",
|
||||
"快哭了",
|
||||
"翻白眼",
|
||||
"互粉",
|
||||
"我想静静",
|
||||
"委屈",
|
||||
"鄙视",
|
||||
"飞吻",
|
||||
"再见",
|
||||
"紫薇别走",
|
||||
"听歌",
|
||||
"求抱抱",
|
||||
"绝望的凝视",
|
||||
"不失礼貌的微笑",
|
||||
"不看",
|
||||
"裂开",
|
||||
"干饭人",
|
||||
"庆祝",
|
||||
"吐舌",
|
||||
"呆无辜",
|
||||
"白眼",
|
||||
"猪头",
|
||||
"冷漠",
|
||||
"暗中观察",
|
||||
"二哈",
|
||||
"菜狗",
|
||||
"黑脸",
|
||||
"展开说说",
|
||||
"蜜蜂狗",
|
||||
"柴犬",
|
||||
"摸头",
|
||||
"皱眉",
|
||||
"擦汗",
|
||||
"红脸",
|
||||
"做鬼脸",
|
||||
"强",
|
||||
"如花",
|
||||
"吐",
|
||||
"惊喜",
|
||||
"敲打",
|
||||
"奋斗",
|
||||
"吐彩虹",
|
||||
"大哭",
|
||||
"嘿哈",
|
||||
"惊恐",
|
||||
"囧",
|
||||
"难过",
|
||||
"斜眼",
|
||||
"阴险",
|
||||
"悠闲",
|
||||
"咒骂",
|
||||
"吃瓜群众",
|
||||
"绿帽子",
|
||||
"敢怒不敢言",
|
||||
"求求了",
|
||||
"眼含热泪",
|
||||
"叹气",
|
||||
"好开心",
|
||||
"不是吧",
|
||||
"鞠躬",
|
||||
"躺平",
|
||||
"九转大肠",
|
||||
"不你不想",
|
||||
"一头乱麻",
|
||||
"kisskiss",
|
||||
"你不大行",
|
||||
"噢买尬",
|
||||
"宕机",
|
||||
"苦涩",
|
||||
"逞强落泪",
|
||||
"求机位-黄脸",
|
||||
"求机位3",
|
||||
"点赞",
|
||||
"精选",
|
||||
"强壮",
|
||||
"碰拳",
|
||||
"OK",
|
||||
"击掌",
|
||||
"左上",
|
||||
"握手",
|
||||
"抱拳",
|
||||
"勾引",
|
||||
"拳头",
|
||||
"弱",
|
||||
"胜利",
|
||||
"右边",
|
||||
"左边",
|
||||
"嘴唇",
|
||||
"心碎",
|
||||
"凋谢",
|
||||
"愤怒",
|
||||
"垃圾",
|
||||
"啤酒",
|
||||
"咖啡",
|
||||
"蛋糕",
|
||||
"礼物",
|
||||
"撒花",
|
||||
"加一",
|
||||
"减一",
|
||||
"okk",
|
||||
"V5",
|
||||
"绝",
|
||||
"给力",
|
||||
"红包",
|
||||
"屎",
|
||||
"发",
|
||||
"18禁",
|
||||
"炸弹",
|
||||
"西瓜",
|
||||
"加鸡腿",
|
||||
"握爪",
|
||||
"太阳",
|
||||
"月亮",
|
||||
"给跪了",
|
||||
"蕉绿",
|
||||
"扎心",
|
||||
"胡瓜",
|
||||
"打call",
|
||||
"栓Q",
|
||||
"雪花",
|
||||
"圣诞树",
|
||||
"平安果",
|
||||
"圣诞帽",
|
||||
"气球",
|
||||
"烟花",
|
||||
"福",
|
||||
"candy",
|
||||
"糖葫芦",
|
||||
"鞭炮",
|
||||
"元宝",
|
||||
"灯笼",
|
||||
"锦鲤",
|
||||
"巧克力",
|
||||
"戒指",
|
||||
"棒棒糖",
|
||||
"纸飞机",
|
||||
"粽子",
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Hooks 导出
|
||||
export { useBackgroundCanvas } from "./useBackgroundCanvas";
|
||||
export { useCommentState } from "./useCommentState";
|
||||
export { useSidePanelState } from "./useSidePanelState";
|
||||
export { useImageCarousel } from "./useImageCarousel";
|
||||
export { useNavigation } from "./useNavigation";
|
||||
export { usePlayerState } from "./usePlayerState";
|
||||
|
||||
@@ -1,73 +1,90 @@
|
||||
import { useEffect } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface UseBackgroundCanvasProps {
|
||||
isVideo: boolean;
|
||||
idx: number;
|
||||
isPlaying: boolean;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
||||
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
|
||||
}
|
||||
|
||||
const CANVAS_DRAW_INTERVAL_MS = 100; // 10fps 对 1/10 分辨率 + blur(40px) 已完全平滑,大幅降低 GPU 负载
|
||||
|
||||
export function useBackgroundCanvas({
|
||||
isVideo,
|
||||
idx,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
scrollerRef,
|
||||
backgroundCanvasRef,
|
||||
}: UseBackgroundCanvasProps) {
|
||||
}: UseBackgroundCanvasProps): void {
|
||||
// 初始化尺寸与卸载清理
|
||||
useEffect(() => {
|
||||
const canvas = backgroundCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
if (!ctx || !isVideo) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCanvasSize = () => {
|
||||
canvas.width = Math.floor(window.innerWidth / 10);
|
||||
canvas.height = Math.floor(window.innerHeight / 10);
|
||||
const updateCanvasSize = (): void => {
|
||||
canvas.width = Math.max(1, Math.floor(window.innerWidth / 10));
|
||||
canvas.height = Math.max(1, Math.floor(window.innerHeight / 10));
|
||||
};
|
||||
updateCanvasSize();
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout;
|
||||
const debouncedResize = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const debouncedResize = (): void => {
|
||||
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(updateCanvasSize, 300);
|
||||
};
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
|
||||
const drawMediaToCanvas = () => {
|
||||
if (!ctx) return;
|
||||
return () => {
|
||||
window.removeEventListener("resize", debouncedResize);
|
||||
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
};
|
||||
}, [isVideo, backgroundCanvasRef]);
|
||||
|
||||
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
|
||||
// 仅在视频实际播放时进行低频采样绘制,暂停时不占 CPU/GPU
|
||||
useEffect(() => {
|
||||
if (!isVideo || !isPlaying) return;
|
||||
const canvas = backgroundCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (isVideo) {
|
||||
sourceElement = videoRef.current;
|
||||
} else {
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller) {
|
||||
const currentImgContainer = scroller.children[idx] as HTMLElement;
|
||||
if (currentImgContainer) {
|
||||
sourceElement = currentImgContainer.querySelector("img");
|
||||
}
|
||||
}
|
||||
const drawMediaToCanvas = (): void => {
|
||||
if (document.hidden) return;
|
||||
const sourceElement = videoRef.current;
|
||||
if (
|
||||
!sourceElement ||
|
||||
sourceElement.readyState < 2 ||
|
||||
sourceElement.paused
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
|
||||
|
||||
const canvasWidth = canvas.width;
|
||||
const canvasHeight = canvas.height;
|
||||
const sourceWidth =
|
||||
sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
|
||||
const sourceHeight =
|
||||
sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
|
||||
const sourceWidth = sourceElement.videoWidth;
|
||||
const sourceHeight = sourceElement.videoHeight;
|
||||
|
||||
if (!sourceWidth || !sourceHeight) return;
|
||||
if (!canvasWidth || !canvasHeight || !sourceWidth || !sourceHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasRatio = canvasWidth / canvasHeight;
|
||||
const sourceRatio = sourceWidth / sourceHeight;
|
||||
|
||||
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
|
||||
let drawWidth: number;
|
||||
let drawHeight: number;
|
||||
let offsetX: number;
|
||||
let offsetY: number;
|
||||
|
||||
if (canvasRatio > sourceRatio) {
|
||||
drawWidth = canvasWidth;
|
||||
@@ -84,12 +101,14 @@ export function useBackgroundCanvas({
|
||||
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(drawMediaToCanvas, 20);
|
||||
drawMediaToCanvas();
|
||||
const intervalId = window.setInterval(
|
||||
drawMediaToCanvas,
|
||||
CANVAS_DRAW_INTERVAL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener("resize", debouncedResize);
|
||||
clearTimeout(resizeTimeout);
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [isVideo, idx, videoRef, scrollerRef, backgroundCanvasRef]);
|
||||
}, [isVideo, isPlaying, videoRef, backgroundCanvasRef]);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStringFromStorage, saveToStorage } from "../utils";
|
||||
|
||||
export function useCommentState() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 从 localStorage 恢复评论区状态
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = getStringFromStorage("aweme_player_comments_open", "false");
|
||||
if (saved === "true") {
|
||||
setOpen(true);
|
||||
}
|
||||
// 短暂延迟后标记为已挂载,启用动画
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setMounted(true);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 持久化评论区状态
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
saveToStorage("aweme_player_comments_open", open.toString());
|
||||
}, [open]);
|
||||
|
||||
return { open, setOpen, mounted };
|
||||
}
|
||||
@@ -1,44 +1,339 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ImageData, LoopMode, Neighbors } from "../types";
|
||||
import type { RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
|
||||
import { releaseMediaElement } from "../utils";
|
||||
|
||||
const MIN_SEGMENT_MS = 250;
|
||||
const UI_UPDATE_INTERVAL_MS = 33;
|
||||
const PROGRESS_EPSILON = 0.001;
|
||||
|
||||
function normalizeDuration(
|
||||
duration: number | null | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (
|
||||
typeof duration !== "number" ||
|
||||
!Number.isFinite(duration) ||
|
||||
duration <= 0
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
|
||||
}
|
||||
|
||||
function sumDurations(durations: number[], end: number): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
interface UseImageCarouselProps {
|
||||
images: ImageData["images"];
|
||||
isPlaying: boolean;
|
||||
playbackRate?: number;
|
||||
loopMode: LoopMode;
|
||||
neighbors: Neighbors;
|
||||
volume: number;
|
||||
audioRef: RefObject<HTMLAudioElement | null>;
|
||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
||||
setProgress: (progress: number) => void;
|
||||
/** 单张图片显示时长(毫秒),默认 5000ms */
|
||||
segmentMs?: number;
|
||||
}
|
||||
|
||||
export interface UseImageCarouselReturn {
|
||||
idx: number;
|
||||
setIdx: (idx: number) => void;
|
||||
segProgress: number;
|
||||
segProgressRef: RefObject<number>;
|
||||
segmentDurations: number[];
|
||||
totalDurationMs: number;
|
||||
mediaSyncToken: number;
|
||||
segStartRef: RefObject<number | null>;
|
||||
idxRef: RefObject<number>;
|
||||
failedAnimatedMap: Record<string, boolean>;
|
||||
setSegmentDuration: (imageId: string, durationMs: number) => void;
|
||||
markMediaReady: (imageId: string) => void;
|
||||
markAnimatedFailed: (imageId: string) => void;
|
||||
handleAnimatedProgress: (imageId: string, progress: number) => void;
|
||||
handleAnimatedEnded: (imageId: string) => void;
|
||||
goToIndex: (index: number, segmentProgress?: number) => void;
|
||||
seekTo: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export function useImageCarousel({
|
||||
images,
|
||||
isPlaying,
|
||||
playbackRate = 1,
|
||||
loopMode,
|
||||
neighbors,
|
||||
volume,
|
||||
audioRef,
|
||||
scrollerRef,
|
||||
setProgress,
|
||||
segmentMs = 5000,
|
||||
}: UseImageCarouselProps) {
|
||||
}: UseImageCarouselProps): UseImageCarouselReturn {
|
||||
const router = useRouter();
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [segProgress, setSegProgress] = useState(0);
|
||||
const [durationOverrides, setDurationOverrides] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [readyMediaMap, setReadyMediaMap] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [failedAnimatedMap, setFailedAnimatedMap] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [mediaSyncToken, setMediaSyncToken] = useState(0);
|
||||
|
||||
const imagesRef = useRef(images);
|
||||
imagesRef.current = images;
|
||||
const segStartRef = useRef<number | null>(null);
|
||||
const idxRef = useRef<number>(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const lastUiUpdateRef = useRef(0);
|
||||
const segProgressRef = useRef(0);
|
||||
const totalProgressRef = useRef(0);
|
||||
const prevImageKeyRef = useRef<string | null>(null);
|
||||
|
||||
const fallbackDuration = normalizeDuration(segmentMs, 5000);
|
||||
const segmentDurations = useMemo(
|
||||
() =>
|
||||
images.map((img) =>
|
||||
normalizeDuration(
|
||||
durationOverrides[img.id] ?? img.duration,
|
||||
fallbackDuration,
|
||||
),
|
||||
),
|
||||
[durationOverrides, fallbackDuration, images],
|
||||
);
|
||||
const imageKey = useMemo(
|
||||
() => images.map((img) => img.id).join("|"),
|
||||
[images],
|
||||
);
|
||||
const totalDurationMs = useMemo(
|
||||
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
|
||||
[segmentDurations],
|
||||
);
|
||||
|
||||
const syncProgress = useCallback(
|
||||
(index: number, segmentProgress: number, force = false): void => {
|
||||
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
|
||||
const completedMs = sumDurations(segmentDurations, index);
|
||||
const totalProgress =
|
||||
totalDurationMs > 0
|
||||
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
|
||||
: 0;
|
||||
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
|
||||
|
||||
if (
|
||||
force ||
|
||||
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
|
||||
) {
|
||||
segProgressRef.current = nextSegProgress;
|
||||
setSegProgress(nextSegProgress);
|
||||
}
|
||||
|
||||
if (
|
||||
force ||
|
||||
Math.abs(nextTotalProgress - totalProgressRef.current) >
|
||||
PROGRESS_EPSILON
|
||||
) {
|
||||
totalProgressRef.current = nextTotalProgress;
|
||||
setProgress(nextTotalProgress);
|
||||
}
|
||||
},
|
||||
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
|
||||
);
|
||||
|
||||
const setSegmentDuration = useCallback(
|
||||
(imageId: string, durationMs: number): void => {
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
|
||||
const normalizedDuration = normalizeDuration(
|
||||
durationMs,
|
||||
fallbackDuration,
|
||||
);
|
||||
|
||||
setDurationOverrides((current) => {
|
||||
if (current[imageId] === normalizedDuration) return current;
|
||||
return { ...current, [imageId]: normalizedDuration };
|
||||
});
|
||||
},
|
||||
[fallbackDuration],
|
||||
);
|
||||
|
||||
const markMediaReady = useCallback((imageId: string): void => {
|
||||
setReadyMediaMap((current) => {
|
||||
if (current[imageId]) return current;
|
||||
return { ...current, [imageId]: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markAnimatedFailed = useCallback((imageId: string): void => {
|
||||
setFailedAnimatedMap((current) => {
|
||||
if (current[imageId]) return current;
|
||||
return { ...current, [imageId]: true };
|
||||
});
|
||||
setReadyMediaMap((current) => {
|
||||
if (current[imageId]) return current;
|
||||
return { ...current, [imageId]: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const advanceSegment = useCallback(
|
||||
(fromIndex: number): void => {
|
||||
const currentImages = imagesRef.current;
|
||||
if (!currentImages.length || idxRef.current !== fromIndex) return;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
if (loopMode === "single") {
|
||||
segStartRef.current = now;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(fromIndex, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromIndex >= currentImages.length - 1) {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(neighbors.next.href);
|
||||
return;
|
||||
}
|
||||
idxRef.current = 0;
|
||||
setIdx(0);
|
||||
segStartRef.current = now;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(0, 0, true);
|
||||
if (currentImages.length === 1) {
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = fromIndex + 1;
|
||||
idxRef.current = nextIndex;
|
||||
setIdx(nextIndex);
|
||||
segStartRef.current = now;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(nextIndex, 0, true);
|
||||
},
|
||||
[loopMode, neighbors?.next, router, syncProgress],
|
||||
);
|
||||
|
||||
const handleAnimatedProgress = useCallback(
|
||||
(imageId: string, progress: number): void => {
|
||||
const currentImage = imagesRef.current[idxRef.current];
|
||||
if (!currentImage || currentImage.id !== imageId) return;
|
||||
|
||||
const now = performance.now();
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
const wrapped = clamped < segProgressRef.current - 0.2;
|
||||
|
||||
if (
|
||||
wrapped ||
|
||||
clamped >= 0.995 ||
|
||||
now - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
|
||||
) {
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(idxRef.current, clamped, wrapped);
|
||||
}
|
||||
},
|
||||
[syncProgress],
|
||||
);
|
||||
|
||||
const handleAnimatedEnded = useCallback(
|
||||
(imageId: string): void => {
|
||||
const currentImage = imagesRef.current[idxRef.current];
|
||||
if (!currentImage || currentImage.id !== imageId) return;
|
||||
advanceSegment(idxRef.current);
|
||||
},
|
||||
[advanceSegment],
|
||||
);
|
||||
|
||||
const goToIndex = useCallback(
|
||||
(index: number, segmentProgress = 0): void => {
|
||||
const count = imagesRef.current.length;
|
||||
if (!count) return;
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(count - 1, index));
|
||||
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||
const now = performance.now();
|
||||
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
|
||||
|
||||
idxRef.current = nextIndex;
|
||||
setIdx(nextIndex);
|
||||
segStartRef.current = now - nextSegmentProgress * segmentDuration;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(nextIndex, nextSegmentProgress, true);
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
},
|
||||
[fallbackDuration, segmentDurations, syncProgress],
|
||||
);
|
||||
|
||||
const seekTo = useCallback(
|
||||
(ratio: number): void => {
|
||||
if (!imagesRef.current.length || totalDurationMs <= 0) return;
|
||||
|
||||
const clampedRatio = Math.max(0, Math.min(1, ratio));
|
||||
const targetMs = Math.min(
|
||||
totalDurationMs - 1,
|
||||
clampedRatio * totalDurationMs,
|
||||
);
|
||||
let accumulatedMs = 0;
|
||||
let targetIndex = 0;
|
||||
let segmentElapsedMs = 0;
|
||||
|
||||
for (let i = 0; i < segmentDurations.length; i++) {
|
||||
const duration = segmentDurations[i] ?? fallbackDuration;
|
||||
if (
|
||||
targetMs < accumulatedMs + duration ||
|
||||
i === segmentDurations.length - 1
|
||||
) {
|
||||
targetIndex = i;
|
||||
segmentElapsedMs = Math.max(
|
||||
0,
|
||||
Math.min(duration, targetMs - accumulatedMs),
|
||||
);
|
||||
break;
|
||||
}
|
||||
accumulatedMs += duration;
|
||||
}
|
||||
|
||||
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
|
||||
const nextSegmentProgress =
|
||||
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
|
||||
goToIndex(targetIndex, nextSegmentProgress);
|
||||
},
|
||||
[fallbackDuration, goToIndex, segmentDurations, totalDurationMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
idxRef.current = idx;
|
||||
}, [idx]);
|
||||
|
||||
// 仅当真正切换到另一个作品(imageKey 变化)时重置状态,避免首次挂载覆盖子组件已上报的 ready 状态
|
||||
useEffect(() => {
|
||||
if (prevImageKeyRef.current === null) {
|
||||
prevImageKeyRef.current = imageKey;
|
||||
return;
|
||||
}
|
||||
if (prevImageKeyRef.current === imageKey) return;
|
||||
prevImageKeyRef.current = imageKey;
|
||||
|
||||
idxRef.current = 0;
|
||||
setIdx(0);
|
||||
segStartRef.current = null;
|
||||
segProgressRef.current = 0;
|
||||
totalProgressRef.current = 0;
|
||||
setSegProgress(0);
|
||||
setProgress(0);
|
||||
setDurationOverrides({});
|
||||
setReadyMediaMap({});
|
||||
setFailedAnimatedMap({});
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
}, [imageKey, setProgress]);
|
||||
|
||||
// BGM 控制
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
@@ -51,64 +346,104 @@ export function useImageCarousel({
|
||||
}
|
||||
}, [audioRef, isPlaying, volume]);
|
||||
|
||||
// 自动切页
|
||||
useEffect(() => {
|
||||
if (!images?.length) return;
|
||||
|
||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
||||
let lastTs = performance.now();
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!images?.length) return;
|
||||
|
||||
if (!isPlaying) segStartRef.current! += ts - lastTs;
|
||||
lastTs = ts;
|
||||
|
||||
let start = segStartRef.current!;
|
||||
let localIdx = idxRef.current;
|
||||
|
||||
let elapsed = ts - start;
|
||||
while (elapsed >= segmentMs) {
|
||||
elapsed -= segmentMs;
|
||||
|
||||
if (localIdx >= images.length - 1) {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
return;
|
||||
}
|
||||
localIdx = 0;
|
||||
} else {
|
||||
localIdx = localIdx + 1;
|
||||
}
|
||||
}
|
||||
segStartRef.current = ts - elapsed;
|
||||
|
||||
if (localIdx !== idxRef.current) {
|
||||
idxRef.current = localIdx;
|
||||
setIdx(localIdx);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / segmentMs));
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
const el = audioRef.current;
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
releaseMediaElement(el);
|
||||
};
|
||||
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
|
||||
}, [audioRef]);
|
||||
|
||||
// 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由 <video> 自身进度与 onEnded 驱动)
|
||||
const currentImage = images[idx];
|
||||
const isCurrentAnimated =
|
||||
Boolean(currentImage?.animated) &&
|
||||
!failedAnimatedMap[currentImage?.id ?? ""];
|
||||
const isCurrentStaticReady = currentImage
|
||||
? Boolean(readyMediaMap[currentImage.id])
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!images.length || !segmentDurations.length || totalDurationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 当前页为正常动图时,交由 ImageCarousel 内 <video> 的真实播放进度和 ended 事件推进
|
||||
if (isCurrentAnimated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态图尚未加载完成或处于暂停态时,冻结分段进度,避免未加载完就偷跑进度裁切展示时长
|
||||
if (!isPlaying || !isCurrentStaticReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeRate = playbackRate > 0 ? playbackRate : 1;
|
||||
const now = performance.now();
|
||||
const currentSegmentDuration =
|
||||
(segmentDurations[idx] ?? fallbackDuration) / safeRate;
|
||||
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
||||
|
||||
const tick = (): void => {
|
||||
const currentImages = imagesRef.current;
|
||||
if (!currentImages.length) return;
|
||||
|
||||
const ts = performance.now();
|
||||
const start = segStartRef.current ?? ts;
|
||||
const activeIdx = idxRef.current;
|
||||
const duration =
|
||||
(segmentDurations[activeIdx] ?? fallbackDuration) / safeRate;
|
||||
const elapsed = ts - start;
|
||||
|
||||
if (elapsed >= duration) {
|
||||
advanceSegment(activeIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / duration));
|
||||
if (ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS) {
|
||||
lastUiUpdateRef.current = ts;
|
||||
syncProgress(activeIdx, localSeg, false);
|
||||
}
|
||||
};
|
||||
|
||||
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
advanceSegment,
|
||||
fallbackDuration,
|
||||
idx,
|
||||
images.length,
|
||||
isCurrentAnimated,
|
||||
isCurrentStaticReady,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
segmentDurations,
|
||||
syncProgress,
|
||||
totalDurationMs,
|
||||
]);
|
||||
|
||||
return {
|
||||
idx,
|
||||
setIdx,
|
||||
segProgress,
|
||||
segProgressRef,
|
||||
segmentDurations,
|
||||
totalDurationMs,
|
||||
mediaSyncToken,
|
||||
segStartRef,
|
||||
idxRef,
|
||||
failedAnimatedMap,
|
||||
setSegmentDuration,
|
||||
markMediaReady,
|
||||
markAnimatedFailed,
|
||||
handleAnimatedProgress,
|
||||
handleAnimatedEnded,
|
||||
goToIndex,
|
||||
seekTo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Neighbors } from "../types";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Neighbors } from "../types.ts";
|
||||
|
||||
const LONG_PRESS_DELAY_MS = 220;
|
||||
|
||||
interface UseNavigationProps {
|
||||
neighbors: Neighbors;
|
||||
@@ -11,6 +13,10 @@ interface UseNavigationProps {
|
||||
prevImg?: () => void;
|
||||
nextImg?: () => void;
|
||||
togglePlay: () => void;
|
||||
onFastForwardChange?: (active: boolean) => void;
|
||||
onSetAbStart?: () => void;
|
||||
onSetAbEnd?: () => void;
|
||||
onClearAbLoop?: () => void;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
@@ -21,15 +27,31 @@ export function useNavigation({
|
||||
prevImg,
|
||||
nextImg,
|
||||
togglePlay,
|
||||
}: UseNavigationProps) {
|
||||
onFastForwardChange,
|
||||
onSetAbStart,
|
||||
onSetAbEnd,
|
||||
onClearAbLoop,
|
||||
}: UseNavigationProps): void {
|
||||
const router = useRouter();
|
||||
const wheelCooldownRef = useRef<number>(0);
|
||||
const rightPressKeyRef = useRef<string | null>(null);
|
||||
const longPressActiveRef = useRef<boolean>(false);
|
||||
const longPressTimerRef = useRef<number | null>(null);
|
||||
|
||||
const onFastForwardChangeRef = useRef(onFastForwardChange);
|
||||
onFastForwardChangeRef.current = onFastForwardChange;
|
||||
const onSetAbStartRef = useRef(onSetAbStart);
|
||||
onSetAbStartRef.current = onSetAbStart;
|
||||
const onSetAbEndRef = useRef(onSetAbEnd);
|
||||
onSetAbEndRef.current = onSetAbEnd;
|
||||
const onClearAbLoopRef = useRef(onClearAbLoop);
|
||||
onClearAbLoopRef.current = onClearAbLoop;
|
||||
|
||||
// 预取路由
|
||||
useEffect(() => {
|
||||
if (!neighbors) return;
|
||||
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`);
|
||||
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`);
|
||||
if (neighbors.next) router.prefetch(neighbors.next.href);
|
||||
if (neighbors.prev) router.prefetch(neighbors.prev.href);
|
||||
}, [neighbors, router]);
|
||||
|
||||
// 鼠标滚轮切换
|
||||
@@ -37,7 +59,7 @@ export function useNavigation({
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
if (e.ctrlKey) return;
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
@@ -50,44 +72,153 @@ export function useNavigation({
|
||||
|
||||
if (dy > 0 && neighbors?.next) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
router.push(neighbors.next.href);
|
||||
} else if (dy < 0 && neighbors?.prev) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
||||
router.push(neighbors.prev.href);
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel as any);
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [neighbors, mediaContainerRef, router]);
|
||||
|
||||
// 键盘快捷键
|
||||
// 触屏上下滑切换(上滑下一条,下滑上一条)。只认明显的竖向手势,
|
||||
// 不影响图文的左右滑动;从按钮、链接、控制栏(data-no-swipe)上开始的触摸不处理。
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let start: { x: number; y: number; t: number } | null = null;
|
||||
let vertical = false;
|
||||
|
||||
const onTouchStart = (e: TouchEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
e.touches.length !== 1 ||
|
||||
target?.closest(
|
||||
"button, a, input, textarea, select, [role='slider'], [data-no-swipe]",
|
||||
)
|
||||
) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
const touch = e.touches[0];
|
||||
start = { x: touch.clientX, y: touch.clientY, t: performance.now() };
|
||||
vertical = false;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent): void => {
|
||||
if (!start) return;
|
||||
if (e.touches.length !== 1) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
const touch = e.touches[0];
|
||||
const dx = touch.clientX - start.x;
|
||||
const dy = touch.clientY - start.y;
|
||||
if (!vertical && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx) * 1.5) {
|
||||
vertical = true;
|
||||
}
|
||||
// 竖向手势由我们处理,阻止浏览器下拉刷新 / 回弹
|
||||
if (vertical && e.cancelable) e.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: TouchEvent): void => {
|
||||
if (!start) return;
|
||||
const touch = e.changedTouches[0];
|
||||
const dy = touch.clientY - start.y;
|
||||
const elapsed = performance.now() - start.t;
|
||||
const wasVertical = vertical;
|
||||
start = null;
|
||||
vertical = false;
|
||||
if (!wasVertical) return;
|
||||
|
||||
// 快速轻扫,或者慢慢拖过屏幕高度的四分之一
|
||||
const flick = Math.abs(dy) > 60 && elapsed < 800;
|
||||
const longDrag = Math.abs(dy) > window.innerHeight * 0.25;
|
||||
if (!flick && !longDrag) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 500) return;
|
||||
if (dy < 0 && neighbors?.next) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(neighbors.next.href);
|
||||
} else if (dy > 0 && neighbors?.prev) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(neighbors.prev.href);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchCancel = (): void => {
|
||||
start = null;
|
||||
vertical = false;
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
el.addEventListener("touchend", onTouchEnd);
|
||||
el.addEventListener("touchcancel", onTouchCancel);
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onTouchStart);
|
||||
el.removeEventListener("touchmove", onTouchMove);
|
||||
el.removeEventListener("touchend", onTouchEnd);
|
||||
el.removeEventListener("touchcancel", onTouchCancel);
|
||||
};
|
||||
}, [neighbors, mediaContainerRef, router]);
|
||||
|
||||
// 键盘快捷键(支持向右箭头 / D 键区分短按与长按 2 倍速,以及视频 [ ] 设置 AB 循环)
|
||||
useEffect(() => {
|
||||
const clearLongPressTimer = (): void => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetRightKeyPress = (): void => {
|
||||
clearLongPressTimer();
|
||||
const wasLongPress = longPressActiveRef.current;
|
||||
rightPressKeyRef.current = null;
|
||||
longPressActiveRef.current = false;
|
||||
if (wasLongPress) {
|
||||
onFastForwardChangeRef.current?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const code = e.code;
|
||||
|
||||
if (key === "arrowup" || key === "w") {
|
||||
if (key === "arrowup" || key === "w" || code === "KeyW") {
|
||||
e.preventDefault();
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
if (neighbors?.prev) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
||||
router.push(neighbors.prev.href);
|
||||
}
|
||||
} else if (key === "arrowdown" || key === "s") {
|
||||
} else if (key === "arrowdown" || key === "s" || code === "KeyS") {
|
||||
e.preventDefault();
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
if (neighbors?.next) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
router.push(neighbors.next.href);
|
||||
}
|
||||
} else if (key === "arrowleft" || key === "a") {
|
||||
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
|
||||
e.preventDefault();
|
||||
if (isVideo) {
|
||||
const v = videoRef?.current;
|
||||
@@ -97,37 +228,134 @@ export function useNavigation({
|
||||
} else {
|
||||
prevImg?.();
|
||||
}
|
||||
} else if (key === "arrowright" || key === "d") {
|
||||
} else if (key === "arrowright" || key === "d" || code === "KeyD") {
|
||||
e.preventDefault();
|
||||
if (isVideo) {
|
||||
const v = videoRef?.current;
|
||||
if (v && v.duration) {
|
||||
v.currentTime = Math.min(v.duration, v.currentTime + 5);
|
||||
const normalizedKey = code === "KeyD" ? "d" : key;
|
||||
// 忽略系统长按重复触发的 keydown,交由定时器统一判定长按阈值
|
||||
if (e.repeat || rightPressKeyRef.current !== null) return;
|
||||
|
||||
rightPressKeyRef.current = normalizedKey;
|
||||
longPressActiveRef.current = false;
|
||||
clearLongPressTimer();
|
||||
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
longPressTimerRef.current = null;
|
||||
if (
|
||||
rightPressKeyRef.current !== null &&
|
||||
!longPressActiveRef.current
|
||||
) {
|
||||
longPressActiveRef.current = true;
|
||||
onFastForwardChangeRef.current?.(true);
|
||||
}
|
||||
} else {
|
||||
nextImg?.();
|
||||
}
|
||||
} else if (key === " ") {
|
||||
}, LONG_PRESS_DELAY_MS);
|
||||
} else if (
|
||||
isVideo &&
|
||||
(code === "BracketLeft" || key === "[" || key === "【" || key === "「")
|
||||
) {
|
||||
if (e.repeat) return;
|
||||
e.preventDefault();
|
||||
onSetAbStartRef.current?.();
|
||||
} else if (
|
||||
isVideo &&
|
||||
(code === "BracketRight" || key === "]" || key === "】" || key === "」")
|
||||
) {
|
||||
if (e.repeat) return;
|
||||
e.preventDefault();
|
||||
onSetAbEndRef.current?.();
|
||||
} else if (
|
||||
isVideo &&
|
||||
(code === "Backslash" || key === "\\" || key === "、")
|
||||
) {
|
||||
if (e.repeat) return;
|
||||
e.preventDefault();
|
||||
onClearAbLoopRef.current?.();
|
||||
} else if (key === " " || code === "Space") {
|
||||
if (e.repeat) return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent): void => {
|
||||
const key = e.key.toLowerCase();
|
||||
const code = e.code;
|
||||
const normalizedKey = code === "KeyD" ? "d" : key;
|
||||
if (normalizedKey !== "arrowright" && normalizedKey !== "d") return;
|
||||
if (rightPressKeyRef.current !== normalizedKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
clearLongPressTimer();
|
||||
const wasLongPress = longPressActiveRef.current;
|
||||
rightPressKeyRef.current = null;
|
||||
longPressActiveRef.current = false;
|
||||
|
||||
if (wasLongPress) {
|
||||
onFastForwardChangeRef.current?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 短按(单击):视频快进 5 秒,图文切换下一张图片
|
||||
if (isVideo) {
|
||||
const v = videoRef?.current;
|
||||
if (v && v.duration) {
|
||||
v.currentTime = Math.min(v.duration, v.currentTime + 5);
|
||||
}
|
||||
} else {
|
||||
nextImg?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.hidden) resetRightKeyPress();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", resetRightKeyPress);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", resetRightKeyPress);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
|
||||
|
||||
// 浏览器返回事件
|
||||
// 组件卸载时确保清理长按定时器并恢复倍速
|
||||
useEffect(() => {
|
||||
window.history.pushState({ interceptBack: true }, "");
|
||||
return () => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
if (longPressActiveRef.current) {
|
||||
longPressActiveRef.current = false;
|
||||
rightPressKeyRef.current = null;
|
||||
onFastForwardChangeRef.current?.(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePopState = () => {
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
if (!document.hidden) {
|
||||
router.push("/");
|
||||
}
|
||||
}, 100);
|
||||
// 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 history 栈
|
||||
useEffect(() => {
|
||||
const currentState = window.history.state as
|
||||
| { interceptBack?: boolean }
|
||||
| null
|
||||
| undefined;
|
||||
if (window.history.length <= 1 && !currentState?.interceptBack) {
|
||||
window.history.pushState({ ...currentState, interceptBack: true }, "");
|
||||
}
|
||||
|
||||
const handlePopState = (): void => {
|
||||
if (window.history.length <= 1) {
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
if (!document.hidden) {
|
||||
router.push("/");
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LoopMode, ObjectFit } from "../types";
|
||||
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
|
||||
import type { LoopMode, ObjectFit } from "../types.ts";
|
||||
import {
|
||||
getNumberFromStorage,
|
||||
getStringFromStorage,
|
||||
saveToStorage,
|
||||
} from "../utils";
|
||||
|
||||
export function usePlayerState() {
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1));
|
||||
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1));
|
||||
const [volume, setVolume] = useState(() =>
|
||||
getNumberFromStorage("aweme_player_volume", 1),
|
||||
);
|
||||
const [rate, setRate] = useState(() =>
|
||||
getNumberFromStorage("aweme_player_rate", 1),
|
||||
);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [progressRestored, setProgressRestored] = useState(false);
|
||||
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
|
||||
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
|
||||
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
|
||||
return saved === "sequential" ? "sequential" : "loop";
|
||||
if (saved === "single" || saved === "sequential") return saved;
|
||||
return "loop";
|
||||
});
|
||||
|
||||
// 持久化音量
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PlaylistItem, PlaylistResponse } from "@/lib/playback-context";
|
||||
|
||||
// 侧栏列表(作者作品 / 搜索结果 / hashtag)缓存在模块里:切换作品时播放页会重新挂载,
|
||||
// 但客户端导航不会清掉 JS 内存,所以在列表里上下切换时可以立刻拿到列表,不用每次重新请求。
|
||||
const STALE_MS = 60_000;
|
||||
|
||||
type CacheEntry = PlaylistResponse & { fetchedAt: number };
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<PlaylistResponse>>();
|
||||
|
||||
function loadPlaylist(query: string): Promise<PlaylistResponse> {
|
||||
const pending = inflight.get(query);
|
||||
if (pending) return pending;
|
||||
const promise = fetch(`/api/playlist${query}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`加载列表失败 (${res.status})`);
|
||||
const data = (await res.json()) as PlaylistResponse;
|
||||
cache.set(query, { ...data, fetchedAt: Date.now() });
|
||||
return data;
|
||||
})
|
||||
.finally(() => inflight.delete(query));
|
||||
inflight.set(query, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export type PlaylistState = {
|
||||
items: PlaylistItem[] | null;
|
||||
/** 列表总数(可能多于 items:侧栏最多列出 500 个) */
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按播放上下文(/api/playlist 的查询串,含 ?)取作品列表。
|
||||
* query 为空或 enabled 为 false 时不请求。
|
||||
*/
|
||||
export function usePlaylist(query: string, enabled: boolean): PlaylistState {
|
||||
const [state, setState] = useState<PlaylistState>(() => {
|
||||
const cached = query ? cache.get(query) : undefined;
|
||||
return {
|
||||
items: cached?.items ?? null,
|
||||
total: cached?.total ?? 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query || !enabled) return;
|
||||
const cached = cache.get(query);
|
||||
setState({
|
||||
items: cached?.items ?? null,
|
||||
total: cached?.total ?? 0,
|
||||
loading: !cached,
|
||||
error: null,
|
||||
});
|
||||
if (cached && Date.now() - cached.fetchedAt < STALE_MS) return;
|
||||
|
||||
let cancelled = false;
|
||||
loadPlaylist(query)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "加载失败",
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/** comments:评论;works:TA的作品;list:搜索结果 / hashtag / 标签列表 */
|
||||
export type SidePanelTab = "comments" | "works" | "list";
|
||||
|
||||
// 侧栏开关沿用评论区原来的 localStorage 键;停在哪个标签页、是否处于作者模式只在本次会话内记住,
|
||||
// 这样下次从首页点进来不会莫名其妙停在作者模式。
|
||||
// 切换作品时播放页会整体重新挂载,靠这些存储把状态带到下一个作品。
|
||||
const OPEN_KEY = "aweme_player_comments_open";
|
||||
const TAB_KEY = "aweme_player_panel_tab";
|
||||
const AUTHOR_MODE_KEY = "aweme_player_author_mode";
|
||||
|
||||
function readStorage(storage: () => Storage, key: string): string | null {
|
||||
try {
|
||||
return storage().getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(
|
||||
storage: () => Storage,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) {
|
||||
try {
|
||||
if (value === null) storage().removeItem(key);
|
||||
else storage().setItem(key, value);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const local = () => window.localStorage;
|
||||
const session = () => window.sessionStorage;
|
||||
|
||||
export function isPortraitViewport(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(orientation: portrait)").matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏状态与作者模式。
|
||||
* 作者模式和侧栏开关是分开的:打开「TA的作品」进入作者模式(上下切换在该作者的作品里移动),
|
||||
* 关掉侧栏不会退出,只有点顶部提示的 ✕、或切到「搜索结果 / #hashtag」列表才退出。
|
||||
*/
|
||||
export function useSidePanelState(authorSecUid: string | undefined) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<SidePanelTab>("comments");
|
||||
// 处于作者模式时记下是哪位作者;切到别的作者的作品(例如浏览器后退)时自动不生效
|
||||
const [authorModeFor, setAuthorModeFor] = useState<string | null>(null);
|
||||
const [restored, setRestored] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(readStorage(local, OPEN_KEY) === "true");
|
||||
const savedTab = readStorage(session, TAB_KEY);
|
||||
setTab(savedTab === "works" || savedTab === "list" ? savedTab : "comments");
|
||||
setAuthorModeFor(readStorage(session, AUTHOR_MODE_KEY));
|
||||
setRestored(true);
|
||||
// 恢复完成后再开启过渡动画,避免进入页面时侧栏滑入
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setMounted(true));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restored) return;
|
||||
writeStorage(local, OPEN_KEY, String(open));
|
||||
writeStorage(session, TAB_KEY, tab);
|
||||
writeStorage(session, AUTHOR_MODE_KEY, authorModeFor);
|
||||
}, [open, tab, authorModeFor, restored]);
|
||||
|
||||
// 切到哪个列表标签页,上下切换就改用哪个列表的顺序;「评论」不影响顺序
|
||||
const applyTabMode = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
if (nextTab === "works" && authorSecUid) setAuthorModeFor(authorSecUid);
|
||||
else if (nextTab === "list") setAuthorModeFor(null);
|
||||
},
|
||||
[authorSecUid],
|
||||
);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
setTab(nextTab);
|
||||
applyTabMode(nextTab);
|
||||
},
|
||||
[applyTabMode],
|
||||
);
|
||||
|
||||
const show = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
selectTab(nextTab);
|
||||
setOpen(true);
|
||||
},
|
||||
[selectTab],
|
||||
);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
/** 打开到指定标签页;已经打开在这个标签页时则关闭(关闭不影响顺序) */
|
||||
const toggle = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
if (open && tab === nextTab) close();
|
||||
else show(nextTab);
|
||||
},
|
||||
[close, open, show, tab],
|
||||
);
|
||||
|
||||
const exitAuthorMode = useCallback(() => setAuthorModeFor(null), []);
|
||||
|
||||
return {
|
||||
open,
|
||||
tab,
|
||||
mounted,
|
||||
/** 上下切换在当前作者的作品里移动 */
|
||||
authorMode: Boolean(authorSecUid) && authorModeFor === authorSecUid,
|
||||
/** 已从存储里恢复完状态(之后才能安全地自动打开侧栏) */
|
||||
restored,
|
||||
selectTab,
|
||||
show,
|
||||
close,
|
||||
toggle,
|
||||
exitAuthorMode,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LoopMode, Neighbors } from "../types";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { AbLoop, LoopMode, Neighbors } from "../types.ts";
|
||||
import { cleanupExpiredProgressStorage, releaseMediaElement } from "../utils";
|
||||
|
||||
interface UseVideoPlayerProps {
|
||||
enabled?: boolean;
|
||||
awemeId: string;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
volume: number;
|
||||
rate: number;
|
||||
loopMode: LoopMode;
|
||||
abLoop?: AbLoop;
|
||||
neighbors: Neighbors;
|
||||
progressRestored: boolean;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
@@ -17,27 +20,41 @@ interface UseVideoPlayerProps {
|
||||
}
|
||||
|
||||
export function useVideoPlayer({
|
||||
enabled = true,
|
||||
awemeId,
|
||||
videoRef,
|
||||
volume,
|
||||
rate,
|
||||
loopMode,
|
||||
abLoop,
|
||||
neighbors,
|
||||
progressRestored,
|
||||
setIsPlaying,
|
||||
setProgress,
|
||||
setProgressRestored,
|
||||
}: UseVideoPlayerProps) {
|
||||
}: UseVideoPlayerProps): void {
|
||||
const router = useRouter();
|
||||
const restoredAwemeIdRef = useRef<string | null>(null);
|
||||
const lastProgressRef = useRef(0);
|
||||
const abLoopRef = useRef<AbLoop | undefined>(abLoop);
|
||||
abLoopRef.current = abLoop;
|
||||
|
||||
// 挂载时清理过期的播放进度缓存
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
cleanupExpiredProgressStorage();
|
||||
}, [enabled]);
|
||||
|
||||
// 恢复播放进度
|
||||
useEffect(() => {
|
||||
if (progressRestored) return;
|
||||
if (!enabled) return;
|
||||
if (progressRestored && restoredAwemeIdRef.current === awemeId) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (progressRestored) return;
|
||||
const onLoadedMetadata = (): void => {
|
||||
if (restoredAwemeIdRef.current === awemeId) return;
|
||||
restoredAwemeIdRef.current = awemeId;
|
||||
|
||||
try {
|
||||
const key = `aweme_progress_${awemeId}`;
|
||||
@@ -47,13 +64,22 @@ export function useVideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
const { time, timestamp } = JSON.parse(saved);
|
||||
const parsed = JSON.parse(saved) as {
|
||||
time?: unknown;
|
||||
timestamp?: unknown;
|
||||
};
|
||||
const time = typeof parsed.time === "number" ? parsed.time : 0;
|
||||
const timestamp =
|
||||
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
|
||||
if (
|
||||
now - timestamp < fiveMinutes &&
|
||||
time > 1 &&
|
||||
time < v.duration - 1
|
||||
) {
|
||||
v.currentTime = time;
|
||||
console.log(`恢复播放进度: ${Math.round(time)}s`);
|
||||
} else if (now - timestamp >= fiveMinutes) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
@@ -66,18 +92,20 @@ export function useVideoPlayer({
|
||||
|
||||
if (v.readyState >= 1) {
|
||||
onLoadedMetadata();
|
||||
} else {
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
return;
|
||||
}
|
||||
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
|
||||
|
||||
// 保存播放进度
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||
|
||||
// 保存播放进度并在卸载/切页时主动释放媒体解码缓冲区
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const saveProgress = () => {
|
||||
const saveProgress = (): void => {
|
||||
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
||||
|
||||
try {
|
||||
@@ -92,80 +120,166 @@ export function useVideoPlayer({
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(saveProgress, 2000);
|
||||
const onBeforeUnload = () => saveProgress();
|
||||
const interval = window.setInterval(saveProgress, 2000);
|
||||
const onBeforeUnload = (): void => saveProgress();
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
saveProgress();
|
||||
releaseMediaElement(v);
|
||||
};
|
||||
}, [awemeId, videoRef]);
|
||||
}, [awemeId, enabled, videoRef]);
|
||||
|
||||
// 监听播放状态和进度
|
||||
// 监听播放状态和进度(含 AB 循环区间控制)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onTime = () => {
|
||||
const syncVideoProgress = (): void => {
|
||||
if (!v.duration || Number.isNaN(v.duration)) return;
|
||||
setProgress(v.currentTime / v.duration);
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
const currentAb = abLoopRef.current;
|
||||
const hasAb = Boolean(
|
||||
currentAb && (currentAb.a !== null || currentAb.b !== null),
|
||||
);
|
||||
|
||||
if (hasAb && currentAb) {
|
||||
const startTime = Math.max(0, currentAb.a ?? 0);
|
||||
const endTime =
|
||||
currentAb.b !== null ? Math.min(v.duration, currentAb.b) : v.duration;
|
||||
|
||||
if (
|
||||
endTime > startTime &&
|
||||
(v.currentTime >= endTime ||
|
||||
(currentAb.b === null && v.currentTime >= v.duration - 0.03))
|
||||
) {
|
||||
v.currentTime = startTime;
|
||||
if (v.paused) {
|
||||
void v.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextProgress = Math.max(0, Math.min(1, v.currentTime / v.duration));
|
||||
if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) {
|
||||
lastProgressRef.current = nextProgress;
|
||||
setProgress(nextProgress);
|
||||
}
|
||||
};
|
||||
|
||||
v.addEventListener("timeupdate", onTime);
|
||||
v.addEventListener("loadedmetadata", onTime);
|
||||
const onPlay = (): void => setIsPlaying(true);
|
||||
const onPause = (): void => setIsPlaying(false);
|
||||
const onEnded = (): void => {
|
||||
const currentAb = abLoopRef.current;
|
||||
if (currentAb && (currentAb.a !== null || currentAb.b !== null)) {
|
||||
const startTime = Math.max(0, currentAb.a ?? 0);
|
||||
v.currentTime = startTime;
|
||||
void v.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(neighbors.next.href);
|
||||
}
|
||||
};
|
||||
|
||||
v.addEventListener("timeupdate", syncVideoProgress);
|
||||
v.addEventListener("loadedmetadata", syncVideoProgress);
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
v.addEventListener("ended", onEnded);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTime);
|
||||
v.removeEventListener("loadedmetadata", onTime);
|
||||
v.removeEventListener("timeupdate", syncVideoProgress);
|
||||
v.removeEventListener("loadedmetadata", syncVideoProgress);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
|
||||
}, [
|
||||
enabled,
|
||||
loopMode,
|
||||
neighbors?.next,
|
||||
router,
|
||||
videoRef,
|
||||
setIsPlaying,
|
||||
setProgress,
|
||||
]);
|
||||
|
||||
// 当启用 AB 循环时,使用 rAF 精确检测终点 B 并无缝跳回起点 A
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!abLoop || (abLoop.a === null && abLoop.b === null)) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
const checkAbLoop = (): void => {
|
||||
const v = videoRef.current;
|
||||
if (v && !v.paused && Number.isFinite(v.duration) && v.duration > 0) {
|
||||
const startTime = Math.max(0, abLoop.a ?? 0);
|
||||
const endTime =
|
||||
abLoop.b !== null ? Math.min(v.duration, abLoop.b) : v.duration;
|
||||
|
||||
if (
|
||||
endTime > startTime &&
|
||||
(v.currentTime >= endTime ||
|
||||
(abLoop.b === null && v.currentTime >= v.duration - 0.03))
|
||||
) {
|
||||
v.currentTime = startTime;
|
||||
const nextProgress = Math.max(0, Math.min(1, startTime / v.duration));
|
||||
lastProgressRef.current = nextProgress;
|
||||
setProgress(nextProgress);
|
||||
}
|
||||
}
|
||||
rafId = window.requestAnimationFrame(checkAbLoop);
|
||||
};
|
||||
|
||||
rafId = window.requestAnimationFrame(checkAbLoop);
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [abLoop, enabled, setProgress, videoRef]);
|
||||
|
||||
// 自动播放检测
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const checkAutoplay = async () => {
|
||||
const checkAutoplay = async (): Promise<void> => {
|
||||
try {
|
||||
await v.play();
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
console.log("自动播放被阻止,需要用户交互");
|
||||
} catch {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (v.readyState >= 1) {
|
||||
checkAutoplay();
|
||||
} else {
|
||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
||||
void checkAutoplay();
|
||||
return;
|
||||
}
|
||||
}, [awemeId, videoRef, setIsPlaying]);
|
||||
|
||||
const onLoaded = (): void => {
|
||||
void checkAutoplay();
|
||||
};
|
||||
v.addEventListener("loadedmetadata", onLoaded, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoaded);
|
||||
}, [awemeId, enabled, videoRef, setIsPlaying]);
|
||||
|
||||
// 更新音量和倍速
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.volume = volume;
|
||||
}, [volume, videoRef]);
|
||||
}, [enabled, volume, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [rate, videoRef]);
|
||||
}, [enabled, rate, videoRef]);
|
||||
}
|
||||
|
||||
+107
-80
@@ -1,17 +1,17 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { Metadata } from "next";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { parsePlaybackContext } from "@/lib/playback-context";
|
||||
import { resolvePlayback } from "@/lib/playback-context.server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { AwemeData, VideoTranscript } from "./types";
|
||||
|
||||
function ms(v?: number | null) {
|
||||
if (!v) return "";
|
||||
const s = Math.round(v / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ awemeId: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const id = (await params).awemeId;
|
||||
|
||||
const [video, post] = await Promise.all([
|
||||
@@ -22,7 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
select: { desc: true, author: { select: { nickname: true } } },
|
||||
})
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = video || post;
|
||||
@@ -34,7 +34,10 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
||||
|
||||
const desc = data.desc || "查看作品详情";
|
||||
const author = data.author.nickname;
|
||||
const title = desc.length > 50 ? `${desc.slice(0, 50)}... - ${author}` : `${desc} - ${author}`;
|
||||
const title =
|
||||
desc.length > 50
|
||||
? `${desc.slice(0, 50)}... - ${author}`
|
||||
: `${desc} - ${author}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
@@ -42,87 +45,104 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
||||
export default async function AwemeDetail({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ awemeId: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const id = (await params).awemeId;
|
||||
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
include: { author: true, transcript: true },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true, images: { orderBy: { order: "asc" } }, comments: { orderBy: { created_at: "desc" }, include: { user: true }, take: 50 } },
|
||||
})
|
||||
include: { author: true, images: { orderBy: { order: "asc" } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||
|
||||
const isVideo = !!video;
|
||||
const data = isVideo
|
||||
? {
|
||||
type: "video" as const,
|
||||
aweme_id: video!.aweme_id,
|
||||
desc: video!.desc,
|
||||
created_at: video!.created_at,
|
||||
duration_ms: video!.duration_ms,
|
||||
video_url: video!.video_url,
|
||||
width: video!.width ?? null,
|
||||
height: video!.height ?? null,
|
||||
author: { nickname: video!.author.nickname, avatar_url: video!.author.avatar_url },
|
||||
comments: video!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
}
|
||||
: {
|
||||
type: "image" as const,
|
||||
aweme_id: post!.aweme_id,
|
||||
desc: post!.desc,
|
||||
created_at: post!.created_at,
|
||||
images: post!.images,
|
||||
music_url: post!.music_url,
|
||||
author: { nickname: post!.author.nickname, avatar_url: post!.author.avatar_url },
|
||||
comments: post!.comments.map((c) => ({
|
||||
cid: c.cid,
|
||||
text: c.text,
|
||||
created_at: c.created_at,
|
||||
digg_count: c.digg_count,
|
||||
user: { nickname: c.user.nickname, avatar_url: c.user.avatar_url },
|
||||
})),
|
||||
};
|
||||
const isVideo = Boolean(video);
|
||||
|
||||
// Compute prev/next neighbors by created_at across videos and image posts
|
||||
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at) as unknown as Date;
|
||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
||||
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
||||
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
||||
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
||||
// 获取评论总数
|
||||
const commentsCount = await prisma.comment.count({
|
||||
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||
});
|
||||
|
||||
let data: AwemeData;
|
||||
let transcript: VideoTranscript | null = null;
|
||||
|
||||
if (video) {
|
||||
transcript = video.transcript ?? null;
|
||||
data = {
|
||||
type: "video",
|
||||
aweme_id: video.aweme_id,
|
||||
desc: video.desc,
|
||||
created_at: video.created_at,
|
||||
likesCount: Number(video.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: video.author.nickname,
|
||||
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: video.author.sec_uid,
|
||||
},
|
||||
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
|
||||
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
|
||||
duration_ms: video.duration_ms,
|
||||
video_url: getFileUrl(video.video_url),
|
||||
width: video.width ?? null,
|
||||
height: video.height ?? null,
|
||||
content_tags: video.manual_content_tags,
|
||||
manual_content_tags: video.manual_content_tags,
|
||||
};
|
||||
} else if (post) {
|
||||
const coverImage = post.images[0];
|
||||
data = {
|
||||
type: "image",
|
||||
aweme_id: post.aweme_id,
|
||||
desc: post.desc,
|
||||
created_at: post.created_at,
|
||||
likesCount: Number(post.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: post.author.nickname,
|
||||
avatar_url: getFileUrl(post.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: post.author.sec_uid,
|
||||
},
|
||||
cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"),
|
||||
cover_size: {
|
||||
h: coverImage?.height ?? 0,
|
||||
w: coverImage?.width ?? 0,
|
||||
},
|
||||
images: post.images.map((img) => ({
|
||||
...img,
|
||||
url: getFileUrl(img.url),
|
||||
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||
})),
|
||||
music_url: getFileUrl(post.music_url || "default-music.mp3"),
|
||||
};
|
||||
} else {
|
||||
return <main className="p-8">找不到该作品</main>;
|
||||
}
|
||||
|
||||
const authorId = video?.authorId ?? post?.authorId ?? null;
|
||||
const [playback, authorWorksCount] = await Promise.all([
|
||||
resolvePlayback(id, parsePlaybackContext(await searchParams)),
|
||||
authorId
|
||||
? Promise.all([
|
||||
prisma.video.count({ where: { authorId } }),
|
||||
prisma.imagePost.count({ where: { authorId } }),
|
||||
]).then(([videos, posts]) => videos + posts)
|
||||
: 0,
|
||||
]);
|
||||
const pickPrev = (() => {
|
||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
||||
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
|
||||
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
|
||||
if (cands.length === 0) return null;
|
||||
cands.sort((a, b) => +a.created_at - +b.created_at);
|
||||
return { aweme_id: cands[0].aweme_id };
|
||||
})();
|
||||
const pickNext = (() => {
|
||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
||||
if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
|
||||
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
|
||||
if (cands.length === 0) return null;
|
||||
cands.sort((a, b) => +b.created_at - +a.created_at);
|
||||
return { aweme_id: cands[0].aweme_id };
|
||||
})();
|
||||
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full overflow-hidden">
|
||||
<main className="min-h-screen w-full">
|
||||
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
||||
<div className="fixed left-3 top-3 z-30">
|
||||
<BackButton
|
||||
@@ -130,7 +150,14 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
|
||||
/>
|
||||
</div>
|
||||
<AwemeDetailClient data={data as any} neighbors={neighbors as any} />
|
||||
<AwemeDetailClient
|
||||
data={data}
|
||||
neighbors={playback.neighbors}
|
||||
playbackContext={playback.context}
|
||||
contextQuery={playback.contextQuery}
|
||||
authorWorksCount={authorWorksCount}
|
||||
transcript={transcript}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type User = { nickname: string; avatar_url: string | null };
|
||||
export type User = {
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
sec_uid?: string;
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
cid: string;
|
||||
@@ -6,38 +10,76 @@ export type Comment = {
|
||||
digg_count: number;
|
||||
created_at: string | Date;
|
||||
user: User;
|
||||
images?: { url: string; width?: number; height?: number }[];
|
||||
};
|
||||
|
||||
export type VideoData = {
|
||||
type: "video";
|
||||
aweme_id: string;
|
||||
cover_url: string;
|
||||
cover_size: { w: number; h: number };
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
duration_ms?: number | null;
|
||||
duration_ms: number | null;
|
||||
video_url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
commentsCount: number;
|
||||
likesCount: number;
|
||||
};
|
||||
|
||||
export type ImageData = {
|
||||
type: "image";
|
||||
aweme_id: string;
|
||||
cover_url: string;
|
||||
cover_size: { w: number; h: number };
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
images: { id: string; url: string; width?: number; height?: number, animated?: string }[];
|
||||
music_url?: string | null;
|
||||
images: {
|
||||
id: string;
|
||||
url: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
animated: string | null;
|
||||
duration: number | null;
|
||||
}[];
|
||||
music_url: string | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
commentsCount: number;
|
||||
likesCount: number;
|
||||
};
|
||||
|
||||
export type AwemeData = VideoData | ImageData;
|
||||
|
||||
export type Neighbors = {
|
||||
prev: { aweme_id: string } | null;
|
||||
next: { aweme_id: string } | null;
|
||||
export type VideoTranscript = {
|
||||
transcript: string[];
|
||||
};
|
||||
|
||||
export type LoopMode = "loop" | "sequential";
|
||||
export type NeighborLink = { aweme_id: string; href: string };
|
||||
|
||||
export type Neighbors = {
|
||||
prev: NeighborLink | null;
|
||||
next: NeighborLink | null;
|
||||
};
|
||||
|
||||
export type PlaybackContextInfo = {
|
||||
kind: "search" | "tag" | "hashtag" | "author";
|
||||
/** 顶部提示里的说明,如「搜索「百姓」」 */
|
||||
label: string;
|
||||
/** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */
|
||||
listTitle: string;
|
||||
/** 从 1 开始 */
|
||||
position: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type LoopMode = "loop" | "single" | "sequential";
|
||||
export type ObjectFit = "contain" | "cover";
|
||||
|
||||
export type AbLoop = {
|
||||
a: number | null;
|
||||
b: number | null;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,9 @@ export function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
// 处理评论文本中的表情占位符
|
||||
export function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] {
|
||||
export function parseCommentText(
|
||||
text: string,
|
||||
): (string | { type: "emoji"; name: string })[] {
|
||||
const parts: (string | { type: "emoji"; name: string })[] = [];
|
||||
const regex = /\[([^\]]+)\]/g;
|
||||
let lastIndex = 0;
|
||||
@@ -51,7 +53,10 @@ export function parseCommentText(text: string): (string | { type: "emoji"; name:
|
||||
}
|
||||
|
||||
// 从 localStorage 获取数值
|
||||
export function getNumberFromStorage(key: string, defaultValue: number): number {
|
||||
export function getNumberFromStorage(
|
||||
key: string,
|
||||
defaultValue: number,
|
||||
): number {
|
||||
if (typeof window === "undefined") return defaultValue;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) return defaultValue;
|
||||
@@ -60,7 +65,10 @@ export function getNumberFromStorage(key: string, defaultValue: number): number
|
||||
}
|
||||
|
||||
// 从 localStorage 获取字符串
|
||||
export function getStringFromStorage(key: string, defaultValue: string): string {
|
||||
export function getStringFromStorage(
|
||||
key: string,
|
||||
defaultValue: string,
|
||||
): string {
|
||||
if (typeof window === "undefined") return defaultValue;
|
||||
return localStorage.getItem(key) || defaultValue;
|
||||
}
|
||||
@@ -83,3 +91,59 @@ export function formatAbsoluteUTC(date: string | Date): string {
|
||||
const s = String(d.getUTCSeconds()).padStart(2, "0");
|
||||
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
|
||||
}
|
||||
|
||||
// 主动释放 HTMLMediaElement 解码缓冲与网络流,防止 SPA 连续切页导致内存/硬件解码器泄漏
|
||||
export function releaseMediaElement(
|
||||
el: HTMLMediaElement | null | undefined,
|
||||
): void {
|
||||
if (!el) return;
|
||||
try {
|
||||
el.pause();
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const PROGRESS_STORAGE_PREFIX = "aweme_progress_";
|
||||
const PROGRESS_TTL_MS = 5 * 60 * 1000;
|
||||
const MAX_PROGRESS_ENTRIES = 30;
|
||||
|
||||
// 清理过期或超量的视频播放进度缓存,避免长期浏览堆积 localStorage
|
||||
export function cleanupExpiredProgressStorage(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const now = Date.now();
|
||||
const validEntries: { key: string; timestamp: number }[] = [];
|
||||
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key || !key.startsWith(PROGRESS_STORAGE_PREFIX)) continue;
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
localStorage.removeItem(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { timestamp?: unknown };
|
||||
const timestamp =
|
||||
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
|
||||
if (now - timestamp >= PROGRESS_TTL_MS) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
validEntries.push({ key, timestamp });
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (validEntries.length > MAX_PROGRESS_ENTRIES) {
|
||||
validEntries.sort((a, b) => b.timestamp - a.timestamp);
|
||||
for (let i = MAX_PROGRESS_ENTRIES; i < validEntries.length; i++) {
|
||||
localStorage.removeItem(validEntries[i].key);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
type BackButtonProps = {
|
||||
className?: string;
|
||||
@@ -18,28 +18,37 @@ type BackButtonProps = {
|
||||
* - Fallback: if close fails (e.g., not opened by script), navigates to '/'
|
||||
* - Uses <Link> so that Ctrl/Cmd-click or middle-click opens the fallback URL in a new tab naturally
|
||||
*/
|
||||
export default function BackButton({ className, ariaLabel = '返回', hrefFallback = '/', children }: BackButtonProps) {
|
||||
export default function BackButton({
|
||||
className,
|
||||
ariaLabel = "返回",
|
||||
hrefFallback = "/",
|
||||
children,
|
||||
}: BackButtonProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>((e) => {
|
||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>(
|
||||
(e) => {
|
||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
||||
return;
|
||||
|
||||
e.preventDefault();
|
||||
e.preventDefault();
|
||||
|
||||
// Try to close the window first
|
||||
if (typeof window !== 'undefined') {
|
||||
window.close();
|
||||
// Try to close the window first
|
||||
if (typeof window !== "undefined") {
|
||||
window.close();
|
||||
|
||||
// If window.close() didn't work (window still open after a short delay),
|
||||
// navigate to the fallback URL
|
||||
setTimeout(() => {
|
||||
if (!document.hidden) {
|
||||
router.push(hrefFallback);
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
}, [router, hrefFallback]);
|
||||
// If window.close() didn't work (window still open after a short delay),
|
||||
// navigate to the fallback URL
|
||||
setTimeout(() => {
|
||||
if (!document.hidden) {
|
||||
router.push(hrefFallback);
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
},
|
||||
[router, hrefFallback],
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
+324
-74
@@ -1,39 +1,70 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import HoverVideo from './HoverVideo';
|
||||
import { ThumbsUp } from 'lucide-react';
|
||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
||||
import { Check, ThumbsUp, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||
import HoverVideo from "./HoverVideo";
|
||||
|
||||
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
|
||||
|
||||
function getAspectRatio(item: FeedItem) {
|
||||
return item.width && item.height ? `${item.width}/${item.height}` : undefined;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialItems: FeedItem[];
|
||||
initialCursor: string | null;
|
||||
fetchUrl?: string;
|
||||
/** 附加在作品链接上的播放上下文查询串(含 ?),决定播放页上下切换的列表 */
|
||||
linkQuery?: string;
|
||||
};
|
||||
|
||||
export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||
type TagUpdateResponse = {
|
||||
updated?: {
|
||||
aweme_id: string;
|
||||
content_tags: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function FeedMasonry({
|
||||
initialItems,
|
||||
initialCursor,
|
||||
fetchUrl = "/api/feed",
|
||||
linkQuery = "",
|
||||
}: Props) {
|
||||
// 哨兵与容器
|
||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ended, setEnded] = useState(!initialCursor);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchTag, setBatchTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
|
||||
const getColumnCount = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 1;
|
||||
if (typeof window === "undefined") return 1;
|
||||
const w = window.innerWidth;
|
||||
if (w >= 1280) return 4; // xl
|
||||
if (w >= 1024) return 3; // lg
|
||||
if (w >= 640) return 2; // sm
|
||||
if (w >= 640) return 2; // sm
|
||||
return 1;
|
||||
}, []);
|
||||
const [columnCount, setColumnCount] = useState<number>(getColumnCount());
|
||||
// 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列),
|
||||
// 这里将初始列数固定为 1,待挂载后再根据窗口宽度更新。
|
||||
const [columnCount, setColumnCount] = useState<number>(1);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载后立即根据当前窗口宽度更新一次列数
|
||||
setColumnCount(getColumnCount());
|
||||
const onResize = () => setColumnCount(getColumnCount());
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [getColumnCount]);
|
||||
|
||||
// 估算卡片高度(用于分配到“最短列”)
|
||||
@@ -41,12 +72,15 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||
// 媒体区域高度
|
||||
let mediaH = 200; // fallback
|
||||
if (item.width && item.height) {
|
||||
mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth);
|
||||
} else if (item.type === 'video') {
|
||||
mediaH = Math.max(
|
||||
80,
|
||||
(Number(item.height) / Number(item.width)) * colWidth,
|
||||
);
|
||||
} else if (item.type === "video") {
|
||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||
}
|
||||
// 文本 + 作者栏的高度粗估
|
||||
const textH = 48; // 标题+标签区域
|
||||
const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域
|
||||
const authorH = 48; // 作者行
|
||||
const gap = 16; // 卡片内边距/间隙
|
||||
return mediaH + textH + authorH + gap;
|
||||
@@ -57,12 +91,15 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||
return cols;
|
||||
});
|
||||
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0));
|
||||
const [colHeights, setColHeights] = useState<number[]>(() =>
|
||||
Array.from({ length: columnCount }, () => 0),
|
||||
);
|
||||
|
||||
// 初始化与当列数变化时重排
|
||||
useEffect(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
const colWidth =
|
||||
columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
// 用 initialItems 重排
|
||||
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
|
||||
@@ -77,23 +114,26 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||
}
|
||||
setColumns(newCols);
|
||||
setColHeights(newHeights);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnCount]);
|
||||
}, [columnCount, estimateItemHeight, initialItems]);
|
||||
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (loading || ended) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (cursor) params.set('before', cursor);
|
||||
params.set('limit', '24');
|
||||
const res = await fetch(`/api/feed?${params.toString()}`, { cache: 'no-store' });
|
||||
if (cursor) params.set("before", cursor);
|
||||
params.set("limit", "24");
|
||||
const url = fetchUrl.includes("?")
|
||||
? `${fetchUrl}&${params.toString()}`
|
||||
: `${fetchUrl}?${params.toString()}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: FeedResponse = await res.json();
|
||||
// 将新数据按最短列分配
|
||||
setColumns((prevCols) => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
const colWidth =
|
||||
columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||
const cols = prevCols.map((c) => [...c]);
|
||||
const heights = [...colHeights];
|
||||
for (const item of data.items) {
|
||||
@@ -111,85 +151,295 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
||||
setCursor(data.nextCursor);
|
||||
if (!data.nextCursor || data.items.length === 0) setEnded(true);
|
||||
} catch (e) {
|
||||
console.error('fetch more feed failed', e);
|
||||
console.error("fetch more feed failed", e);
|
||||
// 失败也不要死循环
|
||||
setEnded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
|
||||
}, [
|
||||
cursor,
|
||||
ended,
|
||||
loading,
|
||||
columnCount,
|
||||
colHeights,
|
||||
estimateItemHeight,
|
||||
fetchUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
fetchMore();
|
||||
}
|
||||
}, { rootMargin: '800px 0px 800px 0px' });
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
fetchMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "800px 0px 800px 0px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [fetchMore]);
|
||||
|
||||
const renderCard = useCallback((item: FeedItem) => (
|
||||
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} target="_blank" className="mb-4 block group">
|
||||
<article className="relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 group-hover:-translate-y-1">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
||||
const toggleSelection = useCallback((awemeId: string) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(awemeId)) next.delete(awemeId);
|
||||
else next.add(awemeId);
|
||||
return next;
|
||||
});
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
const applyUpdatedTags = useCallback(
|
||||
(updates: TagUpdateResponse["updated"]) => {
|
||||
if (!updates?.length) return;
|
||||
const tagMap = new Map(
|
||||
updates.map((item) => [item.aweme_id, item.content_tags] as const),
|
||||
);
|
||||
setColumns((currentColumns) =>
|
||||
currentColumns.map((column) =>
|
||||
column.map((item) =>
|
||||
tagMap.has(item.aweme_id)
|
||||
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
|
||||
: item,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBatchAddTag = useCallback(async () => {
|
||||
const awemeIds = [...selectedIds];
|
||||
if (awemeIds.length === 0 || batchSaving) return;
|
||||
setBatchSaving(true);
|
||||
setBatchError(null);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
|
||||
});
|
||||
if (!response.ok) throw new Error("批量添加标签失败");
|
||||
const data: TagUpdateResponse = await response.json();
|
||||
applyUpdatedTags(data.updated);
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
setBatchError(
|
||||
error instanceof Error ? error.message : "批量添加标签失败",
|
||||
);
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
|
||||
|
||||
const renderCard = useCallback(
|
||||
(item: FeedItem) => {
|
||||
const selected = selectedIds.has(item.aweme_id);
|
||||
return (
|
||||
<article
|
||||
key={item.aweme_id}
|
||||
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
|
||||
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
|
||||
}`}
|
||||
>
|
||||
{item.type === 'video' ? (
|
||||
<HoverVideo
|
||||
videoUrl={(item as any).video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
loading="lazy"
|
||||
src={item.cover_url || '/placeholder.svg'}
|
||||
alt={item.desc?.slice(0, 20) || 'image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{item.type === "video" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSelection(item.aweme_id)}
|
||||
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
|
||||
selected
|
||||
? "border-blue-300 bg-blue-600"
|
||||
: "border-white/25 bg-black/35 hover:bg-black/55"
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? "取消选择视频" : "选择视频"}
|
||||
>
|
||||
{selected && <Check size={17} />}
|
||||
</button>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === 'video' ? '视频' : '图文'}
|
||||
<Link
|
||||
href={`/aweme/${item.aweme_id}${linkQuery}`}
|
||||
target="_blank"
|
||||
className="block relative w-full"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
aspectRatio: getAspectRatio(item),
|
||||
}}
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<HoverVideo
|
||||
videoUrl={item.video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
fill
|
||||
src={item.cover_url || "/placeholder.svg"}
|
||||
alt={item.desc?.slice(0, 20) || "image"}
|
||||
unoptimized
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
{!!item.content_tags?.length && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{item.author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${item.author.sec_uid}`}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||
{item.likes}{" "}
|
||||
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200">
|
||||
{item.author.avatar_url ? (
|
||||
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span>
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300">{item.likes} </span><ThumbsUp size={16} style={{ color: 'var(--color-zinc-700)' }} />
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
), []);
|
||||
</article>
|
||||
);
|
||||
},
|
||||
[selectedIds, toggleSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||
<div ref={containerRef} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="grid gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{columns.map((col, idx) => (
|
||||
<div key={idx} className="flex flex-col">
|
||||
<div key={COLUMN_KEYS[idx]} className="flex flex-col">
|
||||
{col.map((item) => renderCard(item))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div ref={sentinelRef} className="h-10 flex items-center justify-center text-sm text-zinc-500">
|
||||
{ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')}
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
className="h-10 flex items-center justify-center text-sm text-zinc-500"
|
||||
>
|
||||
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||
</div>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 w-[min(92vw,36rem)] -translate-x-1/2 rounded-2xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
已选择 {selectedIds.size} 个视频
|
||||
</span>
|
||||
{batchError && (
|
||||
<span className="text-xs text-rose-300">{batchError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={batchTag}
|
||||
onChange={(event) => setBatchTag(event.target.value)}
|
||||
className="h-9 rounded-full border border-white/15 bg-white/10 px-3 text-sm text-white outline-none"
|
||||
aria-label="选择要添加的标签"
|
||||
>
|
||||
{VIDEO_CONTENT_TAGS.map((tag) => (
|
||||
<option
|
||||
key={tag}
|
||||
value={tag}
|
||||
className="bg-zinc-900 text-white"
|
||||
>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchAddTag}
|
||||
disabled={batchSaving}
|
||||
className="h-9 rounded-full bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||
>
|
||||
{batchSaving ? "添加中..." : "添加标签"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||
aria-label="清空选择"
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
|
||||
type HoverVideoProps = {
|
||||
videoUrl: string;
|
||||
@@ -12,7 +12,12 @@ type HoverVideoProps = {
|
||||
/**
|
||||
* 鼠标移入才加载视频并自动播放,移出暂停并释放资源;默认显示封面。
|
||||
*/
|
||||
export default function HoverVideo({ videoUrl, coverUrl, className, style }: HoverVideoProps) {
|
||||
export default function HoverVideo({
|
||||
videoUrl,
|
||||
coverUrl,
|
||||
className,
|
||||
style,
|
||||
}: HoverVideoProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
@@ -38,14 +43,19 @@ export default function HoverVideo({ videoUrl, coverUrl, className, style }: Hov
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={className} style={style} onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
{/* 封面始终渲染在底层 */}
|
||||
<img
|
||||
src={coverUrl || '/placeholder.svg'}
|
||||
src={coverUrl || "/placeholder.svg"}
|
||||
alt="cover"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
draggable={false}
|
||||
loading='lazy'
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SearchBox({ tag }: { tag?: string | null }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
const params = new URLSearchParams({ q: trimmed });
|
||||
if (tag) params.set("tag", tag);
|
||||
router.push(`/search?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索视频、图文描述或语音内容..."
|
||||
className="w-full bg-white/80 dark:bg-zinc-800/80 border border-zinc-300 dark:border-zinc-700 rounded-full px-5 py-3 pl-12 pr-12 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all shadow-sm"
|
||||
/>
|
||||
<Search
|
||||
size={20}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!query.trim()}
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+48
-5
@@ -3,6 +3,11 @@
|
||||
:root {
|
||||
--background: #161823; /* theme background */
|
||||
--foreground: #ededed;
|
||||
--font-geist-sans:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--font-geist-mono:
|
||||
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -23,17 +28,55 @@ body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
/* 滚动条隐藏 */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.h-screen{
|
||||
/* 搜索结果高亮标记样式 */
|
||||
mark {
|
||||
background-color: rgb(234 179 8 / 0.3); /* yellow-500/30 */
|
||||
color: rgb(253 224 71); /* yellow-300 */
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.h-screen {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.min-h-screen{
|
||||
.min-h-screen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* 评论面板自定义滚动条样式 */
|
||||
.comment-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.comment-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comment-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.comment-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.comment-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
+4
-16
@@ -1,23 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "抖歪 - 记录当下时代",
|
||||
template: "%s - 抖歪",
|
||||
},
|
||||
description: "记录当下时代的精彩瞬间",
|
||||
icons: {
|
||||
icon: "/favicon.webp",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,11 +19,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+129
-34
@@ -1,25 +1,52 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import FeedMasonry from "./components/FeedMasonry";
|
||||
import type { FeedItem } from "./types/feed";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { playbackContextQuery } from "@/lib/playback-context";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||
import FeedMasonry from "./components/FeedMasonry";
|
||||
import SearchBox from "./components/SearchBox";
|
||||
import type { FeedItem } from "./types/feed";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "作品集 - 抖歪",
|
||||
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: { tag?: string };
|
||||
}) {
|
||||
const requestedTag = searchParams?.tag?.trim() || "";
|
||||
const selectedTag =
|
||||
requestedTag && isVideoContentTag(requestedTag) ? requestedTag : null;
|
||||
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: selectedTag
|
||||
? {
|
||||
manual_content_tags: { has: selectedTag },
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
include: {
|
||||
author: true,
|
||||
},
|
||||
}),
|
||||
selectedTag
|
||||
? Promise.resolve([])
|
||||
: prisma.imagePost.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
include: {
|
||||
author: true,
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const feed: FeedItem[] = [
|
||||
@@ -28,39 +55,107 @@ export default async function Home() {
|
||||
aweme_id: v.aweme_id,
|
||||
created_at: v.created_at,
|
||||
desc: v.desc,
|
||||
video_url: v.video_url,
|
||||
cover_url: v.cover_url ?? null,
|
||||
video_url: getFileUrl(v.video_url),
|
||||
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||
width: v.width ?? null,
|
||||
height: v.height ?? null,
|
||||
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null },
|
||||
likes: Number(v.digg_count)
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: p.images?.[0]?.url ?? null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null },
|
||||
likes: Number(p.digg_count)
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
content_tags: v.manual_content_tags,
|
||||
})),
|
||||
...posts.map((p) => {
|
||||
const coverPath = p.images?.[0]?.url;
|
||||
return {
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
};
|
||||
}),
|
||||
]
|
||||
//.sort(() => Math.random() - 0.5)
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full px-4 py-8 md:py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6">
|
||||
作品集
|
||||
</h1>
|
||||
<main className="min-h-screen w-full bg-gray-50 dark:bg-black transition-colors duration-300">
|
||||
<div className="relative pt-16 pb-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center">
|
||||
{/* 装饰背景 */}
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
|
||||
<div className="absolute left-[50%] top-0 h-[40rem] w-[40rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-gradient-to-b from-indigo-500/20 to-transparent blur-3xl dark:from-indigo-500/10" />
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const initial = feed.slice(0, 24);
|
||||
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
||||
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
||||
})()}
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-center mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400">
|
||||
Douyin Archive
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-xl mb-12">
|
||||
<SearchBox tag={selectedTag} />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex w-full max-w-4xl flex-wrap justify-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
selectedTag
|
||||
? "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300"
|
||||
: "border-blue-500 bg-blue-600 text-white"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</Link>
|
||||
{VIDEO_CONTENT_TAGS.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/?tag=${encodeURIComponent(tag)}`}
|
||||
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
selectedTag === tag
|
||||
? "border-blue-500 bg-blue-600 text-white"
|
||||
: "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{(() => {
|
||||
const initial = feed.slice(0, 24);
|
||||
const lastItem = initial.at(-1);
|
||||
const cursor = lastItem
|
||||
? new Date(lastItem.created_at).toISOString()
|
||||
: null;
|
||||
return (
|
||||
<FeedMasonry
|
||||
key={selectedTag ?? "all"}
|
||||
initialItems={initial}
|
||||
initialCursor={cursor}
|
||||
linkQuery={playbackContextQuery(
|
||||
selectedTag ? { kind: "tag", tag: selectedTag } : null,
|
||||
)}
|
||||
fetchUrl={
|
||||
selectedTag
|
||||
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
|
||||
: "/api/feed"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, FileText, Mic, Search, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { playbackContextQuery } from "@/lib/playback-context";
|
||||
import type { SnippetPart } from "@/lib/search";
|
||||
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||
|
||||
// 匹配搜索结果类型(适配后端API)
|
||||
type SearchResultItem = {
|
||||
id: string;
|
||||
awemeId: string;
|
||||
type: "video" | "image";
|
||||
score: number;
|
||||
matchedIn: ("desc" | "transcript")[];
|
||||
descParts: SnippetPart[]; // 完整描述,标出命中位置
|
||||
snippet: SnippetPart[] | null; // 语音转写的命中片段
|
||||
contentTags: string[];
|
||||
video?: {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
video_url: string | null;
|
||||
duration_ms: number;
|
||||
author: {
|
||||
sec_uid: string;
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
imagePost?: {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
author: {
|
||||
sec_uid: string;
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
function renderParts(parts: SnippetPart[]) {
|
||||
let offset = 0;
|
||||
return parts.map((part) => {
|
||||
const key = `${offset}-${part.text}`;
|
||||
offset += part.text.length;
|
||||
return part.hit ? (
|
||||
<mark
|
||||
key={key}
|
||||
className="rounded-sm bg-yellow-400/80 px-0.5 text-zinc-950"
|
||||
>
|
||||
{part.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={key}>{part.text}</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function SearchClient({
|
||||
initialQuery,
|
||||
}: {
|
||||
initialQuery: string;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
// 只采用最近一次请求的结果,避免快速切换关键词时旧结果覆盖新结果
|
||||
const requestIdRef = useRef(0);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
|
||||
const buildSearchHref = useCallback((q: string, tag: string | null) => {
|
||||
const params = new URLSearchParams();
|
||||
const trimmed = q.trim();
|
||||
if (trimmed) params.set("q", trimmed);
|
||||
if (tag) params.set("tag", tag);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/search?${queryString}` : "/search";
|
||||
}, []);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (q: string, tag: string | null, pageNum: number) => {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
page: String(pageNum),
|
||||
});
|
||||
if (q) params.set("q", q);
|
||||
if (tag) params.set("tag", tag);
|
||||
const res = await fetch(`/api/search?${params.toString()}`);
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
return (await res.json()) as {
|
||||
results?: SearchResultItem[];
|
||||
total?: number;
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const performSearch = useCallback(
|
||||
async (q: string, tag: string | null) => {
|
||||
const trimmed = q.trim();
|
||||
const requestId = ++requestIdRef.current;
|
||||
if (!trimmed && !tag) {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setSearched(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
setPage(1);
|
||||
try {
|
||||
const data = await fetchPage(trimmed, tag, 1);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setResults(data.results || []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (err) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
console.error("Search error:", err);
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchPage],
|
||||
);
|
||||
|
||||
const loadMore = async () => {
|
||||
const q = (searchParams.get("q") || "").trim();
|
||||
const tagParam = searchParams.get("tag") || "";
|
||||
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
|
||||
const requestId = requestIdRef.current;
|
||||
const nextPage = page + 1;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const data = await fetchPage(q, tag, nextPage);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setResults((prev) => {
|
||||
const seen = new Set(prev.map((r) => r.id));
|
||||
return [
|
||||
...prev,
|
||||
...(data.results || []).filter((r) => !seen.has(r.id)),
|
||||
];
|
||||
});
|
||||
setTotal(data.total ?? 0);
|
||||
setPage(nextPage);
|
||||
} catch (err) {
|
||||
console.error("Search error:", err);
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q") || "";
|
||||
const tagParam = searchParams.get("tag") || "";
|
||||
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
|
||||
setQuery(q);
|
||||
setSelectedTag(tag);
|
||||
if (q || tag) {
|
||||
performSearch(q, tag);
|
||||
} else {
|
||||
requestIdRef.current++;
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setSearched(false);
|
||||
}
|
||||
}, [searchParams, performSearch]);
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed && !selectedTag) return;
|
||||
router.push(buildSearchHref(trimmed, selectedTag));
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: string) => {
|
||||
const nextTag = selectedTag === tag ? null : tag;
|
||||
setSelectedTag(nextTag);
|
||||
router.push(buildSearchHref(query, nextTag));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery("");
|
||||
setSelectedTag(null);
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setSearched(false);
|
||||
router.push("/search");
|
||||
};
|
||||
|
||||
// 从搜索结果进入播放页时,上下切换在这次的搜索结果里移动(与当前 URL 上的搜索条件一致)
|
||||
const resultLinkQuery = useMemo(() => {
|
||||
const q = (searchParams.get("q") || "").trim();
|
||||
const tagParam = searchParams.get("tag") || "";
|
||||
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
|
||||
if (!q && !tag) return "";
|
||||
return playbackContextQuery({ kind: "search", q, tag });
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white">
|
||||
{/* 顶部搜索栏 */}
|
||||
<header className="sticky top-0 z-50 bg-zinc-900/80 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="返回首页"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
</button>
|
||||
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className="flex-1 flex items-center gap-3"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索视频、图文描述或语音内容..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all"
|
||||
/>
|
||||
<Search
|
||||
size={20}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
||||
/>
|
||||
{(query || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="清空"
|
||||
>
|
||||
<X size={18} className="text-white/60" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || (!query.trim() && !selectedTag)}
|
||||
>
|
||||
{loading ? "搜索中..." : "搜索"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 pb-4">
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{VIDEO_CONTENT_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
className={`flex-shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
selectedTag === tag
|
||||
? "border-blue-400 bg-blue-500/20 text-blue-200"
|
||||
: "border-white/10 bg-white/5 text-white/60 hover:border-white/20 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||
<p className="text-white/60">正在搜索...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && searched && results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-white/60">
|
||||
<Search size={64} className="mb-4 opacity-20" />
|
||||
<p className="text-xl">未找到相关内容</p>
|
||||
<p className="text-sm mt-2">试试其他关键词</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !searched && !query && !selectedTag && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
||||
<Search size={64} className="mb-4 opacity-20" />
|
||||
<p className="text-xl">输入关键词开始搜索</p>
|
||||
<p className="text-sm mt-2">
|
||||
按原文匹配描述和语音转写,多个关键词用空格分隔,以 - 开头表示排除
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg text-white/80">
|
||||
找到 <span className="text-white font-semibold">{total}</span>{" "}
|
||||
个结果
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 单列列表布局 */}
|
||||
<div className="space-y-4">
|
||||
{results.map((item) => {
|
||||
const content =
|
||||
item.type === "video" ? item.video : item.imagePost;
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl overflow-hidden hover:border-white/20 transition-all hover:-translate-y-1"
|
||||
>
|
||||
{/* 主内容区 */}
|
||||
<div className="flex gap-4 p-4">
|
||||
{/* 左侧:封面预览 */}
|
||||
<Link
|
||||
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
|
||||
target="_blank"
|
||||
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
||||
>
|
||||
{content.cover_url ? (
|
||||
<Image
|
||||
src={content.cover_url}
|
||||
alt={content.desc}
|
||||
width={280}
|
||||
height={384}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-zinc-800" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* 右侧:信息区 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* 类型标签 */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
item.type === "video"
|
||||
? "bg-blue-600/20 text-blue-300"
|
||||
: "bg-purple-600/20 text-purple-300"
|
||||
}`}
|
||||
>
|
||||
{item.type === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
{item.matchedIn.includes("desc") && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
|
||||
<FileText size={12} />
|
||||
描述命中
|
||||
</span>
|
||||
)}
|
||||
{item.matchedIn.includes("transcript") && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
|
||||
<Mic size={12} />
|
||||
语音命中
|
||||
</span>
|
||||
)}
|
||||
{item.contentTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-0.5 text-xs rounded bg-blue-500/15 text-blue-200"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<Link
|
||||
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
|
||||
target="_blank"
|
||||
>
|
||||
<p className="text-white/90 text-sm sm:text-base line-clamp-3 mb-3 hover:text-white transition-colors">
|
||||
{item.descParts.length > 0
|
||||
? renderParts(item.descParts)
|
||||
: "无描述"}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
{/* 作者信息 */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{content.author.avatar_url ? (
|
||||
<Image
|
||||
src={content.author.avatar_url}
|
||||
alt={content.author.nickname}
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-6 h-6 rounded-full ring-1 ring-white/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-full bg-white/10 ring-1 ring-white/20" />
|
||||
)}
|
||||
<span className="text-xs text-white/60">
|
||||
{content.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 语音转写片段:命中时展示命中位置附近的文字 */}
|
||||
{item.snippet && item.snippet.length > 0 && (
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium text-green-300 mb-1.5">
|
||||
<Mic size={14} />
|
||||
{item.matchedIn.includes("transcript")
|
||||
? "语音内容"
|
||||
: "语音转写"}
|
||||
</h4>
|
||||
<div
|
||||
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{renderParts(item.snippet)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{results.length < total && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-6 py-2.5 text-sm text-white/80 transition-colors hover:border-white/20 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{loadingMore
|
||||
? "加载中..."
|
||||
: `加载更多(还有 ${total - results.length} 个)`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// 不要写 "use client"
|
||||
import { Suspense } from "react";
|
||||
import SearchClient from "./SearchClient";
|
||||
|
||||
export default function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { q?: string };
|
||||
}) {
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q : "";
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6 text-zinc-400">Loading…</div>}>
|
||||
<SearchClient initialQuery={q} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+270
-95
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, CheckCircle2, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clipboard,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Link2,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
type TaskStatus = "pending" | "running" | "success" | "error";
|
||||
|
||||
@@ -22,7 +35,7 @@ const extractDouyinLinks = (text: string): string[] => {
|
||||
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
||||
const cleaned = matches
|
||||
.map((m) => m.replace(trailing, ""))
|
||||
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾
|
||||
.map((m) => (m.endsWith("/") ? m : m)); // 保持原样,通常短链以 / 结尾
|
||||
// 去重
|
||||
return Array.from(new Set(cleaned));
|
||||
};
|
||||
@@ -43,58 +56,110 @@ export default function TasksPage() {
|
||||
}, []);
|
||||
|
||||
const inProgressUrls = useMemo(
|
||||
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
||||
[tasks]
|
||||
() =>
|
||||
new Set(
|
||||
tasks
|
||||
.filter((t) => t.status === "pending" || t.status === "running")
|
||||
.map((t) => t.url),
|
||||
),
|
||||
[tasks],
|
||||
);
|
||||
|
||||
const addTasks = useCallback((urls: string[]) => {
|
||||
if (!urls.length) return;
|
||||
const now = Date.now();
|
||||
setTasks((prev) => {
|
||||
const existing = new Set(prev.map((t) => t.id));
|
||||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
||||
const newTasks: Task[] = [];
|
||||
for (const url of notDuplicated) {
|
||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
newTasks.push({ id, url, status: "pending" });
|
||||
}
|
||||
// 新任务添加到最前面
|
||||
return [...newTasks, ...prev];
|
||||
});
|
||||
}, [inProgressUrls]);
|
||||
const addTasks = useCallback(
|
||||
(urls: string[]) => {
|
||||
if (!urls.length) return;
|
||||
const now = Date.now();
|
||||
setTasks((prev) => {
|
||||
const existing = new Set(prev.map((t) => t.id));
|
||||
const notDuplicated = urls.filter((u) => !inProgressUrls.has(u));
|
||||
const newTasks: Task[] = [];
|
||||
for (const url of notDuplicated) {
|
||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
newTasks.push({ id, url, status: "pending" });
|
||||
}
|
||||
// 新任务添加到最前面
|
||||
return [...newTasks, ...prev];
|
||||
});
|
||||
},
|
||||
[inProgressUrls],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback((e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const urls = extractDouyinLinks(input);
|
||||
if (!urls.length) {
|
||||
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本");
|
||||
return;
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const urls = extractDouyinLinks(input);
|
||||
if (!urls.length) {
|
||||
alert(
|
||||
"未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本",
|
||||
);
|
||||
return;
|
||||
}
|
||||
addTasks(urls);
|
||||
setInput("");
|
||||
},
|
||||
[input, addTasks],
|
||||
);
|
||||
|
||||
const handlePasteAndAdd = useCallback(async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const urls = extractDouyinLinks(text);
|
||||
if (!urls.length) {
|
||||
alert(
|
||||
"剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本",
|
||||
);
|
||||
return;
|
||||
}
|
||||
addTasks(urls);
|
||||
} catch (err) {
|
||||
alert("无法读取剪贴板内容,请检查浏览器权限设置");
|
||||
console.error("Clipboard read error:", err);
|
||||
}
|
||||
addTasks(urls);
|
||||
setInput("");
|
||||
}, [input, addTasks]);
|
||||
}, [addTasks]);
|
||||
|
||||
const startTask = useCallback(async (task: Task) => {
|
||||
// 若已存在控制器,避免重复启动
|
||||
if (controllers.current.has(task.id)) return;
|
||||
const ctrl = new AbortController();
|
||||
controllers.current.set(task.id, ctrl);
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? { ...t, status: "running", startedAt: Date.now(), error: undefined }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`/api/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
||||
const res = await fetch(
|
||||
`/api/fetcher?url=${encodeURIComponent(task.url)}`,
|
||||
{ signal: ctrl.signal, method: "GET" },
|
||||
);
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
// 使用后端返回的结构化错误信息
|
||||
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
||||
const errorCode = data?.code || 'UNKNOWN';
|
||||
const errorCode = data?.code || "UNKNOWN";
|
||||
throw new Error(`${errorMsg} (${errorCode})`);
|
||||
}
|
||||
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? { ...t, status: "success", finishedAt: Date.now(), result: data }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
} catch (err: any) {
|
||||
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err));
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "error", finishedAt: Date.now(), error: msg } : t));
|
||||
const msg =
|
||||
err?.name === "AbortError" ? "已取消" : err?.message || String(err);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? { ...t, status: "error", finishedAt: Date.now(), error: msg }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
controllers.current.delete(task.id);
|
||||
}
|
||||
@@ -102,17 +167,17 @@ export default function TasksPage() {
|
||||
|
||||
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
||||
useEffect(() => {
|
||||
const pending = tasks.filter(t => t.status === "pending");
|
||||
const pending = tasks.filter((t) => t.status === "pending");
|
||||
pending.forEach((t) => startTask(t));
|
||||
}, [tasks, startTask]);
|
||||
|
||||
// 定时器更新运行中任务的耗时显示
|
||||
useEffect(() => {
|
||||
const hasRunningTasks = tasks.some(t => t.status === "running");
|
||||
const hasRunningTasks = tasks.some((t) => t.status === "running");
|
||||
if (!hasRunningTasks) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTick(prev => prev + 1);
|
||||
setTick((prev) => prev + 1);
|
||||
}, 1000); // 每秒更新一次
|
||||
|
||||
return () => clearInterval(timer);
|
||||
@@ -125,31 +190,45 @@ export default function TasksPage() {
|
||||
}, []);
|
||||
|
||||
const retryTask = useCallback((taskId: string) => {
|
||||
setTasks(prev => prev.map(t => {
|
||||
if (t.id === taskId) {
|
||||
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined };
|
||||
}
|
||||
return t;
|
||||
}));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id === taskId) {
|
||||
return {
|
||||
...t,
|
||||
status: "pending" as TaskStatus,
|
||||
error: undefined,
|
||||
result: undefined,
|
||||
};
|
||||
}
|
||||
return t;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearFinished = useCallback(() => {
|
||||
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running"));
|
||||
setTasks((prev) =>
|
||||
prev.filter((t) => t.status === "pending" || t.status === "running"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleOpen = useCallback((id: string) => {
|
||||
setOpenDetails(prev => {
|
||||
setOpenDetails((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
})
|
||||
});
|
||||
}, []);
|
||||
|
||||
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]);
|
||||
const extractedCount = useMemo(
|
||||
() => extractDouyinLinks(input).length,
|
||||
[input],
|
||||
);
|
||||
|
||||
const formatDuration = (t?: number) => {
|
||||
if (!t) return "";
|
||||
const secs = Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
const formatDuration = (startTime?: number, endTime?: number) => {
|
||||
if (!startTime) return "";
|
||||
const end = endTime || Date.now();
|
||||
const secs = Math.max(0, Math.round((end - startTime) / 1000));
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
@@ -157,11 +236,31 @@ export default function TasksPage() {
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
||||
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
||||
if (status === 'running') return <span className={`${base} bg-indigo-500/15 text-indigo-300`}><Loader2 className="h-3.5 w-3.5 animate-spin"/> 进行中</span>;
|
||||
if (status === 'pending') return <span className={`${base} bg-yellow-500/15 text-yellow-300`}><Clock className="h-3.5 w-3.5"/> 待开始</span>;
|
||||
if (status === 'success') return <span className={`${base} bg-emerald-500/15 text-emerald-300`}><CheckCircle2 className="h-3.5 w-3.5"/> 完成</span>;
|
||||
return <span className={`${base} bg-red-500/15 text-red-300`}><AlertTriangle className="h-3.5 w-3.5"/> 失败</span>;
|
||||
const base =
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
||||
if (status === "running")
|
||||
return (
|
||||
<span className={`${base} bg-indigo-500/15 text-indigo-300`}>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 进行中
|
||||
</span>
|
||||
);
|
||||
if (status === "pending")
|
||||
return (
|
||||
<span className={`${base} bg-yellow-500/15 text-yellow-300`}>
|
||||
<Clock className="h-3.5 w-3.5" /> 待开始
|
||||
</span>
|
||||
);
|
||||
if (status === "success")
|
||||
return (
|
||||
<span className={`${base} bg-emerald-500/15 text-emerald-300`}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> 完成
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className={`${base} bg-red-500/15 text-red-300`}>
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> 失败
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -176,11 +275,16 @@ export default function TasksPage() {
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||
抖音采集任务
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p>
|
||||
<p className="mt-2 text-sm text-neutral-400">
|
||||
粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 输入卡片 */}
|
||||
<form onSubmit={handleSubmit} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
|
||||
<Link2 className="h-5 w-5 text-neutral-400" />
|
||||
@@ -194,15 +298,31 @@ export default function TasksPage() {
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>已提取 {extractedCount} 个短链</span>
|
||||
<span className="hidden sm:inline">快捷键:Ctrl / Cmd + Enter 提交</span>
|
||||
<span className="hidden sm:inline">
|
||||
快捷键:Ctrl / Cmd + Enter 提交
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button type="submit" className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40">
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> 添加任务
|
||||
</button>
|
||||
<button type="button" onClick={clearFinished} className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasteAndAdd}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
|
||||
>
|
||||
<Clipboard className="h-4 w-4" /> 粘贴并添加
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFinished}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> 清除已完成
|
||||
</button>
|
||||
</div>
|
||||
@@ -212,64 +332,91 @@ export default function TasksPage() {
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">进行中的任务</h2>
|
||||
<span className="text-xs text-neutral-400">共 {tasks.length} 个</span>
|
||||
<span className="text-xs text-neutral-400">
|
||||
共 {tasks.length} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{tasks.map((t) => {
|
||||
const isOpen = openDetails.has(t.id);
|
||||
return (
|
||||
<li key={t.id} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
||||
<li
|
||||
key={t.id}
|
||||
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={t.status} />
|
||||
{t.status === 'running' && (
|
||||
<span className="text-xs text-neutral-400">已耗时 {formatDuration(t.startedAt)}</span>
|
||||
{t.status === "running" && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
已耗时 {formatDuration(t.startedAt)}
|
||||
</span>
|
||||
)}
|
||||
{t.status === 'success' && (
|
||||
<span className="text-xs text-neutral-400">用时 {t.startedAt ? formatDuration(t.startedAt) : '--'}</span>
|
||||
{t.status === "success" && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
用时 {formatDuration(t.startedAt, t.finishedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
||||
<span className="truncate" title={t.url}>{t.url}</span>
|
||||
<a className="shrink-0 text-neutral-400 hover:text-neutral-200" href={t.url} target="_blank" rel="noreferrer">
|
||||
<span className="truncate" title={t.url}>
|
||||
{t.url}
|
||||
</span>
|
||||
<a
|
||||
className="shrink-0 text-neutral-400 hover:text-neutral-200"
|
||||
href={t.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{t.status === 'success' && t.result?.data?.aweme_id && (
|
||||
{t.status === "success" && t.result?.data?.aweme_id && (
|
||||
<a
|
||||
href={`/aweme/${t.result.data.aweme_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5"/> 查看作品
|
||||
<ExternalLink className="h-3.5 w-3.5" /> 查看作品
|
||||
</a>
|
||||
)}
|
||||
{t.status === 'error' && (
|
||||
{t.status === "error" && (
|
||||
<button
|
||||
onClick={() => retryTask(t.id)}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5"/> 重试
|
||||
<PlayCircle className="h-3.5 w-3.5" /> 重试
|
||||
</button>
|
||||
)}
|
||||
{t.status === 'running' && (
|
||||
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600">
|
||||
<Square className="h-3.5 w-3.5"/> 取消
|
||||
{t.status === "running" && (
|
||||
<button
|
||||
onClick={() => cancelTask(t.id)}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" /> 取消
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => toggleOpen(t.id)} className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700">
|
||||
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'}
|
||||
<button
|
||||
onClick={() => toggleOpen(t.id)}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700"
|
||||
>
|
||||
{isOpen ? (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}{" "}
|
||||
{isOpen ? "收起" : "详情"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{t.status === 'running' && (
|
||||
{t.status === "running" && (
|
||||
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
|
||||
<div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
|
||||
</div>
|
||||
@@ -277,13 +424,17 @@ export default function TasksPage() {
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
||||
{t.status === 'error' && t.error && (
|
||||
{t.status === "error" && t.error && (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400"/>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-red-200">爬取失败</div>
|
||||
<div className="mt-1 text-xs text-red-300/90">{t.error}</div>
|
||||
<div className="text-sm font-medium text-red-200">
|
||||
爬取失败
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-red-300/90">
|
||||
{t.error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400">
|
||||
@@ -296,34 +447,58 @@ export default function TasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof t.result !== 'undefined' && (
|
||||
{typeof t.result !== "undefined" && (
|
||||
<div>
|
||||
{t.result?.data?.aweme && (
|
||||
<div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400"/>
|
||||
<span className="font-medium text-emerald-200">作品信息</span>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
<span className="font-medium text-emerald-200">
|
||||
作品信息
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-neutral-300">
|
||||
<div><span className="text-neutral-400">ID:</span> {t.result.data.aweme.aweme_id}</div>
|
||||
<div><span className="text-neutral-400">描述:</span> {t.result.data.aweme.desc || '(无)'}</div>
|
||||
<div>
|
||||
<span className="text-neutral-400">ID:</span>{" "}
|
||||
{t.result.data.aweme.aweme_id}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-neutral-400">
|
||||
描述:
|
||||
</span>{" "}
|
||||
{t.result.data.aweme.desc || "(无)"}
|
||||
</div>
|
||||
{t.result.data.aweme.author && (
|
||||
<div><span className="text-neutral-400">作者:</span> {t.result.data.aweme.author.nickname}</div>
|
||||
<div>
|
||||
<span className="text-neutral-400">
|
||||
作者:
|
||||
</span>{" "}
|
||||
{t.result.data.aweme.author.nickname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
|
||||
查看完整响应数据 <span className="group-open:hidden">▶</span><span className="hidden group-open:inline">▼</span>
|
||||
查看完整响应数据{" "}
|
||||
<span className="group-open:hidden">▶</span>
|
||||
<span className="hidden group-open:inline">
|
||||
▼
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">
|
||||
{JSON.stringify(t.result, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
||||
<div className="text-xs text-neutral-400">暂无输出</div>
|
||||
)}
|
||||
{typeof t.result === "undefined" &&
|
||||
t.status !== "error" && (
|
||||
<div className="text-xs text-neutral-400">
|
||||
暂无输出
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
+16
-13
@@ -1,19 +1,22 @@
|
||||
export type FeedItem =
|
||||
| ({
|
||||
export type FeedItem = (
|
||||
| {
|
||||
type: "video";
|
||||
video_url: string;
|
||||
} | {
|
||||
}
|
||||
| {
|
||||
type: "image";
|
||||
}) & {
|
||||
likes: number;
|
||||
author: { nickname: string; avatar_url: string | null };
|
||||
aweme_id: string;
|
||||
created_at: Date | string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
};
|
||||
}
|
||||
) & {
|
||||
likes: number;
|
||||
author: { nickname: string; avatar_url: string | null; sec_uid?: string };
|
||||
aweme_id: string;
|
||||
created_at: Date | string;
|
||||
desc: string;
|
||||
cover_url: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
content_tags?: string[];
|
||||
};
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItem[];
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "douyin-archive",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.3",
|
||||
"chalk": "^5.6.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "15.5.6",
|
||||
"playwright": "^1.56.1",
|
||||
"next": "15.5.7",
|
||||
"playwright": "1.56.1",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tsx": "^4.20.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
@@ -48,6 +53,58 @@
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
|
||||
@@ -106,23 +163,23 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@next/env": ["@next/env@15.5.6", "", {}, "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q=="],
|
||||
"@next/env": ["@next/env@15.5.7", "https://registry.npmmirror.com/@next/env/-/env-15.5.7.tgz", {}, "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
|
||||
|
||||
@@ -172,6 +229,10 @@
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.22", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
@@ -180,12 +241,18 @@
|
||||
|
||||
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
|
||||
|
||||
"arr-union": ["arr-union@3.1.0", "", {}, "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||
@@ -210,14 +277,22 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clone-deep": ["clone-deep@0.2.4", "", { "dependencies": { "for-own": "^0.1.3", "is-plain-object": "^2.0.1", "kind-of": "^3.0.2", "lazy-cache": "^1.0.3", "shallow-clone": "^0.1.2" } }, "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@@ -244,6 +319,8 @@
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
||||
@@ -256,6 +333,14 @@
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
|
||||
|
||||
"for-own": ["for-own@0.1.5", "", { "dependencies": { "for-in": "^1.0.1" } }, "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw=="],
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
@@ -268,6 +353,8 @@
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
@@ -280,22 +367,38 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
|
||||
|
||||
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
|
||||
|
||||
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||
|
||||
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
|
||||
|
||||
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
|
||||
"kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"lazy-cache": ["lazy-cache@1.0.4", "", {}, "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
@@ -326,19 +429,27 @@
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"merge-deep": ["merge-deep@3.0.3", "", { "dependencies": { "arr-union": "^3.1.0", "clone-deep": "^0.2.4", "kind-of": "^3.0.2" } }, "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"mixin-object": ["mixin-object@2.0.1", "", { "dependencies": { "for-in": "^0.1.3", "is-extendable": "^0.1.1" } }, "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="],
|
||||
"next": ["next@15.5.7", "https://registry.npmmirror.com/next/-/next-15.5.7.tgz", { "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
@@ -346,6 +457,10 @@
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
@@ -358,12 +473,22 @@
|
||||
|
||||
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
|
||||
|
||||
"playwright-extra": ["playwright-extra@4.3.6", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "playwright": "*", "playwright-core": "*" }, "optionalPeers": ["playwright", "playwright-core"] }, "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"prisma": ["prisma@6.17.1", "", { "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g=="],
|
||||
|
||||
"puppeteer-extra-plugin": ["puppeteer-extra-plugin@3.2.3", "", { "dependencies": { "@types/debug": "^4.1.0", "debug": "^4.1.1", "merge-deep": "^3.0.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q=="],
|
||||
|
||||
"puppeteer-extra-plugin-stealth": ["puppeteer-extra-plugin-stealth@2.11.2", "", { "dependencies": { "debug": "^4.1.1", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-preferences": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ=="],
|
||||
|
||||
"puppeteer-extra-plugin-user-data-dir": ["puppeteer-extra-plugin-user-data-dir@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^10.0.0", "puppeteer-extra-plugin": "^3.2.3", "rimraf": "^3.0.2" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g=="],
|
||||
|
||||
"puppeteer-extra-plugin-user-preferences": ["puppeteer-extra-plugin-user-preferences@2.4.1", "", { "dependencies": { "debug": "^4.1.1", "deepmerge": "^4.2.2", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-data-dir": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||
@@ -378,6 +503,8 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
@@ -390,6 +517,8 @@
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"shallow-clone": ["shallow-clone@0.1.2", "", { "dependencies": { "is-extendable": "^0.1.1", "kind-of": "^2.0.1", "lazy-cache": "^0.2.3", "mixin-object": "^2.0.1" } }, "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw=="],
|
||||
|
||||
"sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -420,10 +549,14 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
@@ -432,6 +565,8 @@
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
@@ -450,6 +585,14 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"mixin-object/for-in": ["for-in@0.1.8", "", {}, "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"shallow-clone/kind-of": ["kind-of@2.0.1", "", { "dependencies": { "is-buffer": "^1.0.2" } }, "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg=="],
|
||||
|
||||
"shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="],
|
||||
|
||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// scripts/fix-asset-urls.ts
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const FROM = "douyin-archive/";
|
||||
const TO = "";
|
||||
|
||||
function escapeForPgRegex(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串
|
||||
const dryRun = false; // true: 只统计,不修改
|
||||
|
||||
async function main() {
|
||||
if (dryRun) {
|
||||
const rows = await prisma.$queryRawUnsafe<any[]>(`
|
||||
WITH c AS (
|
||||
SELECT 'Author.avatar_url' AS col, COUNT(*) AS n FROM "Author" WHERE avatar_url LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'CommentUser.avatar_url' , COUNT(*) FROM "CommentUser" WHERE avatar_url LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'CommentImage.url' , COUNT(*) FROM "CommentImage" WHERE url LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'Video.cover_url' , COUNT(*) FROM "Video" WHERE cover_url LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'Video.video_url' , COUNT(*) FROM "Video" WHERE video_url LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'ImageFile.url' , COUNT(*) FROM "ImageFile" WHERE url LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'ImageFile.animated' , COUNT(*) FROM "ImageFile" WHERE animated LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'ImagePost.music_url' , COUNT(*) FROM "ImagePost" WHERE music_url LIKE '${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'Video.raw_json' , COUNT(*) FROM "Video" WHERE raw_json::text LIKE '%${FROM}%'
|
||||
UNION ALL
|
||||
SELECT 'ImagePost.raw_json' , COUNT(*) FROM "ImagePost" WHERE raw_json::text LIKE '%${FROM}%'
|
||||
)
|
||||
SELECT * FROM c ORDER BY col;
|
||||
`);
|
||||
console.table(rows);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// CommentUser.avatar_url
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "CommentUser"
|
||||
SET avatar_url = regexp_replace(avatar_url, '${FROM_RE}', '${TO}')
|
||||
WHERE avatar_url LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// CommentImage.url
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "CommentImage"
|
||||
SET url = regexp_replace(url, '${FROM_RE}', '${TO}')
|
||||
WHERE url LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// Author.avatar_url
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "Author"
|
||||
SET avatar_url = regexp_replace(avatar_url, '${FROM_RE}', '${TO}')
|
||||
WHERE avatar_url LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// Video.cover_url
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "Video"
|
||||
SET cover_url = regexp_replace(cover_url, '${FROM_RE}', '${TO}')
|
||||
WHERE cover_url LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// Video.video_url
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "Video"
|
||||
SET video_url = regexp_replace(video_url, '${FROM_RE}', '${TO}')
|
||||
WHERE video_url LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// ImageFile.url
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "ImageFile"
|
||||
SET url = regexp_replace(url, '${FROM_RE}', '${TO}')
|
||||
WHERE url LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// ImageFile.animated
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "ImageFile"
|
||||
SET animated = regexp_replace(animated, '${FROM_RE}', '${TO}')
|
||||
WHERE animated LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// ImagePost.music_url
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "ImagePost"
|
||||
SET music_url = regexp_replace(music_url, '${FROM_RE}', '${TO}')
|
||||
WHERE music_url LIKE '${FROM}%'
|
||||
`);
|
||||
|
||||
// (可选)raw_json:简单文本整体替换;若想更精细可用递归 JSONB 方法
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "Video"
|
||||
SET raw_json = to_jsonb(replace(raw_json::text, '${FROM}', '${TO}'))
|
||||
WHERE raw_json::text LIKE '%${FROM}%'
|
||||
`);
|
||||
await tx.$executeRawUnsafe(`
|
||||
UPDATE "ImagePost"
|
||||
SET raw_json = to_jsonb(replace(raw_json::text, '${FROM}', '${TO}'))
|
||||
WHERE raw_json::text LIKE '%${FROM}%'
|
||||
`);
|
||||
});
|
||||
|
||||
// 快速抽样
|
||||
const sample = await prisma.$queryRawUnsafe<any[]>(`
|
||||
(
|
||||
SELECT 'CommentImage.url' AS col, url AS v FROM "CommentImage" WHERE url LIKE '${TO}%' LIMIT 2
|
||||
) UNION ALL (
|
||||
SELECT 'CommentUser.avatar_url', avatar_url FROM "CommentUser" WHERE avatar_url LIKE '${TO}%' LIMIT 2
|
||||
) UNION ALL (
|
||||
SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2
|
||||
)
|
||||
`);
|
||||
console.log("Sample after update:", sample);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
HASHTAG_EXTRACT_PG,
|
||||
META_HASHTAG_PG,
|
||||
META_HASHTAGS,
|
||||
} from "@/lib/hashtag";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type RelatedHashtag = {
|
||||
name: string;
|
||||
/** 和目标 tag 一起出现的作品数 */
|
||||
together: number;
|
||||
/** 这个 tag 自己的作品数 */
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type RelatedHashtagsResult = {
|
||||
name: string;
|
||||
count: number;
|
||||
related: RelatedHashtag[];
|
||||
};
|
||||
|
||||
const CACHE_MS = 60_000;
|
||||
const cache = new Map<string, { at: number; value: RelatedHashtagsResult }>();
|
||||
|
||||
/**
|
||||
* 按「同一个作品描述里一起出现」计算相关 hashtag(共现图),每次实时从描述里解析,不需要额外的表。
|
||||
*
|
||||
* 打分取两个方向里较大的那个:
|
||||
* - 子话题:候选 tag 大多和目标一起出现(together / 候选数),乘 ln(1+together) 防止只出现一次的巧合排到前面;
|
||||
* 例:#明日方舟 → #明日方舟七周年、#特蕾西娅
|
||||
* - 上位话题:目标大多和候选一起出现(together / 目标数),乘 ln(1+候选数),让热门的上位 tag 排前面;
|
||||
* 例:#圣聆初雪(只有 2 个作品)→ #明日方舟
|
||||
* cos、二次元这类「万金油」tag 与平台活动 tag 不参与推荐。
|
||||
*/
|
||||
export async function relatedHashtags(
|
||||
rawName: string,
|
||||
limit = 12,
|
||||
): Promise<RelatedHashtagsResult> {
|
||||
const name = rawName.toLowerCase();
|
||||
const key = `${name}:${limit}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ name: string; together: number; count: number; target: number }[]
|
||||
>`
|
||||
WITH ht AS (
|
||||
SELECT DISTINCT w.aweme_id, lower(m[1]) AS tag
|
||||
FROM (
|
||||
SELECT aweme_id, "desc" FROM "Video"
|
||||
UNION ALL
|
||||
SELECT aweme_id, "desc" FROM "ImagePost"
|
||||
) w,
|
||||
regexp_matches(w."desc", ${HASHTAG_EXTRACT_PG}, 'g') m
|
||||
),
|
||||
target AS (SELECT aweme_id FROM ht WHERE tag = ${name}),
|
||||
target_count AS (SELECT count(*)::int AS c FROM target),
|
||||
co AS (
|
||||
SELECT ht.tag, count(*)::int AS together
|
||||
FROM ht JOIN target USING (aweme_id)
|
||||
WHERE ht.tag <> ${name}
|
||||
GROUP BY ht.tag
|
||||
),
|
||||
counts AS (
|
||||
SELECT tag, count(*)::int AS c FROM ht
|
||||
WHERE tag IN (SELECT tag FROM co)
|
||||
GROUP BY tag
|
||||
)
|
||||
SELECT
|
||||
co.tag AS name,
|
||||
co.together,
|
||||
counts.c AS count,
|
||||
tc.c AS target
|
||||
FROM co
|
||||
JOIN counts USING (tag)
|
||||
CROSS JOIN target_count tc
|
||||
WHERE co.together >= CASE WHEN tc.c >= 4 THEN 2 ELSE 1 END
|
||||
AND co.tag <> ALL (${META_HASHTAGS}::text[])
|
||||
AND co.tag !~ ${META_HASHTAG_PG}
|
||||
ORDER BY GREATEST(
|
||||
co.together::float8 / counts.c * ln(1 + co.together),
|
||||
co.together::float8 / tc.c * ln(1 + counts.c)
|
||||
) DESC, co.together DESC, co.tag
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
let count = rows[0]?.target;
|
||||
if (count === undefined) {
|
||||
// 没有相关 tag 时单独数一下目标 tag 的作品数
|
||||
const [{ c }] = await prisma.$queryRaw<{ c: number }[]>`
|
||||
SELECT count(DISTINCT w.aweme_id)::int AS c
|
||||
FROM (
|
||||
SELECT aweme_id, "desc" FROM "Video"
|
||||
UNION ALL
|
||||
SELECT aweme_id, "desc" FROM "ImagePost"
|
||||
) w,
|
||||
regexp_matches(w."desc", ${HASHTAG_EXTRACT_PG}, 'g') m
|
||||
WHERE lower(m[1]) = ${name}
|
||||
`;
|
||||
count = c;
|
||||
}
|
||||
|
||||
const value: RelatedHashtagsResult = {
|
||||
name,
|
||||
count,
|
||||
related: rows.map(({ name, together, count }) => ({
|
||||
name,
|
||||
together,
|
||||
count,
|
||||
})),
|
||||
};
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 从作品描述里解析抖音 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}]|$)`;
|
||||
}
|
||||
|
||||
/** 从描述里提取全部 hashtag 名的 Postgres 正则(ARE),配合 regexp_matches(..., 'g') 使用 */
|
||||
export const HASHTAG_EXTRACT_PG = `#([^\\s${STOP_CHARS}]+)`;
|
||||
|
||||
// 「万金油」tag:几乎和什么都一起出现,不提供话题信息,不作为相关 tag 推荐
|
||||
export const META_HASHTAGS = [
|
||||
"cos",
|
||||
"cosplay",
|
||||
"coser",
|
||||
"二次元",
|
||||
"ooc致歉",
|
||||
"搞笑",
|
||||
"抽象",
|
||||
"vlog日常",
|
||||
"内容过于真实",
|
||||
];
|
||||
/** 平台活动类 tag(xx计划、xx挑战赛…)和纯数字 tag 的 Postgres 正则 */
|
||||
export const META_HASHTAG_PG = "(计划|挑战赛|大赛|扶持|热门|浪潮)$|^[0-9]+$";
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// lib/json.ts
|
||||
export function json(data: unknown, init?: ResponseInit) {
|
||||
return new Response(
|
||||
JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v)),
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+32
-32
@@ -14,7 +14,7 @@ import {
|
||||
downloadFile,
|
||||
fileExists,
|
||||
getFileInfo,
|
||||
} from './minio';
|
||||
} from "./minio";
|
||||
|
||||
// ========================================
|
||||
// 1. 上传文件示例
|
||||
@@ -25,15 +25,15 @@ import {
|
||||
*/
|
||||
async function uploadAvatar(file: File, userId: string) {
|
||||
// 验证文件类型
|
||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||
if (!validateFileType(file.name, allowedTypes)) {
|
||||
throw new Error('不支持的图片格式');
|
||||
throw new Error("不支持的图片格式");
|
||||
}
|
||||
|
||||
// 验证文件大小(5MB)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (!validateFileSize(file.size, maxSize)) {
|
||||
throw new Error('文件大小超过限制(最大5MB)');
|
||||
throw new Error("文件大小超过限制(最大5MB)");
|
||||
}
|
||||
|
||||
// 生成唯一文件名,存储在 avatars 目录下
|
||||
@@ -41,8 +41,8 @@ async function uploadAvatar(file: File, userId: string) {
|
||||
|
||||
// 上传文件
|
||||
const url = await uploadFile(file, path, {
|
||||
'Content-Type': file.type,
|
||||
'User-Id': userId,
|
||||
"Content-Type": file.type,
|
||||
"User-Id": userId,
|
||||
});
|
||||
|
||||
return { url, path };
|
||||
@@ -52,24 +52,24 @@ async function uploadAvatar(file: File, userId: string) {
|
||||
* 上传文章封面图
|
||||
*/
|
||||
async function uploadPostCover(file: File, postId: string) {
|
||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
const allowedTypes = ["jpg", "jpeg", "png", "webp"];
|
||||
if (!validateFileType(file.name, allowedTypes)) {
|
||||
throw new Error('不支持的图片格式');
|
||||
throw new Error("不支持的图片格式");
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (!validateFileSize(file.size, maxSize)) {
|
||||
throw new Error('文件大小超过限制(最大10MB)');
|
||||
throw new Error("文件大小超过限制(最大10MB)");
|
||||
}
|
||||
|
||||
// 按日期组织文件
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
||||
const path = generateUniqueFileName(
|
||||
file.name,
|
||||
`posts/${year}/${month}/covers`
|
||||
`posts/${year}/${month}/covers`,
|
||||
);
|
||||
|
||||
const url = await uploadFile(file, path);
|
||||
@@ -81,23 +81,23 @@ async function uploadPostCover(file: File, postId: string) {
|
||||
* 上传文章内容中的图片
|
||||
*/
|
||||
async function uploadPostImage(file: File, postId: string) {
|
||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
const allowedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||
if (!validateFileType(file.name, allowedTypes)) {
|
||||
throw new Error('不支持的图片格式');
|
||||
throw new Error("不支持的图片格式");
|
||||
}
|
||||
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (!validateFileSize(file.size, maxSize)) {
|
||||
throw new Error('文件大小超过限制(最大5MB)');
|
||||
throw new Error("文件大小超过限制(最大5MB)");
|
||||
}
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
||||
const path = generateUniqueFileName(
|
||||
file.name,
|
||||
`posts/${year}/${month}/images`
|
||||
`posts/${year}/${month}/images`,
|
||||
);
|
||||
|
||||
const url = await uploadFile(file, path);
|
||||
@@ -126,9 +126,9 @@ function getPublicFileUrl(path: string) {
|
||||
async function deleteAvatar(avatarPath: string) {
|
||||
try {
|
||||
await deleteFile(avatarPath);
|
||||
console.log('头像删除成功');
|
||||
console.log("头像删除成功");
|
||||
} catch (error) {
|
||||
console.error('删除头像失败:', error);
|
||||
console.error("删除头像失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -142,20 +142,20 @@ async function deletePostImages(postId: string) {
|
||||
const files = await listFiles(`posts/`, true);
|
||||
|
||||
// 过滤出该文章的图片(根据实际情况调整逻辑)
|
||||
const postFiles = files.filter(file =>
|
||||
file.name?.includes(postId)
|
||||
);
|
||||
const postFiles = files.filter((file) => file.name?.includes(postId));
|
||||
|
||||
// 批量删除
|
||||
const paths = postFiles.map(f => f.name).filter((name): name is string => !!name);
|
||||
const paths = postFiles
|
||||
.map((f) => f.name)
|
||||
.filter((name): name is string => !!name);
|
||||
|
||||
if (paths.length > 0) {
|
||||
const { deleteFiles } = await import('./minio');
|
||||
const { deleteFiles } = await import("./minio");
|
||||
await deleteFiles(paths);
|
||||
console.log(`删除了 ${paths.length} 个文件`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除文章图片失败:', error);
|
||||
console.error("删除文章图片失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -171,14 +171,14 @@ async function getUserAvatars(userId: string) {
|
||||
try {
|
||||
const files = await listFiles(`avatars/${userId}/`, false);
|
||||
|
||||
return files.map(file => ({
|
||||
return files.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
url: file.name ? getFileUrl(file.name) : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('获取用户头像列表失败:', error);
|
||||
console.error("获取用户头像列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -188,16 +188,16 @@ async function getUserAvatars(userId: string) {
|
||||
*/
|
||||
async function getPostCoversByMonth(year: number, month: number) {
|
||||
try {
|
||||
const monthStr = String(month).padStart(2, '0');
|
||||
const monthStr = String(month).padStart(2, "0");
|
||||
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
|
||||
|
||||
return files.map(file => ({
|
||||
return files.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url: file.name ? getFileUrl(file.name) : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('获取封面列表失败:', error);
|
||||
console.error("获取封面列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -228,10 +228,10 @@ async function getFileDetails(path: string) {
|
||||
size: info.size,
|
||||
lastModified: info.lastModified,
|
||||
etag: info.etag,
|
||||
contentType: info.metaData?.['content-type'],
|
||||
contentType: info.metaData?.["content-type"],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取文件信息失败:', error);
|
||||
console.error("获取文件信息失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
|
||||
try {
|
||||
return await downloadFile(path);
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error);
|
||||
console.error("下载文件失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+76
-71
@@ -1,22 +1,22 @@
|
||||
import * as Minio from 'minio';
|
||||
import * as Minio from "minio";
|
||||
|
||||
// MinIO 客户端配置
|
||||
const useSSL = process.env.MINIO_USE_SSL === 'true';
|
||||
const useSSL = process.env.MINIO_USE_SSL === "true";
|
||||
const port = Number(process.env.MINIO_PORT) || 9000;
|
||||
|
||||
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
|
||||
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
|
||||
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
endPoint: process.env.MINIO_ENDPOINT || "localhost",
|
||||
...(shouldOmitPort ? {} : { port }),
|
||||
useSSL,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || "",
|
||||
secretKey: process.env.MINIO_SECRET_KEY || "",
|
||||
pathStyle: true, // 使用路径风格,对反向代理更友好
|
||||
});
|
||||
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'home-page';
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "home-page";
|
||||
|
||||
/**
|
||||
* 初始化 MinIO Bucket(确保 bucket 存在)
|
||||
@@ -25,41 +25,39 @@ export async function initBucket(): Promise<void> {
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1');
|
||||
await minioClient.makeBucket(BUCKET_NAME, "us-east-1");
|
||||
console.log(`Bucket ${BUCKET_NAME} created successfully`);
|
||||
}
|
||||
// 设置公共读取策略(可选)
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Effect: "Allow",
|
||||
Principal: { AWS: ["*"] },
|
||||
Action: ["s3:GetObject"],
|
||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
||||
} catch (error) {
|
||||
console.error('Error initializing bucket:', error);
|
||||
console.error("Error initializing bucket:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 上传文件到 MinIO
|
||||
* @param file - File 对象或 Buffer
|
||||
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
|
||||
* @param metadata - 可选的元数据
|
||||
* @returns 文件的访问URL
|
||||
* @returns 文件 path = args.path
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File | Buffer,
|
||||
path: string,
|
||||
metadata?: Record<string, string>
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await initBucket();
|
||||
@@ -69,56 +67,43 @@ export async function uploadFile(
|
||||
|
||||
if (file instanceof File) {
|
||||
buffer = Buffer.from(await file.arrayBuffer());
|
||||
contentType = file.type || 'application/octet-stream';
|
||||
contentType = file.type || "application/octet-stream";
|
||||
} else {
|
||||
buffer = file;
|
||||
contentType = metadata?.['Content-Type'] || 'application/octet-stream';
|
||||
contentType = metadata?.["Content-Type"] || "application/octet-stream";
|
||||
}
|
||||
|
||||
const metaData = {
|
||||
'Content-Type': contentType,
|
||||
"Content-Type": contentType,
|
||||
...metadata,
|
||||
};
|
||||
|
||||
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData);
|
||||
await minioClient.putObject(
|
||||
BUCKET_NAME,
|
||||
path,
|
||||
buffer,
|
||||
buffer.length,
|
||||
metaData,
|
||||
);
|
||||
|
||||
return getFileUrl(path);
|
||||
return path;
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
console.error("Error uploading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取文件的公共访问URL
|
||||
* @param path - 文件路径
|
||||
* @returns 文件的访问URL
|
||||
*/
|
||||
export function getFileUrl(path: string): string {
|
||||
const endpoint = process.env.MINIO_REAL_DOMAIN;
|
||||
const endpoint = process.env.MINIO_PUBLIC_DOMAIN;
|
||||
|
||||
return `${endpoint}/${BUCKET_NAME}/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的临时访问URL(带签名,用于私有文件)
|
||||
* @param path - 文件路径
|
||||
* @param expirySeconds - 过期时间(秒),默认7天
|
||||
* @returns 临时访问URL
|
||||
*/
|
||||
export async function getPresignedUrl(
|
||||
path: string,
|
||||
expirySeconds: number = 7 * 24 * 60 * 60
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await minioClient.presignedGetObject(BUCKET_NAME, path, expirySeconds);
|
||||
} catch (error) {
|
||||
console.error('Error generating presigned URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param path - 文件路径
|
||||
@@ -130,12 +115,12 @@ export async function downloadFile(path: string): Promise<Buffer> {
|
||||
const stream = await minioClient.getObject(BUCKET_NAME, path);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
stream.on("data", (chunk) => chunks.push(chunk));
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
console.error("Error downloading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -145,11 +130,13 @@ export async function downloadFile(path: string): Promise<Buffer> {
|
||||
* @param path - 文件路径
|
||||
* @returns 文件流
|
||||
*/
|
||||
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> {
|
||||
export async function getFileStream(
|
||||
path: string,
|
||||
): Promise<NodeJS.ReadableStream> {
|
||||
try {
|
||||
return await minioClient.getObject(BUCKET_NAME, path);
|
||||
} catch (error) {
|
||||
console.error('Error getting file stream:', error);
|
||||
console.error("Error getting file stream:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -162,7 +149,7 @@ export async function deleteFile(path: string): Promise<void> {
|
||||
try {
|
||||
await minioClient.removeObject(BUCKET_NAME, path);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
console.error("Error deleting file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -175,7 +162,7 @@ export async function deleteFiles(paths: string[]): Promise<void> {
|
||||
try {
|
||||
await minioClient.removeObjects(BUCKET_NAME, paths);
|
||||
} catch (error) {
|
||||
console.error('Error deleting files:', error);
|
||||
console.error("Error deleting files:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -203,7 +190,7 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
||||
try {
|
||||
return await minioClient.statObject(BUCKET_NAME, path);
|
||||
} catch (error) {
|
||||
console.error('Error getting file info:', error);
|
||||
console.error("Error getting file info:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -215,24 +202,27 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
||||
* @returns 文件列表
|
||||
*/
|
||||
export async function listFiles(
|
||||
prefix: string = '',
|
||||
recursive: boolean = false
|
||||
prefix: string = "",
|
||||
recursive: boolean = false,
|
||||
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
|
||||
try {
|
||||
const files: (Minio.BucketItem & { endpoint: string })[] = [];
|
||||
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (obj) => {
|
||||
stream.on("data", (obj) => {
|
||||
if (obj.name) {
|
||||
files.push({ endpoint: `${process.env.MINIO_REAL_DOMAIN}/${BUCKET_NAME}`, ...obj, } as Minio.BucketItem & { endpoint: string });
|
||||
files.push({
|
||||
endpoint: `${process.env.MINIO_PUBLIC_DOMAIN}/${BUCKET_NAME}`,
|
||||
...obj,
|
||||
} as Minio.BucketItem & { endpoint: string });
|
||||
}
|
||||
});
|
||||
stream.on('end', () => resolve(files));
|
||||
stream.on('error', reject);
|
||||
stream.on("end", () => resolve(files));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing files:', error);
|
||||
console.error("Error listing files:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -242,17 +232,20 @@ export async function listFiles(
|
||||
* @param sourcePath - 源文件路径
|
||||
* @param destPath - 目标文件路径
|
||||
*/
|
||||
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
|
||||
export async function copyFile(
|
||||
sourcePath: string,
|
||||
destPath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const conds = new Minio.CopyConditions();
|
||||
await minioClient.copyObject(
|
||||
BUCKET_NAME,
|
||||
destPath,
|
||||
`/${BUCKET_NAME}/${sourcePath}`,
|
||||
conds
|
||||
conds,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error copying file:', error);
|
||||
console.error("Error copying file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -263,15 +256,20 @@ export async function copyFile(sourcePath: string, destPath: string): Promise<vo
|
||||
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
||||
* @returns 带路径的唯一文件名
|
||||
*/
|
||||
export function generateUniqueFileName(originalName: string, prefix: string = ''): string {
|
||||
export function generateUniqueFileName(
|
||||
originalName: string,
|
||||
prefix: string = "",
|
||||
): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const ext = originalName.split('.').pop();
|
||||
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const ext = originalName.split(".").pop();
|
||||
const nameWithoutExt = originalName
|
||||
.replace(`.${ext}`, "")
|
||||
.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
|
||||
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
|
||||
|
||||
return prefix ? `${prefix.replace(/\/$/, '')}/${fileName}` : fileName;
|
||||
return prefix ? `${prefix.replace(/\/$/, "")}/${fileName}` : fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +278,7 @@ export function generateUniqueFileName(originalName: string, prefix: string = ''
|
||||
* @returns 扩展名(不含点)
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop() || '';
|
||||
return filename.split(".").pop() || "";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,9 +287,12 @@ export function getFileExtension(filename: string): string {
|
||||
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
|
||||
* @returns 是否为允许的类型
|
||||
*/
|
||||
export function validateFileType(filename: string, allowedTypes: string[]): boolean {
|
||||
export function validateFileType(
|
||||
filename: string,
|
||||
allowedTypes: string[],
|
||||
): boolean {
|
||||
const ext = getFileExtension(filename).toLowerCase();
|
||||
return allowedTypes.map(t => t.toLowerCase()).includes(ext);
|
||||
return allowedTypes.map((t) => t.toLowerCase()).includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,10 +311,10 @@ export function validateFileSize(size: number, maxSize: number): boolean {
|
||||
* @returns 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
@@ -323,7 +324,6 @@ export default {
|
||||
initBucket,
|
||||
uploadFile,
|
||||
getFileUrl,
|
||||
getPresignedUrl,
|
||||
downloadFile,
|
||||
getFileStream,
|
||||
deleteFile,
|
||||
@@ -338,3 +338,8 @@ export default {
|
||||
validateFileSize,
|
||||
formatFileSize,
|
||||
};
|
||||
|
||||
/** Upload derived ASR audio without changing bucket policy. */
|
||||
export async function uploadTranscriptionAudio(path: string, buffer: Buffer) {
|
||||
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, { "Content-Type": "audio/mpeg" });
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
// 子串搜索的查询解析与摘要高亮工具。
|
||||
// 匹配本身在 Postgres 里用正则匹配 lower() 后的文本完成,这里负责:
|
||||
// - 把用户输入拆成关键词(空格分隔,全部需要命中;以 - 开头的词表示排除)
|
||||
// - 在原文里找命中位置,截取摘要片段并标出高亮
|
||||
|
||||
export type SnippetPart = { text: string; hit: boolean };
|
||||
|
||||
export type ParsedQuery = { terms: string[]; excludes: string[] };
|
||||
|
||||
const MAX_TERMS = 10;
|
||||
const MAX_TERM_LENGTH = 50;
|
||||
|
||||
export function parseSearchQuery(raw: string): ParsedQuery {
|
||||
const terms: string[] = [];
|
||||
const excludes: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of raw.trim().split(/\s+/)) {
|
||||
if (!token) continue;
|
||||
const isExclude = token.length > 1 && token.startsWith("-");
|
||||
const word = (isExclude ? token.slice(1) : token)
|
||||
.slice(0, MAX_TERM_LENGTH)
|
||||
.toLowerCase();
|
||||
const key = `${isExclude ? "-" : "+"}${word}`;
|
||||
if (!word || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
(isExclude ? excludes : terms).push(word);
|
||||
if (terms.length + excludes.length >= MAX_TERMS) break;
|
||||
}
|
||||
return { terms, excludes };
|
||||
}
|
||||
|
||||
type Range = { start: number; end: number; term: string };
|
||||
|
||||
function escapeRegExp(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// 纯英文/数字关键词要求「词首」对齐:前面不能紧挨字母数字。
|
||||
// 这样 "ai" 不会命中 "said",但 "用AI做"、"AIGC" 仍能命中;中文关键词是纯子串匹配。
|
||||
const ASCII_WORD = /^[a-z0-9]/;
|
||||
|
||||
/** 关键词对应的 Postgres 正则(ARE),用于匹配已 lower() 的文本。 */
|
||||
export function termToPgPattern(term: string) {
|
||||
const escaped = escapeRegExp(term);
|
||||
return ASCII_WORD.test(term) ? `(^|[^a-z0-9])${escaped}` : escaped;
|
||||
}
|
||||
|
||||
function termToJsPattern(term: string) {
|
||||
const escaped = escapeRegExp(term);
|
||||
return ASCII_WORD.test(term) ? `(?<![a-z0-9])${escaped}` : escaped;
|
||||
}
|
||||
|
||||
function findMatches(text: string, terms: string[]): Range[] {
|
||||
if (!text || terms.length === 0) return [];
|
||||
// 长词优先,避免 "老百姓" 被 "百姓" 抢先匹配成更短的高亮
|
||||
const sorted = [...terms].sort((a, b) => b.length - a.length);
|
||||
const re = new RegExp(sorted.map(termToJsPattern).join("|"), "giu");
|
||||
const ranges: Range[] = [];
|
||||
for (const m of text.matchAll(re)) {
|
||||
const start = m.index ?? 0;
|
||||
ranges.push({
|
||||
start,
|
||||
end: start + m[0].length,
|
||||
term: m[0].toLowerCase(),
|
||||
});
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// 不要把 UTF-16 代理对(emoji 等)切成两半
|
||||
function safeStart(text: string, i: number) {
|
||||
const c = text.charCodeAt(i);
|
||||
return c >= 0xdc00 && c <= 0xdfff ? i - 1 : i;
|
||||
}
|
||||
function safeEnd(text: string, i: number) {
|
||||
const c = text.charCodeAt(i);
|
||||
return c >= 0xdc00 && c <= 0xdfff ? i + 1 : i;
|
||||
}
|
||||
|
||||
function highlightRange(
|
||||
text: string,
|
||||
from: number,
|
||||
to: number,
|
||||
matches: Range[],
|
||||
): SnippetPart[] {
|
||||
const parts: SnippetPart[] = [];
|
||||
let cursor = from;
|
||||
for (const m of matches) {
|
||||
if (m.end <= from || m.start >= to) continue;
|
||||
const s = Math.max(m.start, from);
|
||||
const e = Math.min(m.end, to);
|
||||
if (s < cursor) continue;
|
||||
if (s > cursor) parts.push({ text: text.slice(cursor, s), hit: false });
|
||||
parts.push({ text: text.slice(s, e), hit: true });
|
||||
cursor = e;
|
||||
}
|
||||
if (cursor < to) parts.push({ text: text.slice(cursor, to), hit: false });
|
||||
return parts;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(parts: SnippetPart[]): SnippetPart[] {
|
||||
return parts
|
||||
.map((p) => ({ ...p, text: p.text.replace(/\s*\n\s*/g, " ") }))
|
||||
.filter((p) => p.text.length > 0);
|
||||
}
|
||||
|
||||
/** 整段文字全部返回,只标出命中位置(用于描述)。 */
|
||||
export function highlightAll(text: string, terms: string[]): SnippetPart[] {
|
||||
if (!text) return [];
|
||||
return normalizeWhitespace(
|
||||
highlightRange(text, 0, text.length, findMatches(text, terms)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取命中位置附近的片段(用于语音转写这类长文本)。
|
||||
* 尽量让不同片段覆盖不同的关键词;片段会对齐到换行(转写的句子边界)。
|
||||
*/
|
||||
export function buildSnippet(
|
||||
text: string,
|
||||
terms: string[],
|
||||
{ before = 24, after = 60, maxFragments = 2 } = {},
|
||||
): SnippetPart[] {
|
||||
if (!text) return [];
|
||||
const matches = findMatches(text, terms);
|
||||
if (matches.length === 0) {
|
||||
const end = safeEnd(text, Math.min(text.length, before + after));
|
||||
const parts = normalizeWhitespace([
|
||||
{ text: text.slice(0, end), hit: false },
|
||||
]);
|
||||
if (end < text.length) parts.push({ text: "…", hit: false });
|
||||
return parts;
|
||||
}
|
||||
|
||||
const windows: { start: number; end: number }[] = [];
|
||||
const covered = new Set<string>();
|
||||
const pickNext = () =>
|
||||
matches.find(
|
||||
(m) =>
|
||||
!covered.has(m.term) &&
|
||||
windows.every((w) => m.start >= w.end || m.end <= w.start),
|
||||
) ??
|
||||
matches.find((m) =>
|
||||
windows.every((w) => m.start >= w.end || m.end <= w.start),
|
||||
);
|
||||
|
||||
while (windows.length < maxFragments) {
|
||||
const m = pickNext();
|
||||
if (!m) break;
|
||||
let start = Math.max(0, m.start - before);
|
||||
let end = Math.min(text.length, m.end + after);
|
||||
const nlBefore = text.lastIndexOf("\n", m.start - 1);
|
||||
if (nlBefore >= start) start = nlBefore + 1;
|
||||
const nlAfter = text.indexOf("\n", m.end);
|
||||
if (nlAfter !== -1 && nlAfter < end) end = nlAfter;
|
||||
windows.push({ start: safeStart(text, start), end: safeEnd(text, end) });
|
||||
for (const other of matches) {
|
||||
if (other.start >= start && other.end <= end) covered.add(other.term);
|
||||
}
|
||||
}
|
||||
|
||||
windows.sort((a, b) => a.start - b.start);
|
||||
const parts: SnippetPart[] = [];
|
||||
windows.forEach((w, i) => {
|
||||
if (i === 0 ? w.start > 0 : true) parts.push({ text: "…", hit: false });
|
||||
parts.push(...highlightRange(text, w.start, w.end, matches));
|
||||
});
|
||||
if (windows[windows.length - 1].end < text.length) {
|
||||
parts.push({ text: "…", hit: false });
|
||||
}
|
||||
return normalizeWhitespace(parts);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { extractAudio } from "../../app/api/media";
|
||||
import { getFileStream, getFileUrl, uploadTranscriptionAudio } from "../minio";
|
||||
import { prisma } from "../prisma";
|
||||
import { AsrError, MAX_DURATION_MS, validateMedia } from "./dashscope";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Whole-file transcription: download to disk, extract once, upload only audio.
|
||||
export async function prepareAudio(videoId: string): Promise<string | null> {
|
||||
const video = await prisma.video.findUniqueOrThrow({
|
||||
where: { aweme_id: videoId },
|
||||
});
|
||||
if (video.duration_ms > MAX_DURATION_MS)
|
||||
throw new AsrError("视频超过 ASR 的 12 小时时长限制");
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
|
||||
try {
|
||||
const input = path.join(dir, "video.mp4");
|
||||
await pipeline(
|
||||
await getFileStream(video.video_url),
|
||||
createWriteStream(input),
|
||||
{ signal: AbortSignal.timeout(60 * 60 * 1000) },
|
||||
);
|
||||
const { stdout } = await execFileAsync(
|
||||
process.env.FFPROBE_PATH || "ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=index:format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
input,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
const probe = JSON.parse(stdout) as {
|
||||
streams?: { index: number }[];
|
||||
format?: { duration?: string };
|
||||
};
|
||||
if (!Array.isArray(probe.streams)) throw new AsrError("无法读取视频音轨");
|
||||
if (!probe.streams.length) return null;
|
||||
const duration = Number(probe.format?.duration) * 1000;
|
||||
if (!Number.isFinite(duration) || duration <= 0)
|
||||
throw new AsrError("无法读取音频时长");
|
||||
if (duration > MAX_DURATION_MS)
|
||||
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||
const audio = await extractAudio(input, { format: "mp3", bitrateKbps: 64 });
|
||||
if (!audio) throw new AsrError("未找到 ffmpeg,请安装或配置 FFMPEG_PATH");
|
||||
validateMedia(duration, audio.buffer.length);
|
||||
const objectPath = `transcription-audio/${encodeURIComponent(videoId)}/${Date.now()}.mp3`;
|
||||
await uploadTranscriptionAudio(objectPath, audio.buffer);
|
||||
const url = getFileUrl(objectPath);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new AsrError("请配置公网可访问的 MINIO_PUBLIC_DOMAIN");
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new AsrError("音频 URL 协议无效");
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new AsrError(`音频公共地址不可访问 (HTTP ${response.status})`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
if (error instanceof AsrError) throw error;
|
||||
throw new AsrError(
|
||||
"音频准备失败,请检查 MinIO、ffmpeg、ffprobe 和文件可访问性后重试",
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
export class AsrError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public retryable = false,
|
||||
public definitiveRejection = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const MAX_DURATION_MS = 12 * 60 * 60 * 1000;
|
||||
export const MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
export function validateMedia(durationMs: number, size: number) {
|
||||
if (durationMs > MAX_DURATION_MS)
|
||||
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||
if (!Number.isFinite(size) || size <= 0)
|
||||
throw new AsrError("音频文件为空或大小未知");
|
||||
if (size > MAX_BYTES) throw new AsrError("音频超过 ASR 的 2 GB 大小限制");
|
||||
}
|
||||
export function getAsrConfig() {
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY?.trim();
|
||||
if (!apiKey) throw new AsrError("未配置 DASHSCOPE_API_KEY,请配置后重试");
|
||||
const baseURL = (
|
||||
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1"
|
||||
).replace(/\/+$/, "");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseURL);
|
||||
} catch {
|
||||
throw new AsrError("DASHSCOPE_BASE_URL 不是有效地址");
|
||||
}
|
||||
if (
|
||||
!["https:", "http:"].includes(url.protocol) ||
|
||||
!url.pathname.endsWith("/api/v1")
|
||||
) {
|
||||
throw new AsrError("DASHSCOPE_BASE_URL 应为原生 API 地址,以 /api/v1 结尾");
|
||||
}
|
||||
const model = process.env.DASHSCOPE_ASR_MODEL || "qwen3-asr-flash-filetrans";
|
||||
if (!/^qwen3-asr-flash-filetrans(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
|
||||
throw new AsrError(
|
||||
"DASHSCOPE_ASR_MODEL 必须是 qwen3-asr-flash-filetrans 或其日期版本",
|
||||
);
|
||||
}
|
||||
return { apiKey, baseURL, model };
|
||||
}
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectValue {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw new AsrError("ASR 返回格式异常");
|
||||
return value as ObjectValue;
|
||||
}
|
||||
function safeCode(value: unknown) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value)
|
||||
? value
|
||||
: "UNKNOWN";
|
||||
}
|
||||
async function request(url: string, init: RequestInit = {}): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new AsrError("ASR 网络请求失败或超时", true);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const raw = await response.json().catch(() => null);
|
||||
const body = raw && typeof raw === "object" ? (raw as ObjectValue) : {};
|
||||
throw new AsrError(
|
||||
`ASR HTTP ${response.status} (${safeCode(body.code)})`,
|
||||
response.status === 429 || response.status >= 500,
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 429,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new AsrError("ASR 返回非 JSON 数据", true);
|
||||
}
|
||||
}
|
||||
export async function submitTranscription(fileURL: string): Promise<string> {
|
||||
const { apiKey, baseURL, model } = getAsrConfig();
|
||||
const data = object(
|
||||
await request(`${baseURL}/services/audio/asr/transcription`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { file_url: fileURL },
|
||||
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const output = object(data.output);
|
||||
if (typeof output.task_id !== "string" || !output.task_id)
|
||||
throw new AsrError("ASR 未返回任务 ID,提交结果不确定");
|
||||
return output.task_id;
|
||||
}
|
||||
export type AsrTask =
|
||||
| { status: "PENDING" | "RUNNING" }
|
||||
| { status: "SUCCEEDED"; resultURL: string };
|
||||
export async function queryTranscription(taskId: string): Promise<AsrTask> {
|
||||
const { apiKey, baseURL } = getAsrConfig();
|
||||
const data = object(
|
||||
await request(`${baseURL}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}),
|
||||
);
|
||||
const output = object(data.output);
|
||||
if (output.task_status === "PENDING" || output.task_status === "RUNNING")
|
||||
return { status: output.task_status };
|
||||
if (output.task_status === "SUCCEEDED") {
|
||||
const result = object(output.result);
|
||||
if (typeof result.transcription_url !== "string")
|
||||
throw new AsrError("ASR 缺少结果下载地址");
|
||||
return { status: "SUCCEEDED", resultURL: result.transcription_url };
|
||||
}
|
||||
throw new AsrError(
|
||||
`ASR 任务失败 (${safeCode(output.code || output.task_status)})`,
|
||||
);
|
||||
}
|
||||
export function extractTranscript(value: unknown): string[] {
|
||||
const data = object(value);
|
||||
if (!Array.isArray(data.transcripts))
|
||||
throw new AsrError("ASR 缺少 transcripts,不能作为空文字保存");
|
||||
return data.transcripts.flatMap((entry: unknown) => {
|
||||
const track = object(entry);
|
||||
if (track.sentences !== undefined && !Array.isArray(track.sentences))
|
||||
throw new AsrError("ASR 句子格式异常");
|
||||
const sentences = ((track.sentences ?? []) as unknown[])
|
||||
.map((sentence) => {
|
||||
const text = object(sentence).text;
|
||||
if (typeof text !== "string") throw new AsrError("ASR 句子缺少文字");
|
||||
return text.trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (sentences.length) return sentences;
|
||||
if (typeof track.text !== "string") throw new AsrError("ASR 音轨缺少文字");
|
||||
return track.text.trim() ? [track.text.trim()] : [];
|
||||
});
|
||||
}
|
||||
export async function downloadTranscript(url: string): Promise<string[]> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new AsrError("ASR 结果地址无效");
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new AsrError("ASR 结果地址协议无效");
|
||||
// Result storage is not the API host: never forward the API key.
|
||||
return extractTranscript(await request(url));
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma, type TranscriptionTask } from "@prisma/client";
|
||||
import { prisma } from "../prisma";
|
||||
import { getAsrConfig } from "./dashscope";
|
||||
import { isActive, transcriptionState } from "./types";
|
||||
|
||||
function configurationError(): string | null {
|
||||
try {
|
||||
getAsrConfig();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
}
|
||||
export async function enqueueTranscription(videoId: string, force = false) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const videos = await tx.$queryRaw<
|
||||
{ aweme_id: string }[]
|
||||
>`SELECT aweme_id FROM "Video" WHERE aweme_id = ${videoId} FOR UPDATE`;
|
||||
if (!videos.length) return null;
|
||||
const [transcript, task] = await Promise.all([
|
||||
tx.videoTranscript.findUnique({ where: { videoId } }),
|
||||
tx.transcriptionTask.findUnique({ where: { videoId } }),
|
||||
]);
|
||||
if ((task && isActive(task.status)) || (!force && transcript))
|
||||
return transcriptionState(transcript, task);
|
||||
const lastError = configurationError();
|
||||
const data = {
|
||||
status: lastError ? "FAILED" : "QUEUED",
|
||||
lastError,
|
||||
providerTaskId: null,
|
||||
submittingAt: null,
|
||||
submittedAt: null,
|
||||
leaseUntil: null,
|
||||
leaseToken: null,
|
||||
attempts: 0,
|
||||
nextPollAt: new Date(),
|
||||
};
|
||||
const saved = await tx.transcriptionTask.upsert({
|
||||
where: { videoId },
|
||||
create: { videoId, ...data },
|
||||
update: data,
|
||||
});
|
||||
return transcriptionState(transcript, saved);
|
||||
});
|
||||
}
|
||||
export async function enqueueMissingTranscriptions() {
|
||||
const error = configurationError();
|
||||
if (error) throw new Error(error);
|
||||
// One durable, atomic batch. Completed historical transcripts are never requeued.
|
||||
return prisma.$executeRaw`
|
||||
INSERT INTO "TranscriptionTask" ("videoId", status, "updatedAt")
|
||||
SELECT v.aweme_id, 'QUEUED', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') FROM "Video" v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "VideoTranscript" t WHERE t."videoId" = v.aweme_id)
|
||||
ON CONFLICT ("videoId") DO UPDATE SET status = 'QUEUED', "providerTaskId" = NULL,
|
||||
"submittingAt" = NULL, "submittedAt" = NULL, "leaseUntil" = NULL, "leaseToken" = NULL,
|
||||
attempts = 0, "lastError" = NULL, "nextPollAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
WHERE "TranscriptionTask".status = 'FAILED'
|
||||
`;
|
||||
}
|
||||
export async function readTranscription(videoId: string) {
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { aweme_id: videoId },
|
||||
include: { transcript: true, transcriptionTask: true },
|
||||
});
|
||||
return video
|
||||
? transcriptionState(video.transcript, video.transcriptionTask)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function claimTask(): Promise<TranscriptionTask | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Serialize admission across all worker processes, including PM2 restarts.
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74196321)::text`;
|
||||
const running = await tx.transcriptionTask.count({
|
||||
where: { status: "PROCESSING" },
|
||||
});
|
||||
const eligible =
|
||||
running < 2
|
||||
? Prisma.sql`status IN ('QUEUED', 'PROCESSING')`
|
||||
: Prisma.sql`status = 'PROCESSING'`;
|
||||
const rows = await tx.$queryRaw<TranscriptionTask[]>`
|
||||
SELECT * FROM "TranscriptionTask" WHERE ${eligible}
|
||||
AND "nextPollAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AND ("leaseUntil" IS NULL OR "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
|
||||
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END, "nextPollAt", "createdAt"
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
`;
|
||||
if (!rows.length) return null;
|
||||
return tx.transcriptionTask.update({
|
||||
where: { videoId: rows[0].videoId },
|
||||
data: {
|
||||
status: "PROCESSING",
|
||||
leaseToken: randomUUID(),
|
||||
leaseUntil: new Date(Date.now() + 180_000),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function updateOwnedTask(
|
||||
task: TranscriptionTask,
|
||||
data: Prisma.TranscriptionTaskUpdateManyMutationInput,
|
||||
) {
|
||||
const result = await prisma.transcriptionTask.updateMany({
|
||||
where: {
|
||||
videoId: task.videoId,
|
||||
leaseToken: task.leaseToken,
|
||||
status: "PROCESSING",
|
||||
leaseUntil: { gt: new Date() },
|
||||
},
|
||||
data,
|
||||
});
|
||||
if (!result.count) throw new Error("转写任务租约已失效");
|
||||
}
|
||||
export async function completeTask(
|
||||
task: TranscriptionTask,
|
||||
transcript: string[],
|
||||
) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.transcriptionTask.updateMany({
|
||||
where: {
|
||||
videoId: task.videoId,
|
||||
leaseToken: task.leaseToken,
|
||||
status: "PROCESSING",
|
||||
leaseUntil: { gt: new Date() },
|
||||
},
|
||||
data: {
|
||||
status: "SUCCEEDED",
|
||||
lastError: null,
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
attempts: 0,
|
||||
},
|
||||
});
|
||||
if (!result.count) throw new Error("转写任务租约已失效");
|
||||
await tx.videoTranscript.upsert({
|
||||
where: { videoId: task.videoId },
|
||||
create: { videoId: task.videoId, transcript },
|
||||
update: { transcript },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type TranscriptionStatus =
|
||||
| "PENDING"
|
||||
| "QUEUED"
|
||||
| "PROCESSING"
|
||||
| "SUCCEEDED"
|
||||
| "FAILED";
|
||||
export type Transcript = { transcript: string[] };
|
||||
export type TranscriptionState = {
|
||||
status: TranscriptionStatus;
|
||||
error: string | null;
|
||||
transcript: string[] | null;
|
||||
};
|
||||
export const statusLabels: Record<TranscriptionStatus, string> = {
|
||||
PENDING: "待转写",
|
||||
QUEUED: "排队中",
|
||||
PROCESSING: "处理中",
|
||||
SUCCEEDED: "已完成",
|
||||
FAILED: "失败",
|
||||
};
|
||||
export function isActive(status: string) {
|
||||
return status === "QUEUED" || status === "PROCESSING";
|
||||
}
|
||||
export function transcriptionState(
|
||||
transcript: Transcript | null,
|
||||
task: { status: string; lastError: string | null } | null,
|
||||
): TranscriptionState {
|
||||
return {
|
||||
status: (task?.status ??
|
||||
(transcript ? "SUCCEEDED" : "PENDING")) as TranscriptionStatus,
|
||||
error: task?.lastError ?? null,
|
||||
transcript: transcript?.transcript ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { TranscriptionTask } from "@prisma/client";
|
||||
import { prepareAudio } from "./audio";
|
||||
import {
|
||||
AsrError,
|
||||
downloadTranscript,
|
||||
getAsrConfig,
|
||||
queryTranscription,
|
||||
submitTranscription,
|
||||
} from "./dashscope";
|
||||
import { claimTask, completeTask, updateOwnedTask } from "./tasks";
|
||||
|
||||
export const defaultServices = {
|
||||
prepareAudio,
|
||||
submitTranscription,
|
||||
queryTranscription,
|
||||
downloadTranscript,
|
||||
};
|
||||
export async function processTask(
|
||||
task: TranscriptionTask,
|
||||
services = defaultServices,
|
||||
) {
|
||||
let submitting = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
void updateOwnedTask(task, {
|
||||
leaseUntil: new Date(Date.now() + 180_000),
|
||||
}).catch(() => {});
|
||||
}, 30_000);
|
||||
try {
|
||||
getAsrConfig();
|
||||
if (!task.providerTaskId) {
|
||||
if (task.submittingAt)
|
||||
throw new AsrError(
|
||||
"上次提交结果不确定,请核对百炼任务后手动重试,避免重复计费",
|
||||
);
|
||||
const url = await services.prepareAudio(task.videoId);
|
||||
if (url === null) {
|
||||
await completeTask(task, []);
|
||||
return;
|
||||
}
|
||||
await updateOwnedTask(task, { submittingAt: new Date() });
|
||||
submitting = true;
|
||||
const providerTaskId = await services.submitTranscription(url);
|
||||
await updateOwnedTask(task, {
|
||||
providerTaskId,
|
||||
submittedAt: new Date(),
|
||||
nextPollAt: new Date(Date.now() + 10_000),
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
lastError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Provider result URLs expire after 24 hours; do not poll forever.
|
||||
if (
|
||||
task.submittedAt &&
|
||||
Date.now() - task.submittedAt.getTime() > 24 * 60 * 60 * 1000
|
||||
) {
|
||||
throw new AsrError("ASR 任务超过 24 小时,请手动重试");
|
||||
}
|
||||
const result = await services.queryTranscription(task.providerTaskId);
|
||||
if (result.status === "SUCCEEDED") {
|
||||
const transcript = await services.downloadTranscript(result.resultURL);
|
||||
await completeTask(task, transcript);
|
||||
} else {
|
||||
await updateOwnedTask(task, {
|
||||
nextPollAt: new Date(Date.now() + 10_000),
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
lastError: null,
|
||||
attempts: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Unknown exceptions (e.g. database outage) leave the lease and submitting marker
|
||||
// intact. Recovery can query a saved ID, but must never blindly submit again.
|
||||
if (!(error instanceof AsrError)) throw error;
|
||||
const retry = !submitting && !!task.providerTaskId && error.retryable;
|
||||
await updateOwnedTask(task, {
|
||||
status: retry ? "PROCESSING" : "FAILED",
|
||||
lastError:
|
||||
submitting && !error.definitiveRejection
|
||||
? `${error.message};提交结果不确定,请核对百炼任务后手动重试`
|
||||
: error.message,
|
||||
attempts: { increment: 1 },
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
nextPollAt: new Date(
|
||||
Date.now() +
|
||||
Math.min(300_000, 10_000 * 2 ** Math.min(task.attempts, 5)),
|
||||
),
|
||||
});
|
||||
console.warn(`[ASR] ${task.videoId}: ${retry ? "等待重试" : "失败"}`);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}
|
||||
export async function workerTick(services = defaultServices) {
|
||||
const tasks: TranscriptionTask[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const task = await claimTask();
|
||||
if (task) tasks.push(task);
|
||||
}
|
||||
await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
try {
|
||||
await processTask(task, services);
|
||||
} catch {
|
||||
console.error(`[ASR] ${task.videoId}: 执行中断,租约到期后恢复`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const VIDEO_CONTENT_TAGS = [
|
||||
"美女",
|
||||
"游戏",
|
||||
"时政",
|
||||
"美食",
|
||||
"旅行",
|
||||
"体育",
|
||||
"科技",
|
||||
"教育",
|
||||
"宠物",
|
||||
"音乐",
|
||||
"影视",
|
||||
"财经",
|
||||
"生活",
|
||||
"其他",
|
||||
] as const;
|
||||
|
||||
export type VideoContentTag = (typeof VIDEO_CONTENT_TAGS)[number];
|
||||
|
||||
const videoContentTagSet = new Set<string>(VIDEO_CONTENT_TAGS);
|
||||
|
||||
export function isVideoContentTag(value: string): value is VideoContentTag {
|
||||
return videoContentTagSet.has(value);
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: [
|
||||
"playwright-extra",
|
||||
"puppeteer-extra-plugin-stealth",
|
||||
"puppeteer-extra-plugin",
|
||||
],
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "s3l.xn--876a.net",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+2065
-104
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user