Compare commits
29
Commits
200283b21b
..
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 |
@@ -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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 2
|
||||||
|
}
|
||||||
Vendored
+18
-26
@@ -1,27 +1,19 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "tsc-check",
|
"label": "tsc-check",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": [
|
"args": ["-e", "require('typescript').transpile('const x: number = 1;')"],
|
||||||
"-e",
|
"problemMatcher": ["$tsc"],
|
||||||
"require('typescript').transpile('const x: number = 1;')"
|
"group": "build"
|
||||||
],
|
},
|
||||||
"problemMatcher": [
|
{
|
||||||
"$tsc"
|
"label": "tsc-check (one-off)",
|
||||||
],
|
"type": "shell",
|
||||||
"group": "build"
|
"command": "node",
|
||||||
},
|
"args": ["-e", "require('typescript').transpile('const x: number = 1;')"]
|
||||||
{
|
}
|
||||||
"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.
|
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
|
## 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.
|
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.
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
||||||
|
## 视频文字转写(DashScope ASR)
|
||||||
|
|
||||||
|
仅使用 `qwen3-asr-flash-filetrans` 做文字识别,不使用通用模型理解、分类、总结或润色。新视频保存后入队;历史文字保留,后台可单条重转或批量补转。关闭页面不影响已入队任务。
|
||||||
|
|
||||||
|
处理流程:从 MinIO 将原视频流式下载到临时文件 → ffprobe 检查音轨及实际时长 → ffmpeg 提取整段 **16 kHz 单声道、64 kbps MP3** → 上传至 MinIO → 将 **音频 URL** 提交到 DashScope → 后台取回文字。没有分块、视频上传到模型或二次模型调用。无音轨直接保存空文字;ASR 空结果显示“未识别到文字”。
|
||||||
|
|
||||||
|
### 配置与启动
|
||||||
|
|
||||||
|
参考 `.env.example`。模型配置统一为:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DASHSCOPE_API_KEY=你的百炼Key
|
||||||
|
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
|
DASHSCOPE_ASR_MODEL=qwen3-asr-flash-filetrans
|
||||||
|
```
|
||||||
|
|
||||||
|
这是 DashScope **原生 API** 地址,不是 `/compatible-mode/v1`。Key 和端点地域必须匹配。也可使用北京业务空间域名的 `/api/v1` 地址。旧 `OPENAI_*`、`QWEN_*` 和转写模块的全局代理配置不再使用;已有 Omni 的百炼 Key 可用于 ASR,仍需账号开通相应模型权限。
|
||||||
|
|
||||||
|
需要 ffmpeg、ffprobe(可通过 `FFMPEG_PATH`、`FFPROBE_PATH` 指定)。`MINIO_ENDPOINT`/端口/SSL 必须是运行机器可鉴权读写的 S3 API;`MINIO_PUBLIC_DOMAIN` 必须让 DashScope 能下载所上传的音频。执行器不修改 Bucket 策略。音频存储于 `transcription-audio/<视频ID>/<时间戳>.mp3`;独立对象防止手动重试覆盖仍在使用的音频。可为该前缀配置不少于 7 天的生命周期,视频原文件不受影响。本地视频临时文件在成功或失败后都会清理。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npx prisma generate
|
||||||
|
npm run dev
|
||||||
|
# 另一个终端(开发与生产均需执行器)
|
||||||
|
npm run stt:worker
|
||||||
|
```
|
||||||
|
|
||||||
|
生产使用 `pm2 startOrReload pm2.config.cjs --env production --update-env`,配置同时包含 `DouyinArchive` 和 `DouyinArchive-ASR`。全局最多两个处理中任务,每 10 秒查一次供应商状态。任务保存在 PostgreSQL,不需要 Redis。未配置 Key 不阻止网站启动,转写会显示配置错误。
|
||||||
|
|
||||||
|
### 数据库迁移与发布顺序
|
||||||
|
|
||||||
|
1. 备份现有数据库,保存旧版本用于回退。
|
||||||
|
2. 在停止服务前安装依赖、生成 Prisma Client 并运行 `npm run build`。
|
||||||
|
3. 停止旧网站及旧后台执行器,运行 `npx prisma migrate deploy`。
|
||||||
|
4. 用上述 PM2 命令启动新网站和 ASR 执行器,检查两者日志;确认转写与搜索后再完成发布验收。
|
||||||
|
|
||||||
|
迁移保留 `VideoTranscript` 的历史文字、关联和时间字段;删除 `speech_detected`、`language`、`audio_type`、`non_speech_summary`,以及部分部署存在的 `content_tags`。不自动补转或重转历史视频。新建 `TranscriptionTask` 保存状态、任务 ID、租约和重试信息。重转成功时原子替换文字,失败保留旧文字。
|
||||||
|
|
||||||
|
迁移会替换已知的旧全文检索触发器,并维护 `transcript_zh_tsv` 与 GIN 索引。**已有 `public.zhcfg` 中文分词配置会保留**;新库没有该配置时创建 `simple` 回退配置,它不提供中文分词。新部署若需要与旧库相同的中文搜索效果,先安装同样的中文分词扩展和 `zhcfg`;后续调整分词配置后应重新计算现有索引字段。不要在迁移后直接回滚到依赖旧字段的应用;需要回退时恢复数据库备份和旧应用版本。
|
||||||
|
|
||||||
|
### 接口和任务行为
|
||||||
|
|
||||||
|
- `POST /api/stt`,JSON `{ "awemeId": "视频ID" }`:首次转写或手动重转;处理中重复点击复用当前任务。
|
||||||
|
- `GET /api/stt?awemeId=视频ID`:只读取 `{ status, error, transcript }`,不触发转写。`transcript` 为 `string[] | null`;`null` 表示没有完成记录,`[]` 表示完成但没有文字。
|
||||||
|
- `POST /api/stt/batch`:一次将所有没有完成记录、且未在排队或处理中的视频加入数据库队列,失败记录可补转,历史完成记录不重转。
|
||||||
|
- 状态为 `PENDING`(尚未入队)、`QUEUED`、`PROCESSING`、`SUCCEEDED`、`FAILED`。后台显示各状态数量、错误和重试按钮。
|
||||||
|
|
||||||
|
音频限制为 12 小时、2 GB,超限失败而非截断。执行器重启后复用已有供应商任务 ID;租约到期可恢复领取。提交前写入标记,提交阶段崩溃或超时无法确认任务 ID 时,标为失败并要求核对百炼任务后手动重试,避免盲目重复付费。查询和结果下载的网络错误/429/5xx 按 10 秒至 5 分钟退避重试;鉴权和明确的供应商失败不自动重提。超过 24 小时的任务标为失败。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:stt
|
||||||
|
# 必须是独立本地库 douyin_asr_test;此测试会重建该库的 public schema
|
||||||
|
TEST_DATABASE_URL=postgresql://你的本地用户名@127.0.0.1:5432/douyin_asr_test npm run test:stt:integration
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
部署后可用管理页测试短视频、超过 5 分钟的视频、无发言/无音轨视频。检查新归档自动入队、关页面继续处理、重启续查、失败保留旧文字及文字搜索。
|
||||||
|
|
||||||
|
也可对已归档的视频独立实测(会上传音频、调用付费 ASR;**不修改数据库里的原转写**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/stt-smoke.ts 7682010237578136858
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。
|
||||||
|
|
||||||
|
### 手动标签兼容
|
||||||
|
|
||||||
|
保留服务器版本的手动标签、首页批量打标签、详情页编辑及标签筛选;标签只来自 `Video.manual_content_tags`,不再回退到模型自动分类。转写成功、失败或重试都不会修改手动标签。后台转写列表支持手动标签筛选与批量添加/清空。
|
||||||
|
|
||||||
|
已有 `/admin/asr`、`/admin/asr/videos`、`/api/admin/asr/videos` 地址继续可用;`/api/asr` 与 `/api/stt` 一样,POST 入队、GET 只读。旧内存批处理和分块模型调用已移除。
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import BackButton from "@/app/components/BackButton";
|
||||||
|
|
||||||
|
export default function AsrAdminPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
|
<BackButton />
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">ASR 管理中心</h1>
|
||||||
|
<p className="text-gray-600 mt-2">管理和配置视频语音识别功能</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-6">
|
||||||
|
{/* 视频转写管理 */}
|
||||||
|
<Link
|
||||||
|
href="/admin/asr/videos"
|
||||||
|
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-6 h-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
视频转写管理
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
|
||||||
|
进入管理
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 转写设置(预留) */}
|
||||||
|
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-6 h-6 text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
转写设置
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
配置转写 API、模型参数和其他高级选项
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 转写统计(预留) */}
|
||||||
|
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-6 h-6 text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
转写统计
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
查看转写使用情况、成功率、语言分布等统计数据
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../../stt/videos/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../asr/page";
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||||
|
import BackButton from "@/app/components/BackButton";
|
||||||
|
import {
|
||||||
|
isActive,
|
||||||
|
statusLabels,
|
||||||
|
type TranscriptionState,
|
||||||
|
type TranscriptionStatus,
|
||||||
|
} from "@/lib/stt/types";
|
||||||
|
|
||||||
|
type Video = {
|
||||||
|
aweme_id: string;
|
||||||
|
desc: string;
|
||||||
|
cover_url: string;
|
||||||
|
duration_ms: number;
|
||||||
|
author: { nickname: string };
|
||||||
|
transcription: TranscriptionState;
|
||||||
|
content_tags: string[];
|
||||||
|
};
|
||||||
|
const filters: ("all" | TranscriptionStatus)[] = [
|
||||||
|
"all",
|
||||||
|
"PENDING",
|
||||||
|
"QUEUED",
|
||||||
|
"PROCESSING",
|
||||||
|
"SUCCEEDED",
|
||||||
|
"FAILED",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SttVideosPage() {
|
||||||
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
|
||||||
|
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [tag, setTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
|
||||||
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
|
const [savingTags, setSavingTags] = useState(false);
|
||||||
|
const fetching = useRef(false);
|
||||||
|
const fetchVideos = useCallback(async () => {
|
||||||
|
if (fetching.current) return;
|
||||||
|
fetching.current = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/stt/videos", {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("读取转写列表失败");
|
||||||
|
const data: { videos: Video[] } = await response.json();
|
||||||
|
setVideos(data.videos);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "读取失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
fetching.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchVideos();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void fetchVideos();
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [fetchVideos]);
|
||||||
|
|
||||||
|
async function handleTranscribe(awemeId: string) {
|
||||||
|
setSubmitting((prev) => new Set(prev).add(awemeId));
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/stt", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ awemeId }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || "任务入队失败");
|
||||||
|
setVideos((prev) =>
|
||||||
|
prev.map((video) =>
|
||||||
|
video.aweme_id === awemeId
|
||||||
|
? { ...video, transcription: data }
|
||||||
|
: video,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : "任务入队失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(awemeId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function handleBatch() {
|
||||||
|
setBatchSubmitting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/stt/batch", { method: "POST" });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || "批量入队失败");
|
||||||
|
setNotice(`已加入 ${data.queued} 个转写任务;关闭页面后仍会继续处理。`);
|
||||||
|
await fetchVideos();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : "批量入队失败");
|
||||||
|
} finally {
|
||||||
|
setBatchSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveTags(clear = false) {
|
||||||
|
setSavingTags(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/asr/tags", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
awemeIds: [...selected],
|
||||||
|
contentTags: clear ? [] : [tag],
|
||||||
|
mode: clear ? "set" : "add",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("保存手动标签失败");
|
||||||
|
setNotice(`已更新 ${selected.size} 个视频的手动标签`);
|
||||||
|
setSelected(new Set());
|
||||||
|
await fetchVideos();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSavingTags(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const count = (status: TranscriptionStatus) =>
|
||||||
|
videos.filter((v) => v.transcription.status === status).length;
|
||||||
|
const eligible = videos.filter(
|
||||||
|
(v) =>
|
||||||
|
v.transcription.transcript === null && !isActive(v.transcription.status),
|
||||||
|
).length;
|
||||||
|
const shown = videos.filter(
|
||||||
|
(v) =>
|
||||||
|
(filter === "all" || v.transcription.status === filter) &&
|
||||||
|
(!tagFilter || v.content_tags.includes(tagFilter)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50 p-6">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-4">
|
||||||
|
<BackButton />
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">视频文字转写</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "}
|
||||||
|
{count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<output className="block text-sm text-gray-700">{notice}</output>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{filters.map((value) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
onClick={() => setFilter(value)}
|
||||||
|
className={`rounded px-3 py-2 text-sm ${filter === value ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
|
||||||
|
>
|
||||||
|
{value === "all"
|
||||||
|
? `全部 (${videos.length})`
|
||||||
|
: `${statusLabels[value]} (${count(value)})`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBatch}
|
||||||
|
disabled={batchSubmitting || !eligible}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
|
||||||
|
<select
|
||||||
|
aria-label="筛选手动标签"
|
||||||
|
value={tagFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTagFilter(e.target.value);
|
||||||
|
setSelected(new Set());
|
||||||
|
}}
|
||||||
|
className="rounded border p-2"
|
||||||
|
>
|
||||||
|
<option value="">全部手动标签</option>
|
||||||
|
{VIDEO_CONTENT_TAGS.map((value) => (
|
||||||
|
<option key={value}>{value}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>已选 {selected.size} 个(最多 200 个)</span>
|
||||||
|
<select
|
||||||
|
aria-label="批量添加的标签"
|
||||||
|
value={tag}
|
||||||
|
onChange={(e) => setTag(e.target.value)}
|
||||||
|
className="rounded border p-2"
|
||||||
|
>
|
||||||
|
{VIDEO_CONTENT_TAGS.map((value) => (
|
||||||
|
<option key={value}>{value}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!selected.size || savingTags}
|
||||||
|
onClick={() => saveTags()}
|
||||||
|
className="rounded border px-3 py-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
添加手动标签
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!selected.size || savingTags}
|
||||||
|
onClick={() => saveTags(true)}
|
||||||
|
className="rounded border px-3 py-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
清空手动标签
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-gray-100 text-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="选择当前列表前200个视频"
|
||||||
|
checked={
|
||||||
|
shown.length > 0 &&
|
||||||
|
shown.slice(0, 200).every((v) => selected.has(v.aweme_id))
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelected(
|
||||||
|
e.target.checked
|
||||||
|
? new Set(shown.slice(0, 200).map((v) => v.aweme_id))
|
||||||
|
: new Set(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="p-3">视频</th>
|
||||||
|
<th className="p-3">时长</th>
|
||||||
|
<th className="p-3">状态与文字</th>
|
||||||
|
<th className="p-3">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map((video) => {
|
||||||
|
const state = video.transcription;
|
||||||
|
const active =
|
||||||
|
isActive(state.status) || submitting.has(video.aweme_id);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={video.aweme_id}
|
||||||
|
className="border-t border-gray-100 align-top"
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`选择视频 ${video.aweme_id}`}
|
||||||
|
checked={selected.has(video.aweme_id)}
|
||||||
|
disabled={
|
||||||
|
!selected.has(video.aweme_id) && selected.size >= 200
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelected((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (e.target.checked) next.add(video.aweme_id);
|
||||||
|
else next.delete(video.aweme_id);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex max-w-sm gap-3">
|
||||||
|
{video.cover_url && (
|
||||||
|
<Image
|
||||||
|
src={video.cover_url}
|
||||||
|
alt=""
|
||||||
|
width={56}
|
||||||
|
height={80}
|
||||||
|
unoptimized
|
||||||
|
className="h-20 w-14 rounded object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
className="line-clamp-2 text-blue-700"
|
||||||
|
href={`/aweme/${video.aweme_id}`}
|
||||||
|
>
|
||||||
|
{video.desc || "无描述"}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 text-gray-500">
|
||||||
|
@{video.author.nickname}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-blue-700">
|
||||||
|
{video.content_tags.join("、")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap p-3 text-gray-700">
|
||||||
|
{Math.floor(video.duration_ms / 1000)} 秒
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
state.status === "FAILED"
|
||||||
|
? "text-red-700"
|
||||||
|
: "text-gray-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusLabels[state.status]}
|
||||||
|
</p>
|
||||||
|
{state.error && (
|
||||||
|
<p className="mt-1 max-w-lg text-xs text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.transcript !== null && (
|
||||||
|
<p className="mt-1 max-w-lg whitespace-pre-wrap text-gray-600">
|
||||||
|
{state.transcript.join("\n") || "未识别到文字"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={active}
|
||||||
|
onClick={() => handleTranscribe(video.aweme_id)}
|
||||||
|
className="rounded bg-gray-100 px-3 py-2 text-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{active
|
||||||
|
? "处理中…"
|
||||||
|
: state.status === "FAILED"
|
||||||
|
? "重试"
|
||||||
|
: state.transcript !== null
|
||||||
|
? "重新转写"
|
||||||
|
: "开始转写"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!shown.length && (
|
||||||
|
<p className="p-8 text-center text-gray-500">
|
||||||
|
{loading ? "加载中…" : "没有找到视频"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET } from "../../stt/videos/route";
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { transcriptionState } from "@/lib/stt/types";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const videos = await prisma.video.findMany({
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
nickname: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transcriptionTask: true,
|
||||||
|
transcript: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
transcript: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedVideos = videos.map((v) => ({
|
||||||
|
aweme_id: v.aweme_id,
|
||||||
|
desc: v.desc,
|
||||||
|
cover_url: v.cover_url ? getFileUrl(v.cover_url) : "",
|
||||||
|
duration_ms: v.duration_ms,
|
||||||
|
author: {
|
||||||
|
nickname: v.author.nickname,
|
||||||
|
},
|
||||||
|
content_tags: v.manual_content_tags,
|
||||||
|
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
videos: formattedVideos,
|
||||||
|
total: videos.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch videos:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch videos" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET, POST } from "../stt/route";
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isVideoContentTag } from "@/lib/video-tags";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type UpdateMode = "set" | "add";
|
||||||
|
|
||||||
|
function uniqueValidTags(tags: unknown[]) {
|
||||||
|
const validTags: string[] = [];
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (typeof tag !== "string" || !isVideoContentTag(tag)) {
|
||||||
|
throw new Error("Invalid tag");
|
||||||
|
}
|
||||||
|
if (!validTags.includes(tag)) validTags.push(tag);
|
||||||
|
}
|
||||||
|
return validTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request body" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = body as {
|
||||||
|
awemeId?: unknown;
|
||||||
|
awemeIds?: unknown;
|
||||||
|
tag?: unknown;
|
||||||
|
contentTags?: unknown;
|
||||||
|
mode?: unknown;
|
||||||
|
};
|
||||||
|
const awemeIds = Array.isArray(raw.awemeIds)
|
||||||
|
? raw.awemeIds
|
||||||
|
: typeof raw.awemeId === "string"
|
||||||
|
? [raw.awemeId]
|
||||||
|
: [];
|
||||||
|
const normalizedAwemeIds = [...new Set(awemeIds)]
|
||||||
|
.filter(
|
||||||
|
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
||||||
|
)
|
||||||
|
.map((id) => id.trim());
|
||||||
|
|
||||||
|
if (normalizedAwemeIds.length === 0 || normalizedAwemeIds.length > 200) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "awemeIds must contain 1 to 200 ids" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode: UpdateMode = raw.mode === "add" ? "add" : "set";
|
||||||
|
const rawTags = Array.isArray(raw.contentTags)
|
||||||
|
? raw.contentTags
|
||||||
|
: typeof raw.tag === "string"
|
||||||
|
? [raw.tag]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let contentTags: string[];
|
||||||
|
try {
|
||||||
|
contentTags = uniqueValidTags(rawTags);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "add" && contentTags.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "add mode requires at least one tag" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = await prisma.video.findMany({
|
||||||
|
where: { aweme_id: { in: normalizedAwemeIds } },
|
||||||
|
select: {
|
||||||
|
aweme_id: true,
|
||||||
|
manual_content_tags: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videos.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No videos found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = videos.map((video) => {
|
||||||
|
const baseTags = video.manual_content_tags;
|
||||||
|
const manualTags =
|
||||||
|
mode === "add"
|
||||||
|
? [...new Set([...baseTags, ...contentTags])]
|
||||||
|
: contentTags;
|
||||||
|
return { awemeId: video.aweme_id, manualTags };
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
updates.map((update) =>
|
||||||
|
prisma.video.update({
|
||||||
|
where: { aweme_id: update.awemeId },
|
||||||
|
data: { manual_content_tags: update.manualTags },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
updated: updates.map((update) => ({
|
||||||
|
aweme_id: update.awemeId,
|
||||||
|
manual_content_tags: update.manualTags,
|
||||||
|
content_tags: update.manualTags,
|
||||||
|
})),
|
||||||
|
missing: normalizedAwemeIds.filter(
|
||||||
|
(id) => !videos.some((video) => video.aweme_id === id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ secUid: string }> },
|
||||||
|
) {
|
||||||
|
const secUid = (await params).secUid;
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const limitParam = searchParams.get("limit");
|
||||||
|
const beforeParam = searchParams.get("before");
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||||
|
const before = beforeParam ? new Date(beforeParam) : null;
|
||||||
|
|
||||||
|
// fetch chunk from both tables
|
||||||
|
const [videos, posts] = await Promise.all([
|
||||||
|
prisma.video.findMany({
|
||||||
|
where: {
|
||||||
|
authorId: secUid,
|
||||||
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: limit,
|
||||||
|
include: { author: true },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findMany({
|
||||||
|
where: {
|
||||||
|
authorId: secUid,
|
||||||
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: limit,
|
||||||
|
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged: FeedItem[] = [
|
||||||
|
...videos.map((v) => ({
|
||||||
|
type: "video" as const,
|
||||||
|
aweme_id: v.aweme_id,
|
||||||
|
created_at: v.created_at,
|
||||||
|
desc: v.desc,
|
||||||
|
video_url: getFileUrl(v.video_url),
|
||||||
|
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||||
|
width: v.width ?? null,
|
||||||
|
height: v.height ?? null,
|
||||||
|
author: {
|
||||||
|
nickname: v.author.nickname,
|
||||||
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
|
sec_uid: v.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(v.digg_count),
|
||||||
|
})),
|
||||||
|
...posts.map((p) => ({
|
||||||
|
type: "image" as const,
|
||||||
|
aweme_id: p.aweme_id,
|
||||||
|
created_at: p.created_at,
|
||||||
|
desc: p.desc,
|
||||||
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
|
width: p.images?.[0]?.width ?? null,
|
||||||
|
height: p.images?.[0]?.height ?? null,
|
||||||
|
author: {
|
||||||
|
nickname: p.author.nickname,
|
||||||
|
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||||
|
sec_uid: p.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(p.digg_count),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
const nextCursor =
|
||||||
|
merged.length > 0
|
||||||
|
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
||||||
|
: null;
|
||||||
|
const payload: FeedResponse = { items: merged, nextCursor };
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
}
|
||||||
@@ -12,8 +12,14 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// Find current item timestamp from either table
|
// Find current item timestamp from either table
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
prisma.video.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
prisma.video.findUnique({
|
||||||
prisma.imagePost.findUnique({ where: { aweme_id: awemeId }, select: { aweme_id: true, created_at: true } }),
|
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;
|
const current = video ?? post;
|
||||||
@@ -52,25 +58,61 @@ export async function GET(req: NextRequest) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const pickPrev = (() => {
|
const pickPrev = (() => {
|
||||||
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
const cands: {
|
||||||
if (newerVideo) cands.push({ type: "video", aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
|
type: "video" | "image";
|
||||||
if (newerPost) cands.push({ type: "image", aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
|
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;
|
if (cands.length === 0) return undefined;
|
||||||
// nearest newer -> minimal created_at
|
// nearest newer -> minimal created_at
|
||||||
cands.sort((a, b) => +a.created_at - +b.created_at);
|
cands.sort((a, b) => +a.created_at - +b.created_at);
|
||||||
const h = cands[0];
|
const h = cands[0];
|
||||||
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
return {
|
||||||
|
type: h.type,
|
||||||
|
aweme_id: h.aweme_id,
|
||||||
|
created_at: h.created_at.toISOString(),
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const pickNext = (() => {
|
const pickNext = (() => {
|
||||||
const cands: { type: "video" | "image"; aweme_id: string; created_at: Date }[] = [];
|
const cands: {
|
||||||
if (olderVideo) cands.push({ type: "video", aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
|
type: "video" | "image";
|
||||||
if (olderPost) cands.push({ type: "image", aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
|
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;
|
if (cands.length === 0) return undefined;
|
||||||
// nearest older -> maximal created_at among older
|
// nearest older -> maximal created_at among older
|
||||||
cands.sort((a, b) => +b.created_at - +a.created_at);
|
cands.sort((a, b) => +b.created_at - +a.created_at);
|
||||||
const h = cands[0];
|
const h = cands[0];
|
||||||
return { type: h.type, aweme_id: h.aweme_id, created_at: h.created_at.toISOString() };
|
return {
|
||||||
|
type: h.type,
|
||||||
|
aweme_id: h.aweme_id,
|
||||||
|
created_at: h.created_at.toISOString(),
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return NextResponse.json({ prev: pickPrev ?? null, next: pickNext ?? null });
|
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 { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma';
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
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
|
// Contract
|
||||||
// Inputs: search params { before?: ISOString, limit?: number }
|
// 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) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const limitParam = searchParams.get('limit');
|
const limitParam = searchParams.get("limit");
|
||||||
const beforeParam = searchParams.get('before');
|
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 before = beforeParam ? new Date(beforeParam) : null;
|
||||||
|
const videoWhere = {
|
||||||
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
|
...(selectedTag
|
||||||
|
? {
|
||||||
|
manual_content_tags: { has: selectedTag },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
// fetch chunk from both tables
|
// fetch chunk from both tables
|
||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: before ? { created_at: { lt: before } } : undefined,
|
where: videoWhere,
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
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 } },
|
|
||||||
}),
|
}),
|
||||||
|
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[] = [
|
const merged: FeedItem[] = [
|
||||||
...videos.map((v) => ({
|
...videos.map((v) => ({
|
||||||
type: 'video' as const,
|
type: "video" as const,
|
||||||
aweme_id: v.aweme_id,
|
aweme_id: v.aweme_id,
|
||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: v.video_url,
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: v.cover_url ?? null,
|
cover_url: getFileUrl(v.cover_url ?? "default_cover.png"),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? 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),
|
likes: Number(v.digg_count),
|
||||||
|
content_tags: v.manual_content_tags,
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => {
|
||||||
type: 'image' as const,
|
const coverPath = p.images?.[0]?.url;
|
||||||
aweme_id: p.aweme_id,
|
return {
|
||||||
created_at: p.created_at,
|
type: "image" as const,
|
||||||
desc: p.desc,
|
aweme_id: p.aweme_id,
|
||||||
cover_url: p.images?.[0]?.url ?? null,
|
created_at: p.created_at,
|
||||||
width: p.images?.[0]?.width ?? null,
|
desc: p.desc,
|
||||||
height: p.images?.[0]?.height ?? null,
|
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||||
author: { nickname: p.author.nickname, avatar_url: p.author.avatar_url ?? null },
|
width: p.images?.[0]?.width ?? null,
|
||||||
likes: Number(p.digg_count),
|
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);
|
.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 };
|
const payload: FeedResponse = { items: merged, nextCursor };
|
||||||
return NextResponse.json(payload);
|
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;
|
||||||
+44
-25
@@ -12,6 +12,22 @@ interface DouyinComment {
|
|||||||
digg_count: number; // 点赞数
|
digg_count: number; // 点赞数
|
||||||
create_time: number; // 创建时间(时间戳)
|
create_time: number; // 创建时间(时间戳)
|
||||||
user: DouyinUser; // 评论用户
|
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 {
|
interface DouyinAuthor {
|
||||||
uid: string; // 用户ID
|
uid: string; // 用户ID
|
||||||
sec_uid: string; // 安全UID
|
sec_uid: string; // 安全UID
|
||||||
nickname: string; // 用户昵称
|
nickname: string; // 用户昵称
|
||||||
signature: string; // 个性签名
|
signature: string; // 个性签名
|
||||||
avatar_thumb: {
|
avatar_thumb: {
|
||||||
url_list: string[]; // 头像URL(可取第一个)
|
url_list: string[]; // 头像URL(可取第一个)
|
||||||
};
|
};
|
||||||
follower_count: number; // 粉丝数
|
follower_count: number; // 粉丝数
|
||||||
total_favorited: number; // 获赞总数
|
total_favorited: number; // 获赞总数
|
||||||
unique_id: string; // 抖音号
|
unique_id: string; // 抖音号
|
||||||
short_id: string; // 短ID
|
short_id: string; // 短ID
|
||||||
}
|
}
|
||||||
/** 视频详情 */
|
/** 视频详情 */
|
||||||
interface DouyinVideoDetail {
|
interface DouyinVideoDetail {
|
||||||
aweme_id: string; // 视频ID
|
aweme_id: string; // 视频ID
|
||||||
desc: string; // 视频描述
|
desc: string; // 视频描述
|
||||||
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
preview_title?: string; // 视频标题(有些字段中叫 preview_title)
|
||||||
duration: number; // 视频时长(毫秒)
|
duration: number; // 视频时长(毫秒)
|
||||||
create_time: number; // 创建时间(时间戳)
|
create_time: number; // 创建时间(时间戳)
|
||||||
share_url: string; // 视频分享链接
|
share_url: string; // 视频分享链接
|
||||||
|
|
||||||
statistics: {
|
statistics: {
|
||||||
digg_count: number; // 点赞数
|
digg_count: number; // 点赞数
|
||||||
comment_count: number; // 评论数
|
comment_count: number; // 评论数
|
||||||
share_count: number; // 分享数
|
share_count: number; // 分享数
|
||||||
collect_count: number; // 收藏数
|
collect_count: number; // 收藏数
|
||||||
};
|
};
|
||||||
|
|
||||||
author: DouyinAuthor; // 作者信息
|
author: DouyinAuthor; // 作者信息
|
||||||
video: VideoPlayBasic;
|
video: VideoPlayBasic;
|
||||||
tags: VideoTagBasic[];
|
tags: VideoTagBasic[];
|
||||||
}
|
}
|
||||||
@@ -72,9 +88,9 @@ interface VideoPlayBasic {
|
|||||||
|
|
||||||
/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */
|
/** 单个清晰度变体(来自 bit_rate[*] + play_addr) */
|
||||||
interface PlayVariant {
|
interface PlayVariant {
|
||||||
format: string; // mp4 等
|
format: string; // mp4 等
|
||||||
FPS: number;
|
FPS: number;
|
||||||
bit_rate: number; // bit_rate.bit_rate
|
bit_rate: number; // bit_rate.bit_rate
|
||||||
|
|
||||||
/** 直连播放地址(最关键) */
|
/** 直连播放地址(最关键) */
|
||||||
play_addr: {
|
play_addr: {
|
||||||
@@ -85,7 +101,7 @@ interface PlayVariant {
|
|||||||
data_size: number;
|
data_size: number;
|
||||||
FPS: number;
|
FPS: number;
|
||||||
is_bytevc1: number; // 0 or 1
|
is_bytevc1: number; // 0 or 1
|
||||||
is_h265: number; // 0 or 1
|
is_h265: number; // 0 or 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +132,7 @@ interface DouyinImageAweme {
|
|||||||
};
|
};
|
||||||
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
author: DouyinAuthor; // 复用视频作者类型(需包含 sec_uid)
|
||||||
images: DouyinImageInfo[]; // 图片列表
|
images: DouyinImageInfo[]; // 图片列表
|
||||||
music?: DouyinMusicBasic; // 背景音乐(可选)
|
music?: DouyinMusicBasic; // 背景音乐(可选)
|
||||||
video_tag?: VideoTagBasic[]; // 标签
|
video_tag?: VideoTagBasic[]; // 标签
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +142,9 @@ interface DouyinImageInfo {
|
|||||||
download_url_list?: string[]; // 可能带水印
|
download_url_list?: string[]; // 可能带水印
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
video: {
|
||||||
|
play_addr: { src: string }[];
|
||||||
|
} | null; // 如果是动图,会有 video 信息
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 音乐基本信息(精简) */
|
/** 音乐基本信息(精简) */
|
||||||
@@ -138,4 +157,4 @@ interface DouyinMusicBasic {
|
|||||||
uri?: string;
|
uri?: string;
|
||||||
url_list: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+939
-866
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";
|
||||||
@@ -1,3 +1,216 @@
|
|||||||
export const emojiList = [
|
export const emojiList = [
|
||||||
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子"
|
"微笑",
|
||||||
]
|
"色",
|
||||||
|
"发呆",
|
||||||
|
"酷拽",
|
||||||
|
"抠鼻",
|
||||||
|
"流泪",
|
||||||
|
"捂脸",
|
||||||
|
"发怒",
|
||||||
|
"呲牙",
|
||||||
|
"尬笑",
|
||||||
|
"害羞",
|
||||||
|
"调皮",
|
||||||
|
"舔屏",
|
||||||
|
"看",
|
||||||
|
"爱心",
|
||||||
|
"比心",
|
||||||
|
"赞",
|
||||||
|
"鼓掌",
|
||||||
|
"感谢",
|
||||||
|
"抱抱你",
|
||||||
|
"玫瑰",
|
||||||
|
"尴尬流汗",
|
||||||
|
"戳手手",
|
||||||
|
"星星眼",
|
||||||
|
"杀马特",
|
||||||
|
"黄脸干杯",
|
||||||
|
"抱紧自己",
|
||||||
|
"拜拜",
|
||||||
|
"热化了",
|
||||||
|
"黄脸祈祷",
|
||||||
|
"懵",
|
||||||
|
"举手",
|
||||||
|
"加功德",
|
||||||
|
"摊手",
|
||||||
|
"无语流汗",
|
||||||
|
"续火花吧",
|
||||||
|
"点火",
|
||||||
|
"哭哭",
|
||||||
|
"吐舌小狗",
|
||||||
|
"送花",
|
||||||
|
"爱心手",
|
||||||
|
"贴贴",
|
||||||
|
"灵机一动",
|
||||||
|
"耶",
|
||||||
|
"打脸",
|
||||||
|
"大笑",
|
||||||
|
"机智",
|
||||||
|
"送心",
|
||||||
|
"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]);
|
||||||
|
}
|
||||||
+134
-75
@@ -1,96 +1,148 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import type { Metadata } from "next";
|
||||||
import BackButton from "@/app/components/BackButton";
|
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 AwemeDetailClient from "./Client";
|
||||||
|
import type { AwemeData, VideoTranscript } from "./types";
|
||||||
|
|
||||||
function ms(v?: number | null) {
|
export async function generateMetadata({
|
||||||
if (!v) return "";
|
params,
|
||||||
const s = Math.round(v / 1000);
|
}: {
|
||||||
const m = Math.floor(s / 60);
|
params: Promise<{ awemeId: string }>;
|
||||||
const r = s % 60;
|
}): Promise<Metadata> {
|
||||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
|
||||||
const id = (await params).awemeId;
|
const id = (await params).awemeId;
|
||||||
|
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
prisma.video.findUnique({
|
prisma.video.findUnique({
|
||||||
where: { aweme_id: id },
|
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({
|
prisma.imagePost.findUnique({
|
||||||
where: { aweme_id: id },
|
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>;
|
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||||
|
|
||||||
const isVideo = !!video;
|
const isVideo = Boolean(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 },
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute prev/next neighbors by created_at across videos and image posts
|
// 获取评论总数
|
||||||
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at) as unknown as Date;
|
const commentsCount = await prisma.comment.count({
|
||||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||||
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
});
|
||||||
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
|
||||||
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
let data: AwemeData;
|
||||||
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
let transcript: VideoTranscript | null = null;
|
||||||
|
|
||||||
|
if (video) {
|
||||||
|
transcript = video.transcript ?? null;
|
||||||
|
data = {
|
||||||
|
type: "video",
|
||||||
|
aweme_id: video.aweme_id,
|
||||||
|
desc: video.desc,
|
||||||
|
created_at: video.created_at,
|
||||||
|
likesCount: Number(video.digg_count),
|
||||||
|
commentsCount,
|
||||||
|
author: {
|
||||||
|
nickname: video.author.nickname,
|
||||||
|
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
|
||||||
|
sec_uid: video.author.sec_uid,
|
||||||
|
},
|
||||||
|
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
|
||||||
|
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
|
||||||
|
duration_ms: video.duration_ms,
|
||||||
|
video_url: getFileUrl(video.video_url),
|
||||||
|
width: video.width ?? null,
|
||||||
|
height: video.height ?? null,
|
||||||
|
content_tags: video.manual_content_tags,
|
||||||
|
manual_content_tags: video.manual_content_tags,
|
||||||
|
};
|
||||||
|
} else if (post) {
|
||||||
|
const coverImage = post.images[0];
|
||||||
|
data = {
|
||||||
|
type: "image",
|
||||||
|
aweme_id: post.aweme_id,
|
||||||
|
desc: post.desc,
|
||||||
|
created_at: post.created_at,
|
||||||
|
likesCount: Number(post.digg_count),
|
||||||
|
commentsCount,
|
||||||
|
author: {
|
||||||
|
nickname: post.author.nickname,
|
||||||
|
avatar_url: getFileUrl(post.author.avatar_url || "default-avatar.png"),
|
||||||
|
sec_uid: post.author.sec_uid,
|
||||||
|
},
|
||||||
|
cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"),
|
||||||
|
cover_size: {
|
||||||
|
h: coverImage?.height ?? 0,
|
||||||
|
w: coverImage?.width ?? 0,
|
||||||
|
},
|
||||||
|
images: post.images.map((img) => ({
|
||||||
|
...img,
|
||||||
|
url: getFileUrl(img.url),
|
||||||
|
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||||
|
})),
|
||||||
|
music_url: getFileUrl(post.music_url || "default-music.mp3"),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return <main className="p-8">找不到该作品</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorId = video?.authorId ?? post?.authorId ?? null;
|
||||||
|
const [playback, authorWorksCount] = await Promise.all([
|
||||||
|
resolvePlayback(id, parsePlaybackContext(await searchParams)),
|
||||||
|
authorId
|
||||||
|
? Promise.all([
|
||||||
|
prisma.video.count({ where: { authorId } }),
|
||||||
|
prisma.imagePost.count({ where: { authorId } }),
|
||||||
|
]).then(([videos, posts]) => videos + posts)
|
||||||
|
: 0,
|
||||||
]);
|
]);
|
||||||
const pickPrev = (() => {
|
|
||||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
|
||||||
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
|
|
||||||
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
|
|
||||||
if (cands.length === 0) return null;
|
|
||||||
cands.sort((a, b) => +a.created_at - +b.created_at);
|
|
||||||
return { aweme_id: cands[0].aweme_id };
|
|
||||||
})();
|
|
||||||
const pickNext = (() => {
|
|
||||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
|
||||||
if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
|
|
||||||
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
|
|
||||||
if (cands.length === 0) return null;
|
|
||||||
cands.sort((a, b) => +b.created_at - +a.created_at);
|
|
||||||
return { aweme_id: cands[0].aweme_id };
|
|
||||||
})();
|
|
||||||
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full overflow-hidden">
|
<main className="min-h-screen w-full">
|
||||||
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
|
||||||
<div className="fixed left-3 top-3 z-30">
|
<div className="fixed left-3 top-3 z-30">
|
||||||
<BackButton
|
<BackButton
|
||||||
@@ -98,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"
|
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>
|
</div>
|
||||||
<AwemeDetailClient data={data as any} neighbors={neighbors as any} />
|
<AwemeDetailClient
|
||||||
|
data={data}
|
||||||
|
neighbors={playback.neighbors}
|
||||||
|
playbackContext={playback.context}
|
||||||
|
contextQuery={playback.contextQuery}
|
||||||
|
authorWorksCount={authorWorksCount}
|
||||||
|
transcript={transcript}
|
||||||
|
/>
|
||||||
</main>
|
</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 React from "react";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
type BackButtonProps = {
|
type BackButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -18,28 +18,37 @@ type BackButtonProps = {
|
|||||||
* - Fallback: if close fails (e.g., not opened by script), navigates to '/'
|
* - 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
|
* - 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 router = useRouter();
|
||||||
|
|
||||||
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>((e) => {
|
const onClick = React.useCallback<React.MouseEventHandler<HTMLAnchorElement>>(
|
||||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
(e) => {
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
||||||
|
return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Try to close the window first
|
// Try to close the window first
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
window.close();
|
window.close();
|
||||||
|
|
||||||
// If window.close() didn't work (window still open after a short delay),
|
// If window.close() didn't work (window still open after a short delay),
|
||||||
// navigate to the fallback URL
|
// navigate to the fallback URL
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
router.push(hrefFallback);
|
router.push(hrefFallback);
|
||||||
}
|
}
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
}, [router, hrefFallback]);
|
},
|
||||||
|
[router, hrefFallback],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+324
-74
@@ -1,39 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Check, ThumbsUp, X } from "lucide-react";
|
||||||
import Link from 'next/link';
|
import Image from "next/image";
|
||||||
import HoverVideo from './HoverVideo';
|
import Link from "next/link";
|
||||||
import { ThumbsUp } from 'lucide-react';
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
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 = {
|
type Props = {
|
||||||
initialItems: FeedItem[];
|
initialItems: FeedItem[];
|
||||||
initialCursor: string | null;
|
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 [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [ended, setEnded] = useState(!initialCursor);
|
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 sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
|
// 响应式列数:<640:1, >=640:2, >=1024:3, >=1280:4
|
||||||
const getColumnCount = useCallback(() => {
|
const getColumnCount = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return 1;
|
if (typeof window === "undefined") return 1;
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
if (w >= 1280) return 4; // xl
|
if (w >= 1280) return 4; // xl
|
||||||
if (w >= 1024) return 3; // lg
|
if (w >= 1024) return 3; // lg
|
||||||
if (w >= 640) return 2; // sm
|
if (w >= 640) return 2; // sm
|
||||||
return 1;
|
return 1;
|
||||||
}, []);
|
}, []);
|
||||||
const [columnCount, setColumnCount] = useState<number>(getColumnCount());
|
// 为避免 SSR 与客户端初次渲染不一致(window 未定义导致服务端为 1 列,客户端首次渲染为多列),
|
||||||
|
// 这里将初始列数固定为 1,待挂载后再根据窗口宽度更新。
|
||||||
|
const [columnCount, setColumnCount] = useState<number>(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 挂载后立即根据当前窗口宽度更新一次列数
|
||||||
|
setColumnCount(getColumnCount());
|
||||||
const onResize = () => setColumnCount(getColumnCount());
|
const onResize = () => setColumnCount(getColumnCount());
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener("resize", onResize);
|
||||||
return () => window.removeEventListener('resize', onResize);
|
return () => window.removeEventListener("resize", onResize);
|
||||||
}, [getColumnCount]);
|
}, [getColumnCount]);
|
||||||
|
|
||||||
// 估算卡片高度(用于分配到“最短列”)
|
// 估算卡片高度(用于分配到“最短列”)
|
||||||
@@ -41,12 +72,15 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
|||||||
// 媒体区域高度
|
// 媒体区域高度
|
||||||
let mediaH = 200; // fallback
|
let mediaH = 200; // fallback
|
||||||
if (item.width && item.height) {
|
if (item.width && item.height) {
|
||||||
mediaH = Math.max(80, (Number(item.height) / Number(item.width)) * colWidth);
|
mediaH = Math.max(
|
||||||
} else if (item.type === 'video') {
|
80,
|
||||||
|
(Number(item.height) / Number(item.width)) * colWidth,
|
||||||
|
);
|
||||||
|
} else if (item.type === "video") {
|
||||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||||
}
|
}
|
||||||
// 文本 + 作者栏的高度粗估
|
// 文本 + 作者栏的高度粗估
|
||||||
const textH = 48; // 标题+标签区域
|
const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域
|
||||||
const authorH = 48; // 作者行
|
const authorH = 48; // 作者行
|
||||||
const gap = 16; // 卡片内边距/间隙
|
const gap = 16; // 卡片内边距/间隙
|
||||||
return mediaH + textH + authorH + gap;
|
return mediaH + textH + authorH + gap;
|
||||||
@@ -57,12 +91,15 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
|||||||
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
const cols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||||
return cols;
|
return cols;
|
||||||
});
|
});
|
||||||
const [colHeights, setColHeights] = useState<number[]>(() => Array.from({ length: columnCount }, () => 0));
|
const [colHeights, setColHeights] = useState<number[]>(() =>
|
||||||
|
Array.from({ length: columnCount }, () => 0),
|
||||||
|
);
|
||||||
|
|
||||||
// 初始化与当列数变化时重排
|
// 初始化与当列数变化时重排
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||||
const colWidth = columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
const colWidth =
|
||||||
|
columnCount > 0 ? containerWidth / columnCount : containerWidth;
|
||||||
// 用 initialItems 重排
|
// 用 initialItems 重排
|
||||||
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
const newCols: FeedItem[][] = Array.from({ length: columnCount }, () => []);
|
||||||
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
|
const newHeights: number[] = Array.from({ length: columnCount }, () => 0);
|
||||||
@@ -77,23 +114,26 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
|||||||
}
|
}
|
||||||
setColumns(newCols);
|
setColumns(newCols);
|
||||||
setColHeights(newHeights);
|
setColHeights(newHeights);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [columnCount, estimateItemHeight, initialItems]);
|
||||||
}, [columnCount]);
|
|
||||||
|
|
||||||
const fetchMore = useCallback(async () => {
|
const fetchMore = useCallback(async () => {
|
||||||
if (loading || ended) return;
|
if (loading || ended) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (cursor) params.set('before', cursor);
|
if (cursor) params.set("before", cursor);
|
||||||
params.set('limit', '24');
|
params.set("limit", "24");
|
||||||
const res = await fetch(`/api/feed?${params.toString()}`, { cache: 'no-store' });
|
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data: FeedResponse = await res.json();
|
const data: FeedResponse = await res.json();
|
||||||
// 将新数据按最短列分配
|
// 将新数据按最短列分配
|
||||||
setColumns((prevCols) => {
|
setColumns((prevCols) => {
|
||||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
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 cols = prevCols.map((c) => [...c]);
|
||||||
const heights = [...colHeights];
|
const heights = [...colHeights];
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
@@ -111,85 +151,295 @@ export default function FeedMasonry({ initialItems, initialCursor }: Props) {
|
|||||||
setCursor(data.nextCursor);
|
setCursor(data.nextCursor);
|
||||||
if (!data.nextCursor || data.items.length === 0) setEnded(true);
|
if (!data.nextCursor || data.items.length === 0) setEnded(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('fetch more feed failed', e);
|
console.error("fetch more feed failed", e);
|
||||||
// 失败也不要死循环
|
// 失败也不要死循环
|
||||||
setEnded(true);
|
setEnded(true);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
|
}, [
|
||||||
|
cursor,
|
||||||
|
ended,
|
||||||
|
loading,
|
||||||
|
columnCount,
|
||||||
|
colHeights,
|
||||||
|
estimateItemHeight,
|
||||||
|
fetchUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const io = new IntersectionObserver((entries) => {
|
const io = new IntersectionObserver(
|
||||||
const entry = entries[0];
|
(entries) => {
|
||||||
if (entry.isIntersecting) {
|
const entry = entries[0];
|
||||||
fetchMore();
|
if (entry.isIntersecting) {
|
||||||
}
|
fetchMore();
|
||||||
}, { rootMargin: '800px 0px 800px 0px' });
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "800px 0px 800px 0px" },
|
||||||
|
);
|
||||||
io.observe(el);
|
io.observe(el);
|
||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [fetchMore]);
|
}, [fetchMore]);
|
||||||
|
|
||||||
const renderCard = useCallback((item: FeedItem) => (
|
const toggleSelection = useCallback((awemeId: string) => {
|
||||||
<Link key={item.aweme_id} href={`/aweme/${item.aweme_id}`} target="_blank" className="mb-4 block group">
|
setSelectedIds((current) => {
|
||||||
<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">
|
const next = new Set(current);
|
||||||
<div
|
if (next.has(awemeId)) next.delete(awemeId);
|
||||||
className="relative w-full"
|
else next.add(awemeId);
|
||||||
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
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' ? (
|
{item.type === "video" && (
|
||||||
<HoverVideo
|
<button
|
||||||
videoUrl={(item as any).video_url}
|
type="button"
|
||||||
coverUrl={item.cover_url}
|
onClick={() => toggleSelection(item.aweme_id)}
|
||||||
className="absolute inset-0 w-full h-full"
|
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"
|
||||||
<img
|
: "border-white/25 bg-black/35 hover:bg-black/55"
|
||||||
loading="lazy"
|
}`}
|
||||||
src={item.cover_url || '/placeholder.svg'}
|
aria-pressed={selected}
|
||||||
alt={item.desc?.slice(0, 20) || 'image'}
|
aria-label={selected ? "取消选择视频" : "选择视频"}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
>
|
||||||
/>
|
{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" />
|
<Link
|
||||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
href={`/aweme/${item.aweme_id}${linkQuery}`}
|
||||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
target="_blank"
|
||||||
{item.desc}
|
className="block relative w-full"
|
||||||
</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">
|
<div
|
||||||
{item.type === 'video' ? '视频' : '图文'}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
|
);
|
||||||
<div className="flex items-center gap-2 p-3">
|
},
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200">
|
[selectedIds, toggleSelection],
|
||||||
{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>
|
|
||||||
), []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
{/* 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) => (
|
{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))}
|
{col.map((item) => renderCard(item))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div ref={sentinelRef} className="h-10 flex items-center justify-center text-sm text-zinc-500">
|
<div
|
||||||
{ended ? '没有更多了' : (loading ? '加载中…' : '下拉加载更多')}
|
ref={sentinelRef}
|
||||||
|
className="h-10 flex items-center justify-center text-sm text-zinc-500"
|
||||||
|
>
|
||||||
|
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||||
</div>
|
</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 = {
|
type HoverVideoProps = {
|
||||||
videoUrl: string;
|
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 [active, setActive] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
@@ -38,14 +43,19 @@ export default function HoverVideo({ videoUrl, coverUrl, className, style }: Hov
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style} onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
<div
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
onMouseEnter={onEnter}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
>
|
||||||
{/* 封面始终渲染在底层 */}
|
{/* 封面始终渲染在底层 */}
|
||||||
<img
|
<img
|
||||||
src={coverUrl || '/placeholder.svg'}
|
src={coverUrl || "/placeholder.svg"}
|
||||||
alt="cover"
|
alt="cover"
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading='lazy'
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 仅在激活后渲染视频;初始不设置 src,防止提前加载 */}
|
{/* 仅在激活后渲染视频;初始不设置 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,71 +0,0 @@
|
|||||||
import { chromium, type BrowserContext } from 'playwright'
|
|
||||||
|
|
||||||
// A simple singleton manager for a persistent Chromium context with ref-counting.
|
|
||||||
// Prevents concurrent tasks from closing the shared window prematurely.
|
|
||||||
|
|
||||||
let contextPromise: Promise<BrowserContext> | null = null
|
|
||||||
let context: BrowserContext | null = null
|
|
||||||
let refCount = 0
|
|
||||||
let idleCloseTimer: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
const USER_DATA_DIR = 'chrome-profile/douyin'
|
|
||||||
const DEFAULT_OPTIONS = { headless: true } as const
|
|
||||||
|
|
||||||
async function launchContext(): Promise<BrowserContext> {
|
|
||||||
const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, DEFAULT_OPTIONS)
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
// src/scrapeDouyin.ts
|
|
||||||
import { BrowserContext, Page, chromium, type Response } from 'playwright';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
|
||||||
import { createCamelCompatibleProxy } from '@/app/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';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import { acquireBrowserContext, releaseBrowserContext } from '@/app/fetcher/browser';
|
|
||||||
|
|
||||||
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
|
|
||||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
|
||||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
|
||||||
|
|
||||||
async function readPostMem(context: BrowserContext, page: Page) {
|
|
||||||
const md = await page.evaluate(() => {
|
|
||||||
// @ts-ignore
|
|
||||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
|
||||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
|
||||||
// return {aweme: { detail: {} } };
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
|
||||||
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
aweme_mem.author = aweme_mem.authorInfo
|
|
||||||
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 浏览器...'));
|
|
||||||
|
|
||||||
const context = await acquireBrowserContext();
|
|
||||||
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 },
|
|
||||||
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 },
|
|
||||||
], 9_000); // 整体 9s 兜底超时,不逐个等待
|
|
||||||
|
|
||||||
// 评论只做短时“有就用、没有不等”的监听
|
|
||||||
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 isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false);
|
|
||||||
if (isNotFound) {
|
|
||||||
console.error(chalk.red('✗ 视频不存在或已被删除'));
|
|
||||||
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 优先尝试从内存读取图文数据
|
|
||||||
let { aweme, comments } = await readPostMem(context, page);
|
|
||||||
console.log(chalk.green('✓ 从内存读取图文数据成功'));
|
|
||||||
|
|
||||||
const uploads = await handleImagePost(context, aweme);
|
|
||||||
if (!comments) {
|
|
||||||
console.warn(chalk.yellow('⚠ 从内存读取评论数据失败,尝试从网络请求获取评论数据'));
|
|
||||||
|
|
||||||
const commentRes = await commentPromise;
|
|
||||||
comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
|
|
||||||
if (!comments) {
|
|
||||||
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
|
|
||||||
comments = { comments: [], total: 0, status_code: 0 };
|
|
||||||
} else {
|
|
||||||
console.log(chalk.green('✓ 从网络请求获取评论数据成功'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(chalk.green('✓ 从内存读取评论数据成功'));
|
|
||||||
}
|
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
|
||||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
|
||||||
return { type: "image", ...saved };
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const commentRes = await commentPromise;
|
|
||||||
const firstType = await firstTypePromise;
|
|
||||||
|
|
||||||
if (!firstType) {
|
|
||||||
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据'));
|
|
||||||
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType.key === 'post' ? '图文' : '视频')}`));
|
|
||||||
|
|
||||||
let comments = commentRes && await safeJson<DouyinCommentResponse>(commentRes);
|
|
||||||
if (!comments) {
|
|
||||||
console.warn(chalk.yellow('⚠ 无法从内存读取评论数据,且网络请求也未返回评论数据'));
|
|
||||||
comments = { comments: [], total: 0, status_code: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分支:视频 or 图文(两者只会有一个命中,先到先得)
|
|
||||||
if (firstType.key === 'post') {
|
|
||||||
// 图文作品
|
|
||||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
|
||||||
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE');
|
|
||||||
|
|
||||||
const currentURL = page.url();
|
|
||||||
const target_aweme_id = currentURL.split('/').at(-1);
|
|
||||||
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
|
||||||
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
|
|
||||||
if (!aweme) {
|
|
||||||
throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploads = await handleImagePost(context, aweme);
|
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON
|
|
||||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
|
||||||
return { type: "image", ...saved };
|
|
||||||
} else if (firstType.key === 'detail') {
|
|
||||||
// 视频作品
|
|
||||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
|
||||||
|
|
||||||
// 找到比特率最高的 url
|
|
||||||
const bestPlayAddr = pickBestPlayAddr(
|
|
||||||
detail?.aweme_detail?.video.bit_rate
|
|
||||||
);
|
|
||||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
|
||||||
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
|
||||||
|
|
||||||
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
|
||||||
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`));
|
|
||||||
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
|
||||||
console.log(chalk.cyan(`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载视频并上传至 MinIO,获取外链
|
|
||||||
let uploadedUrl: string | undefined;
|
|
||||||
let coverUrl: string | undefined;
|
|
||||||
if (bestVUrl && detail?.aweme_detail) {
|
|
||||||
console.log(chalk.blue('⬇️ 正在下载视频...'));
|
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
|
||||||
const awemeId = detail.aweme_detail.aweme_id;
|
|
||||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
|
||||||
|
|
||||||
console.log(chalk.blue('⬆️ 正在上传视频到 MinIO...'));
|
|
||||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
|
||||||
console.log(chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`));
|
|
||||||
|
|
||||||
// 提取首帧作为封面并上传
|
|
||||||
try {
|
|
||||||
console.log(chalk.blue('🖼️ 正在提取视频封面...'));
|
|
||||||
const cover = await extractFirstFrame(buffer);
|
|
||||||
if (cover) {
|
|
||||||
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
|
|
||||||
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
|
|
||||||
console.log(chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || e}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await saveToDB(context, detail, comments, uploadedUrl, bestPlayAddr?.width, bestPlayAddr?.height, coverUrl, fps ?? undefined);
|
|
||||||
console.log(chalk.green.bold('✓ 视频作品保存成功'));
|
|
||||||
return { type: "video", ...saved };
|
|
||||||
} else {
|
|
||||||
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 如果是我们自定义的错误,直接抛出
|
|
||||||
if (error instanceof ScrapeError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理其他类型的错误
|
|
||||||
const errMsg = (error as Error)?.message || String(error);
|
|
||||||
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
|
||||||
|
|
||||||
// 根据错误类型返回不同的状态码
|
|
||||||
if (errMsg.includes('timeout') || errMsg.includes('超时')) {
|
|
||||||
throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT');
|
|
||||||
}
|
|
||||||
if (errMsg.includes('页面内存数据中未找到作品详情')) {
|
|
||||||
throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED');
|
|
||||||
}
|
|
||||||
if (errMsg.includes('net::')) {
|
|
||||||
throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认服务器错误
|
|
||||||
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR');
|
|
||||||
} finally {
|
|
||||||
console.log(chalk.gray('🧹 清理资源...'));
|
|
||||||
try { await page.close({ runBeforeUnload: true }); } catch {}
|
|
||||||
// 仅释放共享上下文的引用,不直接关闭窗口
|
|
||||||
await releaseBrowserContext();
|
|
||||||
await prisma.$disconnect();
|
|
||||||
console.log(chalk.gray('✓ 资源清理完成'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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('下载:', 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,271 +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,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 },
|
|
||||||
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 } = 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,49 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -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 {
|
:root {
|
||||||
--background: #161823; /* theme background */
|
--background: #161823; /* theme background */
|
||||||
--foreground: #ededed;
|
--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 {
|
@theme inline {
|
||||||
@@ -23,9 +28,55 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-geist-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条隐藏 */
|
/* 滚动条隐藏 */
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
default: "抖歪 - 记录当下时代",
|
||||||
|
template: "%s - 抖歪",
|
||||||
|
},
|
||||||
|
description: "记录当下时代的精彩瞬间",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.webp",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,12 +18,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body
|
<body className="antialiased">{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</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 { prisma } from "@/lib/prisma";
|
||||||
|
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||||
import FeedMasonry from "./components/FeedMasonry";
|
import FeedMasonry from "./components/FeedMasonry";
|
||||||
|
import SearchBox from "./components/SearchBox";
|
||||||
import type { FeedItem } from "./types/feed";
|
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([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
|
where: selectedTag
|
||||||
|
? {
|
||||||
|
manual_content_tags: { has: selectedTag },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: 60,
|
take: 60,
|
||||||
include: { author: true },
|
include: {
|
||||||
}),
|
author: true,
|
||||||
prisma.imagePost.findMany({
|
},
|
||||||
orderBy: { created_at: "desc" },
|
|
||||||
take: 60,
|
|
||||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
|
||||||
}),
|
}),
|
||||||
|
selectedTag
|
||||||
|
? Promise.resolve([])
|
||||||
|
: prisma.imagePost.findMany({
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 60,
|
||||||
|
include: {
|
||||||
|
author: true,
|
||||||
|
images: { orderBy: { order: "asc" }, take: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const feed: FeedItem[] = [
|
const feed: FeedItem[] = [
|
||||||
@@ -22,39 +55,107 @@ export default async function Home() {
|
|||||||
aweme_id: v.aweme_id,
|
aweme_id: v.aweme_id,
|
||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: v.video_url,
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: v.cover_url ?? null,
|
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||||
width: v.width ?? null,
|
width: v.width ?? null,
|
||||||
height: v.height ?? null,
|
height: v.height ?? null,
|
||||||
author: { nickname: v.author.nickname, avatar_url: v.author.avatar_url ?? null },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
nickname: v.author.nickname,
|
||||||
})),
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
...posts.map((p) => ({
|
sec_uid: v.author.sec_uid,
|
||||||
type: "image" as const,
|
},
|
||||||
aweme_id: p.aweme_id,
|
likes: Number(v.digg_count),
|
||||||
created_at: p.created_at,
|
content_tags: v.manual_content_tags,
|
||||||
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(() => Math.random() - 0.5)
|
//.sort(() => Math.random() - 0.5)
|
||||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full px-4 py-8 md:py-12">
|
<main className="min-h-screen w-full bg-gray-50 dark:bg-black transition-colors duration-300">
|
||||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight mb-6">
|
<div className="relative pt-16 pb-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center">
|
||||||
作品集
|
{/* 装饰背景 */}
|
||||||
</h1>
|
<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>
|
||||||
|
|
||||||
{(() => {
|
<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">
|
||||||
const initial = feed.slice(0, 24);
|
Douyin Archive
|
||||||
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
</h1>
|
||||||
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
|
||||||
})()}
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+285
-102
@@ -1,7 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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";
|
type TaskStatus = "pending" | "running" | "success" | "error";
|
||||||
|
|
||||||
@@ -22,7 +35,7 @@ const extractDouyinLinks = (text: string): string[] => {
|
|||||||
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
||||||
const cleaned = matches
|
const cleaned = matches
|
||||||
.map((m) => m.replace(trailing, ""))
|
.map((m) => m.replace(trailing, ""))
|
||||||
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾
|
.map((m) => (m.endsWith("/") ? m : m)); // 保持原样,通常短链以 / 结尾
|
||||||
// 去重
|
// 去重
|
||||||
return Array.from(new Set(cleaned));
|
return Array.from(new Set(cleaned));
|
||||||
};
|
};
|
||||||
@@ -34,59 +47,119 @@ export default function TasksPage() {
|
|||||||
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
||||||
const [, setTick] = useState(0); // 用于强制更新计时显示
|
const [, setTick] = useState(0); // 用于强制更新计时显示
|
||||||
|
|
||||||
|
// 设置页面标题
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "任务管理 - 抖歪";
|
||||||
|
return () => {
|
||||||
|
document.title = "抖歪 - 记录当下时代";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const inProgressUrls = useMemo(
|
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[]) => {
|
const addTasks = useCallback(
|
||||||
if (!urls.length) return;
|
(urls: string[]) => {
|
||||||
const now = Date.now();
|
if (!urls.length) return;
|
||||||
setTasks((prev) => {
|
const now = Date.now();
|
||||||
const existing = new Set(prev.map((t) => t.id));
|
setTasks((prev) => {
|
||||||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
const existing = new Set(prev.map((t) => t.id));
|
||||||
const newTasks: Task[] = [];
|
const notDuplicated = urls.filter((u) => !inProgressUrls.has(u));
|
||||||
for (const url of notDuplicated) {
|
const newTasks: Task[] = [];
|
||||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
for (const url of notDuplicated) {
|
||||||
newTasks.push({ id, url, status: "pending" });
|
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
newTasks.push({ id, url, status: "pending" });
|
||||||
// 新任务添加到最前面
|
}
|
||||||
return [...newTasks, ...prev];
|
// 新任务添加到最前面
|
||||||
});
|
return [...newTasks, ...prev];
|
||||||
}, [inProgressUrls]);
|
});
|
||||||
|
},
|
||||||
|
[inProgressUrls],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback((e?: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
e?.preventDefault();
|
(e?: React.FormEvent) => {
|
||||||
const urls = extractDouyinLinks(input);
|
e?.preventDefault();
|
||||||
if (!urls.length) {
|
const urls = extractDouyinLinks(input);
|
||||||
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本");
|
if (!urls.length) {
|
||||||
return;
|
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);
|
}, [addTasks]);
|
||||||
setInput("");
|
|
||||||
}, [input, addTasks]);
|
|
||||||
|
|
||||||
const startTask = useCallback(async (task: Task) => {
|
const startTask = useCallback(async (task: Task) => {
|
||||||
// 若已存在控制器,避免重复启动
|
// 若已存在控制器,避免重复启动
|
||||||
if (controllers.current.has(task.id)) return;
|
if (controllers.current.has(task.id)) return;
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
controllers.current.set(task.id, ctrl);
|
controllers.current.set(task.id, ctrl);
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t));
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === task.id
|
||||||
|
? { ...t, status: "running", startedAt: Date.now(), error: undefined }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
try {
|
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);
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// 使用后端返回的结构化错误信息
|
// 使用后端返回的结构化错误信息
|
||||||
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
||||||
const errorCode = data?.code || 'UNKNOWN';
|
const errorCode = data?.code || "UNKNOWN";
|
||||||
throw new Error(`${errorMsg} (${errorCode})`);
|
throw new Error(`${errorMsg} (${errorCode})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t));
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === task.id
|
||||||
|
? { ...t, status: "success", finishedAt: Date.now(), result: data }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err));
|
const msg =
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "error", finishedAt: Date.now(), error: msg } : t));
|
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 {
|
} finally {
|
||||||
controllers.current.delete(task.id);
|
controllers.current.delete(task.id);
|
||||||
}
|
}
|
||||||
@@ -94,17 +167,17 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pending = tasks.filter(t => t.status === "pending");
|
const pending = tasks.filter((t) => t.status === "pending");
|
||||||
pending.forEach((t) => startTask(t));
|
pending.forEach((t) => startTask(t));
|
||||||
}, [tasks, startTask]);
|
}, [tasks, startTask]);
|
||||||
|
|
||||||
// 定时器更新运行中任务的耗时显示
|
// 定时器更新运行中任务的耗时显示
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasRunningTasks = tasks.some(t => t.status === "running");
|
const hasRunningTasks = tasks.some((t) => t.status === "running");
|
||||||
if (!hasRunningTasks) return;
|
if (!hasRunningTasks) return;
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setTick(prev => prev + 1);
|
setTick((prev) => prev + 1);
|
||||||
}, 1000); // 每秒更新一次
|
}, 1000); // 每秒更新一次
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
@@ -117,31 +190,45 @@ export default function TasksPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const retryTask = useCallback((taskId: string) => {
|
const retryTask = useCallback((taskId: string) => {
|
||||||
setTasks(prev => prev.map(t => {
|
setTasks((prev) =>
|
||||||
if (t.id === taskId) {
|
prev.map((t) => {
|
||||||
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined };
|
if (t.id === taskId) {
|
||||||
}
|
return {
|
||||||
return t;
|
...t,
|
||||||
}));
|
status: "pending" as TaskStatus,
|
||||||
|
error: undefined,
|
||||||
|
result: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearFinished = useCallback(() => {
|
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) => {
|
const toggleOpen = useCallback((id: string) => {
|
||||||
setOpenDetails(prev => {
|
setOpenDetails((prev) => {
|
||||||
const next = new Set(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;
|
return next;
|
||||||
})
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]);
|
const extractedCount = useMemo(
|
||||||
|
() => extractDouyinLinks(input).length,
|
||||||
|
[input],
|
||||||
|
);
|
||||||
|
|
||||||
const formatDuration = (t?: number) => {
|
const formatDuration = (startTime?: number, endTime?: number) => {
|
||||||
if (!t) return "";
|
if (!startTime) return "";
|
||||||
const secs = Math.max(0, Math.round((Date.now() - t) / 1000));
|
const end = endTime || Date.now();
|
||||||
|
const secs = Math.max(0, Math.round((end - startTime) / 1000));
|
||||||
if (secs < 60) return `${secs}s`;
|
if (secs < 60) return `${secs}s`;
|
||||||
const m = Math.floor(secs / 60);
|
const m = Math.floor(secs / 60);
|
||||||
const s = secs % 60;
|
const s = secs % 60;
|
||||||
@@ -149,11 +236,31 @@ export default function TasksPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
||||||
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
const base =
|
||||||
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>;
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
||||||
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 === "running")
|
||||||
if (status === 'success') return <span className={`${base} bg-emerald-500/15 text-emerald-300`}><CheckCircle2 className="h-3.5 w-3.5"/> 完成</span>;
|
return (
|
||||||
return <span className={`${base} bg-red-500/15 text-red-300`}><AlertTriangle className="h-3.5 w-3.5"/> 失败</span>;
|
<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 (
|
return (
|
||||||
@@ -168,11 +275,16 @@ export default function TasksPage() {
|
|||||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||||
抖音采集任务
|
抖音采集任务
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p>
|
<p className="mt-2 text-sm text-neutral-400">
|
||||||
|
粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。
|
||||||
|
</p>
|
||||||
</div>
|
</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="flex items-start gap-3">
|
||||||
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
|
<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" />
|
<Link2 className="h-5 w-5 text-neutral-400" />
|
||||||
@@ -186,15 +298,31 @@ export default function TasksPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
||||||
<span>已提取 {extractedCount} 个短链</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<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">
|
<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" /> 添加任务
|
<Plus className="h-4 w-4" /> 添加任务
|
||||||
</button>
|
</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" /> 清除已完成
|
<Trash2 className="h-4 w-4" /> 清除已完成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,64 +332,91 @@ export default function TasksPage() {
|
|||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-medium">进行中的任务</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{tasks.map((t) => {
|
{tasks.map((t) => {
|
||||||
const isOpen = openDetails.has(t.id);
|
const isOpen = openDetails.has(t.id);
|
||||||
return (
|
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="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusBadge status={t.status} />
|
<StatusBadge status={t.status} />
|
||||||
{t.status === 'running' && (
|
{t.status === "running" && (
|
||||||
<span className="text-xs text-neutral-400">已耗时 {formatDuration(t.startedAt)}</span>
|
<span className="text-xs text-neutral-400">
|
||||||
|
已耗时 {formatDuration(t.startedAt)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{t.status === 'success' && (
|
{t.status === "success" && (
|
||||||
<span className="text-xs text-neutral-400">用时 {t.startedAt ? formatDuration(t.startedAt) : '--'}</span>
|
<span className="text-xs text-neutral-400">
|
||||||
|
用时 {formatDuration(t.startedAt, t.finishedAt)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
||||||
<span className="truncate" title={t.url}>{t.url}</span>
|
<span className="truncate" title={t.url}>
|
||||||
<a className="shrink-0 text-neutral-400 hover:text-neutral-200" href={t.url} target="_blank" rel="noreferrer">
|
{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" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{t.status === 'success' && t.result?.data?.aweme_id && (
|
{t.status === "success" && t.result?.data?.aweme_id && (
|
||||||
<a
|
<a
|
||||||
href={`/aweme/${t.result.data.aweme_id}`}
|
href={`/aweme/${t.result.data.aweme_id}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5"/> 查看作品
|
<ExternalLink className="h-3.5 w-3.5" /> 查看作品
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{t.status === 'error' && (
|
{t.status === "error" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => retryTask(t.id)}
|
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"
|
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
|
||||||
>
|
>
|
||||||
<PlayCircle className="h-3.5 w-3.5"/> 重试
|
<PlayCircle className="h-3.5 w-3.5" /> 重试
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{t.status === 'running' && (
|
{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">
|
<button
|
||||||
<Square className="h-3.5 w-3.5"/> 取消
|
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>
|
||||||
)}
|
)}
|
||||||
<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">
|
<button
|
||||||
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
{t.status === 'running' && (
|
{t.status === "running" && (
|
||||||
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
|
<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 className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
|
||||||
</div>
|
</div>
|
||||||
@@ -269,13 +424,17 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
||||||
{t.status === 'error' && t.error && (
|
{t.status === "error" && t.error && (
|
||||||
<div className="mb-3 space-y-2">
|
<div className="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">
|
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400"/>
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-red-200">爬取失败</div>
|
<div className="text-sm font-medium text-red-200">
|
||||||
<div className="mt-1 text-xs text-red-300/90">{t.error}</div>
|
爬取失败
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-red-300/90">
|
||||||
|
{t.error}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-400">
|
<div className="text-xs text-neutral-400">
|
||||||
@@ -288,34 +447,58 @@ export default function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof t.result !== 'undefined' && (
|
{typeof t.result !== "undefined" && (
|
||||||
<div>
|
<div>
|
||||||
{t.result?.data?.aweme && (
|
{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="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">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-400"/>
|
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||||
<span className="font-medium text-emerald-200">作品信息</span>
|
<span className="font-medium text-emerald-200">
|
||||||
|
作品信息
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-neutral-300">
|
<div className="space-y-1 text-neutral-300">
|
||||||
<div><span className="text-neutral-400">ID:</span> {t.result.data.aweme.aweme_id}</div>
|
<div>
|
||||||
<div><span className="text-neutral-400">描述:</span> {t.result.data.aweme.desc || '(无)'}</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 && (
|
{t.result.data.aweme.author && (
|
||||||
<div><span className="text-neutral-400">作者:</span> {t.result.data.aweme.author.nickname}</div>
|
<div>
|
||||||
|
<span className="text-neutral-400">
|
||||||
|
作者:
|
||||||
|
</span>{" "}
|
||||||
|
{t.result.data.aweme.author.nickname}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
|
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
|
||||||
查看完整响应数据 <span className="group-open:hidden">▶</span><span className="hidden group-open:inline">▼</span>
|
查看完整响应数据{" "}
|
||||||
|
<span className="group-open:hidden">▶</span>
|
||||||
|
<span className="hidden group-open:inline">
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">
|
||||||
|
{JSON.stringify(t.result, null, 2)}
|
||||||
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
{typeof t.result === "undefined" &&
|
||||||
<div className="text-xs text-neutral-400">暂无输出</div>
|
t.status !== "error" && (
|
||||||
)}
|
<div className="text-xs text-neutral-400">
|
||||||
|
暂无输出
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+16
-13
@@ -1,19 +1,22 @@
|
|||||||
export type FeedItem =
|
export type FeedItem = (
|
||||||
| ({
|
| {
|
||||||
type: "video";
|
type: "video";
|
||||||
video_url: string;
|
video_url: string;
|
||||||
} | {
|
}
|
||||||
|
| {
|
||||||
type: "image";
|
type: "image";
|
||||||
}) & {
|
}
|
||||||
likes: number;
|
) & {
|
||||||
author: { nickname: string; avatar_url: string | null };
|
likes: number;
|
||||||
aweme_id: string;
|
author: { nickname: string; avatar_url: string | null; sec_uid?: string };
|
||||||
created_at: Date | string;
|
aweme_id: string;
|
||||||
desc: string;
|
created_at: Date | string;
|
||||||
cover_url: string | null;
|
desc: string;
|
||||||
width?: number | null;
|
cover_url: string | null;
|
||||||
height?: number | null;
|
width?: number | null;
|
||||||
};
|
height?: number | null;
|
||||||
|
content_tags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
items: FeedItem[];
|
items: FeedItem[];
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "douyin-archive",
|
"name": "douyin-archive",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.3",
|
"@prisma/client": "^6.16.3",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "15.5.6",
|
"next": "15.5.7",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "1.56.1",
|
||||||
|
"playwright-extra": "^4.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
@@ -48,6 +53,58 @@
|
|||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
"@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/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=="],
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
|
||||||
@@ -106,23 +163,23 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@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=="],
|
"@prisma/client": ["@prisma/client@6.17.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw=="],
|
||||||
|
|
||||||
@@ -172,6 +229,10 @@
|
|||||||
|
|
||||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.14", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "postcss": "^8.4.41", "tailwindcss": "4.1.14" } }, "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg=="],
|
"@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/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=="],
|
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||||
@@ -180,12 +241,18 @@
|
|||||||
|
|
||||||
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||||
@@ -210,14 +277,22 @@
|
|||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"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=="],
|
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||||
|
|
||||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||||
@@ -244,6 +319,8 @@
|
|||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
"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=="],
|
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||||
|
|
||||||
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
||||||
@@ -256,6 +333,14 @@
|
|||||||
|
|
||||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
"for-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=="],
|
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
@@ -268,6 +353,8 @@
|
|||||||
|
|
||||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
"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=="],
|
"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=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
@@ -280,22 +367,38 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"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=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
|
"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-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-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-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-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=="],
|
"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=="],
|
"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": ["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=="],
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||||
@@ -326,19 +429,27 @@
|
|||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||||
|
|
||||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
"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=="],
|
"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=="],
|
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
||||||
@@ -346,6 +457,10 @@
|
|||||||
|
|
||||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
"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=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||||
@@ -358,12 +473,22 @@
|
|||||||
|
|
||||||
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
|
"playwright-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||||
@@ -378,6 +503,8 @@
|
|||||||
|
|
||||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"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-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=="],
|
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||||
@@ -390,6 +517,8 @@
|
|||||||
|
|
||||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
"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=="],
|
"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=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
@@ -420,10 +549,14 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
@@ -432,6 +565,8 @@
|
|||||||
|
|
||||||
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
"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=="],
|
"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=="],
|
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||||
@@ -450,6 +585,14 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@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=="],
|
"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,
|
downloadFile,
|
||||||
fileExists,
|
fileExists,
|
||||||
getFileInfo,
|
getFileInfo,
|
||||||
} from './minio';
|
} from "./minio";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 1. 上传文件示例
|
// 1. 上传文件示例
|
||||||
@@ -25,15 +25,15 @@ import {
|
|||||||
*/
|
*/
|
||||||
async function uploadAvatar(file: File, userId: string) {
|
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)) {
|
if (!validateFileType(file.name, allowedTypes)) {
|
||||||
throw new Error('不支持的图片格式');
|
throw new Error("不支持的图片格式");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小(5MB)
|
// 验证文件大小(5MB)
|
||||||
const maxSize = 5 * 1024 * 1024;
|
const maxSize = 5 * 1024 * 1024;
|
||||||
if (!validateFileSize(file.size, maxSize)) {
|
if (!validateFileSize(file.size, maxSize)) {
|
||||||
throw new Error('文件大小超过限制(最大5MB)');
|
throw new Error("文件大小超过限制(最大5MB)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一文件名,存储在 avatars 目录下
|
// 生成唯一文件名,存储在 avatars 目录下
|
||||||
@@ -41,8 +41,8 @@ async function uploadAvatar(file: File, userId: string) {
|
|||||||
|
|
||||||
// 上传文件
|
// 上传文件
|
||||||
const url = await uploadFile(file, path, {
|
const url = await uploadFile(file, path, {
|
||||||
'Content-Type': file.type,
|
"Content-Type": file.type,
|
||||||
'User-Id': userId,
|
"User-Id": userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { url, path };
|
return { url, path };
|
||||||
@@ -52,28 +52,28 @@ async function uploadAvatar(file: File, userId: string) {
|
|||||||
* 上传文章封面图
|
* 上传文章封面图
|
||||||
*/
|
*/
|
||||||
async function uploadPostCover(file: File, postId: 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)) {
|
if (!validateFileType(file.name, allowedTypes)) {
|
||||||
throw new Error('不支持的图片格式');
|
throw new Error("不支持的图片格式");
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
if (!validateFileSize(file.size, maxSize)) {
|
if (!validateFileSize(file.size, maxSize)) {
|
||||||
throw new Error('文件大小超过限制(最大10MB)');
|
throw new Error("文件大小超过限制(最大10MB)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按日期组织文件
|
// 按日期组织文件
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const year = date.getFullYear();
|
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(
|
const path = generateUniqueFileName(
|
||||||
file.name,
|
file.name,
|
||||||
`posts/${year}/${month}/covers`
|
`posts/${year}/${month}/covers`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = await uploadFile(file, path);
|
const url = await uploadFile(file, path);
|
||||||
|
|
||||||
return { url, path };
|
return { url, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,27 +81,27 @@ async function uploadPostCover(file: File, postId: string) {
|
|||||||
* 上传文章内容中的图片
|
* 上传文章内容中的图片
|
||||||
*/
|
*/
|
||||||
async function uploadPostImage(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)) {
|
if (!validateFileType(file.name, allowedTypes)) {
|
||||||
throw new Error('不支持的图片格式');
|
throw new Error("不支持的图片格式");
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||||
if (!validateFileSize(file.size, maxSize)) {
|
if (!validateFileSize(file.size, maxSize)) {
|
||||||
throw new Error('文件大小超过限制(最大5MB)');
|
throw new Error("文件大小超过限制(最大5MB)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const year = date.getFullYear();
|
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(
|
const path = generateUniqueFileName(
|
||||||
file.name,
|
file.name,
|
||||||
`posts/${year}/${month}/images`
|
`posts/${year}/${month}/images`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = await uploadFile(file, path);
|
const url = await uploadFile(file, path);
|
||||||
|
|
||||||
return { url, path };
|
return { url, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +126,9 @@ function getPublicFileUrl(path: string) {
|
|||||||
async function deleteAvatar(avatarPath: string) {
|
async function deleteAvatar(avatarPath: string) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(avatarPath);
|
await deleteFile(avatarPath);
|
||||||
console.log('头像删除成功');
|
console.log("头像删除成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除头像失败:', error);
|
console.error("删除头像失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,22 +140,22 @@ async function deletePostImages(postId: string) {
|
|||||||
try {
|
try {
|
||||||
// 列出文章相关的所有图片
|
// 列出文章相关的所有图片
|
||||||
const files = await listFiles(`posts/`, true);
|
const files = await listFiles(`posts/`, true);
|
||||||
|
|
||||||
// 过滤出该文章的图片(根据实际情况调整逻辑)
|
// 过滤出该文章的图片(根据实际情况调整逻辑)
|
||||||
const postFiles = files.filter(file =>
|
const postFiles = files.filter((file) => file.name?.includes(postId));
|
||||||
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) {
|
if (paths.length > 0) {
|
||||||
const { deleteFiles } = await import('./minio');
|
const { deleteFiles } = await import("./minio");
|
||||||
await deleteFiles(paths);
|
await deleteFiles(paths);
|
||||||
console.log(`删除了 ${paths.length} 个文件`);
|
console.log(`删除了 ${paths.length} 个文件`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除文章图片失败:', error);
|
console.error("删除文章图片失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,15 +170,15 @@ async function deletePostImages(postId: string) {
|
|||||||
async function getUserAvatars(userId: string) {
|
async function getUserAvatars(userId: string) {
|
||||||
try {
|
try {
|
||||||
const files = await listFiles(`avatars/${userId}/`, false);
|
const files = await listFiles(`avatars/${userId}/`, false);
|
||||||
|
|
||||||
return files.map(file => ({
|
return files.map((file) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: file.lastModified,
|
lastModified: file.lastModified,
|
||||||
url: file.name ? getFileUrl(file.name) : null,
|
url: file.name ? getFileUrl(file.name) : null,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取用户头像列表失败:', error);
|
console.error("获取用户头像列表失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,16 +188,16 @@ async function getUserAvatars(userId: string) {
|
|||||||
*/
|
*/
|
||||||
async function getPostCoversByMonth(year: number, month: number) {
|
async function getPostCoversByMonth(year: number, month: number) {
|
||||||
try {
|
try {
|
||||||
const monthStr = String(month).padStart(2, '0');
|
const monthStr = String(month).padStart(2, "0");
|
||||||
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
|
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
|
||||||
|
|
||||||
return files.map(file => ({
|
return files.map((file) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
url: file.name ? getFileUrl(file.name) : null,
|
url: file.name ? getFileUrl(file.name) : null,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取封面列表失败:', error);
|
console.error("获取封面列表失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,15 +223,15 @@ async function checkAvatarExists(avatarPath: string): Promise<boolean> {
|
|||||||
async function getFileDetails(path: string) {
|
async function getFileDetails(path: string) {
|
||||||
try {
|
try {
|
||||||
const info = await getFileInfo(path);
|
const info = await getFileInfo(path);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
size: info.size,
|
size: info.size,
|
||||||
lastModified: info.lastModified,
|
lastModified: info.lastModified,
|
||||||
etag: info.etag,
|
etag: info.etag,
|
||||||
contentType: info.metaData?.['content-type'],
|
contentType: info.metaData?.["content-type"],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取文件信息失败:', error);
|
console.error("获取文件信息失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
|
|||||||
try {
|
try {
|
||||||
return await downloadFile(path);
|
return await downloadFile(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载文件失败:', error);
|
console.error("下载文件失败:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,42 +258,42 @@ async function downloadFileToBuffer(path: string): Promise<Buffer> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js API Route 示例:上传文件
|
* Next.js API Route 示例:上传文件
|
||||||
*
|
*
|
||||||
* 使用方法:
|
* 使用方法:
|
||||||
*
|
*
|
||||||
* // app/api/upload/route.ts
|
* // app/api/upload/route.ts
|
||||||
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
||||||
*
|
*
|
||||||
* export async function POST(request: Request) {
|
* export async function POST(request: Request) {
|
||||||
* const formData = await request.formData();
|
* const formData = await request.formData();
|
||||||
* const file = formData.get('file') as File;
|
* const file = formData.get('file') as File;
|
||||||
*
|
*
|
||||||
* if (!file) {
|
* if (!file) {
|
||||||
* return Response.json({ error: '没有文件' }, { status: 400 });
|
* return Response.json({ error: '没有文件' }, { status: 400 });
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* const path = generateUniqueFileName(file.name, 'uploads');
|
* const path = generateUniqueFileName(file.name, 'uploads');
|
||||||
* const url = await uploadFile(file, path);
|
* const url = await uploadFile(file, path);
|
||||||
*
|
*
|
||||||
* return Response.json({ url, path });
|
* return Response.json({ url, path });
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js API Route 示例:删除文件
|
* Next.js API Route 示例:删除文件
|
||||||
*
|
*
|
||||||
* // app/api/delete/route.ts
|
* // app/api/delete/route.ts
|
||||||
* import { deleteFile } from '@/lib/minio';
|
* import { deleteFile } from '@/lib/minio';
|
||||||
*
|
*
|
||||||
* export async function DELETE(request: Request) {
|
* export async function DELETE(request: Request) {
|
||||||
* const { path } = await request.json();
|
* const { path } = await request.json();
|
||||||
*
|
*
|
||||||
* if (!path) {
|
* if (!path) {
|
||||||
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
|
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* await deleteFile(path);
|
* await deleteFile(path);
|
||||||
*
|
*
|
||||||
* return Response.json({ success: true });
|
* return Response.json({ success: true });
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|||||||
+76
-71
@@ -1,22 +1,22 @@
|
|||||||
import * as Minio from 'minio';
|
import * as Minio from "minio";
|
||||||
|
|
||||||
// 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;
|
const port = Number(process.env.MINIO_PORT) || 9000;
|
||||||
|
|
||||||
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
|
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
|
||||||
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
|
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
|
||||||
|
|
||||||
const minioClient = new Minio.Client({
|
const minioClient = new Minio.Client({
|
||||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
endPoint: process.env.MINIO_ENDPOINT || "localhost",
|
||||||
...(shouldOmitPort ? {} : { port }),
|
...(shouldOmitPort ? {} : { port }),
|
||||||
useSSL,
|
useSSL,
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
accessKey: process.env.MINIO_ACCESS_KEY || "",
|
||||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
secretKey: process.env.MINIO_SECRET_KEY || "",
|
||||||
pathStyle: true, // 使用路径风格,对反向代理更友好
|
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 存在)
|
* 初始化 MinIO Bucket(确保 bucket 存在)
|
||||||
@@ -25,41 +25,39 @@ export async function initBucket(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
||||||
if (!exists) {
|
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`);
|
console.log(`Bucket ${BUCKET_NAME} created successfully`);
|
||||||
}
|
}
|
||||||
// 设置公共读取策略(可选)
|
// 设置公共读取策略(可选)
|
||||||
const policy = {
|
const policy = {
|
||||||
Version: '2012-10-17',
|
Version: "2012-10-17",
|
||||||
Statement: [
|
Statement: [
|
||||||
{
|
{
|
||||||
Effect: 'Allow',
|
Effect: "Allow",
|
||||||
Principal: { AWS: ['*'] },
|
Principal: { AWS: ["*"] },
|
||||||
Action: ['s3:GetObject'],
|
Action: ["s3:GetObject"],
|
||||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing bucket:', error);
|
console.error("Error initializing bucket:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件到 MinIO
|
* 上传文件到 MinIO
|
||||||
* @param file - File 对象或 Buffer
|
* @param file - File 对象或 Buffer
|
||||||
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
|
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
|
||||||
* @param metadata - 可选的元数据
|
* @param metadata - 可选的元数据
|
||||||
* @returns 文件的访问URL
|
* @returns 文件 path = args.path
|
||||||
*/
|
*/
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
file: File | Buffer,
|
file: File | Buffer,
|
||||||
path: string,
|
path: string,
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
await initBucket();
|
await initBucket();
|
||||||
@@ -69,56 +67,43 @@ export async function uploadFile(
|
|||||||
|
|
||||||
if (file instanceof File) {
|
if (file instanceof File) {
|
||||||
buffer = Buffer.from(await file.arrayBuffer());
|
buffer = Buffer.from(await file.arrayBuffer());
|
||||||
contentType = file.type || 'application/octet-stream';
|
contentType = file.type || "application/octet-stream";
|
||||||
} else {
|
} else {
|
||||||
buffer = file;
|
buffer = file;
|
||||||
contentType = metadata?.['Content-Type'] || 'application/octet-stream';
|
contentType = metadata?.["Content-Type"] || "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaData = {
|
const metaData = {
|
||||||
'Content-Type': contentType,
|
"Content-Type": contentType,
|
||||||
...metadata,
|
...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) {
|
} catch (error) {
|
||||||
console.error('Error uploading file:', error);
|
console.error("Error uploading file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件的公共访问URL
|
* 获取文件的公共访问URL
|
||||||
* @param path - 文件路径
|
* @param path - 文件路径
|
||||||
* @returns 文件的访问URL
|
* @returns 文件的访问URL
|
||||||
*/
|
*/
|
||||||
export function getFileUrl(path: string): string {
|
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}`;
|
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 - 文件路径
|
* @param path - 文件路径
|
||||||
@@ -130,12 +115,12 @@ export async function downloadFile(path: string): Promise<Buffer> {
|
|||||||
const stream = await minioClient.getObject(BUCKET_NAME, path);
|
const stream = await minioClient.getObject(BUCKET_NAME, path);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
stream.on('data', (chunk) => chunks.push(chunk));
|
stream.on("data", (chunk) => chunks.push(chunk));
|
||||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
stream.on('error', reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading file:', error);
|
console.error("Error downloading file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,11 +130,13 @@ export async function downloadFile(path: string): Promise<Buffer> {
|
|||||||
* @param path - 文件路径
|
* @param path - 文件路径
|
||||||
* @returns 文件流
|
* @returns 文件流
|
||||||
*/
|
*/
|
||||||
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> {
|
export async function getFileStream(
|
||||||
|
path: string,
|
||||||
|
): Promise<NodeJS.ReadableStream> {
|
||||||
try {
|
try {
|
||||||
return await minioClient.getObject(BUCKET_NAME, path);
|
return await minioClient.getObject(BUCKET_NAME, path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting file stream:', error);
|
console.error("Error getting file stream:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +149,7 @@ export async function deleteFile(path: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await minioClient.removeObject(BUCKET_NAME, path);
|
await minioClient.removeObject(BUCKET_NAME, path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting file:', error);
|
console.error("Error deleting file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +162,7 @@ export async function deleteFiles(paths: string[]): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await minioClient.removeObjects(BUCKET_NAME, paths);
|
await minioClient.removeObjects(BUCKET_NAME, paths);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting files:', error);
|
console.error("Error deleting files:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +190,7 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
|||||||
try {
|
try {
|
||||||
return await minioClient.statObject(BUCKET_NAME, path);
|
return await minioClient.statObject(BUCKET_NAME, path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting file info:', error);
|
console.error("Error getting file info:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,24 +202,27 @@ export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
|
|||||||
* @returns 文件列表
|
* @returns 文件列表
|
||||||
*/
|
*/
|
||||||
export async function listFiles(
|
export async function listFiles(
|
||||||
prefix: string = '',
|
prefix: string = "",
|
||||||
recursive: boolean = false
|
recursive: boolean = false,
|
||||||
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
|
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
|
||||||
try {
|
try {
|
||||||
const files: (Minio.BucketItem & { endpoint: string })[] = [];
|
const files: (Minio.BucketItem & { endpoint: string })[] = [];
|
||||||
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
|
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
stream.on('data', (obj) => {
|
stream.on("data", (obj) => {
|
||||||
if (obj.name) {
|
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("end", () => resolve(files));
|
||||||
stream.on('error', reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error listing files:', error);
|
console.error("Error listing files:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,17 +232,20 @@ export async function listFiles(
|
|||||||
* @param sourcePath - 源文件路径
|
* @param sourcePath - 源文件路径
|
||||||
* @param destPath - 目标文件路径
|
* @param destPath - 目标文件路径
|
||||||
*/
|
*/
|
||||||
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
|
export async function copyFile(
|
||||||
|
sourcePath: string,
|
||||||
|
destPath: string,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const conds = new Minio.CopyConditions();
|
const conds = new Minio.CopyConditions();
|
||||||
await minioClient.copyObject(
|
await minioClient.copyObject(
|
||||||
BUCKET_NAME,
|
BUCKET_NAME,
|
||||||
destPath,
|
destPath,
|
||||||
`/${BUCKET_NAME}/${sourcePath}`,
|
`/${BUCKET_NAME}/${sourcePath}`,
|
||||||
conds
|
conds,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error copying file:', error);
|
console.error("Error copying file:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,15 +256,20 @@ export async function copyFile(sourcePath: string, destPath: string): Promise<vo
|
|||||||
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
|
||||||
* @returns 带路径的唯一文件名
|
* @returns 带路径的唯一文件名
|
||||||
*/
|
*/
|
||||||
export function generateUniqueFileName(originalName: string, prefix: string = ''): string {
|
export function generateUniqueFileName(
|
||||||
|
originalName: string,
|
||||||
|
prefix: string = "",
|
||||||
|
): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
const ext = originalName.split('.').pop();
|
const ext = originalName.split(".").pop();
|
||||||
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_');
|
const nameWithoutExt = originalName
|
||||||
|
.replace(`.${ext}`, "")
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, "_");
|
||||||
|
|
||||||
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
|
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 扩展名(不含点)
|
* @returns 扩展名(不含点)
|
||||||
*/
|
*/
|
||||||
export function getFileExtension(filename: string): string {
|
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'])
|
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
|
||||||
* @returns 是否为允许的类型
|
* @returns 是否为允许的类型
|
||||||
*/
|
*/
|
||||||
export function validateFileType(filename: string, allowedTypes: string[]): boolean {
|
export function validateFileType(
|
||||||
|
filename: string,
|
||||||
|
allowedTypes: string[],
|
||||||
|
): boolean {
|
||||||
const ext = getFileExtension(filename).toLowerCase();
|
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 格式化后的文件大小
|
* @returns 格式化后的文件大小
|
||||||
*/
|
*/
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return "0 B";
|
||||||
|
|
||||||
const k = 1024;
|
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));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
@@ -323,7 +324,6 @@ export default {
|
|||||||
initBucket,
|
initBucket,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
getFileUrl,
|
getFileUrl,
|
||||||
getPresignedUrl,
|
|
||||||
downloadFile,
|
downloadFile,
|
||||||
getFileStream,
|
getFileStream,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
@@ -338,3 +338,8 @@ export default {
|
|||||||
validateFileSize,
|
validateFileSize,
|
||||||
formatFileSize,
|
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 {
|
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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user