Compare commits
31
Commits
9b45c4e3e8
...
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 | ||
|
|
77bc2c5775 | ||
|
|
fe9bc8fd6c | ||
|
|
5868407216 | ||
|
|
657c8320b5 | ||
|
|
5c98257366 | ||
|
|
200283b21b | ||
|
|
9d1598a5ab |
@@ -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
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
Vendored
+18
-17
@@ -1,18 +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"
|
||||
}
|
||||
]
|
||||
}
|
||||
"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);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET /api/aweme/around?awemeId=xxxx
|
||||
// Response: { prev?: { type: 'video'|'image', aweme_id: string, created_at: string }, next?: { ... } }
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const awemeId = searchParams.get("awemeId");
|
||||
if (!awemeId) {
|
||||
return NextResponse.json({ error: "missing awemeId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 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 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const current = video ?? post;
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: "aweme not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const createdAt = current.created_at as unknown as Date;
|
||||
|
||||
// Newer than current (prev in a desc-ordered feed): pick the nearest newer by created_at
|
||||
const [newerVideo, newerPost] = await Promise.all([
|
||||
prisma.video.findFirst({
|
||||
where: { created_at: { gt: createdAt } },
|
||||
orderBy: { created_at: "asc" },
|
||||
select: { aweme_id: true, created_at: true },
|
||||
}),
|
||||
prisma.imagePost.findFirst({
|
||||
where: { created_at: { gt: createdAt } },
|
||||
orderBy: { created_at: "asc" },
|
||||
select: { aweme_id: true, created_at: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Older than current (next in a desc-ordered feed): pick the nearest older by created_at
|
||||
const [olderVideo, olderPost] = await Promise.all([
|
||||
prisma.video.findFirst({
|
||||
where: { created_at: { lt: createdAt } },
|
||||
orderBy: { created_at: "desc" },
|
||||
select: { aweme_id: true, created_at: true },
|
||||
}),
|
||||
prisma.imagePost.findFirst({
|
||||
where: { created_at: { lt: createdAt } },
|
||||
orderBy: { created_at: "desc" },
|
||||
select: { aweme_id: true, created_at: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
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,
|
||||
});
|
||||
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(),
|
||||
};
|
||||
})();
|
||||
|
||||
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,
|
||||
});
|
||||
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 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);
|
||||
})();
|
||||
@@ -0,0 +1,140 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
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;
|
||||
|
||||
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: 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;
|
||||
if (idleCloseTimer) {
|
||||
clearTimeout(idleCloseTimer);
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function acquireBrowserContext(): Promise<BrowserContext> {
|
||||
// Cancel any pending idle close if a new consumer arrives
|
||||
if (idleCloseTimer) {
|
||||
clearTimeout(idleCloseTimer);
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
|
||||
if (context) {
|
||||
refCount += 1;
|
||||
return context;
|
||||
}
|
||||
|
||||
if (!contextPromise) {
|
||||
contextPromise = launchContext();
|
||||
}
|
||||
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);
|
||||
|
||||
if (refCount > 0 || !context) return;
|
||||
|
||||
// Delay the close to allow bursty workloads to reuse the context
|
||||
if (idleCloseTimer) {
|
||||
clearTimeout(idleCloseTimer);
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
idleCloseTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (context && refCount === 0) {
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
context = null;
|
||||
contextPromise = null;
|
||||
idleCloseTimer = null;
|
||||
}
|
||||
}, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
export const runtime = "nodejs";
|
||||
// src/scrapeDouyin.ts
|
||||
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/";
|
||||
|
||||
/**
|
||||
* 滚动页面并收集评论
|
||||
* @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} 秒)...`),
|
||||
);
|
||||
|
||||
// 启动评论响应收集器
|
||||
const commentResponsesPromise = collectResponsesWithinTime(
|
||||
context,
|
||||
(r: Response) =>
|
||||
r.url().includes(COMMENT_PATH) &&
|
||||
r.status() === 200 &&
|
||||
r.request().frame()?.page() === page,
|
||||
durationMs,
|
||||
);
|
||||
|
||||
// 在指定时间内持续滚动页面
|
||||
const startTime = Date.now();
|
||||
const scrollInterval = 500;
|
||||
let scrollCount = 0;
|
||||
const selector = "div[data-e2e='comment-list']";
|
||||
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrapeDouyin(url: string) {
|
||||
console.log(chalk.blue("🚀 启动共享 Chromium 浏览器..."));
|
||||
let context: BrowserContext | null = await acquireIsolatedContext();
|
||||
const page = await context.newPage();
|
||||
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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: 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");
|
||||
}
|
||||
|
||||
// 等待作品类型判定
|
||||
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("✓ 资源清理完成"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | 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;
|
||||
});
|
||||
|
||||
return best?.play_addr ?? null;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Playwright 的 APIRequestContext 下载二进制内容
|
||||
* - 使用指定 headers 模拟浏览器请求
|
||||
* - referrer 使用链接本身
|
||||
*/
|
||||
export async function downloadBinary(
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||
console.log("下载:", url);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在多个候选匹配中“先到先得”地返回首个命中的 Response。
|
||||
* - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。
|
||||
*/
|
||||
export function waitForFirstResponse(
|
||||
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;
|
||||
|
||||
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
|
||||
* 用于评论等需要滚动加载的数据
|
||||
*/
|
||||
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,
|
||||
): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
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,
|
||||
) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// 处理评论贴纸/配图上传与入库
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
duration?: number;
|
||||
}[];
|
||||
musicUrl?: string;
|
||||
},
|
||||
rawJson?: any,
|
||||
) {
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// 处理评论贴纸/配图上传与入库
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
aweme_id: imagePost.aweme_id,
|
||||
author_sec_uid: author.sec_uid,
|
||||
image_count: uploads.images.length,
|
||||
comment_count: comments.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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");
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
// 处理未知错误
|
||||
console.error("未捕获的错误:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "服务器内部错误",
|
||||
code: "INTERNAL_ERROR",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handleDouyinScrape;
|
||||
+46
-24
@@ -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: {
|
||||
@@ -83,6 +99,9 @@ interface PlayVariant {
|
||||
width: number;
|
||||
height: number;
|
||||
data_size: number;
|
||||
FPS: number;
|
||||
is_bytevc1: number; // 0 or 1
|
||||
is_h265: number; // 0 or 1
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +132,7 @@ interface DouyinImageAweme {
|
||||
};
|
||||
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
||||
images: DouyinImageInfo[]; // 图片列表
|
||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||
video_tag?: VideoTagBasic[]; // 标签
|
||||
}
|
||||
|
||||
@@ -123,6 +142,9 @@ interface DouyinImageInfo {
|
||||
download_url_list?: string[]; // 可能带水印
|
||||
width: number;
|
||||
height: number;
|
||||
video: {
|
||||
play_addr: { src: string }[];
|
||||
} | null; // 如果是动图,会有 video 信息
|
||||
}
|
||||
|
||||
/** 音乐基本信息(精简) */
|
||||
@@ -135,4 +157,4 @@ interface DouyinMusicBasic {
|
||||
uri?: string;
|
||||
url_list: string[]; // 真实可下载地址
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载任意图片并上传到 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;
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return { images: uploadedImages, musicUrl };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function toCamelCaseKey(key: string): string {
|
||||
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()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个代理对象,使得读取属性时同时兼容 snake_case 与 camelCase 键名。
|
||||
* 访问顺序:原名 -> camelCase -> snake_case
|
||||
*/
|
||||
export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
||||
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 handler: ProxyHandler<any> = {
|
||||
get(target, prop, receiver) {
|
||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||
if (typeof prop !== "string") {
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
}
|
||||
|
||||
const primary = prop;
|
||||
|
||||
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 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(root);
|
||||
}
|
||||
|
||||
/** 选择首个可用 URL */
|
||||
export function pickFirstUrl(list?: string[]) {
|
||||
return Array.isArray(list) && list.length ? list[0] : undefined;
|
||||
}
|
||||
|
||||
// 别名,兼容旧命名
|
||||
export const firstUrl = pickFirstUrl;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+948
-537
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { forwardRef, memo } from "react";
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,133 @@
|
||||
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 {
|
||||
author: User;
|
||||
createdAt: string | Date;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
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"
|
||||
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)}
|
||||
>
|
||||
发布于 {formatRelativeTime(createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-4 sm:space-y-5">
|
||||
{comments.map((c) => (
|
||||
<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"
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Comment, User } from "../types.ts";
|
||||
import { CommentList } from "./CommentList";
|
||||
|
||||
interface CommentPanelProps {
|
||||
/** 侧栏打开且停在「评论」标签页 */
|
||||
open: boolean;
|
||||
author: User;
|
||||
createdAt: string | Date;
|
||||
awemeId: string;
|
||||
}
|
||||
|
||||
interface CommentsApiResponse {
|
||||
comments?: Comment[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
mode?: string;
|
||||
seed?: string | number;
|
||||
snapshot?: string | number;
|
||||
}
|
||||
|
||||
// 侧栏「评论」标签页的内容(外壳与标签切换在 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);
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { parseCommentText } from "../utils";
|
||||
|
||||
// 渲染评论文本(包含表情)
|
||||
export function CommentText({ text }: { text: string }) {
|
||||
const parts = parseCommentText(text);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, idx: number) => {
|
||||
if (typeof part === "string") {
|
||||
return <span key={idx}>{part}</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
key={idx}
|
||||
src={`/emojis/${part.name}.webp`}
|
||||
alt={part.name}
|
||||
className="inline-block w-5 h-5 align-text-bottom mx-0.5"
|
||||
onError={(e) => {
|
||||
// 如果图片加载失败,显示原始文本
|
||||
e.currentTarget.style.display = "none";
|
||||
const textNode = document.createTextNode(`[${part.name}]`);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,489 @@
|
||||
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 = 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>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
ImageCarousel.displayName = "ImageCarousel";
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ImageNavigationButtonsProps {
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
currentIndex: number;
|
||||
totalImages: number;
|
||||
}
|
||||
|
||||
export const ImageNavigationButtons = memo(function ImageNavigationButtons({
|
||||
onPrev,
|
||||
onNext,
|
||||
currentIndex,
|
||||
totalImages,
|
||||
}: ImageNavigationButtonsProps) {
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < totalImages - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 左侧按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
absolute left-4 top-6/17 -translate-y-1/2 z-20
|
||||
w-12 h-12 rounded-full
|
||||
bg-black/55 border border-white/20
|
||||
flex items-center justify-center
|
||||
text-white transition-opacity
|
||||
${hasPrev ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||
`}
|
||||
onClick={onPrev}
|
||||
disabled={!hasPrev}
|
||||
aria-label="上一张图片"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
absolute right-4 top-6/17 -translate-y-1/2 z-20
|
||||
w-12 h-12 rounded-full
|
||||
bg-black/55 border border-white/20
|
||||
flex items-center justify-center
|
||||
text-white transition-opacity
|
||||
${hasNext ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||
`}
|
||||
onClick={onNext}
|
||||
disabled={!hasNext}
|
||||
aria-label="下一张图片"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Download,
|
||||
FileText,
|
||||
Maximize,
|
||||
Maximize2,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
interface MediaControlsProps {
|
||||
isVideo: boolean;
|
||||
isPlaying: boolean;
|
||||
progress: number;
|
||||
volume: number;
|
||||
rate: number;
|
||||
rotation: number;
|
||||
objectFit: ObjectFit;
|
||||
loopMode: LoopMode;
|
||||
isFullscreen: boolean;
|
||||
author: User;
|
||||
createdAt: string | Date;
|
||||
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;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onRateChange: (rate: number) => void;
|
||||
onRotationChange: (rotation: number) => void;
|
||||
onObjectFitChange: (fit: ObjectFit) => void;
|
||||
onLoopModeChange: (mode: LoopMode) => void;
|
||||
onDownload: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
|
||||
onAuthorClick?: () => void;
|
||||
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
|
||||
onHashtagClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
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,
|
||||
volume,
|
||||
rate,
|
||||
rotation,
|
||||
objectFit,
|
||||
loopMode,
|
||||
isFullscreen,
|
||||
author,
|
||||
createdAt,
|
||||
desc,
|
||||
videoRef,
|
||||
abLoop,
|
||||
onClearAbLoop,
|
||||
currentIndex = 0,
|
||||
totalSegments = 0,
|
||||
segmentProgress = 0,
|
||||
musicUrl,
|
||||
audioRef,
|
||||
hasTranscript = false,
|
||||
onShowTranscript,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
onVolumeChange,
|
||||
onRateChange,
|
||||
onRotationChange,
|
||||
onObjectFitChange,
|
||||
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
|
||||
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="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)}
|
||||
>
|
||||
{formatRelativeTime(createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{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}
|
||||
|
||||
{/* 进度条:图文=分段;视频=单段 */}
|
||||
{!isVideo && totalSegments > 0 ? (
|
||||
<SegmentedProgressBar
|
||||
totalSegments={totalSegments}
|
||||
currentIndex={currentIndex}
|
||||
segmentProgress={segmentProgress}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
) : (
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
onSeek={onSeek}
|
||||
abLoop={isVideo ? abLoop : undefined}
|
||||
duration={isVideo ? (videoRef?.current?.duration ?? 0) : 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 控制按钮行 - 响应式布局 */}
|
||||
<div className="flex items-center justify-between gap-1.5 sm:gap-2.5">
|
||||
{/* 左侧:播放控制 + 时间/进度 */}
|
||||
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className={`${CONTROL_BTN_CLASS} shrink-0`}
|
||||
onClick={onTogglePlay}
|
||||
aria-label={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 播放进度显示 - 所有设备都显示 */}
|
||||
<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}`}
|
||||
</div>
|
||||
|
||||
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
|
||||
{isVideo && (
|
||||
<button
|
||||
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);
|
||||
const next = steps[(i + 1) % steps.length];
|
||||
onRateChange(next);
|
||||
}}
|
||||
aria-label="切换倍速"
|
||||
>
|
||||
{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
|
||||
type="button"
|
||||
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={() => onRotationChange((rotation + 270) % 360)}
|
||||
aria-label="向左旋转 90 度"
|
||||
title="向左旋转 90 度"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL_BTN_CLASS}
|
||||
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||
>
|
||||
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-20 lg:w-28 accent-white cursor-pointer"
|
||||
aria-label="音量"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={() => onRotationChange((rotation + 90) % 360)}
|
||||
aria-label="向右旋转 90 度"
|
||||
title="向右旋转 90 度"
|
||||
>
|
||||
<RotateCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧:功能按钮组 */}
|
||||
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
|
||||
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
|
||||
<button
|
||||
type="button"
|
||||
className={`md:hidden ${CONTROL_BTN_CLASS}`}
|
||||
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||
>
|
||||
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
||||
</button>
|
||||
|
||||
{/* 循环模式 - 中等屏幕以上显示 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={handleLoopModeToggle}
|
||||
aria-label={loopLabel}
|
||||
title={loopLabel}
|
||||
>
|
||||
{renderLoopIcon()}
|
||||
</button>
|
||||
|
||||
{/* 适配模式 - 小屏幕以上显示 */}
|
||||
<button
|
||||
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} />
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||
onClick={onDownload}
|
||||
aria-label={isVideo ? "下载视频" : "下载当前图片"}
|
||||
title={isVideo ? "下载视频" : "下载当前图片"}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className={CONTROL_BTN_CLASS}
|
||||
onClick={onToggleFullscreen}
|
||||
aria-label="切换全屏"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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 const NavigationButtons = memo(function NavigationButtons({
|
||||
neighbors,
|
||||
commentsCount,
|
||||
likesCount,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onToggleComments,
|
||||
}: NavigationButtonsProps) {
|
||||
return (
|
||||
<>
|
||||
<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="" />
|
||||
|
||||
<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="" />
|
||||
|
||||
{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-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}
|
||||
aria-label="上一条"
|
||||
>
|
||||
<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}
|
||||
aria-label="下一条"
|
||||
>
|
||||
<ChevronDown size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +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 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 cursor-pointer touch-none select-none"
|
||||
onClick={(e) => {
|
||||
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="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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { memo } from "react";
|
||||
|
||||
interface SegmentedProgressBarProps {
|
||||
totalSegments: number;
|
||||
currentIndex: number;
|
||||
segmentProgress: number;
|
||||
onSeek: (ratio: number) => void;
|
||||
}
|
||||
|
||||
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 touch-none"
|
||||
onClick={(e) => {
|
||||
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">
|
||||
{Array.from({ length: totalSegments }).map((_, i) => {
|
||||
let fill = 0;
|
||||
if (i < currentIndex) fill = 1;
|
||||
else if (i === currentIndex) fill = segmentProgress;
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { forwardRef, memo } from "react";
|
||||
import type { ObjectFit } from "../types.ts";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoUrl: string;
|
||||
rotation: number;
|
||||
objectFit: ObjectFit;
|
||||
loop: boolean;
|
||||
onTogglePlay: () => void;
|
||||
}
|
||||
|
||||
export const VideoPlayer = memo(
|
||||
forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={videoUrl}
|
||||
className={
|
||||
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"
|
||||
}
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// 组件导出
|
||||
export { BackgroundCanvas } from "./BackgroundCanvas";
|
||||
export { CommentPanel } from "./CommentPanel";
|
||||
export { CommentList } from "./CommentList";
|
||||
export { CommentText } from "./CommentText";
|
||||
export { ImageCarousel } from "./ImageCarousel";
|
||||
export { ImageNavigationButtons } from "./ImageNavigationButtons";
|
||||
export { MediaControls } from "./MediaControls";
|
||||
export { NavigationButtons } from "./NavigationButtons";
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export { SegmentedProgressBar } from "./SegmentedProgressBar";
|
||||
export { VideoPlayer } from "./VideoPlayer";
|
||||
@@ -0,0 +1,216 @@
|
||||
export const emojiList = [
|
||||
"微笑",
|
||||
"色",
|
||||
"发呆",
|
||||
"酷拽",
|
||||
"抠鼻",
|
||||
"流泪",
|
||||
"捂脸",
|
||||
"发怒",
|
||||
"呲牙",
|
||||
"尬笑",
|
||||
"害羞",
|
||||
"调皮",
|
||||
"舔屏",
|
||||
"看",
|
||||
"爱心",
|
||||
"比心",
|
||||
"赞",
|
||||
"鼓掌",
|
||||
"感谢",
|
||||
"抱抱你",
|
||||
"玫瑰",
|
||||
"尴尬流汗",
|
||||
"戳手手",
|
||||
"星星眼",
|
||||
"杀马特",
|
||||
"黄脸干杯",
|
||||
"抱紧自己",
|
||||
"拜拜",
|
||||
"热化了",
|
||||
"黄脸祈祷",
|
||||
"懵",
|
||||
"举手",
|
||||
"加功德",
|
||||
"摊手",
|
||||
"无语流汗",
|
||||
"续火花吧",
|
||||
"点火",
|
||||
"哭哭",
|
||||
"吐舌小狗",
|
||||
"送花",
|
||||
"爱心手",
|
||||
"贴贴",
|
||||
"灵机一动",
|
||||
"耶",
|
||||
"打脸",
|
||||
"大笑",
|
||||
"机智",
|
||||
"送心",
|
||||
"666",
|
||||
"闭嘴",
|
||||
"来看我",
|
||||
"一起加油",
|
||||
"哈欠",
|
||||
"震惊",
|
||||
"晕",
|
||||
"衰",
|
||||
"困",
|
||||
"疑问",
|
||||
"泣不成声",
|
||||
"小鼓掌",
|
||||
"大金牙",
|
||||
"偷笑",
|
||||
"石化",
|
||||
"思考",
|
||||
"吐血",
|
||||
"可怜",
|
||||
"嘘",
|
||||
"撇嘴",
|
||||
"笑哭",
|
||||
"奸笑",
|
||||
"得意",
|
||||
"憨笑",
|
||||
"坏笑",
|
||||
"抓狂",
|
||||
"泪奔",
|
||||
"钱",
|
||||
"恐惧",
|
||||
"愉快",
|
||||
"快哭了",
|
||||
"翻白眼",
|
||||
"互粉",
|
||||
"我想静静",
|
||||
"委屈",
|
||||
"鄙视",
|
||||
"飞吻",
|
||||
"再见",
|
||||
"紫薇别走",
|
||||
"听歌",
|
||||
"求抱抱",
|
||||
"绝望的凝视",
|
||||
"不失礼貌的微笑",
|
||||
"不看",
|
||||
"裂开",
|
||||
"干饭人",
|
||||
"庆祝",
|
||||
"吐舌",
|
||||
"呆无辜",
|
||||
"白眼",
|
||||
"猪头",
|
||||
"冷漠",
|
||||
"暗中观察",
|
||||
"二哈",
|
||||
"菜狗",
|
||||
"黑脸",
|
||||
"展开说说",
|
||||
"蜜蜂狗",
|
||||
"柴犬",
|
||||
"摸头",
|
||||
"皱眉",
|
||||
"擦汗",
|
||||
"红脸",
|
||||
"做鬼脸",
|
||||
"强",
|
||||
"如花",
|
||||
"吐",
|
||||
"惊喜",
|
||||
"敲打",
|
||||
"奋斗",
|
||||
"吐彩虹",
|
||||
"大哭",
|
||||
"嘿哈",
|
||||
"惊恐",
|
||||
"囧",
|
||||
"难过",
|
||||
"斜眼",
|
||||
"阴险",
|
||||
"悠闲",
|
||||
"咒骂",
|
||||
"吃瓜群众",
|
||||
"绿帽子",
|
||||
"敢怒不敢言",
|
||||
"求求了",
|
||||
"眼含热泪",
|
||||
"叹气",
|
||||
"好开心",
|
||||
"不是吧",
|
||||
"鞠躬",
|
||||
"躺平",
|
||||
"九转大肠",
|
||||
"不你不想",
|
||||
"一头乱麻",
|
||||
"kisskiss",
|
||||
"你不大行",
|
||||
"噢买尬",
|
||||
"宕机",
|
||||
"苦涩",
|
||||
"逞强落泪",
|
||||
"求机位-黄脸",
|
||||
"求机位3",
|
||||
"点赞",
|
||||
"精选",
|
||||
"强壮",
|
||||
"碰拳",
|
||||
"OK",
|
||||
"击掌",
|
||||
"左上",
|
||||
"握手",
|
||||
"抱拳",
|
||||
"勾引",
|
||||
"拳头",
|
||||
"弱",
|
||||
"胜利",
|
||||
"右边",
|
||||
"左边",
|
||||
"嘴唇",
|
||||
"心碎",
|
||||
"凋谢",
|
||||
"愤怒",
|
||||
"垃圾",
|
||||
"啤酒",
|
||||
"咖啡",
|
||||
"蛋糕",
|
||||
"礼物",
|
||||
"撒花",
|
||||
"加一",
|
||||
"减一",
|
||||
"okk",
|
||||
"V5",
|
||||
"绝",
|
||||
"给力",
|
||||
"红包",
|
||||
"屎",
|
||||
"发",
|
||||
"18禁",
|
||||
"炸弹",
|
||||
"西瓜",
|
||||
"加鸡腿",
|
||||
"握爪",
|
||||
"太阳",
|
||||
"月亮",
|
||||
"给跪了",
|
||||
"蕉绿",
|
||||
"扎心",
|
||||
"胡瓜",
|
||||
"打call",
|
||||
"栓Q",
|
||||
"雪花",
|
||||
"圣诞树",
|
||||
"平安果",
|
||||
"圣诞帽",
|
||||
"气球",
|
||||
"烟花",
|
||||
"福",
|
||||
"candy",
|
||||
"糖葫芦",
|
||||
"鞭炮",
|
||||
"元宝",
|
||||
"灯笼",
|
||||
"锦鲤",
|
||||
"巧克力",
|
||||
"戒指",
|
||||
"棒棒糖",
|
||||
"纸飞机",
|
||||
"粽子",
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
// Hooks 导出
|
||||
export { useBackgroundCanvas } from "./useBackgroundCanvas";
|
||||
export { useSidePanelState } from "./useSidePanelState";
|
||||
export { useImageCarousel } from "./useImageCarousel";
|
||||
export { useNavigation } from "./useNavigation";
|
||||
export { usePlayerState } from "./usePlayerState";
|
||||
export { useVideoPlayer } from "./useVideoPlayer";
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface UseBackgroundCanvasProps {
|
||||
isVideo: boolean;
|
||||
isPlaying: boolean;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
|
||||
}
|
||||
|
||||
const CANVAS_DRAW_INTERVAL_MS = 100; // 10fps 对 1/10 分辨率 + blur(40px) 已完全平滑,大幅降低 GPU 负载
|
||||
|
||||
export function useBackgroundCanvas({
|
||||
isVideo,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
backgroundCanvasRef,
|
||||
}: UseBackgroundCanvasProps): void {
|
||||
// 初始化尺寸与卸载清理
|
||||
useEffect(() => {
|
||||
const canvas = backgroundCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx || !isVideo) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
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: ReturnType<typeof setTimeout> | null = null;
|
||||
const debouncedResize = (): void => {
|
||||
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(updateCanvasSize, 300);
|
||||
};
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
|
||||
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]);
|
||||
|
||||
// 仅在视频实际播放时进行低频采样绘制,暂停时不占 CPU/GPU
|
||||
useEffect(() => {
|
||||
if (!isVideo || !isPlaying) return;
|
||||
const canvas = backgroundCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const drawMediaToCanvas = (): void => {
|
||||
if (document.hidden) return;
|
||||
const sourceElement = videoRef.current;
|
||||
if (
|
||||
!sourceElement ||
|
||||
sourceElement.readyState < 2 ||
|
||||
sourceElement.paused
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasWidth = canvas.width;
|
||||
const canvasHeight = canvas.height;
|
||||
const sourceWidth = sourceElement.videoWidth;
|
||||
const sourceHeight = sourceElement.videoHeight;
|
||||
|
||||
if (!canvasWidth || !canvasHeight || !sourceWidth || !sourceHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasRatio = canvasWidth / canvasHeight;
|
||||
const sourceRatio = sourceWidth / sourceHeight;
|
||||
|
||||
let drawWidth: number;
|
||||
let drawHeight: number;
|
||||
let offsetX: number;
|
||||
let offsetY: number;
|
||||
|
||||
if (canvasRatio > sourceRatio) {
|
||||
drawWidth = canvasWidth;
|
||||
drawHeight = canvasWidth / sourceRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (canvasHeight - drawHeight) / 2;
|
||||
} else {
|
||||
drawHeight = canvasHeight;
|
||||
drawWidth = canvasHeight * sourceRatio;
|
||||
offsetX = (canvasWidth - drawWidth) / 2;
|
||||
offsetY = 0;
|
||||
}
|
||||
|
||||
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
||||
};
|
||||
|
||||
drawMediaToCanvas();
|
||||
const intervalId = window.setInterval(
|
||||
drawMediaToCanvas,
|
||||
CANVAS_DRAW_INTERVAL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [isVideo, isPlaying, videoRef, backgroundCanvasRef]);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
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>;
|
||||
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,
|
||||
setProgress,
|
||||
segmentMs = 5000,
|
||||
}: 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 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;
|
||||
if (!el) return;
|
||||
el.volume = volume;
|
||||
if (isPlaying) {
|
||||
el.play().catch(() => {});
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [audioRef, isPlaying, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
return () => {
|
||||
releaseMediaElement(el);
|
||||
};
|
||||
}, [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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
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;
|
||||
isVideo: boolean;
|
||||
mediaContainerRef: RefObject<HTMLDivElement | null>;
|
||||
videoRef?: RefObject<HTMLVideoElement | null>;
|
||||
prevImg?: () => void;
|
||||
nextImg?: () => void;
|
||||
togglePlay: () => void;
|
||||
onFastForwardChange?: (active: boolean) => void;
|
||||
onSetAbStart?: () => void;
|
||||
onSetAbEnd?: () => void;
|
||||
onClearAbLoop?: () => void;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
neighbors,
|
||||
isVideo,
|
||||
mediaContainerRef,
|
||||
videoRef,
|
||||
prevImg,
|
||||
nextImg,
|
||||
togglePlay,
|
||||
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(neighbors.next.href);
|
||||
if (neighbors.prev) router.prefetch(neighbors.prev.href);
|
||||
}, [neighbors, router]);
|
||||
|
||||
// 鼠标滚轮切换
|
||||
useEffect(() => {
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
if (e.ctrlKey) return;
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
const dy = e.deltaY;
|
||||
if (Math.abs(dy) < 40) return;
|
||||
|
||||
if ((dy > 0 && neighbors?.next) || (dy < 0 && neighbors?.prev)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [neighbors, mediaContainerRef, router]);
|
||||
|
||||
// 触屏上下滑切换(上滑下一条,下滑上一条)。只认明显的竖向手势,
|
||||
// 不影响图文的左右滑动;从按钮、链接、控制栏(data-no-swipe)上开始的触摸不处理。
|
||||
useEffect(() => {
|
||||
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" || code === "KeyW") {
|
||||
e.preventDefault();
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
if (neighbors?.prev) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(neighbors.prev.href);
|
||||
}
|
||||
} 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(neighbors.next.href);
|
||||
}
|
||||
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
|
||||
e.preventDefault();
|
||||
if (isVideo) {
|
||||
const v = videoRef?.current;
|
||||
if (v && v.duration) {
|
||||
v.currentTime = Math.max(0, v.currentTime - 5);
|
||||
}
|
||||
} else {
|
||||
prevImg?.();
|
||||
}
|
||||
} else if (key === "arrowright" || key === "d" || code === "KeyD") {
|
||||
e.preventDefault();
|
||||
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);
|
||||
}
|
||||
}, 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);
|
||||
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(() => {
|
||||
return () => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
if (longPressActiveRef.current) {
|
||||
longPressActiveRef.current = false;
|
||||
rightPressKeyRef.current = null;
|
||||
onFastForwardChangeRef.current?.(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 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);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}, [router]);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 [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");
|
||||
if (saved === "single" || saved === "sequential") return saved;
|
||||
return "loop";
|
||||
});
|
||||
|
||||
// 持久化音量
|
||||
useEffect(() => {
|
||||
saveToStorage("aweme_player_volume", volume);
|
||||
}, [volume]);
|
||||
|
||||
// 持久化倍速
|
||||
useEffect(() => {
|
||||
saveToStorage("aweme_player_rate", rate);
|
||||
}, [rate]);
|
||||
|
||||
// 持久化循环模式
|
||||
useEffect(() => {
|
||||
saveToStorage("aweme_player_loop_mode", loopMode);
|
||||
}, [loopMode]);
|
||||
|
||||
// 监听全屏状态
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
volume,
|
||||
setVolume,
|
||||
rate,
|
||||
setRate,
|
||||
progress,
|
||||
setProgress,
|
||||
rotation,
|
||||
setRotation,
|
||||
progressRestored,
|
||||
setProgressRestored,
|
||||
objectFit,
|
||||
setObjectFit,
|
||||
loopMode,
|
||||
setLoopMode,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
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;
|
||||
setProgress: (progress: number) => void;
|
||||
setProgressRestored: (restored: boolean) => void;
|
||||
}
|
||||
|
||||
export function useVideoPlayer({
|
||||
enabled = true,
|
||||
awemeId,
|
||||
videoRef,
|
||||
volume,
|
||||
rate,
|
||||
loopMode,
|
||||
abLoop,
|
||||
neighbors,
|
||||
progressRestored,
|
||||
setIsPlaying,
|
||||
setProgress,
|
||||
setProgressRestored,
|
||||
}: 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 (!enabled) return;
|
||||
if (progressRestored && restoredAwemeIdRef.current === awemeId) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onLoadedMetadata = (): void => {
|
||||
if (restoredAwemeIdRef.current === awemeId) return;
|
||||
restoredAwemeIdRef.current = awemeId;
|
||||
|
||||
try {
|
||||
const key = `aweme_progress_${awemeId}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) {
|
||||
setProgressRestored(true);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
v.currentTime = time;
|
||||
} else if (now - timestamp >= fiveMinutes) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("恢复播放进度失败", e);
|
||||
}
|
||||
|
||||
setProgressRestored(true);
|
||||
};
|
||||
|
||||
if (v.readyState >= 1) {
|
||||
onLoadedMetadata();
|
||||
return;
|
||||
}
|
||||
|
||||
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 = (): void => {
|
||||
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
||||
|
||||
try {
|
||||
const key = `aweme_progress_${awemeId}`;
|
||||
const value = JSON.stringify({
|
||||
time: v.currentTime,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
localStorage.setItem(key, value);
|
||||
} catch (e) {
|
||||
console.error("保存播放进度失败", e);
|
||||
}
|
||||
};
|
||||
|
||||
const interval = window.setInterval(saveProgress, 2000);
|
||||
const onBeforeUnload = (): void => saveProgress();
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
saveProgress();
|
||||
releaseMediaElement(v);
|
||||
};
|
||||
}, [awemeId, enabled, videoRef]);
|
||||
|
||||
// 监听播放状态和进度(含 AB 循环区间控制)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const syncVideoProgress = (): void => {
|
||||
if (!v.duration || Number.isNaN(v.duration)) return;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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", syncVideoProgress);
|
||||
v.removeEventListener("loadedmetadata", syncVideoProgress);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [
|
||||
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 (): Promise<void> => {
|
||||
try {
|
||||
await v.play();
|
||||
setIsPlaying(true);
|
||||
} catch {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (v.readyState >= 1) {
|
||||
void checkAutoplay();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}, [enabled, volume, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [enabled, rate, videoRef]);
|
||||
}
|
||||
+136
-51
@@ -1,70 +1,148 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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 default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ awemeId: string }>;
|
||||
}): Promise<Metadata> {
|
||||
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 } },
|
||||
select: { desc: true, author: { select: { nickname: 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 } },
|
||||
})
|
||||
select: { desc: true, author: { select: { nickname: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = video || post;
|
||||
if (!data) {
|
||||
return {
|
||||
title: "作品不存在",
|
||||
};
|
||||
}
|
||||
|
||||
const desc = data.desc || "查看作品详情";
|
||||
const author = data.author.nickname;
|
||||
const title =
|
||||
desc.length > 50
|
||||
? `${desc.slice(0, 50)}... - ${author}`
|
||||
: `${desc} - ${author}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: desc,
|
||||
};
|
||||
}
|
||||
|
||||
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, transcript: true },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
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.map((i) => ({ id: i.id, url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
|
||||
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);
|
||||
|
||||
// 获取评论总数
|
||||
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,
|
||||
]);
|
||||
|
||||
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
|
||||
@@ -72,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} />
|
||||
<AwemeDetailClient
|
||||
data={data}
|
||||
neighbors={playback.neighbors}
|
||||
playbackContext={playback.context}
|
||||
contextQuery={playback.contextQuery}
|
||||
authorWorksCount={authorWorksCount}
|
||||
transcript={transcript}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export type User = {
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
sec_uid?: string;
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
cid: string;
|
||||
text: string;
|
||||
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;
|
||||
video_url: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
author: User;
|
||||
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 | null;
|
||||
height: number | null;
|
||||
animated: string | null;
|
||||
duration: number | null;
|
||||
}[];
|
||||
music_url: string | null;
|
||||
author: User;
|
||||
commentsCount: number;
|
||||
likesCount: number;
|
||||
};
|
||||
|
||||
export type AwemeData = VideoData | ImageData;
|
||||
|
||||
export type VideoTranscript = {
|
||||
transcript: string[];
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
// 格式化相对时间
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = now.getTime() - target.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
if (diffYears > 0) return `${diffYears}年前`;
|
||||
if (diffMonths > 0) return `${diffMonths}月前`;
|
||||
if (diffDays > 0) return `${diffDays}天前`;
|
||||
if (diffHours > 0) return `${diffHours}小时前`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}分钟前`;
|
||||
return "刚刚";
|
||||
}
|
||||
|
||||
// 格式化时间显示 (mm:ss)
|
||||
export function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// 处理评论文本中的表情占位符
|
||||
export function parseCommentText(
|
||||
text: string,
|
||||
): (string | { type: "emoji"; name: string })[] {
|
||||
const parts: (string | { type: "emoji"; name: string })[] = [];
|
||||
const regex = /\[([^\]]+)\]/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// 添加表情前的文本
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
// 添加表情
|
||||
parts.push({ type: "emoji", name: match[1] });
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// 从 localStorage 获取数值
|
||||
export function getNumberFromStorage(
|
||||
key: string,
|
||||
defaultValue: number,
|
||||
): number {
|
||||
if (typeof window === "undefined") return defaultValue;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) return defaultValue;
|
||||
const parsed = parseFloat(saved);
|
||||
return Number.isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
// 从 localStorage 获取字符串
|
||||
export function getStringFromStorage(
|
||||
key: string,
|
||||
defaultValue: string,
|
||||
): string {
|
||||
if (typeof window === "undefined") return defaultValue;
|
||||
return localStorage.getItem(key) || defaultValue;
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
export function saveToStorage(key: string, value: string | number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(key, value.toString());
|
||||
}
|
||||
|
||||
// 稳定的绝对时间格式(与 locale 无关),用于避免 SSR/CSR 水合不一致
|
||||
// 输出示例:2025-10-20 08:23:05 UTC
|
||||
export function formatAbsoluteUTC(date: string | Date): string {
|
||||
const d = new Date(date);
|
||||
const Y = d.getUTCFullYear();
|
||||
const M = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const D = String(d.getUTCDate()).padStart(2, "0");
|
||||
const h = String(d.getUTCHours()).padStart(2, "0");
|
||||
const m = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
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;
|
||||
@@ -14,24 +14,41 @@ type BackButtonProps = {
|
||||
|
||||
/**
|
||||
* BackButton
|
||||
* - Primary: behaves like browser back (router.back()), preserving previous page state (e.g., scroll, filters)
|
||||
* - Fallback: if no history entry exists (e.g., opened directly), navigates to '/'
|
||||
* - Primary: attempts to close the current window/tab (window.close())
|
||||
* - 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;
|
||||
|
||||
// Prefer SPA back when we have some history to go back to
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}
|
||||
// else: allow default <Link href> to navigate to fallback
|
||||
}, [router]);
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
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}`} 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 |
@@ -1,170 +0,0 @@
|
||||
// src/scrapeDouyin.ts
|
||||
import { chromium, type Response } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { createCamelCompatibleProxy } from '@/app/fetcher/utils';
|
||||
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/fetcher/network';
|
||||
import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media';
|
||||
import { handleImagePost } from '@/app/fetcher/uploader';
|
||||
import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist';
|
||||
|
||||
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
||||
export async function scrapeDouyin(url: string) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
console.log("Launch chromium");
|
||||
|
||||
const context = await chromium.launchPersistentContext('chrome-profile/douyin', { headless: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
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/window 上的同名队列都指向我们的 proxy
|
||||
// 有些站点用 self,有些用 window
|
||||
(self as any).__pace_f = proxyArr;
|
||||
(window as any).__pace_f = proxyArr;
|
||||
});
|
||||
|
||||
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 },
|
||||
], 20_000); // 整体 20s 兜底超时,不逐个等待
|
||||
|
||||
// 评论只做短时“有就用、没有不等”的监听
|
||||
const commentPromise = waitForResponseWithTimeout(
|
||||
context,
|
||||
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200,
|
||||
8_000
|
||||
).catch(() => null);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
|
||||
const firstType = await firstTypePromise; // { key, response } | null
|
||||
const commentRes = await commentPromise; // Response | null
|
||||
|
||||
if (!firstType) {
|
||||
console.warn('无法判定作品类型(未捕获详情或图文接口)');
|
||||
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: {} } };
|
||||
});
|
||||
let aweme_mem = md.aweme.detail as DouyinImageAweme;
|
||||
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
||||
|
||||
//@ts-ignore
|
||||
aweme_mem.author = aweme_mem.authorInfo
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
||||
return { type: "image", ...saved };
|
||||
}
|
||||
|
||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
||||
if (firstType.key === 'post') {
|
||||
// 图文作品
|
||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
||||
if (!postJson?.aweme_list?.length) throw new Error('图文作品响应为空');
|
||||
|
||||
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) {
|
||||
console.warn(`图文作品响应中未找到对应作品,look for aweme_id=${target_aweme_id}, have ${postJson.aweme_list.map(pt => pt.aweme_id).join(', ')}`);
|
||||
// Try read from memory
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000000));
|
||||
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: {} } };
|
||||
});
|
||||
aweme = md.aweme.detail as DouyinImageAweme;
|
||||
}
|
||||
// console.log(aweme);
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000000));
|
||||
console.log(aweme);
|
||||
|
||||
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
const uploads = await handleImagePost(context, aweme);
|
||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads);
|
||||
return { type: "image", ...saved };
|
||||
} else if (firstType.key === 'detail') {
|
||||
// 视频作品
|
||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
||||
const comments = commentRes ? (await safeJson<DouyinCommentResponse>(commentRes))! : { comments: [], total: 0, status_code: 0 };
|
||||
|
||||
// 找到比特率最高的 url
|
||||
const bestPlayAddr = pickBestPlayAddr(
|
||||
detail?.aweme_detail?.video.bit_rate
|
||||
);
|
||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||
|
||||
console.log('Best video URL:', bestVUrl);
|
||||
|
||||
// 下载视频并上传至 MinIO,获取外链
|
||||
let uploadedUrl: string | undefined;
|
||||
let coverUrl: string | undefined;
|
||||
if (bestVUrl && detail?.aweme_detail) {
|
||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
||||
const awemeId = detail.aweme_detail.aweme_id;
|
||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
||||
console.log('Uploaded to MinIO:', uploadedUrl);
|
||||
|
||||
// 提取首帧作为封面并上传
|
||||
try {
|
||||
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('Cover uploaded to MinIO:', coverUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Extract first frame failed, skip cover:', (e as Error)?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl);
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new Error('无法判定作品类型(未命中详情或图文接口)');
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | 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;
|
||||
});
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Playwright 的 APIRequestContext 下载二进制内容
|
||||
* - 使用指定 headers 模拟浏览器请求
|
||||
* - referrer 使用链接本身
|
||||
*/
|
||||
export async function downloadBinary(
|
||||
context: BrowserContext,
|
||||
url: string
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||
console.log('Download bin:', url);
|
||||
|
||||
const headers = {
|
||||
referer: url,
|
||||
} as Record<string, string>;
|
||||
|
||||
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()}`);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在多个候选匹配中“先到先得”地返回首个命中的 Response。
|
||||
* - 不为每个候选单独设长超时,改用整体兜底超时,避免无意义等待。
|
||||
*/
|
||||
export function waitForFirstResponse(
|
||||
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;
|
||||
|
||||
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,带短超时;用于评论等“可有可无”的数据。
|
||||
*/
|
||||
export function waitForResponseWithTimeout(
|
||||
context: BrowserContext,
|
||||
predicate: (r: Response) => boolean,
|
||||
timeoutMs = 5_000
|
||||
): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadAvatarFromUrl } from './uploader';
|
||||
import { firstUrl } from './utils';
|
||||
|
||||
export async function saveToDB(
|
||||
context: BrowserContext,
|
||||
detailResp: DouyinVideoDetailResponse,
|
||||
commentResp: DouyinCommentResponse,
|
||||
videoUrl?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
coverUrl?: string
|
||||
) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 }[]; musicUrl?: string }
|
||||
) {
|
||||
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 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert ImageFiles(按顺序)
|
||||
for (let i = 0; i < uploads.images.length; i++) {
|
||||
const { url, width, height } = 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,
|
||||
},
|
||||
update: {
|
||||
url,
|
||||
width: typeof width === 'number' ? width : null,
|
||||
height: typeof height === 'number' ? height : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 评论入库:关联到 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 };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scrapeDouyin } from '.';
|
||||
|
||||
async function handleDouyinScrape(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const videoUrl = searchParams.get('url');
|
||||
if (!videoUrl) {
|
||||
return NextResponse.json({ error: '缺少视频URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 调用爬虫函数
|
||||
const result = await scrapeDouyin(videoUrl);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export const GET = handleDouyinScrape
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
import { downloadBinary } from './network';
|
||||
import { pickFirstUrl } from './utils';
|
||||
|
||||
/**
|
||||
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||
*/
|
||||
export async function uploadAvatarFromUrl(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载图文作品的图片和音乐并上传到 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 }[] = [];
|
||||
|
||||
// 下载图片(顺序保持)
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
|
||||
return { images: uploadedImages, musicUrl };
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
export function toCamelCaseKey(key: string): string {
|
||||
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()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个代理对象,使得读取属性时同时兼容 snake_case 与 camelCase 键名。
|
||||
* 访问顺序:原名 -> camelCase -> snake_case
|
||||
*/
|
||||
export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
||||
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 handler: ProxyHandler<any> = {
|
||||
get(target, prop, receiver) {
|
||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||
if (typeof prop !== 'string') {
|
||||
return wrap(Reflect.get(target, prop, receiver));
|
||||
}
|
||||
|
||||
const primary = prop;
|
||||
|
||||
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 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(root);
|
||||
}
|
||||
|
||||
/** 选择首个可用 URL */
|
||||
export function pickFirstUrl(list?: string[]) {
|
||||
return Array.isArray(list) && list.length ? list[0] : undefined;
|
||||
}
|
||||
|
||||
// 别名,兼容旧命名
|
||||
export const firstUrl = pickFirstUrl;
|
||||
+55
-4
@@ -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,9 +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;
|
||||
}
|
||||
|
||||
/* 搜索结果高亮标记样式 */
|
||||
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-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;
|
||||
}
|
||||
|
||||
+10
-19
@@ -1,20 +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: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "抖歪 - 记录当下时代",
|
||||
template: "%s - 抖歪",
|
||||
},
|
||||
description: "记录当下时代的精彩瞬间",
|
||||
icons: {
|
||||
icon: "/favicon.webp",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,12 +18,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
<html lang="zh-CN">
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+132
-31
@@ -1,19 +1,52 @@
|
||||
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 default async function Home() {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "作品集 - 抖歪",
|
||||
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
||||
};
|
||||
|
||||
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[] = [
|
||||
@@ -22,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>
|
||||
);
|
||||
}
|
||||
+342
-78
@@ -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));
|
||||
};
|
||||
@@ -32,55 +45,121 @@ export default function TasksPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const controllers = useRef<Map<string, AbortController>>(new Map());
|
||||
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
||||
const [, setTick] = useState(0); // 用于强制更新计时显示
|
||||
|
||||
// 设置页面标题
|
||||
useEffect(() => {
|
||||
document.title = "任务管理 - 抖歪";
|
||||
return () => {
|
||||
document.title = "抖歪 - 记录当下时代";
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 next: Task[] = [...prev];
|
||||
for (const url of notDuplicated) {
|
||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
next.push({ id, url, status: "pending" });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [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() } : t));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? { ...t, status: "running", startedAt: Date.now(), error: undefined }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`/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 text = await res.text().catch(() => "");
|
||||
throw new Error(text || `请求失败: ${res.status}`);
|
||||
// 使用后端返回的结构化错误信息
|
||||
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
||||
const errorCode = data?.code || "UNKNOWN";
|
||||
throw new Error(`${errorMsg} (${errorCode})`);
|
||||
}
|
||||
const data = await res.json().catch(() => undefined);
|
||||
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);
|
||||
}
|
||||
@@ -88,33 +167,68 @@ 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");
|
||||
if (!hasRunningTasks) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTick((prev) => prev + 1);
|
||||
}, 1000); // 每秒更新一次
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [tasks]);
|
||||
|
||||
const cancelTask = useCallback((id: string) => {
|
||||
const ctrl = controllers.current.get(id);
|
||||
if (ctrl) ctrl.abort();
|
||||
controllers.current.delete(id);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@@ -122,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 (
|
||||
@@ -141,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" />
|
||||
@@ -159,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>
|
||||
@@ -177,46 +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="shrink-0 space-x-2">
|
||||
{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"/> 取消
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{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" /> 查看作品
|
||||
</a>
|
||||
)}
|
||||
{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" /> 重试
|
||||
</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 ? '收起' : '详情'}
|
||||
{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>
|
||||
</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>
|
||||
@@ -224,17 +424,81 @@ 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' && (
|
||||
<div className="mb-2 inline-flex items-center gap-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200">
|
||||
<AlertTriangle className="h-4 w-4"/> {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" />
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400">
|
||||
<strong>常见原因:</strong>
|
||||
<ul className="ml-4 mt-1 list-disc space-y-0.5">
|
||||
<li>视频已被删除或设为私密</li>
|
||||
<li>网络连接问题或请求超时</li>
|
||||
<li>抖音接口限流或反爬虫</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof t.result !== 'undefined' && (
|
||||
<pre className="max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
||||
)}
|
||||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
||||
<div className="text-xs text-neutral-400">暂无输出</div>
|
||||
{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>
|
||||
</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>
|
||||
{t.result.data.aweme.author && (
|
||||
<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>
|
||||
</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>
|
||||
</details>
|
||||
</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,16 +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",
|
||||
@@ -47,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=="],
|
||||
@@ -105,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=="],
|
||||
|
||||
@@ -171,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=="],
|
||||
@@ -179,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=="],
|
||||
@@ -199,6 +267,8 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
@@ -207,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=="],
|
||||
@@ -241,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=="],
|
||||
@@ -253,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=="],
|
||||
@@ -265,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=="],
|
||||
@@ -277,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=="],
|
||||
@@ -323,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=="],
|
||||
|
||||
@@ -343,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=="],
|
||||
@@ -355,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=="],
|
||||
@@ -375,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=="],
|
||||
@@ -387,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=="],
|
||||
@@ -417,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=="],
|
||||
@@ -429,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=="],
|
||||
@@ -447,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 || {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+52
-52
@@ -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,28 +52,28 @@ 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);
|
||||
|
||||
|
||||
return { url, path };
|
||||
}
|
||||
|
||||
@@ -81,27 +81,27 @@ 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);
|
||||
|
||||
|
||||
return { url, 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;
|
||||
}
|
||||
}
|
||||
@@ -140,22 +140,22 @@ async function deletePostImages(postId: string) {
|
||||
try {
|
||||
// 列出文章相关的所有图片
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -170,15 +170,15 @@ async function deletePostImages(postId: string) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -223,15 +223,15 @@ async function checkAvatarExists(avatarPath: string): Promise<boolean> {
|
||||
async function getFileDetails(path: string) {
|
||||
try {
|
||||
const info = await getFileInfo(path);
|
||||
|
||||
|
||||
return {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -258,42 +258,42 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
|
||||
|
||||
/**
|
||||
* Next.js API Route 示例:上传文件
|
||||
*
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
*
|
||||
* // app/api/upload/route.ts
|
||||
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||
*
|
||||
*
|
||||
* export async function POST(request: Request) {
|
||||
* const formData = await request.formData();
|
||||
* const file = formData.get('file') as File;
|
||||
*
|
||||
*
|
||||
* if (!file) {
|
||||
* return Response.json({ error: '没有文件' }, { status: 400 });
|
||||
* }
|
||||
*
|
||||
*
|
||||
* const path = generateUniqueFileName(file.name, 'uploads');
|
||||
* const url = await uploadFile(file, path);
|
||||
*
|
||||
*
|
||||
* return Response.json({ url, path });
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Next.js API Route 示例:删除文件
|
||||
*
|
||||
*
|
||||
* // app/api/delete/route.ts
|
||||
* import { deleteFile } from '@/lib/minio';
|
||||
*
|
||||
*
|
||||
* export async function DELETE(request: Request) {
|
||||
* const { path } = await request.json();
|
||||
*
|
||||
*
|
||||
* if (!path) {
|
||||
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
|
||||
* }
|
||||
*
|
||||
*
|
||||
* await deleteFile(path);
|
||||
*
|
||||
*
|
||||
* return Response.json({ success: true });
|
||||
* }
|
||||
*/
|
||||
|
||||
+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 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user