Compare commits
14
Commits
674c202264
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2391abdeea | ||
|
|
b84fcec77b | ||
|
|
182ca9cf05 | ||
|
|
11d9d35002 | ||
|
|
bcf1e6e349 | ||
|
|
10f2520279 | ||
|
|
990e1b1804 | ||
|
|
d3896522ec | ||
|
|
6f0e38054b | ||
|
|
b9af979582 | ||
|
|
eac6ad2343 | ||
|
|
02d37f3953 | ||
|
|
206a561ee7 | ||
|
|
040273586b |
@@ -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
-11
@@ -5,23 +5,15 @@
|
|||||||
"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;')"
|
|
||||||
],
|
|
||||||
"problemMatcher": [
|
|
||||||
"$tsc"
|
|
||||||
],
|
|
||||||
"group": "build"
|
"group": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "tsc-check (one-off)",
|
"label": "tsc-check (one-off)",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": [
|
"args": ["-e", "require('typescript').transpile('const x: number = 1;')"]
|
||||||
"-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";
|
||||||
+1
-143
@@ -1,143 +1 @@
|
|||||||
'use client';
|
export { default } from "../asr/page";
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import BackButton from '@/app/components/BackButton';
|
|
||||||
|
|
||||||
export default function SttAdminPage() {
|
|
||||||
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">
|
|
||||||
STT 管理中心
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-2">
|
|
||||||
管理和配置视频语音转写功能
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-6">
|
|
||||||
{/* 视频转写管理 */}
|
|
||||||
<Link
|
|
||||||
href="/admin/stt/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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+293
-349
@@ -1,424 +1,368 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import Image from "next/image";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import BackButton from '@/app/components/BackButton';
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||||
type VideoTranscript = {
|
import BackButton from "@/app/components/BackButton";
|
||||||
id: string;
|
import {
|
||||||
speech_detected: boolean;
|
isActive,
|
||||||
language: string | null;
|
statusLabels,
|
||||||
audio_type: string | null;
|
type TranscriptionState,
|
||||||
transcript: string | null;
|
type TranscriptionStatus,
|
||||||
non_speech_summary: string | null;
|
} from "@/lib/stt/types";
|
||||||
};
|
|
||||||
|
|
||||||
type Video = {
|
type Video = {
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
author: {
|
author: { nickname: string };
|
||||||
nickname: string;
|
transcription: TranscriptionState;
|
||||||
};
|
content_tags: string[];
|
||||||
transcript: VideoTranscript | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ApiResponse = {
|
|
||||||
videos: Video[];
|
|
||||||
total: number;
|
|
||||||
};
|
};
|
||||||
|
const filters: ("all" | TranscriptionStatus)[] = [
|
||||||
|
"all",
|
||||||
|
"PENDING",
|
||||||
|
"QUEUED",
|
||||||
|
"PROCESSING",
|
||||||
|
"SUCCEEDED",
|
||||||
|
"FAILED",
|
||||||
|
];
|
||||||
|
|
||||||
export default function SttVideosPage() {
|
export default function SttVideosPage() {
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
const [notice, setNotice] = useState("");
|
||||||
const [filter, setFilter] = useState<'all' | 'transcribed' | 'pending'>('all');
|
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
|
||||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
useEffect(() => {
|
const [tag, setTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
|
||||||
fetchVideos();
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
}, []);
|
const [savingTags, setSavingTags] = useState(false);
|
||||||
|
const fetching = useRef(false);
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = useCallback(async () => {
|
||||||
|
if (fetching.current) return;
|
||||||
|
fetching.current = true;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
const response = await fetch("/api/admin/stt/videos", {
|
||||||
const response = await fetch('/api/admin/stt/videos');
|
cache: "no-store",
|
||||||
if (!response.ok) {
|
});
|
||||||
throw new Error('Failed to fetch videos');
|
if (!response.ok) throw new Error("读取转写列表失败");
|
||||||
}
|
const data: { videos: Video[] } = await response.json();
|
||||||
const data: ApiResponse = await response.json();
|
|
||||||
setVideos(data.videos);
|
setVideos(data.videos);
|
||||||
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : "读取失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
fetching.current = false;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchVideos();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void fetchVideos();
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [fetchVideos]);
|
||||||
|
|
||||||
// 简单的 sleep 工具
|
async function handleTranscribe(awemeId: string) {
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
setSubmitting((prev) => new Set(prev).add(awemeId));
|
||||||
|
|
||||||
type PollOptions = {
|
|
||||||
intervalMs?: number;
|
|
||||||
maxAttempts?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 轮询某个视频的转写状态直至完成或超时
|
|
||||||
const pollVideoUntilTranscribed = async (
|
|
||||||
awemeId: string,
|
|
||||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {}
|
|
||||||
): Promise<boolean> => {
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/admin/stt/videos');
|
const response = await fetch("/api/stt", {
|
||||||
if (resp.ok) {
|
method: "POST",
|
||||||
const data: ApiResponse = await resp.json();
|
headers: { "Content-Type": "application/json" },
|
||||||
setVideos(data.videos);
|
body: JSON.stringify({ awemeId }),
|
||||||
const v = data.videos.find((x) => x.aweme_id === awemeId);
|
});
|
||||||
if (v && v.transcript) {
|
const data = await response.json();
|
||||||
return true;
|
if (!response.ok) throw new Error(data.error || "任务入队失败");
|
||||||
}
|
setVideos((prev) =>
|
||||||
}
|
prev.map((video) =>
|
||||||
} catch (e) {
|
video.aweme_id === awemeId
|
||||||
// 忽略网络错误,继续轮询
|
? { ...video, transcription: data }
|
||||||
}
|
: video,
|
||||||
await sleep(intervalMs);
|
),
|
||||||
}
|
);
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTranscribe = async (awemeId: string) => {
|
|
||||||
setTranscribing(prev => new Set(prev).add(awemeId));
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/stt?awemeId=${awemeId}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to transcribe video');
|
|
||||||
}
|
|
||||||
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
|
|
||||||
const done = await pollVideoUntilTranscribed(awemeId);
|
|
||||||
if (!done) {
|
|
||||||
// 兜底刷新一次
|
|
||||||
await fetchVideos();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : 'Transcription failed');
|
setNotice(err instanceof Error ? err.message : "任务入队失败");
|
||||||
} finally {
|
} finally {
|
||||||
setTranscribing(prev => {
|
setSubmitting((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(awemeId);
|
next.delete(awemeId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleBatchTranscribe = async () => {
|
|
||||||
const pendingVideos = videos.filter(v => v.transcript === null);
|
|
||||||
if (pendingVideos.length === 0) {
|
|
||||||
alert('没有待转写的视频');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
async function handleBatch() {
|
||||||
if (!confirm(`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`)) {
|
setBatchSubmitting(true);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBatchTranscribing(true);
|
|
||||||
setBatchProgress({ current: 0, total: pendingVideos.length });
|
|
||||||
|
|
||||||
const pollers: Promise<boolean>[] = [];
|
|
||||||
for (let i = 0; i < pendingVideos.length; i++) {
|
|
||||||
const video = pendingVideos[i];
|
|
||||||
setBatchProgress({ current: i + 1, total: pendingVideos.length });
|
|
||||||
setTranscribing(prev => new Set(prev).add(video.aweme_id));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
|
const response = await fetch("/api/stt/batch", { method: "POST" });
|
||||||
if (!response.ok) {
|
const data = await response.json();
|
||||||
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
if (!response.ok) throw new Error(data.error || "批量入队失败");
|
||||||
}
|
setNotice(`已加入 ${data.queued} 个转写任务;关闭页面后仍会继续处理。`);
|
||||||
// 为该视频启动后台轮询,不阻塞批处理的顺序执行
|
|
||||||
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
|
|
||||||
setTranscribing(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(video.aweme_id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
pollers.push(p);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error transcribing video ${video.aweme_id}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待所有轮询结束(完成或超时),再进行最终刷新
|
|
||||||
if (pollers.length > 0) {
|
|
||||||
await Promise.allSettled(pollers);
|
|
||||||
}
|
|
||||||
await fetchVideos();
|
await fetchVideos();
|
||||||
setBatchTranscribing(false);
|
} catch (err) {
|
||||||
setBatchProgress({ current: 0, total: 0 });
|
setNotice(err instanceof Error ? err.message : "批量入队失败");
|
||||||
alert('批量转写完成!');
|
} finally {
|
||||||
};
|
setBatchSubmitting(false);
|
||||||
|
}
|
||||||
const filteredVideos = videos.filter(v => {
|
}
|
||||||
if (filter === 'transcribed') return v.transcript !== null;
|
async function saveTags(clear = false) {
|
||||||
if (filter === 'pending') return v.transcript === null;
|
setSavingTags(true);
|
||||||
return 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("保存手动标签失败");
|
||||||
const stats = {
|
setNotice(`已更新 ${selected.size} 个视频的手动标签`);
|
||||||
total: videos.length,
|
setSelected(new Set());
|
||||||
transcribed: videos.filter(v => v.transcript !== null).length,
|
await fetchVideos();
|
||||||
pending: videos.filter(v => v.transcript === null).length,
|
} catch (err) {
|
||||||
};
|
setNotice(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
if (loading) {
|
setSavingTags(false);
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 p-8">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
|
||||||
<p className="mt-4 text-gray-600">加载中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 p-8">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
|
||||||
<p className="text-red-600">错误: {error}</p>
|
|
||||||
<button
|
|
||||||
onClick={fetchVideos}
|
|
||||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<main className="min-h-screen bg-gray-50 p-6">
|
||||||
<div className="max-w-7xl mx-auto p-8">
|
<div className="mx-auto max-w-7xl space-y-4">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mt-4">视频转写管理</h1>
|
<h1 className="text-2xl font-semibold text-gray-900">视频文字转写</h1>
|
||||||
<p className="text-gray-600 mt-2">管理视频的语音转写状态</p>
|
<p className="text-sm text-gray-600">
|
||||||
</div>
|
已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "}
|
||||||
|
{count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}
|
||||||
{/* Stats */}
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
{error && (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<p role="alert" className="text-red-700">
|
||||||
<div className="text-sm text-gray-600">总视频数</div>
|
{error}
|
||||||
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</div>
|
</p>
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="text-sm text-gray-600">已转写</div>
|
|
||||||
<div className="text-3xl font-bold text-green-600 mt-2">{stats.transcribed}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="text-sm text-gray-600">待转写</div>
|
|
||||||
<div className="text-3xl font-bold text-orange-600 mt-2">{stats.pending}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
filter === 'all'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
全部 ({stats.total})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('transcribed')}
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
filter === 'transcribed'
|
|
||||||
? 'bg-green-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
已转写 ({stats.transcribed})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('pending')}
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
filter === 'pending'
|
|
||||||
? 'bg-orange-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
待转写 ({stats.pending})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{batchTranscribing && (
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
正在转写: {batchProgress.current} / {batchProgress.total}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<button
|
{notice && (
|
||||||
onClick={handleBatchTranscribe}
|
<output className="block text-sm text-gray-700">{notice}</output>
|
||||||
disabled={batchTranscribing || stats.pending === 0}
|
|
||||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
|
||||||
>
|
|
||||||
{batchTranscribing ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
|
||||||
批量转写中...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'全部转写'
|
|
||||||
)}
|
)}
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
{/* Video List */}
|
<thead className="bg-gray-100 text-gray-600">
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="p-3">
|
||||||
视频
|
<input
|
||||||
</th>
|
type="checkbox"
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
aria-label="选择当前列表前200个视频"
|
||||||
时长
|
checked={
|
||||||
</th>
|
shown.length > 0 &&
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
shown.slice(0, 200).every((v) => selected.has(v.aweme_id))
|
||||||
转写状态
|
}
|
||||||
</th>
|
onChange={(e) =>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
setSelected(
|
||||||
操作
|
e.target.checked
|
||||||
|
? new Set(shown.slice(0, 200).map((v) => v.aweme_id))
|
||||||
|
: new Set(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
<th className="p-3">视频</th>
|
||||||
|
<th className="p-3">时长</th>
|
||||||
|
<th className="p-3">状态与文字</th>
|
||||||
|
<th className="p-3">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody>
|
||||||
{filteredVideos.map((video) => (
|
{shown.map((video) => {
|
||||||
<tr key={video.aweme_id} className="hover:bg-gray-50">
|
const state = video.transcription;
|
||||||
<td className="px-6 py-4">
|
const active =
|
||||||
<div className="flex items-center gap-4">
|
isActive(state.status) || submitting.has(video.aweme_id);
|
||||||
<img
|
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}
|
src={video.cover_url}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-20 h-28 object-cover rounded"
|
width={56}
|
||||||
|
height={80}
|
||||||
|
unoptimized
|
||||||
|
className="h-20 w-14 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
)}
|
||||||
|
<div>
|
||||||
<Link
|
<Link
|
||||||
|
className="line-clamp-2 text-blue-700"
|
||||||
href={`/aweme/${video.aweme_id}`}
|
href={`/aweme/${video.aweme_id}`}
|
||||||
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
|
|
||||||
>
|
>
|
||||||
{video.desc || '无描述'}
|
{video.desc || "无描述"}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="mt-1 text-gray-500">
|
||||||
@{video.author.nickname}
|
@{video.author.nickname}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-blue-700">
|
||||||
|
{video.content_tags.join("、")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="whitespace-nowrap p-3 text-gray-700">
|
||||||
{Math.floor(video.duration_ms / 1000)}s
|
{Math.floor(video.duration_ms / 1000)} 秒
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="p-3">
|
||||||
{video.transcript ? (
|
<p
|
||||||
<div className="space-y-1">
|
className={
|
||||||
<div className="flex items-center gap-2">
|
state.status === "FAILED"
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
? "text-red-700"
|
||||||
已转写
|
: "text-gray-700"
|
||||||
</span>
|
}
|
||||||
{video.transcript.language && (
|
>
|
||||||
<span className="text-xs text-gray-500">
|
{statusLabels[state.status]}
|
||||||
{video.transcript.language}
|
</p>
|
||||||
</span>
|
{state.error && (
|
||||||
|
<p className="mt-1 max-w-lg text-xs text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
{state.transcript !== null && (
|
||||||
{video.transcript.speech_detected ? (
|
<p className="mt-1 max-w-lg whitespace-pre-wrap text-gray-600">
|
||||||
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
|
{state.transcript.join("\n") || "未识别到文字"}
|
||||||
{video.transcript.transcript}
|
</p>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
<span className="font-medium">
|
|
||||||
{video.transcript.audio_type || '非语音'}
|
|
||||||
</span>
|
|
||||||
{video.transcript.non_speech_summary && (
|
|
||||||
<span className="ml-1">
|
|
||||||
- {video.transcript.non_speech_summary}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
|
||||||
待转写
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="whitespace-nowrap p-3">
|
||||||
{video.transcript ? (
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={active}
|
||||||
onClick={() => handleTranscribe(video.aweme_id)}
|
onClick={() => handleTranscribe(video.aweme_id)}
|
||||||
disabled={transcribing.has(video.aweme_id)}
|
className="rounded bg-gray-100 px-3 py-2 text-gray-800 disabled:opacity-50"
|
||||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
>
|
||||||
{transcribing.has(video.aweme_id) ? (
|
{active
|
||||||
<span className="flex items-center gap-2">
|
? "处理中…"
|
||||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
: state.status === "FAILED"
|
||||||
重新转写...
|
? "重试"
|
||||||
</span>
|
: state.transcript !== null
|
||||||
) : (
|
? "重新转写"
|
||||||
'重新转写'
|
: "开始转写"}
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleTranscribe(video.aweme_id)}
|
|
||||||
disabled={transcribing.has(video.aweme_id)}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{transcribing.has(video.aweme_id) ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
|
||||||
转写中...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'开始转写'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
{!shown.length && (
|
||||||
|
<p className="p-8 text-center text-gray-500">
|
||||||
{filteredVideos.length === 0 && (
|
{loading ? "加载中…" : "没有找到视频"}
|
||||||
<div className="text-center py-12 text-gray-500">
|
</p>
|
||||||
没有找到视频
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET } from "../../stt/videos/route";
|
||||||
@@ -1,26 +1,23 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { transcriptionState } from "@/lib/stt/types";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const videos = await prisma.video.findMany({
|
const videos = await prisma.video.findMany({
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: 1000,
|
|
||||||
include: {
|
include: {
|
||||||
author: {
|
author: {
|
||||||
select: {
|
select: {
|
||||||
nickname: true,
|
nickname: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
transcriptionTask: true,
|
||||||
transcript: {
|
transcript: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
speech_detected: true,
|
|
||||||
language: true,
|
|
||||||
audio_type: true,
|
|
||||||
transcript: true,
|
transcript: true,
|
||||||
non_speech_summary: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -29,21 +26,13 @@ export async function GET() {
|
|||||||
const formattedVideos = videos.map((v) => ({
|
const formattedVideos = videos.map((v) => ({
|
||||||
aweme_id: v.aweme_id,
|
aweme_id: v.aweme_id,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
cover_url: getFileUrl(v.cover_url ?? ''),
|
cover_url: v.cover_url ? getFileUrl(v.cover_url) : "",
|
||||||
duration_ms: v.duration_ms,
|
duration_ms: v.duration_ms,
|
||||||
author: {
|
author: {
|
||||||
nickname: v.author.nickname,
|
nickname: v.author.nickname,
|
||||||
},
|
},
|
||||||
transcript: v.transcript
|
content_tags: v.manual_content_tags,
|
||||||
? {
|
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||||
id: v.transcript.id,
|
|
||||||
speech_detected: v.transcript.speech_detected,
|
|
||||||
language: v.transcript.language,
|
|
||||||
audio_type: v.transcript.audio_type,
|
|
||||||
transcript: v.transcript.transcript,
|
|
||||||
non_speech_summary: v.transcript.non_speech_summary,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -51,10 +40,10 @@ export async function GET() {
|
|||||||
total: videos.length,
|
total: videos.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch videos:', error);
|
console.error("Failed to fetch videos:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch videos' },
|
{ error: "Failed to fetch videos" },
|
||||||
{ status: 500 }
|
{ 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),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ secUid: string }> }) {
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ secUid: string }> },
|
||||||
|
) {
|
||||||
const secUid = (await params).secUid;
|
const secUid = (await params).secUid;
|
||||||
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 limit = Math.min(Math.max(Number(limitParam ?? '24'), 1), 60); // 1..60
|
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;
|
||||||
|
|
||||||
// fetch chunk from both tables
|
// fetch chunk from both tables
|
||||||
@@ -17,20 +20,20 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
|
|||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: {
|
where: {
|
||||||
authorId: secUid,
|
authorId: secUid,
|
||||||
...(before ? { created_at: { lt: before } } : {})
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
include: { author: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findMany({
|
prisma.imagePost.findMany({
|
||||||
where: {
|
where: {
|
||||||
authorId: secUid,
|
authorId: secUid,
|
||||||
...(before ? { created_at: { lt: before } } : {})
|
...(before ? { created_at: { lt: before } } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -41,11 +44,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
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: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
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) => ({
|
...posts.map((p) => ({
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
@@ -55,13 +62,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ secU
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
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))
|
]
|
||||||
|
.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 as any).toISOString()
|
||||||
|
: null;
|
||||||
const payload: FeedResponse = { items: merged, nextCursor };
|
const payload: FeedResponse = { items: merged, nextCursor };
|
||||||
return NextResponse.json(payload);
|
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 });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ awemeId: string }> }
|
{ params }: { params: Promise<{ awemeId: string }> },
|
||||||
) {
|
) {
|
||||||
const awemeId = (await params).awemeId;
|
const awemeId = (await params).awemeId;
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@@ -13,7 +13,8 @@ export async function GET(
|
|||||||
const take = parseInt(searchParams.get("take") || "20", 10);
|
const take = parseInt(searchParams.get("take") || "20", 10);
|
||||||
|
|
||||||
// ranked 模式参数(均为可选,提供合理默认值)
|
// ranked 模式参数(均为可选,提供合理默认值)
|
||||||
const seed = searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
|
const seed =
|
||||||
|
searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
|
||||||
const snapshotIso = searchParams.get("snapshot");
|
const snapshotIso = searchParams.get("snapshot");
|
||||||
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
|
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
|
||||||
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
|
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
|
||||||
@@ -21,8 +22,6 @@ export async function GET(
|
|||||||
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
|
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
|
||||||
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
|
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 查找是视频还是图文
|
// 查找是视频还是图文
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
@@ -41,9 +40,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建查询条件
|
// 构建查询条件
|
||||||
const where = video
|
const where = video ? { videoId: awemeId } : { imagePostId: awemeId };
|
||||||
? { videoId: awemeId }
|
|
||||||
: { imagePostId: awemeId };
|
|
||||||
|
|
||||||
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
|
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
|
||||||
const total = await prisma.comment.count({ where });
|
const total = await prisma.comment.count({ where });
|
||||||
@@ -68,7 +65,9 @@ export async function GET(
|
|||||||
${wJit} * ${jitterExpr}
|
${wJit} * ${jitterExpr}
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
const whereField = (await video) ? Prisma.sql`c."videoId"` : Prisma.sql`c."imagePostId"`;
|
const whereField = (await video)
|
||||||
|
? Prisma.sql`c."videoId"`
|
||||||
|
: Prisma.sql`c."imagePostId"`;
|
||||||
|
|
||||||
const rows: Array<{
|
const rows: Array<{
|
||||||
cid: string;
|
cid: string;
|
||||||
@@ -92,22 +91,41 @@ export async function GET(
|
|||||||
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
|
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
|
||||||
OFFSET ${skip}
|
OFFSET ${skip}
|
||||||
LIMIT ${take}
|
LIMIT ${take}
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 批量查询每条评论的配图/贴纸
|
// 批量查询每条评论的配图/贴纸
|
||||||
const cids = rows.map(r => r.cid);
|
const cids = rows.map((r) => r.cid);
|
||||||
const images = cids.length
|
const images = cids.length
|
||||||
? await prisma.commentImage.findMany({
|
? await prisma.commentImage.findMany({
|
||||||
where: { commentId: { in: cids } },
|
where: { commentId: { in: cids } },
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: "asc" },
|
||||||
select: { commentId: true, url: true, width: true, height: true, order: true },
|
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 }[]>();
|
const group = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
url: string;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
order: number;
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
const arr = group.get(img.commentId) || [];
|
const arr = group.get(img.commentId) || [];
|
||||||
arr.push({ url: img.url, width: img.width, height: img.height, order: img.order });
|
arr.push({
|
||||||
|
url: img.url,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
order: img.order,
|
||||||
|
});
|
||||||
group.set(img.commentId, arr);
|
group.set(img.commentId, arr);
|
||||||
}
|
}
|
||||||
const formattedComments = rows.map((c) => ({
|
const formattedComments = rows.map((c) => ({
|
||||||
@@ -117,9 +135,15 @@ export async function GET(
|
|||||||
digg_count: Number(c.digg_count),
|
digg_count: Number(c.digg_count),
|
||||||
user: {
|
user: {
|
||||||
nickname: c.nickname,
|
nickname: c.nickname,
|
||||||
avatar_url: getFileUrl(c.avatar_url || 'default_avatar.png'),
|
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 })),
|
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({
|
return NextResponse.json({
|
||||||
@@ -134,9 +158,6 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取评论失败:", error);
|
console.error("获取评论失败:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "获取评论失败" }, { status: 500 });
|
||||||
{ error: "获取评论失败" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-23
@@ -1,7 +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 { 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 }
|
||||||
@@ -9,25 +10,46 @@ import { getFileUrl } from '@/lib/minio';
|
|||||||
|
|
||||||
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({
|
selectedTag
|
||||||
|
? Promise.resolve([])
|
||||||
|
: prisma.imagePost.findMany({
|
||||||
where: before ? { created_at: { lt: before } } : undefined,
|
where: before ? { created_at: { lt: before } } : undefined,
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
include: {
|
||||||
|
author: true,
|
||||||
|
images: { orderBy: { order: "asc" }, take: 1 },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -38,27 +60,43 @@ export async function GET(req: NextRequest) {
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
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: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
nickname: v.author.nickname,
|
||||||
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
|
sec_uid: v.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(v.digg_count),
|
||||||
|
content_tags: v.manual_content_tags,
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => {
|
||||||
|
const coverPath = p.images?.[0]?.url;
|
||||||
|
return {
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
aweme_id: p.aweme_id,
|
aweme_id: p.aweme_id,
|
||||||
created_at: p.created_at,
|
created_at: p.created_at,
|
||||||
desc: p.desc,
|
desc: p.desc,
|
||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
nickname: p.author.nickname,
|
||||||
})),
|
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||||
].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
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);
|
.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// index.js
|
// index.js
|
||||||
import { chromium } from 'playwright-extra';
|
import { chromium } from "playwright-extra";
|
||||||
import stealth from 'puppeteer-extra-plugin-stealth'
|
import stealth from "puppeteer-extra-plugin-stealth";
|
||||||
|
|
||||||
chromium.use(stealth());
|
chromium.use(stealth());
|
||||||
|
|
||||||
@@ -8,9 +8,9 @@ chromium.use(stealth());
|
|||||||
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
|
const browser = await chromium.launch({ headless: false }); // 需要可视化就 false
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto('https://bot.sannysoft.com/'); // 常用自测页
|
await page.goto("https://bot.sannysoft.com/"); // 常用自测页
|
||||||
console.log('Title:', await page.title());
|
console.log("Title:", await page.title());
|
||||||
await page.screenshot({ path: 'stealth.png', fullPage: true });
|
await page.screenshot({ path: "stealth.png", fullPage: true });
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}, 1000_000);
|
}, 1000_000);
|
||||||
|
|||||||
+70
-69
@@ -1,99 +1,98 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import { type Browser, type BrowserContext } from 'playwright'
|
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 { 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'
|
import stealth from "puppeteer-extra-plugin-stealth";
|
||||||
chromium.use(stealth());
|
chromium.use(stealth());
|
||||||
|
|
||||||
let contextPromise: Promise<BrowserContext> | null = null
|
let contextPromise: Promise<BrowserContext> | null = null;
|
||||||
let context: BrowserContext | null = null
|
let context: BrowserContext | null = null;
|
||||||
let refCount = 0
|
let refCount = 0;
|
||||||
let idleCloseTimer: NodeJS.Timeout | null = null
|
let idleCloseTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
const USER_DATA_DIR = process.env.USER_DATA_DIR ?? 'chrome-profile/douyin'
|
const USER_DATA_DIR = process.env.USER_DATA_DIR ?? "chrome-profile/douyin";
|
||||||
|
|
||||||
async function launchContext(): Promise<BrowserContext> {
|
async function launchContext(): Promise<BrowserContext> {
|
||||||
const ctx = await chromium.launchPersistentContext(
|
const ctx = await chromium.launchPersistentContext(USER_DATA_DIR, {
|
||||||
USER_DATA_DIR,
|
headless: process.env.CHROMIUM_HEADLESS === "true",
|
||||||
{
|
|
||||||
headless: process.env.CHROMIUM_HEADLESS === 'true',
|
|
||||||
viewport: {
|
viewport: {
|
||||||
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
||||||
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080)
|
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
)
|
|
||||||
// When the context is closed externally, reset manager state
|
// When the context is closed externally, reset manager state
|
||||||
ctx.on('close', () => {
|
ctx.on("close", () => {
|
||||||
context = null
|
context = null;
|
||||||
contextPromise = null
|
contextPromise = null;
|
||||||
refCount = 0
|
refCount = 0;
|
||||||
if (idleCloseTimer) {
|
if (idleCloseTimer) {
|
||||||
clearTimeout(idleCloseTimer)
|
clearTimeout(idleCloseTimer);
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
return ctx
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acquireBrowserContext(): Promise<BrowserContext> {
|
export async function acquireBrowserContext(): Promise<BrowserContext> {
|
||||||
// Cancel any pending idle close if a new consumer arrives
|
// Cancel any pending idle close if a new consumer arrives
|
||||||
if (idleCloseTimer) {
|
if (idleCloseTimer) {
|
||||||
clearTimeout(idleCloseTimer)
|
clearTimeout(idleCloseTimer);
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context) {
|
if (context) {
|
||||||
refCount += 1
|
refCount += 1;
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contextPromise) {
|
if (!contextPromise) {
|
||||||
contextPromise = launchContext()
|
contextPromise = launchContext();
|
||||||
}
|
}
|
||||||
context = await contextPromise
|
context = await contextPromise;
|
||||||
refCount += 1
|
refCount += 1;
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseBrowserContext(options?: { idleMillis?: number }): Promise<void> {
|
export async function releaseBrowserContext(options?: {
|
||||||
const idleMillis = options?.idleMillis ?? 15_000
|
idleMillis?: number;
|
||||||
refCount = Math.max(0, refCount - 1)
|
}): Promise<void> {
|
||||||
|
const idleMillis = options?.idleMillis ?? 15_000;
|
||||||
|
refCount = Math.max(0, refCount - 1);
|
||||||
|
|
||||||
if (refCount > 0 || !context) return
|
if (refCount > 0 || !context) return;
|
||||||
|
|
||||||
// Delay the close to allow bursty workloads to reuse the context
|
// Delay the close to allow bursty workloads to reuse the context
|
||||||
if (idleCloseTimer) {
|
if (idleCloseTimer) {
|
||||||
clearTimeout(idleCloseTimer)
|
clearTimeout(idleCloseTimer);
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
idleCloseTimer = setTimeout(async () => {
|
idleCloseTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
if (context && refCount === 0) {
|
if (context && refCount === 0) {
|
||||||
await context.close()
|
await context.close();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
context = null
|
context = null;
|
||||||
contextPromise = null
|
contextPromise = null;
|
||||||
idleCloseTimer = null
|
idleCloseTimer = null;
|
||||||
}
|
}
|
||||||
}, idleMillis)
|
}, idleMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Isolated context support for per-scrape independence ---
|
// --- Isolated context support for per-scrape independence ---
|
||||||
|
|
||||||
const isolatedMap = new WeakMap<BrowserContext, Browser>()
|
const isolatedMap = new WeakMap<BrowserContext, Browser>();
|
||||||
|
|
||||||
async function getSharedStorageState(): Promise<any | undefined> {
|
async function getSharedStorageState(): Promise<any | undefined> {
|
||||||
try {
|
try {
|
||||||
const shared = await acquireBrowserContext()
|
const shared = await acquireBrowserContext();
|
||||||
const state = await shared.storageState()
|
const state = await shared.storageState();
|
||||||
// Do not force-close immediately; keep ref-counting behavior
|
// Do not force-close immediately; keep ref-counting behavior
|
||||||
await releaseBrowserContext()
|
await releaseBrowserContext();
|
||||||
return state
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
// If shared context not available, proceed without storageState
|
// If shared context not available, proceed without storageState
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,37 +102,39 @@ async function getSharedStorageState(): Promise<any | undefined> {
|
|||||||
* so you remain logged-in, but isolates network events, cache and listeners.
|
* so you remain logged-in, but isolates network events, cache and listeners.
|
||||||
*/
|
*/
|
||||||
export async function acquireIsolatedContext(): Promise<BrowserContext> {
|
export async function acquireIsolatedContext(): Promise<BrowserContext> {
|
||||||
const storageState = await getSharedStorageState()
|
const storageState = await getSharedStorageState();
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: process.env.CHROMIUM_HEADLESS === 'true'
|
headless: process.env.CHROMIUM_HEADLESS === "true",
|
||||||
})
|
});
|
||||||
const ctx = await browser.newContext({
|
const ctx = await browser.newContext({
|
||||||
storageState,
|
storageState,
|
||||||
viewport: {
|
viewport: {
|
||||||
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
width: Number(process.env.CHROMIUM_VIEWPORT_WIDTH ?? 1280),
|
||||||
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080)
|
height: Number(process.env.CHROMIUM_VIEWPORT_HEIGHT ?? 1080),
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
isolatedMap.set(ctx, browser)
|
isolatedMap.set(ctx, browser);
|
||||||
ctx.on('close', () => {
|
ctx.on("close", () => {
|
||||||
const b = isolatedMap.get(ctx)
|
const b = isolatedMap.get(ctx);
|
||||||
if (b) {
|
if (b) {
|
||||||
b.close().catch(() => {})
|
b.close().catch(() => {});
|
||||||
isolatedMap.delete(ctx)
|
isolatedMap.delete(ctx);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
return ctx
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseIsolatedContext(ctx: BrowserContext | null | undefined): Promise<void> {
|
export async function releaseIsolatedContext(
|
||||||
if (!ctx) return
|
ctx: BrowserContext | null | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx) return;
|
||||||
try {
|
try {
|
||||||
await ctx.close()
|
await ctx.close();
|
||||||
} finally {
|
} finally {
|
||||||
const b = isolatedMap.get(ctx)
|
const b = isolatedMap.get(ctx);
|
||||||
if (b) {
|
if (b) {
|
||||||
await b.close().catch(() => {})
|
await b.close().catch(() => {});
|
||||||
isolatedMap.delete(ctx)
|
isolatedMap.delete(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+241
-108
@@ -1,22 +1,31 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
// src/scrapeDouyin.ts
|
// src/scrapeDouyin.ts
|
||||||
import { BrowserContext, Page, type Response } from 'playwright';
|
import { BrowserContext, Page, type Response } from "playwright";
|
||||||
import { chromium } from 'playwright-extra';
|
import { chromium } from "playwright-extra";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
import { uploadFile, generateUniqueFileName } from "@/lib/minio";
|
||||||
import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils';
|
import { createCamelCompatibleProxy } from "@/app/api/fetcher/utils";
|
||||||
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary, collectResponsesWithinTime } from '@/app/api/fetcher/network';
|
import {
|
||||||
import { pickBestPlayAddr } from '@/app/api/fetcher/media';
|
waitForFirstResponse,
|
||||||
import { handleImagePost } from '@/app/api/fetcher/uploader';
|
waitForResponseWithTimeout,
|
||||||
import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist';
|
safeJson,
|
||||||
import chalk from 'chalk';
|
downloadBinary,
|
||||||
import { acquireIsolatedContext, releaseIsolatedContext } from '@/app/api/fetcher/browser';
|
collectResponsesWithinTime,
|
||||||
import { extractFirstFrame } from '@/app/api/media';
|
} from "@/app/api/fetcher/network";
|
||||||
import { transcriptAweme } from '../stt';
|
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 DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
|
||||||
const COMMENT_PATH = '/aweme/v1/web/comment/list/';
|
const COMMENT_PATH = "/aweme/v1/web/comment/list/";
|
||||||
const POST_PATH = '/aweme/v1/web/aweme/post/'
|
const POST_PATH = "/aweme/v1/web/aweme/post/";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 滚动页面并收集评论
|
* 滚动页面并收集评论
|
||||||
@@ -28,15 +37,20 @@ const POST_PATH = '/aweme/v1/web/aweme/post/'
|
|||||||
async function scrollAndCollectComments(
|
async function scrollAndCollectComments(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
page: Page,
|
page: Page,
|
||||||
durationMs: number = 10_000
|
durationMs: number = 10_000,
|
||||||
): Promise<Response[]> {
|
): Promise<Response[]> {
|
||||||
console.log(chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`));
|
console.log(
|
||||||
|
chalk.blue(`📜 开始滚动页面收集评论(持续 ${durationMs / 1000} 秒)...`),
|
||||||
|
);
|
||||||
|
|
||||||
// 启动评论响应收集器
|
// 启动评论响应收集器
|
||||||
const commentResponsesPromise = collectResponsesWithinTime(
|
const commentResponsesPromise = collectResponsesWithinTime(
|
||||||
context,
|
context,
|
||||||
(r: Response) => r.url().includes(COMMENT_PATH) && r.status() === 200 && r.request().frame()?.page() === page,
|
(r: Response) =>
|
||||||
durationMs
|
r.url().includes(COMMENT_PATH) &&
|
||||||
|
r.status() === 200 &&
|
||||||
|
r.request().frame()?.page() === page,
|
||||||
|
durationMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 在指定时间内持续滚动页面
|
// 在指定时间内持续滚动页面
|
||||||
@@ -46,17 +60,18 @@ async function scrollAndCollectComments(
|
|||||||
const selector = "div[data-e2e='comment-list']";
|
const selector = "div[data-e2e='comment-list']";
|
||||||
|
|
||||||
// 1) 等元素出现并可见
|
// 1) 等元素出现并可见
|
||||||
await page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
|
await page.waitForSelector(selector, { state: "visible", timeout: 5000 });
|
||||||
|
|
||||||
// 2) 确保滚动到可见区域
|
// 2) 确保滚动到可见区域
|
||||||
const list = page.locator(selector);
|
const list = page.locator(selector);
|
||||||
await list.scrollIntoViewIfNeeded();
|
await list.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
// 3) 执行 hover(推荐用 locator 的 hover)
|
// 3) 执行 hover(推荐用 locator 的 hover)
|
||||||
list.hover({ timeout: 5000 }).catch(() => { });
|
list.hover({ timeout: 5000 }).catch(() => {});
|
||||||
while (Date.now() - startTime < durationMs - 500) { // 留 500ms 缓冲
|
while (Date.now() - startTime < durationMs - 500) {
|
||||||
|
// 留 500ms 缓冲
|
||||||
try {
|
try {
|
||||||
list.hover({ timeout: 2000 }).catch(() => { });
|
list.hover({ timeout: 2000 }).catch(() => {});
|
||||||
// 使用 Playwright 的 mouse.wheel 方法滚动
|
// 使用 Playwright 的 mouse.wheel 方法滚动
|
||||||
// 每次滚动一大段距离
|
// 每次滚动一大段距离
|
||||||
// await list.hover();
|
// await list.hover();
|
||||||
@@ -68,57 +83,68 @@ async function scrollAndCollectComments(
|
|||||||
|
|
||||||
// 等待一段时间,让评论加载
|
// 等待一段时间,让评论加载
|
||||||
await page.waitForTimeout(scrollInterval);
|
await page.waitForTimeout(scrollInterval);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`));
|
console.warn(
|
||||||
|
chalk.yellow(` ⚠ 滚动时出现警告: ${(e as Error)?.message}`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待收集器完成
|
// 等待收集器完成
|
||||||
const commentResponses = await commentResponsesPromise;
|
const commentResponses = await commentResponsesPromise;
|
||||||
console.log(chalk.green(`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`));
|
console.log(
|
||||||
|
chalk.green(
|
||||||
|
`✓ 评论收集完成,共收集到 ${commentResponses.length} 个评论响应`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return commentResponses;
|
return commentResponses;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPostMem(context: BrowserContext, page: Page) {
|
async function readPostMem(context: BrowserContext, page: Page) {
|
||||||
const md = await page.evaluate(() => {
|
const md = await page
|
||||||
// @ts-ignore
|
.evaluate(() => {
|
||||||
let data = window.__pace_captured__.find(i => i[1] && i[1].includes(`"awemeId":`))[1]
|
const captured = (window as any).__pace_captured__ as Array<unknown[]>;
|
||||||
return JSON.parse(data.slice(data.indexOf("{")).replaceAll("]\n", ''))
|
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: {} } };
|
// return {aweme: { detail: {} } };
|
||||||
}).catch(() => null);
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
// await new Promise((res) => setTimeout(res, 1000000));
|
// await new Promise((res) => setTimeout(res, 1000000));
|
||||||
|
|
||||||
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
let aweme_mem = md?.aweme?.detail as DouyinImageAweme;
|
||||||
if (!aweme_mem) throw new Error('页面内存数据中未找到作品详情');
|
if (!aweme_mem) throw new Error("页面内存数据中未找到作品详情");
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
aweme_mem.author = aweme_mem.authorInfo
|
aweme_mem.author = aweme_mem.authorInfo;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
aweme_mem.statistics = aweme_mem.stats
|
aweme_mem.statistics = aweme_mem.stats;
|
||||||
|
|
||||||
const comments = md.comment ? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment) : null;
|
const comments = md.comment
|
||||||
|
? createCamelCompatibleProxy<DouyinCommentResponse>(md.comment)
|
||||||
|
: null;
|
||||||
const aweme = createCamelCompatibleProxy(aweme_mem);
|
const aweme = createCamelCompatibleProxy(aweme_mem);
|
||||||
|
|
||||||
return { aweme, comments }
|
return { aweme, comments };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class ScrapeError extends Error {
|
export class ScrapeError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public statusCode: number = 500,
|
public statusCode: number = 500,
|
||||||
public code?: string
|
public code?: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ScrapeError';
|
this.name = "ScrapeError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scrapeDouyin(url: string) {
|
export async function scrapeDouyin(url: string) {
|
||||||
console.log(chalk.blue('🚀 启动共享 Chromium 浏览器...'));
|
console.log(chalk.blue("🚀 启动共享 Chromium 浏览器..."));
|
||||||
let context: BrowserContext | null = await acquireIsolatedContext();
|
let context: BrowserContext | null = await acquireIsolatedContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
console.log(chalk.cyan(`📄 正在访问: ${chalk.underline(url)}`));
|
||||||
@@ -131,9 +157,11 @@ export async function scrapeDouyin(url: string) {
|
|||||||
const captured = (window as any).__pace_captured__;
|
const captured = (window as any).__pace_captured__;
|
||||||
const proxyArr = new Proxy([] as any[], {
|
const proxyArr = new Proxy([] as any[], {
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver) {
|
||||||
if (prop === 'push') {
|
if (prop === "push") {
|
||||||
return (...items: any[]) => {
|
return (...items: any[]) => {
|
||||||
try { captured.push(...items); } catch { }
|
try {
|
||||||
|
captured.push(...items);
|
||||||
|
} catch {}
|
||||||
return Array.prototype.push.apply(target, items);
|
return Array.prototype.push.apply(target, items);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -141,9 +169,10 @@ export async function scrapeDouyin(url: string) {
|
|||||||
},
|
},
|
||||||
set(target, prop, value, receiver) {
|
set(target, prop, value, receiver) {
|
||||||
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
|
// 兼容站点可能直接赋初始数组: self.__pace_f = [a,b]
|
||||||
if (prop === 'length') return Reflect.set(target, prop, value, receiver);
|
if (prop === "length")
|
||||||
return Reflect.set(target, prop, value, receiver);
|
return Reflect.set(target, prop, value, receiver);
|
||||||
}
|
return Reflect.set(target, prop, value, receiver);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
(self as any).__pace_f = proxyArr;
|
(self as any).__pace_f = proxyArr;
|
||||||
@@ -152,38 +181,69 @@ export async function scrapeDouyin(url: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 先注册“先到先得”的监听,再导航,避免漏包
|
// 先注册“先到先得”的监听,再导航,避免漏包
|
||||||
const firstTypePromise = waitForFirstResponse(context, [
|
const firstTypePromise = waitForFirstResponse(
|
||||||
{ key: 'detail', test: (r: Response) => r.url().includes(DETAIL_PATH) && r.status() === 200 && r.request().frame()?.page() === page },
|
context,
|
||||||
{ key: 'post', test: (r: Response) => r.url().includes(POST_PATH) && r.status() === 200 && r.request().frame()?.page() === page },
|
[
|
||||||
], 10_000);
|
{
|
||||||
|
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 });
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
||||||
|
|
||||||
// 查找页面中是否存在 "视频不存在" 的提示
|
// 查找页面中是否存在 "视频不存在" 的提示
|
||||||
const isNotFound = await page.locator('text=视频不存在').count().then(count => count > 0).catch(() => false);
|
const isNotFound = await page
|
||||||
|
.locator("text=视频不存在")
|
||||||
|
.count()
|
||||||
|
.then((count) => count > 0)
|
||||||
|
.catch(() => false);
|
||||||
if (isNotFound) {
|
if (isNotFound) {
|
||||||
console.error(chalk.red('✗ 视频不存在或已被删除'));
|
console.error(chalk.red("✗ 视频不存在或已被删除"));
|
||||||
throw new ScrapeError('视频不存在或已被删除', 404, 'VIDEO_NOT_FOUND');
|
throw new ScrapeError("视频不存在或已被删除", 404, "VIDEO_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待作品类型判定
|
// 等待作品类型判定
|
||||||
const firstType = await firstTypePromise;
|
const firstType = await firstTypePromise;
|
||||||
|
|
||||||
// 尝试从内存读取图文数据(如果是图文作品)
|
// 尝试从内存读取图文数据(如果是图文作品)
|
||||||
let memoryData: { aweme: any; comments: DouyinCommentResponse | null } | null = null;
|
let memoryData: {
|
||||||
|
aweme: any;
|
||||||
|
comments: DouyinCommentResponse | null;
|
||||||
|
} | null = null;
|
||||||
try {
|
try {
|
||||||
memoryData = await readPostMem(context, page);
|
memoryData = await readPostMem(context, page);
|
||||||
console.log(chalk.green('✓ 从内存读取图文数据成功'));
|
console.log(chalk.green("✓ 从内存读取图文数据成功"));
|
||||||
} catch {
|
} catch {
|
||||||
// 内存读取失败,稍后通过网络获取
|
// 内存读取失败,稍后通过网络获取
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firstType && !memoryData) {
|
if (!firstType && !memoryData) {
|
||||||
console.error(chalk.red('✗ 既无法从内存读取数据,也无法从网络获得数据'));
|
console.error(chalk.red("✗ 既无法从内存读取数据,也无法从网络获得数据"));
|
||||||
throw new ScrapeError('无法获取作品数据,可能是网络问题或作品已下架', 404, 'NO_DATA');
|
throw new ScrapeError(
|
||||||
|
"无法获取作品数据,可能是网络问题或作品已下架",
|
||||||
|
404,
|
||||||
|
"NO_DATA",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(chalk.cyan(`📡 检测到作品类型: ${chalk.bold(firstType?.key === 'post' || memoryData ? '图文' : '视频')}`));
|
console.log(
|
||||||
|
chalk.cyan(
|
||||||
|
`📡 检测到作品类型: ${chalk.bold(firstType?.key === "post" || memoryData ? "图文" : "视频")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let allComments: DouyinComment[] = [];
|
let allComments: DouyinComment[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -198,27 +258,38 @@ export async function scrapeDouyin(url: string) {
|
|||||||
allComments.push(...commentData.comments);
|
allComments.push(...commentData.comments);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`));
|
console.warn(
|
||||||
|
chalk.yellow(`⚠ 解析评论响应失败: ${(e as Error)?.message}`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`));
|
console.warn(
|
||||||
|
chalk.yellow(`⚠ 评论收集失败: ${(error as Error)?.message}`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 去重评论(根据 cid)
|
// 去重评论(根据 cid)
|
||||||
const uniqueComments = Array.from(
|
const uniqueComments = Array.from(
|
||||||
new Map(allComments.map(c => [c.cid, c])).values()
|
new Map(allComments.map((c) => [c.cid, c])).values(),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(chalk.green(`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length})`));
|
console.log(
|
||||||
|
chalk.green(
|
||||||
|
`✓ 共收集到 ${uniqueComments.length} 条独立评论(去重前: ${allComments.length})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// 如果从内存读取到了评论,合并进来作为兜底
|
// 如果从内存读取到了评论,合并进来作为兜底
|
||||||
let comments: DouyinCommentResponse;
|
let comments: DouyinCommentResponse;
|
||||||
if (memoryData?.comments?.comments?.length) {
|
if (memoryData?.comments?.comments?.length) {
|
||||||
console.log(chalk.blue(`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`));
|
console.log(
|
||||||
|
chalk.blue(
|
||||||
|
`📝 合并内存中的 ${memoryData.comments.comments.length} 条评论`,
|
||||||
|
),
|
||||||
|
);
|
||||||
const memComments = memoryData.comments.comments;
|
const memComments = memoryData.comments.comments;
|
||||||
const mergedMap = new Map(uniqueComments.map(c => [c.cid, c]));
|
const mergedMap = new Map(uniqueComments.map((c) => [c.cid, c]));
|
||||||
for (const c of memComments) {
|
for (const c of memComments) {
|
||||||
if (!mergedMap.has(c.cid)) {
|
if (!mergedMap.has(c.cid)) {
|
||||||
mergedMap.set(c.cid, c);
|
mergedMap.set(c.cid, c);
|
||||||
@@ -227,14 +298,14 @@ export async function scrapeDouyin(url: string) {
|
|||||||
comments = {
|
comments = {
|
||||||
comments: Array.from(mergedMap.values()),
|
comments: Array.from(mergedMap.values()),
|
||||||
total: mergedMap.size,
|
total: mergedMap.size,
|
||||||
status_code: 0
|
status_code: 0,
|
||||||
};
|
};
|
||||||
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
|
console.log(chalk.green(`✓ 合并后共 ${comments.comments.length} 条评论`));
|
||||||
} else {
|
} else {
|
||||||
comments = {
|
comments = {
|
||||||
comments: uniqueComments,
|
comments: uniqueComments,
|
||||||
total: uniqueComments.length,
|
total: uniqueComments.length,
|
||||||
status_code: 0
|
status_code: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,75 +315,132 @@ export async function scrapeDouyin(url: string) {
|
|||||||
const aweme = memoryData.aweme;
|
const aweme = memoryData.aweme;
|
||||||
const uploads = await handleImagePost(context, aweme);
|
const uploads = await handleImagePost(context, aweme);
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
const saved = await saveImagePostToDB(context, aweme, comments, uploads); // 传递完整 JSON
|
||||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
console.log(chalk.green.bold("✓ 图文作品保存成功"));
|
||||||
return { type: "image", ...saved };
|
return { type: "image", ...saved };
|
||||||
} else if (firstType?.key === 'post') {
|
} else if (firstType?.key === "post") {
|
||||||
// 图文作品(网络)
|
// 图文作品(网络)
|
||||||
const postJson = await safeJson<DouyinPostListResponse>(firstType.response);
|
const postJson = await safeJson<DouyinPostListResponse>(
|
||||||
if (!postJson?.aweme_list?.length) throw new ScrapeError('图文作品响应为空', 404, 'EMPTY_POST_RESPONSE');
|
firstType.response,
|
||||||
|
);
|
||||||
|
if (!postJson?.aweme_list?.length)
|
||||||
|
throw new ScrapeError("图文作品响应为空", 404, "EMPTY_POST_RESPONSE");
|
||||||
|
|
||||||
const currentURL = page.url();
|
const currentURL = page.url();
|
||||||
const target_aweme_id = currentURL.split('/').at(-1);
|
const target_aweme_id = currentURL.split("/").at(-1);
|
||||||
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
const awemeList = postJson.aweme_list as unknown as DouyinImageAweme[];
|
||||||
let aweme = awemeList.find((pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id);
|
let aweme = awemeList.find(
|
||||||
|
(pt: DouyinImageAweme) => pt.aweme_id === target_aweme_id,
|
||||||
|
);
|
||||||
if (!aweme) {
|
if (!aweme) {
|
||||||
throw new ScrapeError('无法找到目标作品,可能已被删除', 404, 'POST_NOT_FOUND');
|
throw new ScrapeError(
|
||||||
|
"无法找到目标作品,可能已被删除",
|
||||||
|
404,
|
||||||
|
"POST_NOT_FOUND",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploads = await handleImagePost(context, aweme);
|
const uploads = await handleImagePost(context, aweme);
|
||||||
const saved = await saveImagePostToDB(context, aweme, comments, uploads, postJson); // 传递完整 JSON
|
const saved = await saveImagePostToDB(
|
||||||
console.log(chalk.green.bold('✓ 图文作品保存成功'));
|
context,
|
||||||
|
aweme,
|
||||||
|
comments,
|
||||||
|
uploads,
|
||||||
|
postJson,
|
||||||
|
); // 传递完整 JSON
|
||||||
|
console.log(chalk.green.bold("✓ 图文作品保存成功"));
|
||||||
return { type: "image", ...saved };
|
return { type: "image", ...saved };
|
||||||
} else if (firstType?.key === 'detail') {
|
} else if (firstType?.key === "detail") {
|
||||||
// 视频作品
|
// 视频作品
|
||||||
const detail = (await safeJson<DouyinVideoDetailResponse>(firstType.response))!;
|
const detail = (await safeJson<DouyinVideoDetailResponse>(
|
||||||
|
firstType.response,
|
||||||
|
))!;
|
||||||
|
|
||||||
// 找到比特率最高的 url
|
// 找到比特率最高的 url
|
||||||
const bestPlayAddr = pickBestPlayAddr(
|
const bestPlayAddr = pickBestPlayAddr(
|
||||||
detail?.aweme_detail?.video.bit_rate
|
detail?.aweme_detail?.video.bit_rate,
|
||||||
);
|
);
|
||||||
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
const bestVUrl = bestPlayAddr?.url_list?.[0];
|
||||||
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
const fps = bestPlayAddr?.FPS ?? null; // 提取 FPS
|
||||||
|
|
||||||
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
console.log(chalk.cyan(`📹 最佳视频 URL: ${chalk.dim(bestVUrl)}`));
|
||||||
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || 'N/A')} FPS`));
|
console.log(chalk.cyan(`🎞️ 视频帧率: ${chalk.bold(fps || "N/A")} FPS`));
|
||||||
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
if (bestPlayAddr?.width && bestPlayAddr?.height) {
|
||||||
console.log(chalk.cyan(`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`));
|
console.log(
|
||||||
|
chalk.cyan(
|
||||||
|
`📐 视频分辨率: ${chalk.bold(`${bestPlayAddr.width}x${bestPlayAddr.height}`)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载视频并上传至 MinIO,获取外链
|
// 下载视频并上传至 MinIO,获取外链
|
||||||
let uploadedUrl: string | undefined;
|
let uploadedUrl: string | undefined;
|
||||||
let coverUrl: string | undefined;
|
let coverUrl: string | undefined;
|
||||||
if (bestVUrl && detail?.aweme_detail) {
|
if (bestVUrl && detail?.aweme_detail) {
|
||||||
console.log(chalk.blue('⬇️ 正在下载视频...'));
|
console.log(chalk.blue("⬇️ 正在下载视频..."));
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, bestVUrl);
|
const { buffer, contentType, ext } = await downloadBinary(
|
||||||
|
context,
|
||||||
|
bestVUrl,
|
||||||
|
);
|
||||||
const awemeId = detail.aweme_detail.aweme_id;
|
const awemeId = detail.aweme_detail.aweme_id;
|
||||||
const fileName = generateUniqueFileName(`${awemeId}.${ext}`, 'douyin/videos');
|
const fileName = generateUniqueFileName(
|
||||||
|
`${awemeId}.${ext}`,
|
||||||
|
"douyin/videos",
|
||||||
|
);
|
||||||
|
|
||||||
console.log(chalk.blue('⬆️ 正在上传视频到 MinIO...'));
|
console.log(chalk.blue("⬆️ 正在上传视频到 MinIO..."));
|
||||||
uploadedUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
uploadedUrl = await uploadFile(buffer, fileName, {
|
||||||
console.log(chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`));
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
chalk.green(`✓ 视频上传成功: ${chalk.underline(uploadedUrl)}`),
|
||||||
|
);
|
||||||
|
|
||||||
// 提取首帧作为封面并上传
|
// 提取首帧作为封面并上传
|
||||||
try {
|
try {
|
||||||
console.log(chalk.blue('🖼️ 正在提取视频封面...'));
|
console.log(chalk.blue("🖼️ 正在提取视频封面..."));
|
||||||
const cover = await extractFirstFrame(buffer);
|
const cover = await extractFirstFrame(buffer);
|
||||||
if (cover) {
|
if (cover) {
|
||||||
const coverName = generateUniqueFileName(`${awemeId}.jpg`, 'douyin/covers');
|
const coverName = generateUniqueFileName(
|
||||||
coverUrl = await uploadFile(cover.buffer, coverName, { 'Content-Type': cover.contentType });
|
`${awemeId}.jpg`,
|
||||||
console.log(chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`));
|
"douyin/covers",
|
||||||
|
);
|
||||||
|
coverUrl = await uploadFile(cover.buffer, coverName, {
|
||||||
|
"Content-Type": cover.contentType,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
chalk.green(`✓ 封面上传成功: ${chalk.underline(coverUrl)}`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(chalk.yellow(`⚠ 提取封面失败,跳过: ${(e as Error)?.message || 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);
|
const saved = await saveToDB(
|
||||||
console.log(chalk.green.bold('✓ 视频作品保存成功'));
|
context,
|
||||||
transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
|
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 };
|
return { type: "video", ...saved };
|
||||||
} else {
|
} else {
|
||||||
throw new ScrapeError('无法判定作品类型,接口响应异常', 500, 'UNKNOWN_TYPE');
|
throw new ScrapeError(
|
||||||
|
"无法判定作品类型,接口响应异常",
|
||||||
|
500,
|
||||||
|
"UNKNOWN_TYPE",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 如果是我们自定义的错误,直接抛出
|
// 如果是我们自定义的错误,直接抛出
|
||||||
@@ -325,25 +453,30 @@ export async function scrapeDouyin(url: string) {
|
|||||||
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
console.error(chalk.red(`✗ 爬取失败: ${errMsg}`));
|
||||||
|
|
||||||
// 根据错误类型返回不同的状态码
|
// 根据错误类型返回不同的状态码
|
||||||
if (errMsg.includes('timeout') || errMsg.includes('超时')) {
|
if (errMsg.includes("timeout") || errMsg.includes("超时")) {
|
||||||
throw new ScrapeError('请求超时,请稍后重试', 408, 'TIMEOUT');
|
throw new ScrapeError("请求超时,请稍后重试", 408, "TIMEOUT");
|
||||||
}
|
}
|
||||||
if (errMsg.includes('页面内存数据中未找到作品详情')) {
|
if (errMsg.includes("页面内存数据中未找到作品详情")) {
|
||||||
throw new ScrapeError('作品数据加载失败', 404, 'DATA_NOT_LOADED');
|
throw new ScrapeError("作品数据加载失败", 404, "DATA_NOT_LOADED");
|
||||||
}
|
}
|
||||||
if (errMsg.includes('net::')) {
|
if (errMsg.includes("net::")) {
|
||||||
throw new ScrapeError('网络连接失败', 503, 'NETWORK_ERROR');
|
throw new ScrapeError("网络连接失败", 503, "NETWORK_ERROR");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认服务器错误
|
// 默认服务器错误
|
||||||
throw new ScrapeError(errMsg || '爬取过程中发生未知错误', 500, 'UNKNOWN_ERROR');
|
throw new ScrapeError(
|
||||||
|
errMsg || "爬取过程中发生未知错误",
|
||||||
|
500,
|
||||||
|
"UNKNOWN_ERROR",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
console.log(chalk.gray('🧹 清理资源...'));
|
console.log(chalk.gray("🧹 清理资源..."));
|
||||||
try { await page.close({ runBeforeUnload: true }); } catch { }
|
try {
|
||||||
|
await page.close({ runBeforeUnload: true });
|
||||||
|
} catch {}
|
||||||
// 关闭本次任务的隔离上下文与浏览器
|
// 关闭本次任务的隔离上下文与浏览器
|
||||||
await releaseIsolatedContext(context);
|
await releaseIsolatedContext(context);
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
console.log(chalk.gray('✓ 资源清理完成'));
|
console.log(chalk.gray("✓ 资源清理完成"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import { execFile } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
|
import { execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
|
export function pickBestPlayAddr(variants: PlayVariant[] | undefined | null) {
|
||||||
if (!variants?.length) return null;
|
if (!variants?.length) return null;
|
||||||
|
|||||||
+19
-18
@@ -1,10 +1,10 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import type { BrowserContext, Response } from 'playwright';
|
import type { BrowserContext, Response } from "playwright";
|
||||||
|
|
||||||
export async function safeJson<T>(res: Response): Promise<T | null> {
|
export async function safeJson<T>(res: Response): Promise<T | null> {
|
||||||
const ctype = res.headers()['content-type'] || '';
|
const ctype = res.headers()["content-type"] || "";
|
||||||
if (ctype.includes('application/json')) {
|
if (ctype.includes("application/json")) {
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
@@ -24,10 +24,10 @@ export async function downloadBinary(
|
|||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; ext: string }> {
|
||||||
console.log('下载:', url);
|
console.log("下载:", url);
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
referer: 'https://www.douyin.com/',
|
referer: "https://www.douyin.com/",
|
||||||
} as Record<string, string>;
|
} as Record<string, string>;
|
||||||
|
|
||||||
const res = await context.request.get(url, {
|
const res = await context.request.get(url, {
|
||||||
@@ -42,8 +42,9 @@ export async function downloadBinary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await res.body();
|
const buffer = await res.body();
|
||||||
const contentType = res.headers()['content-type'] || 'application/octet-stream';
|
const contentType =
|
||||||
const ext = (contentType.split('/')[1] || 'bin').split(';')[0] || 'bin';
|
res.headers()["content-type"] || "application/octet-stream";
|
||||||
|
const ext = (contentType.split("/")[1] || "bin").split(";")[0] || "bin";
|
||||||
return { buffer, contentType, ext };
|
return { buffer, contentType, ext };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ export async function downloadBinary(
|
|||||||
export function waitForFirstResponse(
|
export function waitForFirstResponse(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
candidates: { key: string; test: (r: Response) => boolean }[],
|
candidates: { key: string; test: (r: Response) => boolean }[],
|
||||||
timeoutMs = 20_000
|
timeoutMs = 20_000,
|
||||||
): Promise<{ key: string; response: Response } | null> {
|
): Promise<{ key: string; response: Response } | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
@@ -77,11 +78,11 @@ export function waitForFirstResponse(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
context.off('response', handler);
|
context.off("response", handler);
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
|
|
||||||
context.on('response', handler);
|
context.on("response", handler);
|
||||||
if (timeoutMs > 0) {
|
if (timeoutMs > 0) {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
@@ -101,7 +102,7 @@ export function waitForFirstResponse(
|
|||||||
export function collectResponsesWithinTime(
|
export function collectResponsesWithinTime(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
predicate: (r: Response) => boolean,
|
predicate: (r: Response) => boolean,
|
||||||
durationMs: number
|
durationMs: number,
|
||||||
): Promise<Response[]> {
|
): Promise<Response[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const collected: Response[] = [];
|
const collected: Response[] = [];
|
||||||
@@ -124,11 +125,11 @@ export function collectResponsesWithinTime(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
context.off('response', handler);
|
context.off("response", handler);
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
|
|
||||||
context.on('response', handler);
|
context.on("response", handler);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve(collected);
|
resolve(collected);
|
||||||
@@ -142,7 +143,7 @@ export function collectResponsesWithinTime(
|
|||||||
export function waitForResponseWithTimeout(
|
export function waitForResponseWithTimeout(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
predicate: (r: Response) => boolean,
|
predicate: (r: Response) => boolean,
|
||||||
timeoutMs = 5_000
|
timeoutMs = 5_000,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return new Promise<Response>((resolve, reject) => {
|
return new Promise<Response>((resolve, reject) => {
|
||||||
let timer: NodeJS.Timeout | undefined;
|
let timer: NodeJS.Timeout | undefined;
|
||||||
@@ -159,15 +160,15 @@ export function waitForResponseWithTimeout(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
context.off('response', handler);
|
context.off("response", handler);
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
|
|
||||||
context.on('response', handler);
|
context.on("response", handler);
|
||||||
if (timeoutMs > 0) {
|
if (timeoutMs > 0) {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(new Error('timeout'));
|
reject(new Error("timeout"));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+119
-51
@@ -1,7 +1,7 @@
|
|||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from "playwright";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from "@/lib/prisma";
|
||||||
import { uploadAvatarFromUrl, uploadImageFromUrl } from './uploader';
|
import { uploadAvatarFromUrl, uploadImageFromUrl } from "./uploader";
|
||||||
import { firstUrl } from './utils';
|
import { firstUrl } from "./utils";
|
||||||
|
|
||||||
export async function saveToDB(
|
export async function saveToDB(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
@@ -11,14 +11,18 @@ export async function saveToDB(
|
|||||||
width?: number,
|
width?: number,
|
||||||
height?: number,
|
height?: number,
|
||||||
coverUrl?: string,
|
coverUrl?: string,
|
||||||
fps?: number
|
fps?: number,
|
||||||
) {
|
) {
|
||||||
if (!detailResp?.aweme_detail) throw new Error('视频详情为空');
|
if (!detailResp?.aweme_detail) throw new Error("视频详情为空");
|
||||||
const d = detailResp.aweme_detail;
|
const d = detailResp.aweme_detail;
|
||||||
|
|
||||||
// 1) Upsert Author
|
// 1) Upsert Author
|
||||||
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
const authorAvatarSrc = firstUrl(d.author.avatar_thumb?.url_list);
|
||||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${d.author.sec_uid}`);
|
const authorAvatarUploaded = await uploadAvatarFromUrl(
|
||||||
|
context,
|
||||||
|
authorAvatarSrc,
|
||||||
|
`authors/${d.author.sec_uid}`,
|
||||||
|
);
|
||||||
const author = await prisma.author.upsert({
|
const author = await prisma.author.upsert({
|
||||||
where: { sec_uid: d.author.sec_uid },
|
where: { sec_uid: d.author.sec_uid },
|
||||||
create: {
|
create: {
|
||||||
@@ -59,8 +63,8 @@ export async function saveToDB(
|
|||||||
share_count: BigInt(d.statistics?.share_count || 0),
|
share_count: BigInt(d.statistics?.share_count || 0),
|
||||||
collect_count: BigInt(d.statistics?.collect_count || 0),
|
collect_count: BigInt(d.statistics?.collect_count || 0),
|
||||||
authorId: author.sec_uid,
|
authorId: author.sec_uid,
|
||||||
tags: (d.tags?.map(t => t.tag_name) ?? []),
|
tags: d.tags?.map((t) => t.tag_name) ?? [],
|
||||||
video_url: videoUrl ?? '',
|
video_url: videoUrl ?? "",
|
||||||
width: width ?? null,
|
width: width ?? null,
|
||||||
height: height ?? null,
|
height: height ?? null,
|
||||||
cover_url: coverUrl ?? null,
|
cover_url: coverUrl ?? null,
|
||||||
@@ -90,20 +94,25 @@ export async function saveToDB(
|
|||||||
// 3) Upsert Comments + CommentUser
|
// 3) Upsert Comments + CommentUser
|
||||||
const comments = commentResp?.comments ?? [];
|
const comments = commentResp?.comments ?? [];
|
||||||
for (const c of comments) {
|
for (const c of comments) {
|
||||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
const origAvatar: string | null =
|
||||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
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 finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||||
const finalAvatarKey = finalAvatar ?? '';
|
const finalAvatarKey = finalAvatar ?? "";
|
||||||
const cu = await prisma.commentUser.upsert({
|
const cu = await prisma.commentUser.upsert({
|
||||||
where: {
|
where: {
|
||||||
nickname_avatar_url: {
|
nickname_avatar_url: {
|
||||||
nickname: c.user?.nickname || '未知用户',
|
nickname: c.user?.nickname || "未知用户",
|
||||||
avatar_url: finalAvatarKey,
|
avatar_url: finalAvatarKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
nickname: c.user?.nickname || '未知用户',
|
nickname: c.user?.nickname || "未知用户",
|
||||||
avatar_url: finalAvatar ?? null,
|
avatar_url: finalAvatar ?? null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -132,17 +141,30 @@ export async function saveToDB(
|
|||||||
|
|
||||||
// 处理评论贴纸/配图上传与入库
|
// 处理评论贴纸/配图上传与入库
|
||||||
try {
|
try {
|
||||||
const sources: { url?: string | null; width?: number; height?: number }[] = [];
|
const sources: {
|
||||||
|
url?: string | null;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}[] = [];
|
||||||
// 贴纸(当作第一张)
|
// 贴纸(当作第一张)
|
||||||
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
||||||
if (stickerUrl) {
|
if (stickerUrl) {
|
||||||
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
|
sources.push({
|
||||||
|
url: stickerUrl,
|
||||||
|
width: c.sticker?.animate_url?.width,
|
||||||
|
height: c.sticker?.animate_url?.height,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// 配图列表
|
// 配图列表
|
||||||
const imgs = c.image_list || [];
|
const imgs = c.image_list || [];
|
||||||
for (const it of imgs) {
|
for (const it of imgs) {
|
||||||
const u = firstUrl(it?.origin_url?.url_list);
|
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 });
|
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++) {
|
for (let i = 0; i < sources.length; i++) {
|
||||||
@@ -159,36 +181,53 @@ export async function saveToDB(
|
|||||||
commentId: savedComment.cid,
|
commentId: savedComment.cid,
|
||||||
order: i,
|
order: i,
|
||||||
url: uploaded,
|
url: uploaded,
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
url: uploaded,
|
url: uploaded,
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[comment-images] 保存失败:', (e as Error)?.message || e);
|
console.warn("[comment-images] 保存失败:", (e as Error)?.message || e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { aweme_id: video.aweme_id, author_sec_uid: author.sec_uid, comment_count: comments.length };
|
return {
|
||||||
|
aweme_id: video.aweme_id,
|
||||||
|
author_sec_uid: author.sec_uid,
|
||||||
|
comment_count: comments.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveImagePostToDB(
|
export async function saveImagePostToDB(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
aweme: DouyinImageAweme,
|
aweme: DouyinImageAweme,
|
||||||
commentResp: DouyinCommentResponse,
|
commentResp: DouyinCommentResponse,
|
||||||
uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string },
|
uploads: {
|
||||||
rawJson?: any
|
images: {
|
||||||
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
video?: string;
|
||||||
|
duration?: number;
|
||||||
|
}[];
|
||||||
|
musicUrl?: string;
|
||||||
|
},
|
||||||
|
rawJson?: any,
|
||||||
) {
|
) {
|
||||||
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
|
if (!aweme?.author?.sec_uid) throw new Error("作者 sec_uid 缺失");
|
||||||
|
|
||||||
// Upsert Author(与视频一致)
|
// Upsert Author(与视频一致)
|
||||||
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
const authorAvatarSrc = firstUrl(aweme.author.avatar_thumb?.url_list);
|
||||||
const authorAvatarUploaded = await uploadAvatarFromUrl(context, authorAvatarSrc, `authors/${aweme.author.sec_uid}`);
|
const authorAvatarUploaded = await uploadAvatarFromUrl(
|
||||||
|
context,
|
||||||
|
authorAvatarSrc,
|
||||||
|
`authors/${aweme.author.sec_uid}`,
|
||||||
|
);
|
||||||
const author = await prisma.author.upsert({
|
const author = await prisma.author.upsert({
|
||||||
where: { sec_uid: aweme.author.sec_uid },
|
where: { sec_uid: aweme.author.sec_uid },
|
||||||
create: {
|
create: {
|
||||||
@@ -221,13 +260,13 @@ export async function saveImagePostToDB(
|
|||||||
aweme_id: aweme.aweme_id,
|
aweme_id: aweme.aweme_id,
|
||||||
desc: aweme.desc,
|
desc: aweme.desc,
|
||||||
created_at: new Date((aweme.create_time || 0) * 1000),
|
created_at: new Date((aweme.create_time || 0) * 1000),
|
||||||
share_url: aweme.share_url || '',
|
share_url: aweme.share_url || "",
|
||||||
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
digg_count: BigInt(aweme.statistics?.digg_count || 0),
|
||||||
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
comment_count: BigInt(aweme.statistics?.comment_count || 0),
|
||||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||||
authorId: author.sec_uid,
|
authorId: author.sec_uid,
|
||||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
|
||||||
music_url: uploads.musicUrl ?? null,
|
music_url: uploads.musicUrl ?? null,
|
||||||
raw_json: rawJson ?? null, // 保存完整接口 JSON
|
raw_json: rawJson ?? null, // 保存完整接口 JSON
|
||||||
},
|
},
|
||||||
@@ -240,7 +279,7 @@ export async function saveImagePostToDB(
|
|||||||
share_count: BigInt(aweme.statistics?.share_count || 0),
|
share_count: BigInt(aweme.statistics?.share_count || 0),
|
||||||
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
collect_count: BigInt(aweme.statistics?.collect_count || 0),
|
||||||
authorId: author.sec_uid,
|
authorId: author.sec_uid,
|
||||||
tags: (aweme.video_tag?.map(t => t.tag_name) ?? []),
|
tags: aweme.video_tag?.map((t) => t.tag_name) ?? [],
|
||||||
music_url: uploads.musicUrl ?? undefined,
|
music_url: uploads.musicUrl ?? undefined,
|
||||||
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
|
raw_json: rawJson ?? undefined, // 更新完整接口 JSON
|
||||||
},
|
},
|
||||||
@@ -248,22 +287,28 @@ export async function saveImagePostToDB(
|
|||||||
|
|
||||||
// Upsert ImageFiles(按顺序)
|
// Upsert ImageFiles(按顺序)
|
||||||
for (let i = 0; i < uploads.images.length; i++) {
|
for (let i = 0; i < uploads.images.length; i++) {
|
||||||
const { url, width, height, video } = uploads.images[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({
|
await prisma.imageFile.upsert({
|
||||||
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
where: { postId_order: { postId: imagePost.aweme_id, order: i } },
|
||||||
create: {
|
create: {
|
||||||
postId: imagePost.aweme_id,
|
postId: imagePost.aweme_id,
|
||||||
order: i,
|
order: i,
|
||||||
url,
|
url,
|
||||||
width: typeof width === 'number' ? width : null,
|
width: typeof width === "number" ? width : null,
|
||||||
height: typeof height === 'number' ? height : null,
|
height: typeof height === "number" ? height : null,
|
||||||
animated: video || null,
|
animated: video || null,
|
||||||
|
duration: durationMs,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
url,
|
url,
|
||||||
width: typeof width === 'number' ? width : null,
|
width: typeof width === "number" ? width : null,
|
||||||
height: typeof height === 'number' ? height : null,
|
height: typeof height === "number" ? height : null,
|
||||||
animated: video || null,
|
animated: video || null,
|
||||||
|
duration: durationMs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -271,20 +316,25 @@ export async function saveImagePostToDB(
|
|||||||
// 评论入库:关联到 ImagePost
|
// 评论入库:关联到 ImagePost
|
||||||
const comments = commentResp?.comments ?? [];
|
const comments = commentResp?.comments ?? [];
|
||||||
for (const c of comments) {
|
for (const c of comments) {
|
||||||
const origAvatar: string | null = firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
const origAvatar: string | null =
|
||||||
const nameHint = `comment-users/${(c.user?.nickname || 'unknown').replace(/\s+/g, '_')}-${c.cid}`;
|
firstUrl(c.user?.avatar_thumb?.url_list) ?? null;
|
||||||
const uploadedAvatar = await uploadAvatarFromUrl(context, origAvatar ?? undefined, nameHint);
|
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 finalAvatar = uploadedAvatar ?? origAvatar; // string | null
|
||||||
const finalAvatarKey = finalAvatar ?? '';
|
const finalAvatarKey = finalAvatar ?? "";
|
||||||
const cu = await prisma.commentUser.upsert({
|
const cu = await prisma.commentUser.upsert({
|
||||||
where: {
|
where: {
|
||||||
nickname_avatar_url: {
|
nickname_avatar_url: {
|
||||||
nickname: c.user?.nickname || '未知用户',
|
nickname: c.user?.nickname || "未知用户",
|
||||||
avatar_url: finalAvatarKey,
|
avatar_url: finalAvatarKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
nickname: c.user?.nickname || '未知用户',
|
nickname: c.user?.nickname || "未知用户",
|
||||||
avatar_url: finalAvatar ?? null,
|
avatar_url: finalAvatar ?? null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -313,17 +363,30 @@ export async function saveImagePostToDB(
|
|||||||
|
|
||||||
// 处理评论贴纸/配图上传与入库
|
// 处理评论贴纸/配图上传与入库
|
||||||
try {
|
try {
|
||||||
const sources: { url?: string | null; width?: number; height?: number }[] = [];
|
const sources: {
|
||||||
|
url?: string | null;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}[] = [];
|
||||||
// 贴纸(当作第一张)
|
// 贴纸(当作第一张)
|
||||||
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
const stickerUrl = firstUrl(c.sticker?.animate_url?.url_list);
|
||||||
if (stickerUrl) {
|
if (stickerUrl) {
|
||||||
sources.push({ url: stickerUrl, width: c.sticker?.animate_url?.width, height: c.sticker?.animate_url?.height });
|
sources.push({
|
||||||
|
url: stickerUrl,
|
||||||
|
width: c.sticker?.animate_url?.width,
|
||||||
|
height: c.sticker?.animate_url?.height,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// 配图列表
|
// 配图列表
|
||||||
const imgs = c.image_list || [];
|
const imgs = c.image_list || [];
|
||||||
for (const it of imgs) {
|
for (const it of imgs) {
|
||||||
const u = firstUrl(it?.origin_url?.url_list);
|
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 });
|
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++) {
|
for (let i = 0; i < sources.length; i++) {
|
||||||
@@ -340,20 +403,25 @@ export async function saveImagePostToDB(
|
|||||||
commentId: savedComment.cid,
|
commentId: savedComment.cid,
|
||||||
order: i,
|
order: i,
|
||||||
url: uploaded,
|
url: uploaded,
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
url: uploaded,
|
url: uploaded,
|
||||||
width: typeof s.width === 'number' ? s.width : null,
|
width: typeof s.width === "number" ? s.width : null,
|
||||||
height: typeof s.height === 'number' ? s.height : null,
|
height: typeof s.height === "number" ? s.height : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[comment-images] 保存失败:', (e as Error)?.message || 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 };
|
return {
|
||||||
|
aweme_id: imagePost.aweme_id,
|
||||||
|
author_sec_uid: author.sec_uid,
|
||||||
|
image_count: uploads.images.length,
|
||||||
|
comment_count: comments.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-15
@@ -1,17 +1,17 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { scrapeDouyin, ScrapeError } from '.';
|
import { scrapeDouyin, ScrapeError } from ".";
|
||||||
|
|
||||||
async function handleDouyinScrape(req: NextRequest) {
|
async function handleDouyinScrape(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const videoUrl = searchParams.get('url');
|
const videoUrl = searchParams.get("url");
|
||||||
|
|
||||||
if (!videoUrl) {
|
if (!videoUrl) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '缺少视频URL', code: 'MISSING_URL' },
|
{ error: "缺少视频URL", code: "MISSING_URL" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ async function handleDouyinScrape(req: NextRequest) {
|
|||||||
const result = await scrapeDouyin(videoUrl);
|
const result = await scrapeDouyin(videoUrl);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result
|
data: result,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 处理自定义的 ScrapeError
|
// 处理自定义的 ScrapeError
|
||||||
@@ -29,23 +29,23 @@ async function handleDouyinScrape(req: NextRequest) {
|
|||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
code: error.code
|
code: error.code,
|
||||||
},
|
},
|
||||||
{ status: error.statusCode }
|
{ status: error.statusCode },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理未知错误
|
// 处理未知错误
|
||||||
console.error('未捕获的错误:', error);
|
console.error("未捕获的错误:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: '服务器内部错误',
|
error: "服务器内部错误",
|
||||||
code: 'INTERNAL_ERROR'
|
code: "INTERNAL_ERROR",
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handleDouyinScrape
|
export const GET = handleDouyinScrape;
|
||||||
|
|||||||
Vendored
+8
-8
@@ -17,17 +17,17 @@ interface DouyinComment {
|
|||||||
animate_url: {
|
animate_url: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
url_list: string[]
|
url_list: string[];
|
||||||
}
|
};
|
||||||
},
|
};
|
||||||
|
|
||||||
image_list?: {
|
image_list?: {
|
||||||
origin_url:{
|
origin_url: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
url_list: string[]
|
url_list: string[];
|
||||||
}
|
};
|
||||||
}[]
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户信息(精简版) */
|
/** 用户信息(精简版) */
|
||||||
@@ -143,7 +143,7 @@ interface DouyinImageInfo {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
video: {
|
video: {
|
||||||
play_addr: { src: string }[]
|
play_addr: { src: string }[];
|
||||||
} | null; // 如果是动图,会有 video 信息
|
} | null; // 如果是动图,会有 video 信息
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-32
@@ -1,10 +1,10 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from "playwright";
|
||||||
import { uploadFile, generateUniqueFileName } from '@/lib/minio';
|
import { uploadFile, generateUniqueFileName } from "@/lib/minio";
|
||||||
import { downloadBinary } from './network';
|
import { downloadBinary } from "./network";
|
||||||
import { pickFirstUrl } from './utils';
|
import { pickFirstUrl } from "./utils";
|
||||||
import { getVideoDuration } from '@/app/api/media';
|
import { getVideoDuration } from "@/app/api/media";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
* 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。
|
||||||
@@ -17,13 +17,18 @@ export async function uploadAvatarFromUrl(
|
|||||||
if (!srcUrl) return undefined;
|
if (!srcUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||||
const safeExt = ext || 'jpg';
|
const safeExt = ext || "jpg";
|
||||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
const baseName = nameHint ? `${nameHint}.${safeExt}` : `avatar.${safeExt}`;
|
||||||
const fileName = generateUniqueFileName(baseName, 'douyin/avatars');
|
const fileName = generateUniqueFileName(baseName, "douyin/avatars");
|
||||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
const uploaded = await uploadFile(buffer, fileName, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
return uploaded;
|
return uploaded;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[avatar] 上传失败,使用原始链接:', (e as Error)?.message || e);
|
console.warn(
|
||||||
|
"[avatar] 上传失败,使用原始链接:",
|
||||||
|
(e as Error)?.message || e,
|
||||||
|
);
|
||||||
return srcUrl || undefined;
|
return srcUrl || undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,13 +44,18 @@ export async function uploadImageFromUrl(
|
|||||||
if (!srcUrl) return undefined;
|
if (!srcUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
const { buffer, contentType, ext } = await downloadBinary(context, srcUrl);
|
||||||
const safeExt = ext || 'jpg';
|
const safeExt = ext || "jpg";
|
||||||
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
|
const baseName = nameHint ? `${nameHint}.${safeExt}` : `image.${safeExt}`;
|
||||||
const fileName = generateUniqueFileName(baseName, 'douyin/comment-images');
|
const fileName = generateUniqueFileName(baseName, "douyin/comment-images");
|
||||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
const uploaded = await uploadFile(buffer, fileName, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
return uploaded;
|
return uploaded;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[image] 上传失败,使用原始链接:', (e as Error)?.message || e);
|
console.warn(
|
||||||
|
"[image] 上传失败,使用原始链接:",
|
||||||
|
(e as Error)?.message || e,
|
||||||
|
);
|
||||||
return srcUrl || undefined;
|
return srcUrl || undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,10 +63,25 @@ export async function uploadImageFromUrl(
|
|||||||
/** 下载图文作品的图片和音乐并上传到 MinIO */
|
/** 下载图文作品的图片和音乐并上传到 MinIO */
|
||||||
export async function handleImagePost(
|
export async function handleImagePost(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
aweme: DouyinImageAweme
|
aweme: DouyinImageAweme,
|
||||||
): Promise<{ images: { url: string; width?: number; height?: number; video?: string; duration?: number }[]; musicUrl?: string }> {
|
): Promise<{
|
||||||
|
images: {
|
||||||
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
video?: string;
|
||||||
|
duration?: number;
|
||||||
|
}[];
|
||||||
|
musicUrl?: string;
|
||||||
|
}> {
|
||||||
const awemeId = aweme.aweme_id;
|
const awemeId = aweme.aweme_id;
|
||||||
const uploadedImages: { url: string; width?: number; height?: number; video?: string; duration?: number }[] = [];
|
const uploadedImages: {
|
||||||
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
video?: string;
|
||||||
|
duration?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// 下载图片(顺序保持)
|
// 下载图片(顺序保持)
|
||||||
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
for (let i = 0; i < (aweme.images?.length || 0); i++) {
|
||||||
@@ -64,19 +89,33 @@ export async function handleImagePost(
|
|||||||
const url = pickFirstUrl(img?.url_list);
|
const url = pickFirstUrl(img?.url_list);
|
||||||
if (!url) continue;
|
if (!url) continue;
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
const { buffer, contentType, ext } = await downloadBinary(context, url);
|
||||||
const safeExt = ext || 'jpg';
|
const safeExt = ext || "jpg";
|
||||||
const fileName = generateUniqueFileName(`${awemeId}/${i}.${safeExt}`, 'douyin/images');
|
const fileName = generateUniqueFileName(
|
||||||
const uploaded = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
`${awemeId}/${i}.${safeExt}`,
|
||||||
|
"douyin/images",
|
||||||
|
);
|
||||||
|
const uploaded = await uploadFile(buffer, fileName, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
|
|
||||||
if (img.video?.play_addr) {
|
if (img.video?.play_addr) {
|
||||||
// 如果是动图,下载 video 并上传
|
// 如果是动图,下载 video 并上传
|
||||||
const videoUrl = img.video.play_addr[0]?.src;
|
const videoUrl = img.video.play_addr[0]?.src;
|
||||||
if (videoUrl) {
|
if (videoUrl) {
|
||||||
try {
|
try {
|
||||||
const { buffer: videoBuffer, contentType: videoContentType, ext: videoExt } = await downloadBinary(context, videoUrl);
|
const {
|
||||||
const safeVideoExt = videoExt || 'mp4';
|
buffer: videoBuffer,
|
||||||
const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images');
|
contentType: videoContentType,
|
||||||
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': 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);
|
const duration = await getVideoDuration(videoBuffer);
|
||||||
@@ -87,19 +126,30 @@ export async function handleImagePost(
|
|||||||
width: img?.width,
|
width: img?.width,
|
||||||
height: img?.height,
|
height: img?.height,
|
||||||
video: uploadedVideo,
|
video: uploadedVideo,
|
||||||
duration: duration ?? undefined
|
duration: duration ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (duration) {
|
if (duration) {
|
||||||
console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
|
console.log(`[image] 动图 ${i} 时长: ${duration}ms`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e);
|
console.warn(
|
||||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
|
`[image] 动图视频上传失败,跳过:`,
|
||||||
|
(e as Error)?.message || e,
|
||||||
|
);
|
||||||
|
uploadedImages.push({
|
||||||
|
url: uploaded,
|
||||||
|
width: img?.width,
|
||||||
|
height: img?.height,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
|
uploadedImages.push({
|
||||||
|
url: uploaded,
|
||||||
|
width: img?.width,
|
||||||
|
height: img?.height,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +157,18 @@ export async function handleImagePost(
|
|||||||
let musicUrl: string | undefined;
|
let musicUrl: string | undefined;
|
||||||
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
const audioSrc = pickFirstUrl(aweme.music?.play_url?.url_list);
|
||||||
if (audioSrc) {
|
if (audioSrc) {
|
||||||
const { buffer, contentType, ext } = await downloadBinary(context, audioSrc);
|
const { buffer, contentType, ext } = await downloadBinary(
|
||||||
const safeExt = ext || 'mp3';
|
context,
|
||||||
const fileName = generateUniqueFileName(`${awemeId}.${safeExt}`, 'douyin/audios');
|
audioSrc,
|
||||||
musicUrl = await uploadFile(buffer, fileName, { 'Content-Type': contentType });
|
);
|
||||||
|
const safeExt = ext || "mp3";
|
||||||
|
const fileName = generateUniqueFileName(
|
||||||
|
`${awemeId}.${safeExt}`,
|
||||||
|
"douyin/audios",
|
||||||
|
);
|
||||||
|
musicUrl = await uploadFile(buffer, fileName, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { images: uploadedImages, musicUrl };
|
return { images: uploadedImages, musicUrl };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export function toCamelCaseKey(key: string): string {
|
export function toCamelCaseKey(key: string): string {
|
||||||
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
return key.replace(/_([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
|
||||||
@@ -16,7 +16,7 @@ export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
|||||||
const seen = new WeakMap<object, any>();
|
const seen = new WeakMap<object, any>();
|
||||||
|
|
||||||
const wrap = (value: any): any => {
|
const wrap = (value: any): any => {
|
||||||
if (value === null || typeof value !== 'object') return value;
|
if (value === null || typeof value !== "object") return value;
|
||||||
if (seen.has(value)) return seen.get(value);
|
if (seen.has(value)) return seen.get(value);
|
||||||
const proxied = new Proxy(value, handler);
|
const proxied = new Proxy(value, handler);
|
||||||
seen.set(value, proxied);
|
seen.set(value, proxied);
|
||||||
@@ -26,13 +26,14 @@ export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
|||||||
const handler: ProxyHandler<any> = {
|
const handler: ProxyHandler<any> = {
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver) {
|
||||||
// 非字符串属性(如 Symbol、数字索引)直接透传
|
// 非字符串属性(如 Symbol、数字索引)直接透传
|
||||||
if (typeof prop !== 'string') {
|
if (typeof prop !== "string") {
|
||||||
return wrap(Reflect.get(target, prop, receiver));
|
return wrap(Reflect.get(target, prop, receiver));
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = prop;
|
const primary = prop;
|
||||||
|
|
||||||
if (primary in target) return wrap(Reflect.get(target, primary, receiver));
|
if (primary in target)
|
||||||
|
return wrap(Reflect.get(target, primary, receiver));
|
||||||
|
|
||||||
const camel = toCamelCaseKey(primary);
|
const camel = toCamelCaseKey(primary);
|
||||||
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
if (camel in target) return wrap(Reflect.get(target, camel, receiver));
|
||||||
@@ -43,14 +44,14 @@ export function createCamelCompatibleProxy<T extends object>(root: T): T {
|
|||||||
return wrap(Reflect.get(target, prop, receiver));
|
return wrap(Reflect.get(target, prop, receiver));
|
||||||
},
|
},
|
||||||
has(target, prop) {
|
has(target, prop) {
|
||||||
if (typeof prop !== 'string') return prop in target;
|
if (typeof prop !== "string") return prop in target;
|
||||||
const primary = prop === 'auther' ? 'autherInfo' : prop;
|
const primary = prop === "auther" ? "autherInfo" : prop;
|
||||||
return (
|
return (
|
||||||
primary in target ||
|
primary in target ||
|
||||||
toCamelCaseKey(primary) in target ||
|
toCamelCaseKey(primary) in target ||
|
||||||
toSnakeCaseKey(primary) in target
|
toSnakeCaseKey(primary) in target
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return wrap(root);
|
return wrap(root);
|
||||||
|
|||||||
@@ -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";
|
||||||
+129
-60
@@ -1,16 +1,18 @@
|
|||||||
import { execFile } from 'child_process';
|
import { execFile } from "child_process";
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from "fs";
|
||||||
import os from 'os';
|
import os from "os";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
import { promisify } from 'util';
|
import { promisify } from "util";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
* 使用 ffmpeg 从视频二进制中提取第一帧,返回 JPEG buffer
|
||||||
*/
|
*/
|
||||||
export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
export async function extractFirstFrame(
|
||||||
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg';
|
videoBuffer: Buffer,
|
||||||
|
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||||
|
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||||
const tmpDir = os.tmpdir();
|
const tmpDir = os.tmpdir();
|
||||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||||
@@ -19,36 +21,54 @@ export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer:
|
|||||||
try {
|
try {
|
||||||
await fs.writeFile(inPath, videoBuffer);
|
await fs.writeFile(inPath, videoBuffer);
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
"-hide_banner",
|
||||||
'-loglevel', 'error',
|
"-loglevel",
|
||||||
'-ss', '0',
|
"error",
|
||||||
'-i', inPath,
|
"-ss",
|
||||||
'-frames:v', '1',
|
"0",
|
||||||
'-q:v', '2',
|
"-i",
|
||||||
'-f', 'image2',
|
inPath,
|
||||||
'-y',
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-q:v",
|
||||||
|
"2",
|
||||||
|
"-f",
|
||||||
|
"image2",
|
||||||
|
"-y",
|
||||||
outPath,
|
outPath,
|
||||||
];
|
];
|
||||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||||
const img = await fs.readFile(outPath);
|
const img = await fs.readFile(outPath);
|
||||||
return { buffer: img, contentType: 'image/jpeg', ext: 'jpg' };
|
return { buffer: img, contentType: "image/jpeg", ext: "jpg" };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
if (
|
||||||
console.warn('系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。');
|
e &&
|
||||||
|
(e.code === "ENOENT" ||
|
||||||
|
/not found|is not recognized/i.test(String(e.message)))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用封面提取。",
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
try { await fs.unlink(inPath); } catch { }
|
try {
|
||||||
try { await fs.unlink(outPath); } catch { }
|
await fs.unlink(inPath);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await fs.unlink(outPath);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffprobe 获取视频时长(毫秒)
|
* 使用 ffprobe 获取视频时长(毫秒)
|
||||||
*/
|
*/
|
||||||
export async function getVideoDuration(videoBuffer: Buffer): Promise<number | null> {
|
export async function getVideoDuration(
|
||||||
const ffprobeCmd = process.env.FFPROBE_PATH || 'ffprobe';
|
videoBuffer: Buffer,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const ffprobeCmd = process.env.FFPROBE_PATH || "ffprobe";
|
||||||
const tmpDir = os.tmpdir();
|
const tmpDir = os.tmpdir();
|
||||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||||
@@ -56,100 +76,149 @@ export async function getVideoDuration(videoBuffer: Buffer): Promise<number | nu
|
|||||||
try {
|
try {
|
||||||
await fs.writeFile(inPath, videoBuffer);
|
await fs.writeFile(inPath, videoBuffer);
|
||||||
const args = [
|
const args = [
|
||||||
'-v', 'error',
|
"-v",
|
||||||
'-show_entries', 'format=duration',
|
"error",
|
||||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
inPath,
|
inPath,
|
||||||
];
|
];
|
||||||
const { stdout } = await execFileAsync(ffprobeCmd, args, { windowsHide: true });
|
const { stdout } = await execFileAsync(ffprobeCmd, args, {
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
const durationSeconds = parseFloat(stdout.trim());
|
const durationSeconds = parseFloat(stdout.trim());
|
||||||
if (isNaN(durationSeconds)) return null;
|
if (isNaN(durationSeconds)) return null;
|
||||||
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
return Math.round(durationSeconds * 1000); // 转换为毫秒
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
if (
|
||||||
console.warn('系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。');
|
e &&
|
||||||
|
(e.code === "ENOENT" ||
|
||||||
|
/not found|is not recognized/i.test(String(e.message)))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。",
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.warn(`获取视频时长失败: ${e?.message || e}`);
|
console.warn(`获取视频时长失败: ${e?.message || e}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
try { await fs.unlink(inPath); } catch { }
|
try {
|
||||||
|
await fs.unlink(inPath);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 将视频二进制转换为音频,返回音频 Buffer
|
* 使用 ffmpeg 将视频 Buffer 或本地文件转换为音频,返回音频 Buffer
|
||||||
*
|
*
|
||||||
* 默认输出 mp3,可通过 opts.format 指定 'mp3' | 'aac' | 'wav'
|
* 默认输出 mp3,可通过 opts.format 指定 'mp3' | 'aac' | 'wav'
|
||||||
* - mp3: 使用 libmp3lame,contentType 为 'audio/mpeg'
|
* - mp3: 使用 libmp3lame,16 kHz 单声道,contentType 为 'audio/mpeg'
|
||||||
* - aac: 输出 m4a 容器(AAC 编码),contentType 为 'audio/mp4'
|
* - aac: 输出 m4a 容器(AAC 编码),contentType 为 'audio/mp4'
|
||||||
* - wav: PCM 16-bit,44100Hz,双声道,contentType 为 'audio/wav'
|
* - wav: PCM 16-bit,44100Hz,双声道,contentType 为 'audio/wav'
|
||||||
*
|
*
|
||||||
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
|
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
|
||||||
*/
|
*/
|
||||||
export async function extractAudio(
|
export async function extractAudio(
|
||||||
videoBuffer: Buffer,
|
videoBuffer: Buffer | string,
|
||||||
opts?: { format?: 'mp3' | 'aac' | 'wav'; bitrateKbps?: number }
|
opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
|
||||||
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||||
const ffmpegCmd = process.env.FFMPEG_PATH || 'ffmpeg';
|
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||||
const tmpDir = os.tmpdir();
|
const tmpDir = os.tmpdir();
|
||||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||||
|
|
||||||
const format = opts?.format ?? 'mp3';
|
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
|
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
|
||||||
|
|
||||||
// 根据目标格式设置输出路径、MIME 与编码参数
|
// 根据目标格式设置输出路径、MIME 与编码参数
|
||||||
let outPath = '';
|
let outPath = "";
|
||||||
let contentType = '';
|
let contentType = "";
|
||||||
let ext = '';
|
let ext = "";
|
||||||
let codecArgs: string[] = [];
|
let codecArgs: string[] = [];
|
||||||
|
|
||||||
if (format === 'mp3') {
|
if (format === "mp3") {
|
||||||
ext = 'mp3';
|
ext = "mp3";
|
||||||
contentType = 'audio/mpeg';
|
contentType = "audio/mpeg";
|
||||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||||
codecArgs = ['-c:a', 'libmp3lame', '-b:a', `${bitrate}k`];
|
codecArgs = ["-c:a", "libmp3lame", "-b:a", `${bitrate}k`];
|
||||||
} else if (format === 'aac') {
|
} else if (format === "aac") {
|
||||||
// 使用 m4a 容器更通用
|
// 使用 m4a 容器更通用
|
||||||
ext = 'm4a';
|
ext = "m4a";
|
||||||
contentType = 'audio/mp4';
|
contentType = "audio/mp4";
|
||||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||||
codecArgs = ['-c:a', 'aac', '-b:a', `${bitrate}k`, '-movflags', '+faststart'];
|
codecArgs = [
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
`${bitrate}k`,
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
// wav
|
// wav
|
||||||
ext = 'wav';
|
ext = "wav";
|
||||||
contentType = 'audio/wav';
|
contentType = "audio/wav";
|
||||||
outPath = path.join(tmpDir, `${base}.${ext}`);
|
outPath = path.join(tmpDir, `${base}.${ext}`);
|
||||||
codecArgs = ['-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2'];
|
codecArgs = [
|
||||||
|
"-f",
|
||||||
|
"wav",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-ar",
|
||||||
|
"44100",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(inPath, videoBuffer);
|
if (typeof videoBuffer !== "string") await fs.writeFile(inPath, videoBuffer);
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
"-hide_banner",
|
||||||
'-loglevel', 'error',
|
"-loglevel",
|
||||||
'-i', inPath,
|
"error",
|
||||||
'-vn', // 丢弃视频流
|
"-i",
|
||||||
|
inputPath,
|
||||||
|
"-map",
|
||||||
|
"0:a:0",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-vn", // 丢弃视频流
|
||||||
...codecArgs,
|
...codecArgs,
|
||||||
'-y',
|
"-y",
|
||||||
outPath,
|
outPath,
|
||||||
];
|
];
|
||||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
await execFileAsync(ffmpegCmd, args, {
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 60 * 60 * 1000,
|
||||||
|
});
|
||||||
const audio = await fs.readFile(outPath);
|
const audio = await fs.readFile(outPath);
|
||||||
return { buffer: audio, contentType, ext };
|
return { buffer: audio, contentType, ext };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) {
|
if (
|
||||||
console.warn('系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。');
|
e &&
|
||||||
|
(e.code === "ENOENT" ||
|
||||||
|
/not found|is not recognized/i.test(String(e.message)))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"系统未检测到 ffmpeg,可安装并配置 PATH 或设置 FFMPEG_PATH 后启用音频提取。",
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
// 一些环境可能缺少特定编码器(如 libmp3lame),提示并抛出原始错误
|
||||||
console.warn(`提取音频失败: ${e?.message || e}`);
|
console.warn(`提取音频失败: ${e?.message || e}`);
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
try { await fs.unlink(inPath); } catch { }
|
try {
|
||||||
|
await fs.unlink(inPath);
|
||||||
|
} catch {}
|
||||||
try {
|
try {
|
||||||
if (outPath) await fs.unlink(outPath);
|
if (outPath) await fs.unlink(outPath);
|
||||||
} catch { }
|
} 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";
|
||||||
+99
-128
@@ -1,114 +1,49 @@
|
|||||||
import { json } from '@/lib/json';
|
import { NextResponse } from "next/server";
|
||||||
import { getFileUrl } from '@/lib/minio';
|
import { json } from "@/lib/json";
|
||||||
import { prisma } from '@/lib/prisma'; // 你的 Prisma 客户端实例
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { NextResponse } from 'next/server';
|
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) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = (searchParams.get('q') || '').trim();
|
const q = (searchParams.get("q") || "").trim();
|
||||||
const page = Math.max(1, Number(searchParams.get('page') || 1));
|
const rawTag = (searchParams.get("tag") || "").trim();
|
||||||
const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit') || 20)));
|
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;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
if (!q) {
|
if (rawTag && !contentTag) {
|
||||||
return NextResponse.json({ results: [], total: 0 });
|
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 {
|
try {
|
||||||
// 核心查询:Postgres 中文全文检索(视频转写 + 视频desc + 图文desc)+ 相关度排序 + 摘要高亮
|
const rows = await searchRows(
|
||||||
// 说明:
|
{ terms, excludes, contentTag },
|
||||||
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
|
offset,
|
||||||
// - 使用 UNION ALL 合并视频和图文的搜索结果
|
limit,
|
||||||
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
|
);
|
||||||
const rows = await prisma.$queryRaw<
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
awemeId: string;
|
|
||||||
type: 'video' | 'image';
|
|
||||||
rank: number;
|
|
||||||
snippet: string;
|
|
||||||
}[]
|
|
||||||
>`
|
|
||||||
WITH tsq AS (
|
|
||||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
|
||||||
)
|
|
||||||
-- 视频搜索结果
|
|
||||||
SELECT
|
|
||||||
COALESCE(vt.id, 'desc:' || v.aweme_id) AS id,
|
|
||||||
v.aweme_id AS "awemeId",
|
|
||||||
'video'::text AS type,
|
|
||||||
GREATEST(
|
|
||||||
COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0),
|
|
||||||
COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0)
|
|
||||||
) AS rank,
|
|
||||||
CASE
|
|
||||||
WHEN vt.transcript_zh_tsv @@ tsq.query THEN
|
|
||||||
ts_headline(
|
|
||||||
'zhcfg',
|
|
||||||
array_to_string(vt.transcript, ' '),
|
|
||||||
tsq.query,
|
|
||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
|
||||||
)
|
|
||||||
ELSE
|
|
||||||
ts_headline(
|
|
||||||
'zhcfg',
|
|
||||||
v.desc,
|
|
||||||
tsq.query,
|
|
||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
|
||||||
)
|
|
||||||
END AS snippet
|
|
||||||
FROM "Video" v
|
|
||||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id,
|
|
||||||
tsq
|
|
||||||
WHERE
|
|
||||||
(vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
|
||||||
|
|
||||||
UNION ALL
|
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([
|
||||||
SELECT
|
videoIds.length > 0
|
||||||
'img_desc:' || ip.aweme_id AS id,
|
? prisma.video.findMany({
|
||||||
ip.aweme_id AS "awemeId",
|
|
||||||
'image'::text AS type,
|
|
||||||
ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank,
|
|
||||||
ts_headline(
|
|
||||||
'zhcfg',
|
|
||||||
ip.desc,
|
|
||||||
tsq.query,
|
|
||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
|
||||||
) AS snippet
|
|
||||||
FROM "ImagePost" ip, tsq
|
|
||||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
|
||||||
|
|
||||||
ORDER BY rank DESC
|
|
||||||
OFFSET ${offset}
|
|
||||||
LIMIT ${limit};
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 查询总数(参数化,避免注入)
|
|
||||||
const totalRows = await prisma.$queryRaw<{
|
|
||||||
count: number;
|
|
||||||
}[]>`
|
|
||||||
WITH tsq AS (
|
|
||||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
|
||||||
)
|
|
||||||
SELECT (
|
|
||||||
(SELECT COUNT(*)::int FROM "Video" v
|
|
||||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
|
||||||
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query))
|
|
||||||
+
|
|
||||||
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
|
||||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query)
|
|
||||||
) AS count
|
|
||||||
FROM tsq;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 分离视频和图文ID
|
|
||||||
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 = videoIds.length > 0 ? (await prisma.video.findMany({
|
|
||||||
where: { aweme_id: { in: videoIds } },
|
where: { aweme_id: { in: videoIds } },
|
||||||
select: {
|
select: {
|
||||||
aweme_id: true,
|
aweme_id: true,
|
||||||
@@ -116,49 +51,85 @@ export async function GET(req: Request) {
|
|||||||
cover_url: true,
|
cover_url: true,
|
||||||
video_url: true,
|
video_url: true,
|
||||||
duration_ms: true,
|
duration_ms: true,
|
||||||
author: true
|
author: true,
|
||||||
},
|
},
|
||||||
})).map(v => (
|
})
|
||||||
{ ...v, cover_url: getFileUrl(v.cover_url || ''),
|
: [],
|
||||||
author: { ...v.author, avatar_url: getFileUrl(v.author.avatar_url || '') },
|
imagePostIds.length > 0
|
||||||
video_url: getFileUrl(v.video_url || '') })
|
? prisma.imagePost.findMany({
|
||||||
) : [];
|
|
||||||
|
|
||||||
// 批量查询图文元信息
|
|
||||||
const imagePosts = imagePostIds.length > 0 ? (await prisma.imagePost.findMany({
|
|
||||||
where: { aweme_id: { in: imagePostIds } },
|
where: { aweme_id: { in: imagePostIds } },
|
||||||
select: {
|
select: {
|
||||||
aweme_id: true,
|
aweme_id: true,
|
||||||
desc: true,
|
desc: true,
|
||||||
author: true,
|
author: true,
|
||||||
images: {
|
images: {
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: "asc" },
|
||||||
take: 1,
|
take: 1,
|
||||||
select: {
|
select: { url: true, width: true, height: true },
|
||||||
url: true,
|
|
||||||
width: true,
|
|
||||||
height: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})).map(ip => ({
|
},
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
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,
|
...ip,
|
||||||
author: { ...ip.author, avatar_url: getFileUrl(ip.author.avatar_url || '') },
|
author: {
|
||||||
|
...ip.author,
|
||||||
|
avatar_url: getFileUrl(ip.author.avatar_url || ""),
|
||||||
|
},
|
||||||
cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null,
|
cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null,
|
||||||
})) : [];
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
results: rows.map(r => ({
|
results: rows.map((r) => {
|
||||||
...r,
|
const matchedIn: ("desc" | "transcript")[] = [];
|
||||||
video: r.type === 'video' ? videos.find(v => v.aweme_id === r.awemeId) : undefined,
|
if (r.inDesc) matchedIn.push("desc");
|
||||||
imagePost: r.type === 'image' ? imagePosts.find(ip => ip.aweme_id === r.awemeId) : undefined,
|
if (r.inTranscript) matchedIn.push("transcript");
|
||||||
})),
|
return {
|
||||||
total: totalRows?.[0]?.count ?? 0,
|
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,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
terms,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Search error:', err);
|
console.error("Search error:", err);
|
||||||
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import OpenAI from "openai";
|
|
||||||
import prompt from "./prompt.md";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { downloadFile } from "@/lib/minio";
|
|
||||||
import { extractAudio } from "../media";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResponseFormat } from "openai/helpers/zod";
|
|
||||||
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
|
||||||
|
|
||||||
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
|
||||||
if (proxy) {
|
|
||||||
setGlobalDispatcher(new ProxyAgent(proxy));
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
|
||||||
baseURL: process.env.OPENAI_API_BASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Zod schema aligned with prompt.md
|
|
||||||
export const SttSchema = z
|
|
||||||
.object({
|
|
||||||
speech_detected: z.boolean(),
|
|
||||||
language: z.string().min(1).nullable(),
|
|
||||||
audio_type: z.string().nullable(),
|
|
||||||
transcript: z.array(z.string()),
|
|
||||||
non_speech_summary: z.string().nullable(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export type SttResult = z.infer<typeof SttSchema>;
|
|
||||||
|
|
||||||
async function transcriptAudio(audio: Buffer | string) {
|
|
||||||
if (typeof audio === "string") {
|
|
||||||
audio = fs.readFileSync(audio);
|
|
||||||
}
|
|
||||||
const base64Audio = Buffer.from(audio).toString("base64");
|
|
||||||
|
|
||||||
const completion = await client.chat.completions.create({
|
|
||||||
model: "gemini-2.5-flash",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: prompt },
|
|
||||||
{
|
|
||||||
type: "input_audio",
|
|
||||||
input_audio: { data: base64Audio, format: "mp3" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response_format: zodResponseFormat(SttSchema, "stt_result"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = completion.choices?.[0]?.message
|
|
||||||
console.log("转写结果", data.content);
|
|
||||||
|
|
||||||
if (!data || !data.content) {
|
|
||||||
throw new Error("No STT result returned from model");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(data.content) as SttResult;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error("Failed to parse STT result JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
|
||||||
const aweme = await prisma.video.findUnique({
|
|
||||||
where: { aweme_id: awemeId },
|
|
||||||
});
|
|
||||||
if (!aweme) {
|
|
||||||
throw new Error("Aweme not found or aweme is not a video post");
|
|
||||||
}
|
|
||||||
const vPath = aweme.video_url
|
|
||||||
const buffer = await downloadFile(vPath);
|
|
||||||
|
|
||||||
const audioDat = await extractAudio(buffer, { format: "mp3", bitrateKbps: 128 });
|
|
||||||
|
|
||||||
if (!audioDat || !audioDat.buffer) {
|
|
||||||
throw new Error("Failed to extract audio from video");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用大模型生成转写结果
|
|
||||||
const result = await transcriptAudio(audioDat.buffer);
|
|
||||||
|
|
||||||
// 将转写结果持久化到数据库
|
|
||||||
await prisma.videoTranscript.upsert({
|
|
||||||
where: { videoId: awemeId },
|
|
||||||
update: {
|
|
||||||
speech_detected: result.speech_detected,
|
|
||||||
language: result.language,
|
|
||||||
audio_type: result.audio_type,
|
|
||||||
transcript: result.transcript,
|
|
||||||
non_speech_summary: result.non_speech_summary,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
videoId: awemeId,
|
|
||||||
speech_detected: result.speech_detected,
|
|
||||||
language: result.language,
|
|
||||||
audio_type: result.audio_type,
|
|
||||||
transcript: result.transcript,
|
|
||||||
non_speech_summary: result.non_speech_summary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
你将接收一段音频。请完成:你将接收一段音频。语音活动检测(VAD)与声源分类。
|
|
||||||
|
|
||||||
**输出 JSON Schema(示例)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"speech_detected": true,
|
|
||||||
"language": "zh-CN",
|
|
||||||
"audio_type": null,
|
|
||||||
"transcript": ["大家好,我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"],
|
|
||||||
"non_speech_summary": null,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**当无发言时返回:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"speech_detected": false,
|
|
||||||
"language": null,
|
|
||||||
"audio_type": "music | ambience | animal | mechanical | other",
|
|
||||||
"transcript": [],
|
|
||||||
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
** 规则补充 **
|
|
||||||
|
|
||||||
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。
|
|
||||||
+29
-16
@@ -1,20 +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 runtime = "nodejs";
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
export const dynamic = "force-dynamic";
|
||||||
import { prisma } from '@/lib/prisma';
|
const validId = (id: unknown): id is string =>
|
||||||
import type { FeedItem, FeedResponse } from '@/app/types/feed';
|
typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
||||||
import { getFileUrl } from '@/lib/minio';
|
|
||||||
import { transcriptAweme } from '.';
|
|
||||||
|
|
||||||
// Contract
|
|
||||||
// Inputs: search params { before?: ISOString, limit?: number }
|
|
||||||
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { searchParams } = new URL(req.url)
|
const id = req.nextUrl.searchParams.get("awemeId");
|
||||||
const awemeId = searchParams.get('awemeId');
|
if (!validId(id))
|
||||||
if (!awemeId) {
|
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
|
||||||
return NextResponse.json({ error: "Missing awemeId parameter" }, { 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 });
|
||||||
}
|
}
|
||||||
const script = await transcriptAweme(awemeId);
|
|
||||||
return NextResponse.json(script);
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { playbackContextQuery } from "@/lib/playback-context";
|
||||||
import FeedMasonry from "@/app/components/FeedMasonry";
|
import FeedMasonry from "@/app/components/FeedMasonry";
|
||||||
import BackButton from "@/app/components/BackButton";
|
import BackButton from "@/app/components/BackButton";
|
||||||
import { FeedItem } from "@/app/types/feed";
|
import { FeedItem } from "@/app/types/feed";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
export default async function AuthorPage({ params }: { params: Promise<{ secUid: string }> }) {
|
export default async function AuthorPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ secUid: string }>;
|
||||||
|
}) {
|
||||||
const secUid = (await params).secUid;
|
const secUid = (await params).secUid;
|
||||||
|
|
||||||
const author = await prisma.author.findUnique({
|
const author = await prisma.author.findUnique({
|
||||||
@@ -22,15 +28,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: { authorId: secUid },
|
where: { authorId: secUid },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
include: { author: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findMany({
|
prisma.imagePost.findMany({
|
||||||
where: { authorId: secUid },
|
where: { authorId: secUid },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true, images: { orderBy: { order: 'asc' }, take: 1 } },
|
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -41,11 +47,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? 'default_cover.png'),
|
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: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
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) => ({
|
...posts.map((p) => ({
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
@@ -55,13 +65,23 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
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))
|
]
|
||||||
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
const initialCursor = initialItems.length > 0 ? new Date(initialItems[initialItems.length - 1].created_at as any).toISOString() : null;
|
const initialCursor =
|
||||||
|
initialItems.length > 0
|
||||||
|
? new Date(
|
||||||
|
initialItems[initialItems.length - 1].created_at as any,
|
||||||
|
).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
|
<main className="min-h-screen bg-white dark:bg-black text-black dark:text-white">
|
||||||
@@ -75,7 +95,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-6 mb-12">
|
<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">
|
<div className="relative w-24 h-24 md:w-32 md:h-32 shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={getFileUrl(author.avatar_url || 'default-avatar.png')}
|
src={getFileUrl(author.avatar_url || "default-avatar.png")}
|
||||||
alt={author.nickname}
|
alt={author.nickname}
|
||||||
fill
|
fill
|
||||||
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
|
className="rounded-full object-cover border-2 border-gray-200 dark:border-gray-800"
|
||||||
@@ -86,8 +106,17 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">{author.nickname}</h2>
|
<h2 className="text-2xl font-bold">{author.nickname}</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
抖音号:{author.unique_id || author.short_id || '未知'}
|
抖音号:{author.unique_id || author.short_id || "未知"}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{author.signature && (
|
{author.signature && (
|
||||||
@@ -98,11 +127,15 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
|
|
||||||
<div className="flex items-center justify-center md:justify-start gap-6 text-sm">
|
<div className="flex items-center justify-center md:justify-start gap-6 text-sm">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-bold text-lg">{Number(author.total_favorited).toLocaleString()}</span>
|
<span className="font-bold text-lg">
|
||||||
|
{Number(author.total_favorited).toLocaleString()}
|
||||||
|
</span>
|
||||||
<span className="text-gray-500">获赞</span>
|
<span className="text-gray-500">获赞</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-bold text-lg">{Number(author.follower_count).toLocaleString()}</span>
|
<span className="font-bold text-lg">
|
||||||
|
{Number(author.follower_count).toLocaleString()}
|
||||||
|
</span>
|
||||||
<span className="text-gray-500">粉丝</span>
|
<span className="text-gray-500">粉丝</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +148,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ secUid:
|
|||||||
initialItems={initialItems}
|
initialItems={initialItems}
|
||||||
initialCursor={initialCursor}
|
initialCursor={initialCursor}
|
||||||
fetchUrl={`/api/author/${secUid}/feed`}
|
fetchUrl={`/api/author/${secUid}/feed`}
|
||||||
|
linkQuery={playbackContextQuery({ kind: "author", secUid })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+698
-225
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { memo } from "react";
|
||||||
|
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||||
|
import type { User } from "../types.ts";
|
||||||
|
import { WorksGrid } from "./WorksGrid";
|
||||||
|
|
||||||
|
interface AuthorWorksTabProps {
|
||||||
|
/** 侧栏打开且停在「TA的作品」标签页 */
|
||||||
|
active: boolean;
|
||||||
|
author: User;
|
||||||
|
works: PlaylistState;
|
||||||
|
currentId: string;
|
||||||
|
/** 跳转时原样带上的播放上下文查询串 */
|
||||||
|
contextQuery: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧栏「TA的作品」:作者在库里的全部作品。点作品就地切换;点作者名字才去独立的作者页。
|
||||||
|
export const AuthorWorksTab = memo(function AuthorWorksTab({
|
||||||
|
active,
|
||||||
|
author,
|
||||||
|
works,
|
||||||
|
currentId,
|
||||||
|
contextQuery,
|
||||||
|
onSelect,
|
||||||
|
}: AuthorWorksTabProps) {
|
||||||
|
const header = author.sec_uid ? (
|
||||||
|
<Link
|
||||||
|
href={`/author/${author.sec_uid}`}
|
||||||
|
className="group flex min-w-0 flex-1 items-center gap-2.5"
|
||||||
|
title="打开作者主页"
|
||||||
|
>
|
||||||
|
{/* biome-ignore lint/performance/noImgElement: 头像来自对象存储,不走 next/image */}
|
||||||
|
<img
|
||||||
|
src={author.avatar_url ?? ""}
|
||||||
|
alt=""
|
||||||
|
className="h-9 w-9 shrink-0 rounded-full bg-white/10 object-cover"
|
||||||
|
/>
|
||||||
|
<span className="truncate font-semibold text-white group-hover:underline">
|
||||||
|
@{author.nickname}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} className="shrink-0 text-white/60" />
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-white/40">作者主页</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-semibold text-white">
|
||||||
|
@{author.nickname}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorksGrid
|
||||||
|
active={active}
|
||||||
|
list={works}
|
||||||
|
currentId={currentId}
|
||||||
|
hrefQuery={contextQuery}
|
||||||
|
onSelect={onSelect}
|
||||||
|
singleHint="库里只存了这位作者的这一个作品"
|
||||||
|
header={header}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { forwardRef } from "react";
|
import { forwardRef, memo } from "react";
|
||||||
|
|
||||||
interface BackgroundCanvasProps {}
|
export const BackgroundCanvas = memo(
|
||||||
|
forwardRef<HTMLCanvasElement>((_props, ref) => {
|
||||||
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="fixed inset-0 w-full h-full -z-10 scale-110"
|
className="pointer-events-none fixed inset-0 w-full h-full -z-10 scale-110"
|
||||||
style={{ filter: "blur(40px)" }}
|
style={{ filter: "blur(40px)" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
BackgroundCanvas.displayName = "BackgroundCanvas";
|
BackgroundCanvas.displayName = "BackgroundCanvas";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ThumbsUp, X } from "lucide-react";
|
import { ThumbsUp, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { memo, useState } from "react";
|
||||||
import type { Comment, User } from "../types.ts";
|
import type { Comment, User } from "../types.ts";
|
||||||
import { formatRelativeTime, formatAbsoluteUTC } from "../utils";
|
import { formatAbsoluteUTC, formatRelativeTime } from "../utils";
|
||||||
import { CommentText } from "./CommentText";
|
import { CommentText } from "./CommentText";
|
||||||
|
|
||||||
interface CommentListProps {
|
interface CommentListProps {
|
||||||
@@ -10,7 +10,11 @@ interface CommentListProps {
|
|||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
export const CommentList = memo(function CommentList({
|
||||||
|
author,
|
||||||
|
createdAt,
|
||||||
|
comments,
|
||||||
|
}: CommentListProps) {
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,12 +22,23 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
<header className="flex items-center gap-4 mb-5">
|
<header className="flex items-center gap-4 mb-5">
|
||||||
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
|
||||||
{author.avatar_url ? (
|
{author.avatar_url ? (
|
||||||
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
<img
|
||||||
|
src={author.avatar_url}
|
||||||
|
alt="avatar"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
|
<div className="font-medium text-white/95 text-sm sm:text-base">
|
||||||
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}>
|
{author.nickname}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs text-white/50"
|
||||||
|
title={formatAbsoluteUTC(createdAt)}
|
||||||
|
>
|
||||||
发布于 {formatRelativeTime(createdAt)}
|
发布于 {formatRelativeTime(createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,13 +49,23 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
|
<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">
|
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
|
||||||
{c.user.avatar_url ? (
|
{c.user.avatar_url ? (
|
||||||
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
<img
|
||||||
|
src={c.user.avatar_url}
|
||||||
|
alt="avatar"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
|
<span className="font-medium text-white/95 text-sm">
|
||||||
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span>
|
{c.user.nickname}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
{formatRelativeTime(c.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
|
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
|
||||||
<CommentText text={c.text} />
|
<CommentText text={c.text} />
|
||||||
@@ -49,13 +74,12 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
<div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2">
|
<div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||||
{c.images.map((img, idx) => (
|
{c.images.map((img, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={`${c.cid}-img-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPreviewImage(img.url)}
|
onClick={() => setPreviewImage(img.url)}
|
||||||
className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity"
|
className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
title="点击预览大图"
|
title="点击预览大图"
|
||||||
>
|
>
|
||||||
{/* 以真实比例显示:利用 width/height 属性提供固有尺寸,CSS 设定宽度 100% 高度自适应 */}
|
|
||||||
<img
|
<img
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt="comment-image"
|
alt="comment-image"
|
||||||
@@ -63,6 +87,7 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
height={img.height}
|
height={img.height}
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -75,16 +100,17 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
|
{comments.length === 0 ? (
|
||||||
|
<li className="text-sm text-white/60">暂无评论</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* 图片预览灯箱 */}
|
{/* 图片预览灯箱 */}
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
|
||||||
onClick={() => setPreviewImage(null)}
|
onClick={() => setPreviewImage(null)}
|
||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
||||||
@@ -94,7 +120,6 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 大图 */}
|
|
||||||
<img
|
<img
|
||||||
src={previewImage}
|
src={previewImage}
|
||||||
alt="preview"
|
alt="preview"
|
||||||
@@ -105,4 +130,4 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,242 +1,196 @@
|
|||||||
import { X } from "lucide-react";
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { Comment, User } from "../types.ts";
|
import type { Comment, User } from "../types.ts";
|
||||||
import { CommentList } from "./CommentList";
|
import { CommentList } from "./CommentList";
|
||||||
|
|
||||||
interface CommentPanelProps {
|
interface CommentPanelProps {
|
||||||
|
/** 侧栏打开且停在「评论」标签页 */
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
|
||||||
author: User;
|
author: User;
|
||||||
createdAt: string | Date;
|
createdAt: string | Date;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
mounted: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounted }: CommentPanelProps) {
|
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 [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
|
const [hasOpened, setHasOpened] = useState(open);
|
||||||
const [rankParams, setRankParams] = useState<null | { seed: string; snapshot: string }>(null);
|
|
||||||
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
|
|
||||||
const scrollRefLandscape = useRef<HTMLDivElement>(null);
|
|
||||||
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
|
|
||||||
const scrollRefPortrait = useRef<HTMLDivElement>(null);
|
|
||||||
const sentinelRefPortrait = useRef<HTMLDivElement>(null);
|
|
||||||
const loadingRef = useRef(false);
|
|
||||||
|
|
||||||
// 加载评论
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const loadComments = useCallback(async (reset = false) => {
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
if (loadingRef.current || (!reset && !hasMore)) return;
|
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;
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const skip = reset ? 0 : comments.length;
|
const skip = reset ? 0 : commentsCountRef.current;
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
skip: String(skip),
|
skip: String(skip),
|
||||||
take: String(20),
|
take: String(20),
|
||||||
mode: 'ranked',
|
mode: "ranked",
|
||||||
});
|
});
|
||||||
if (rankParams) {
|
const currentRank = reset ? null : rankParamsRef.current;
|
||||||
query.set('seed', rankParams.seed);
|
if (currentRank) {
|
||||||
query.set('snapshot', rankParams.snapshot);
|
query.set("seed", currentRank.seed);
|
||||||
|
query.set("snapshot", currentRank.snapshot);
|
||||||
}
|
}
|
||||||
const response = await fetch(`/api/comments/${awemeId}?${query.toString()}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// 统一做一次基于 cid 的去重,避免分页偶发重复
|
const response = await fetch(
|
||||||
if (reset) {
|
`/api/comments/${awemeId}?${query.toString()}`,
|
||||||
setComments(() => {
|
{ signal: controller.signal },
|
||||||
const seen = new Set<string>();
|
);
|
||||||
return (data.comments as Comment[]).filter((c) => {
|
const data = (await response.json()) as CommentsApiResponse;
|
||||||
if (seen.has(c.cid)) return false;
|
if (controller.signal.aborted) return;
|
||||||
seen.add(c.cid);
|
|
||||||
return true;
|
const incoming = Array.isArray(data.comments) ? data.comments : [];
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setComments((prev) => {
|
setComments((prev) => {
|
||||||
const merged = [...prev, ...(data.comments as Comment[])];
|
const source = reset ? incoming : [...prev, ...incoming];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
// 保留首次出现的项,既保证顺序也避免重复
|
const deduped = source.filter((c) => {
|
||||||
return merged.filter((c) => {
|
|
||||||
if (seen.has(c.cid)) return false;
|
if (seen.has(c.cid)) return false;
|
||||||
seen.add(c.cid);
|
seen.add(c.cid);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
commentsCountRef.current = deduped.length;
|
||||||
|
return deduped;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
setTotal(data.total);
|
const nextHasMore = Boolean(data.hasMore);
|
||||||
setHasMore(data.hasMore);
|
hasMoreRef.current = nextHasMore;
|
||||||
if (data.mode === 'ranked' && data.seed && data.snapshot) {
|
setHasMore(nextHasMore);
|
||||||
// 初始化或重置时更新稳定参数
|
|
||||||
setRankParams((prev) => {
|
if (data.mode === "ranked" && data.seed && data.snapshot) {
|
||||||
if (reset) return { seed: String(data.seed), snapshot: String(data.snapshot) };
|
if (reset || !rankParamsRef.current) {
|
||||||
return prev ?? { seed: String(data.seed), snapshot: String(data.snapshot) };
|
rankParamsRef.current = {
|
||||||
});
|
seed: String(data.seed),
|
||||||
|
snapshot: String(data.snapshot),
|
||||||
|
};
|
||||||
|
}
|
||||||
} else if (reset) {
|
} else if (reset) {
|
||||||
setRankParams(null);
|
rankParamsRef.current = null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("加载评论失败:", error);
|
console.error("加载评论失败:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (abortControllerRef.current === controller) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [awemeId, comments.length, hasMore]);
|
|
||||||
|
|
||||||
// 面板打开时加载初始评论
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && comments.length === 0) {
|
|
||||||
loadComments(true);
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
},
|
||||||
|
[awemeId],
|
||||||
|
);
|
||||||
|
|
||||||
// Intersection Observer 监听底部触发加载(针对横屏与竖屏两个容器分别监听)
|
// 切换作品时清理旧评论状态与未完成请求
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
const rootEl = scrollRef.current;
|
||||||
const observers: IntersectionObserver[] = [];
|
const targetEl = sentinelRef.current;
|
||||||
|
|
||||||
const setup = (rootEl: HTMLDivElement | null, targetEl: HTMLDivElement | null) => {
|
|
||||||
if (!rootEl || !targetEl) return;
|
if (!rootEl || !targetEl) return;
|
||||||
|
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (entry.isIntersecting && !loadingRef.current && hasMore) {
|
if (
|
||||||
loadComments();
|
entry?.isIntersecting &&
|
||||||
|
!loadingRef.current &&
|
||||||
|
hasMoreRef.current
|
||||||
|
) {
|
||||||
|
void loadComments(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: rootEl,
|
root: rootEl,
|
||||||
rootMargin: '0px 0px 200px 0px', // 距底部 200px 触发
|
rootMargin: "0px 0px 200px 0px",
|
||||||
threshold: 0,
|
threshold: 0,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
io.observe(targetEl);
|
io.observe(targetEl);
|
||||||
observers.push(io);
|
return () => io.disconnect();
|
||||||
};
|
|
||||||
|
|
||||||
setup(scrollRefLandscape.current, sentinelRefLandscape.current);
|
|
||||||
setup(scrollRefPortrait.current, sentinelRefPortrait.current);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
for (const io of observers) io.disconnect();
|
|
||||||
};
|
|
||||||
}, [open, hasMore, loadComments]);
|
}, [open, hasMore, loadComments]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
|
||||||
|
{(open || hasOpened) && (
|
||||||
<>
|
<>
|
||||||
<style jsx global>{`
|
<CommentList
|
||||||
/* 自定义滚动条样式 */
|
author={author}
|
||||||
.comment-scroll::-webkit-scrollbar {
|
createdAt={createdAt}
|
||||||
width: 6px;
|
comments={comments}
|
||||||
}
|
/>
|
||||||
|
|
||||||
.comment-scroll::-webkit-scrollbar-track {
|
{hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox */
|
|
||||||
.comment-scroll {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
{/* 横屏评论面板:并排分栏 */}
|
|
||||||
<aside
|
|
||||||
className={`
|
|
||||||
hidden landscape:flex
|
|
||||||
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
|
|
||||||
relative h-full overflow-hidden
|
|
||||||
${mounted ? "transition-[width] duration-200 ease-out" : ""}
|
|
||||||
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
|
||||||
<button
|
|
||||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="关闭评论"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
<div className="text-white font-semibold">
|
|
||||||
评论 {total > 0 ? `(${total})` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={scrollRefLandscape} className="p-3 overflow-auto comment-scroll">
|
|
||||||
<CommentList author={author} createdAt={createdAt} comments={comments} />
|
|
||||||
|
|
||||||
{/* 底部加载触发区 */}
|
|
||||||
{hasMore && (
|
|
||||||
<div ref={sentinelRefLandscape} className="h-1 w-full" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="py-4 text-center text-white/60 text-sm">加载中...</div>
|
<div className="py-4 text-center text-white/60 text-sm">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMore && comments.length > 0 && (
|
{!hasMore && comments.length > 0 && (
|
||||||
<div className="py-4 text-center text-white/40 text-sm">没有更多评论了</div>
|
<div className="py-4 text-center text-white/40 text-sm">
|
||||||
)}
|
没有更多评论了
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* 竖屏评论面板:bottom sheet */}
|
|
||||||
<aside
|
|
||||||
className={`
|
|
||||||
landscape:hidden
|
|
||||||
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
|
|
||||||
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
|
|
||||||
${mounted ? "transition-transform duration-200 ease-out" : ""}
|
|
||||||
border-t border-white/10
|
|
||||||
${open ? "translate-y-0" : "translate-y-full"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
|
||||||
<div className="text-white font-semibold">
|
|
||||||
评论 {total > 0 ? `(${total})` : ""}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="关闭评论"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={scrollRefPortrait} className="p-3 overflow-auto comment-scroll">
|
|
||||||
<CommentList author={author} createdAt={createdAt} comments={comments} />
|
|
||||||
|
|
||||||
{/* 底部加载触发区 */}
|
|
||||||
{hasMore && (
|
|
||||||
<div ref={sentinelRefPortrait} 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>
|
|
||||||
</aside>
|
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) {
|
|||||||
// 如果图片加载失败,显示原始文本
|
// 如果图片加载失败,显示原始文本
|
||||||
e.currentTarget.style.display = "none";
|
e.currentTarget.style.display = "none";
|
||||||
const textNode = document.createTextNode(`[${part.name}]`);
|
const textNode = document.createTextNode(`[${part.name}]`);
|
||||||
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
|
e.currentTarget.parentNode?.insertBefore(
|
||||||
|
textNode,
|
||||||
|
e.currentTarget,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ChevronUp, ListVideo, X } from "lucide-react";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
interface ContextChipProps {
|
||||||
|
label: string;
|
||||||
|
position: number;
|
||||||
|
total: number;
|
||||||
|
/** 提供时整个标签可点:展开 / 打开侧栏里的完整列表 */
|
||||||
|
onExpand?: () => void;
|
||||||
|
expandLabel?: string;
|
||||||
|
onExit: () => void;
|
||||||
|
exitLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部的模式提示:说明上下切换当前在哪个列表里移动,避免「为什么跳到这里了」的困惑
|
||||||
|
export const ContextChip = memo(function ContextChip({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
onExpand,
|
||||||
|
expandLabel,
|
||||||
|
onExit,
|
||||||
|
exitLabel,
|
||||||
|
}: ContextChipProps) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<ListVideo size={14} className="shrink-0 text-rose-300" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-white/60">
|
||||||
|
{position}/{total}
|
||||||
|
</span>
|
||||||
|
{onExpand && <ChevronUp size={14} className="shrink-0 text-white/70" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute left-1/2 top-14 z-40 flex max-w-[calc(100%-1.5rem)] sm:top-3 sm:max-w-[calc(100%-14rem)] -translate-x-1/2 items-center rounded-full border border-white/20 bg-black/60 text-xs font-medium text-white shadow-lg backdrop-blur">
|
||||||
|
{onExpand ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExpand}
|
||||||
|
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1 cursor-pointer"
|
||||||
|
aria-label={expandLabel}
|
||||||
|
title={expandLabel}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExit}
|
||||||
|
className="mr-1 grid h-6 w-6 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/15 hover:text-white cursor-pointer"
|
||||||
|
aria-label={exitLabel}
|
||||||
|
title={exitLabel}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ArrowLeft, Hash, Search } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { playbackContextQuery } from "@/lib/playback-context";
|
||||||
|
import { type PlaylistState, usePlaylist } from "../hooks/usePlaylist";
|
||||||
|
import { useRelatedHashtags } from "../hooks/useRelatedHashtags";
|
||||||
|
import type { PlaybackContextInfo } from "../types.ts";
|
||||||
|
import { WorksGrid } from "./WorksGrid";
|
||||||
|
|
||||||
|
interface ContextListTabProps {
|
||||||
|
active: boolean;
|
||||||
|
context: PlaybackContextInfo;
|
||||||
|
list: PlaylistState;
|
||||||
|
currentId: string;
|
||||||
|
contextQuery: string;
|
||||||
|
/** hashtag 上下文时的 tag 名(用来取相关 tag) */
|
||||||
|
hashtagName: string | null;
|
||||||
|
/** 对应的搜索结果页链接(搜索上下文才有) */
|
||||||
|
searchPageHref: string | null;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致。
|
||||||
|
// hashtag 列表上方显示相关 tag(按共现计算);点相关 tag 只在侧栏里预览它的作品,不打断当前播放,
|
||||||
|
// 在预览里点作品才切换到那个 tag 的列表。
|
||||||
|
export const ContextListTab = memo(function ContextListTab({
|
||||||
|
active,
|
||||||
|
context,
|
||||||
|
list,
|
||||||
|
currentId,
|
||||||
|
contextQuery,
|
||||||
|
hashtagName,
|
||||||
|
searchPageHref,
|
||||||
|
onSelect,
|
||||||
|
}: ContextListTabProps) {
|
||||||
|
// 正在预览的相关 tag(null 表示显示当前列表本身)
|
||||||
|
const [browsing, setBrowsing] = useState<string | null>(null);
|
||||||
|
const viewTag = browsing ?? hashtagName;
|
||||||
|
|
||||||
|
const related = useRelatedHashtags(viewTag, active);
|
||||||
|
const browseQuery = browsing
|
||||||
|
? playbackContextQuery({ kind: "hashtag", name: browsing })
|
||||||
|
: "";
|
||||||
|
const browseList = usePlaylist(browseQuery, active && Boolean(browsing));
|
||||||
|
|
||||||
|
const pickTag = (name: string) => {
|
||||||
|
setBrowsing(
|
||||||
|
name.toLowerCase() === hashtagName?.toLowerCase() ? null : name,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = context.kind === "hashtag" ? Hash : Search;
|
||||||
|
const header = browsing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrowsing(null)}
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-white/80 hover:bg-white/20 cursor-pointer"
|
||||||
|
aria-label={`返回 ${context.label}`}
|
||||||
|
title={`返回 ${context.label}`}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-semibold text-white">#{browsing}</div>
|
||||||
|
<div className="text-xs text-white/50">
|
||||||
|
{related ? `共 ${related.count} 个 · ` : ""}点作品切换到这个 tag
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon size={18} className="shrink-0 text-rose-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-semibold text-white">{context.label}</div>
|
||||||
|
<div className="text-xs text-white/50">
|
||||||
|
第 {context.position} 个 · 共 {context.total} 个
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{searchPageHref && (
|
||||||
|
<Link
|
||||||
|
href={searchPageHref}
|
||||||
|
className="shrink-0 text-xs text-white/50 hover:text-white hover:underline"
|
||||||
|
>
|
||||||
|
在搜索页查看
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const relatedRow =
|
||||||
|
viewTag && related && related.related.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto px-3 py-2 border-b border-white/10 comment-scroll">
|
||||||
|
<span className="shrink-0 text-xs text-white/40">相关</span>
|
||||||
|
{related.related.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickTag(tag.name)}
|
||||||
|
className="shrink-0 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:border-white/30 hover:text-white cursor-pointer"
|
||||||
|
title={`一起出现在 ${tag.together} 个作品里,#${tag.name} 共 ${tag.count} 个作品`}
|
||||||
|
>
|
||||||
|
#{tag.name}
|
||||||
|
<span className="ml-1 text-white/40">{tag.together}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorksGrid
|
||||||
|
active={active}
|
||||||
|
list={browsing ? browseList : list}
|
||||||
|
currentId={currentId}
|
||||||
|
hrefQuery={browsing ? browseQuery : contextQuery}
|
||||||
|
onSelect={onSelect}
|
||||||
|
header={header}
|
||||||
|
subheader={relatedRow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,135 +1,478 @@
|
|||||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
import {
|
||||||
import type { ImageData } from "../types.ts";
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
|
||||||
|
import { releaseMediaElement } from "../utils";
|
||||||
|
|
||||||
interface ImageCarouselProps {
|
interface ImageCarouselProps {
|
||||||
images: ImageData["images"];
|
images: ImageData["images"];
|
||||||
currentIndex: number;
|
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;
|
onTogglePlay: () => void;
|
||||||
|
onAnimatedDuration: (imageId: string, durationMs: number) => void;
|
||||||
|
onMediaReady: (imageId: string) => void;
|
||||||
|
onAnimatedFailed: (imageId: string) => void;
|
||||||
|
onAnimatedProgress: (imageId: string, progress: number) => void;
|
||||||
|
onAnimatedEnded: (imageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
export const ImageCarousel = memo(
|
||||||
({ images, currentIndex, onTogglePlay }, ref) => {
|
forwardRef<HTMLDivElement, ImageCarouselProps>(
|
||||||
const [offset, setOffset] = useState(0);
|
(
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
{
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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 videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
||||||
const playedVideos = useRef<Set<string>>(new Set());
|
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 =
|
||||||
const visibleIndices = (() => {
|
loopMode === "single" ||
|
||||||
const indices: number[] = [];
|
(images.length === 1 &&
|
||||||
if (currentIndex > 0) indices.push(currentIndex - 1);
|
(loopMode !== "sequential" || !hasNextNeighbor));
|
||||||
indices.push(currentIndex);
|
|
||||||
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
|
|
||||||
return indices;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 当 currentIndex 变化时,触发滚动动画
|
const seekVideoToProgress = useCallback(
|
||||||
useEffect(() => {
|
(videoEl: HTMLVideoElement, progress: number): boolean => {
|
||||||
setIsTransitioning(true);
|
const duration = videoEl.duration;
|
||||||
setOffset(-currentIndex * 100);
|
if (!Number.isFinite(duration) || duration <= 0) return false;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
||||||
setIsTransitioning(false);
|
const maxTime = Math.max(0, duration - 0.05);
|
||||||
}, 300); // 与 CSS transition 时间匹配
|
const targetTime = Math.min(maxTime, duration * clampedProgress);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
if (Math.abs(videoEl.currentTime - targetTime) > 0.08) {
|
||||||
}, [currentIndex]);
|
try {
|
||||||
|
videoEl.currentTime = targetTime;
|
||||||
// 管理动图播放:进入视口时播放一次
|
} catch {
|
||||||
useEffect(() => {
|
return false;
|
||||||
const currentImage = images[currentIndex];
|
|
||||||
if (!currentImage?.animated) return;
|
|
||||||
|
|
||||||
const videoKey = currentImage.id;
|
|
||||||
const videoEl = videoRefs.current.get(videoKey);
|
|
||||||
|
|
||||||
if (videoEl) {
|
|
||||||
// 检查是否已经播放过
|
|
||||||
if (!playedVideos.current.has(videoKey)) {
|
|
||||||
// 重置并播放
|
|
||||||
videoEl.currentTime = 0;
|
|
||||||
videoEl.play().catch(() => {});
|
|
||||||
playedVideos.current.add(videoKey);
|
|
||||||
} else {
|
|
||||||
// 已播放过,重置到开头但不自动播放
|
|
||||||
videoEl.currentTime = 0;
|
|
||||||
videoEl.play().catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentIndex, images]);
|
return true;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
|
const tryPlayVideo = useCallback(
|
||||||
useEffect(() => {
|
(videoEl: HTMLVideoElement, imageId: string): void => {
|
||||||
const currentImage = images[currentIndex];
|
videoEl.play().catch((err: unknown) => {
|
||||||
if (currentImage?.animated) {
|
const errorName = err instanceof DOMException ? err.name : "";
|
||||||
const videoKey = currentImage.id;
|
if (
|
||||||
|
errorName === "NotAllowedError" ||
|
||||||
// 清除其他视频的播放记录
|
errorName === "NotSupportedError"
|
||||||
playedVideos.current.forEach(key => {
|
) {
|
||||||
if (key !== videoKey) {
|
onAnimatedFailed(imageId);
|
||||||
playedVideos.current.delete(key);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}, [currentIndex, images]);
|
[onAnimatedFailed],
|
||||||
|
);
|
||||||
|
|
||||||
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
|
// 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个 <video>
|
||||||
if (el) {
|
const visibleIndices = useMemo((): number[] => {
|
||||||
videoRefs.current.set(imageId, el);
|
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 {
|
} 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);
|
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 (
|
return (
|
||||||
<div
|
<div ref={ref} className="relative h-full w-full overflow-hidden">
|
||||||
ref={ref}
|
<div className="relative h-full w-full">
|
||||||
className="relative h-full w-full overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="flex h-full w-full"
|
|
||||||
style={{
|
|
||||||
transform: `translateX(${offset}%)`,
|
|
||||||
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibleIndices.map((i) => {
|
{visibleIndices.map((i) => {
|
||||||
const img = images[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
data-active={isCurrent ? "true" : undefined}
|
||||||
|
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${i * 100}%)`,
|
transform: `translateX(${(i - currentIndex) * 100}%)`,
|
||||||
position: 'absolute',
|
transition: "transform 240ms ease-out",
|
||||||
left: 0,
|
zIndex: isCurrent ? 1 : 0,
|
||||||
top: 0,
|
|
||||||
width: '100%',
|
|
||||||
}}
|
}}
|
||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
>
|
>
|
||||||
{img.animated ? (
|
{animatedSrc ? (
|
||||||
<video
|
<video
|
||||||
ref={(el) => handleVideoRef(el, img.id)}
|
ref={getVideoRefCallback(img.id)}
|
||||||
src={img.animated}
|
src={animatedSrc}
|
||||||
|
poster={img.url}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
className="max-w-full max-h-full object-contain"
|
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) => {
|
onEnded={(e) => {
|
||||||
// 播放结束后停留在最后一帧
|
if (shouldLoopCurrentVideo) {
|
||||||
|
try {
|
||||||
|
e.currentTarget.currentTime = 0;
|
||||||
|
} catch {}
|
||||||
|
if (isPlaying) {
|
||||||
|
tryPlayVideo(e.currentTarget, img.id);
|
||||||
|
}
|
||||||
|
onAnimatedEnded(img.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.currentTarget.pause();
|
e.currentTarget.pause();
|
||||||
|
onAnimatedEnded(img.id);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
onAnimatedFailed(img.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
|
ref={(el) => handleImgRef(el, img.id)}
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={`image-${i + 1}`}
|
alt={`image-${i + 1}`}
|
||||||
className="max-w-full max-h-full object-contain"
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
onLoad={() => onMediaReady(img.id)}
|
||||||
|
onError={() => onMediaReady(img.id)}
|
||||||
|
className="max-w-full max-h-full"
|
||||||
style={{
|
style={{
|
||||||
width: img.width ? `${img.width}px` : undefined,
|
width: img.width ? `${img.width}px` : undefined,
|
||||||
height: img.height ? `${img.height}px` : undefined,
|
height: img.height ? `${img.height}px` : undefined,
|
||||||
|
...mediaStyle,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -139,7 +482,8 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
ImageCarousel.displayName = "ImageCarousel";
|
ImageCarousel.displayName = "ImageCarousel";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
interface ImageNavigationButtonsProps {
|
interface ImageNavigationButtonsProps {
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
@@ -7,7 +8,7 @@ interface ImageNavigationButtonsProps {
|
|||||||
totalImages: number;
|
totalImages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageNavigationButtons({
|
export const ImageNavigationButtons = memo(function ImageNavigationButtons({
|
||||||
onPrev,
|
onPrev,
|
||||||
onNext,
|
onNext,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
@@ -20,13 +21,14 @@ export function ImageNavigationButtons({
|
|||||||
<>
|
<>
|
||||||
{/* 左侧按钮 */}
|
{/* 左侧按钮 */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={`
|
className={`
|
||||||
absolute left-4 top-6/17 -translate-y-1/2 z-20
|
absolute left-4 top-6/17 -translate-y-1/2 z-20
|
||||||
w-12 h-12 rounded-full
|
w-12 h-12 rounded-full
|
||||||
bg-black/40 backdrop-blur-sm border border-white/20
|
bg-black/55 border border-white/20
|
||||||
flex items-center justify-center
|
flex items-center justify-center
|
||||||
text-white transition-all
|
text-white transition-opacity
|
||||||
${hasPrev ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
${hasPrev ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||||
`}
|
`}
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={!hasPrev}
|
disabled={!hasPrev}
|
||||||
@@ -37,13 +39,14 @@ export function ImageNavigationButtons({
|
|||||||
|
|
||||||
{/* 右侧按钮 */}
|
{/* 右侧按钮 */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={`
|
className={`
|
||||||
absolute right-4 top-6/17 -translate-y-1/2 z-20
|
absolute right-4 top-6/17 -translate-y-1/2 z-20
|
||||||
w-12 h-12 rounded-full
|
w-12 h-12 rounded-full
|
||||||
bg-black/40 backdrop-blur-sm border border-white/20
|
bg-black/55 border border-white/20
|
||||||
flex items-center justify-center
|
flex items-center justify-center
|
||||||
text-white transition-all
|
text-white transition-opacity
|
||||||
${hasNext ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
${hasNext ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
|
||||||
`}
|
`}
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
@@ -53,4 +56,4 @@ export function ImageNavigationButtons({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
Download,
|
Download,
|
||||||
|
FileText,
|
||||||
Maximize,
|
Maximize,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize,
|
Minimize,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
Repeat,
|
||||||
Repeat1,
|
Repeat1,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { RefObject } from "react";
|
import Link from "next/link";
|
||||||
import type { LoopMode, ObjectFit, User } from "../types.ts";
|
import { memo, type ReactNode, type RefObject, useMemo } from "react";
|
||||||
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils";
|
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 { ProgressBar } from "./ProgressBar";
|
||||||
import { SegmentedProgressBar } from "./SegmentedProgressBar";
|
import { SegmentedProgressBar } from "./SegmentedProgressBar";
|
||||||
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
|
|
||||||
|
|
||||||
interface MediaControlsProps {
|
interface MediaControlsProps {
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
@@ -37,6 +39,8 @@ interface MediaControlsProps {
|
|||||||
desc: string;
|
desc: string;
|
||||||
// 视频专用
|
// 视频专用
|
||||||
videoRef?: RefObject<HTMLVideoElement | null>;
|
videoRef?: RefObject<HTMLVideoElement | null>;
|
||||||
|
abLoop?: AbLoop;
|
||||||
|
onClearAbLoop?: () => void;
|
||||||
// 图文专用
|
// 图文专用
|
||||||
currentIndex?: number;
|
currentIndex?: number;
|
||||||
totalSegments?: number;
|
totalSegments?: number;
|
||||||
@@ -56,9 +60,16 @@ interface MediaControlsProps {
|
|||||||
onLoopModeChange: (mode: LoopMode) => void;
|
onLoopModeChange: (mode: LoopMode) => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
|
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
|
||||||
|
onAuthorClick?: () => void;
|
||||||
|
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
|
||||||
|
onHashtagClick?: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaControls({
|
const CONTROL_BTN_CLASS =
|
||||||
|
"w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 cursor-pointer hover:bg-white/25 transition-colors";
|
||||||
|
|
||||||
|
export const MediaControls = memo(function MediaControls({
|
||||||
isVideo,
|
isVideo,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
progress,
|
progress,
|
||||||
@@ -72,6 +83,8 @@ export function MediaControls({
|
|||||||
createdAt,
|
createdAt,
|
||||||
desc,
|
desc,
|
||||||
videoRef,
|
videoRef,
|
||||||
|
abLoop,
|
||||||
|
onClearAbLoop,
|
||||||
currentIndex = 0,
|
currentIndex = 0,
|
||||||
totalSegments = 0,
|
totalSegments = 0,
|
||||||
segmentProgress = 0,
|
segmentProgress = 0,
|
||||||
@@ -88,26 +101,93 @@ export function MediaControls({
|
|||||||
onLoopModeChange,
|
onLoopModeChange,
|
||||||
onDownload,
|
onDownload,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
|
onAuthorClick,
|
||||||
|
onHashtagClick,
|
||||||
}: MediaControlsProps) {
|
}: 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 (
|
return (
|
||||||
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
|
<div
|
||||||
|
data-no-swipe
|
||||||
|
className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5"
|
||||||
|
>
|
||||||
{/* 描述行 */}
|
{/* 描述行 */}
|
||||||
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
|
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
|
||||||
{author.sec_uid ? (
|
{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
|
<Link
|
||||||
href={`/author/${author.sec_uid}`}
|
href={`/author/${author.sec_uid}`}
|
||||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
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" />
|
<img
|
||||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">{author.nickname}</span>
|
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>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
|
<img
|
||||||
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
|
<span className="text-[13px] leading-tight text-white/95 drop-shadow">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
||||||
title={formatAbsoluteUTC(createdAt)}
|
title={formatAbsoluteUTC(createdAt)}
|
||||||
@@ -119,7 +199,23 @@ export function MediaControls({
|
|||||||
{desc ? (
|
{desc ? (
|
||||||
<div className="pointer-events-none">
|
<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">
|
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
|
||||||
{desc}
|
{descParts.map((part, i) =>
|
||||||
|
part.type === "hashtag" && onHashtagClick ? (
|
||||||
|
<button
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onHashtagClick(part.name)}
|
||||||
|
className="pointer-events-auto font-semibold text-white hover:underline cursor-pointer"
|
||||||
|
title={`查看所有带 #${part.name} 的作品`}
|
||||||
|
>
|
||||||
|
{part.text}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
|
||||||
|
<span key={i}>{part.text}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -133,7 +229,12 @@ export function MediaControls({
|
|||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProgressBar progress={progress} onSeek={onSeek} />
|
<ProgressBar
|
||||||
|
progress={progress}
|
||||||
|
onSeek={onSeek}
|
||||||
|
abLoop={isVideo ? abLoop : undefined}
|
||||||
|
duration={isVideo ? (videoRef?.current?.duration ?? 0) : 0}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 控制按钮行 - 响应式布局 */}
|
{/* 控制按钮行 - 响应式布局 */}
|
||||||
@@ -141,7 +242,8 @@ export function MediaControls({
|
|||||||
{/* 左侧:播放控制 + 时间/进度 */}
|
{/* 左侧:播放控制 + 时间/进度 */}
|
||||||
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
|
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
|
||||||
<button
|
<button
|
||||||
className="w-[34px] h-[34px] shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={`${CONTROL_BTN_CLASS} shrink-0`}
|
||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
aria-label={isPlaying ? "暂停" : "播放"}
|
aria-label={isPlaying ? "暂停" : "播放"}
|
||||||
>
|
>
|
||||||
@@ -150,22 +252,23 @@ export function MediaControls({
|
|||||||
|
|
||||||
{/* 播放进度显示 - 所有设备都显示 */}
|
{/* 播放进度显示 - 所有设备都显示 */}
|
||||||
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
|
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
|
||||||
{isVideo ? (
|
{isVideo
|
||||||
(() => {
|
? (() => {
|
||||||
const v = videoRef?.current;
|
const v = videoRef?.current;
|
||||||
const current = v?.currentTime ?? 0;
|
const current = v?.currentTime ?? 0;
|
||||||
const total = v?.duration ?? 0;
|
const total = v?.duration ?? 0;
|
||||||
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--";
|
return total > 0
|
||||||
|
? `${formatTime(current)} / ${formatTime(total)}`
|
||||||
|
: "--:-- / --:--";
|
||||||
})()
|
})()
|
||||||
) : (
|
: `${currentIndex + 1} / ${totalSegments}`}
|
||||||
`${currentIndex + 1} / ${totalSegments}`
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
|
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<button
|
<button
|
||||||
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer shrink-0"
|
type="button"
|
||||||
|
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 cursor-pointer shrink-0 hover:bg-white/25 transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
||||||
const i = steps.indexOf(rate);
|
const i = steps.indexOf(rate);
|
||||||
@@ -177,13 +280,35 @@ export function MediaControls({
|
|||||||
{rate}x
|
{rate}x
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
{/* 中间:音量控制 - 中等屏幕以上显示 */}
|
{/* 中间:音量控制 - 中等屏幕以上显示 */}
|
||||||
<div className="hidden md:inline-flex items-center gap-2 shrink-0">
|
<div className="hidden md:inline-flex items-center gap-2 shrink-0">
|
||||||
{/* 旋转按钮 - 小屏幕以上显示 */}
|
{/* 旋转按钮 - 小屏幕以上显示 */}
|
||||||
<button
|
<button
|
||||||
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||||
onClick={() => onRotationChange((rotation + 270) % 360)}
|
onClick={() => onRotationChange((rotation + 270) % 360)}
|
||||||
aria-label="向左旋转 90 度"
|
aria-label="向左旋转 90 度"
|
||||||
title="向左旋转 90 度"
|
title="向左旋转 90 度"
|
||||||
@@ -191,7 +316,8 @@ export function MediaControls({
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={CONTROL_BTN_CLASS}
|
||||||
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
||||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||||
>
|
>
|
||||||
@@ -208,7 +334,8 @@ export function MediaControls({
|
|||||||
aria-label="音量"
|
aria-label="音量"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||||
onClick={() => onRotationChange((rotation + 90) % 360)}
|
onClick={() => onRotationChange((rotation + 90) % 360)}
|
||||||
aria-label="向右旋转 90 度"
|
aria-label="向右旋转 90 度"
|
||||||
title="向右旋转 90 度"
|
title="向右旋转 90 度"
|
||||||
@@ -221,7 +348,8 @@ export function MediaControls({
|
|||||||
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
|
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
|
||||||
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
|
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
|
||||||
<button
|
<button
|
||||||
className="md:hidden w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={`md:hidden ${CONTROL_BTN_CLASS}`}
|
||||||
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
||||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||||
>
|
>
|
||||||
@@ -230,28 +358,41 @@ export function MediaControls({
|
|||||||
|
|
||||||
{/* 循环模式 - 中等屏幕以上显示 */}
|
{/* 循环模式 - 中等屏幕以上显示 */}
|
||||||
<button
|
<button
|
||||||
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
|
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||||
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
onClick={handleLoopModeToggle}
|
||||||
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
aria-label={loopLabel}
|
||||||
|
title={loopLabel}
|
||||||
>
|
>
|
||||||
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
|
{renderLoopIcon()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 适配模式 - 小屏幕以上显示 */}
|
{/* 适配模式 - 小屏幕以上显示 */}
|
||||||
<button
|
<button
|
||||||
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
|
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||||
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
onClick={() =>
|
||||||
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
{objectFit === "contain" ? (
|
||||||
|
<Maximize2 size={18} />
|
||||||
|
) : (
|
||||||
|
<Minimize size={18} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
|
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
|
||||||
{isVideo && hasTranscript && onShowTranscript && (
|
{isVideo && hasTranscript && onShowTranscript && (
|
||||||
<button
|
<button
|
||||||
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||||
onClick={onShowTranscript}
|
onClick={onShowTranscript}
|
||||||
aria-label="显示转录文本"
|
aria-label="显示转录文本"
|
||||||
title="显示转录文本"
|
title="显示转录文本"
|
||||||
@@ -262,7 +403,8 @@ export function MediaControls({
|
|||||||
|
|
||||||
{/* 下载 - 中等屏幕以上显示 */}
|
{/* 下载 - 中等屏幕以上显示 */}
|
||||||
<button
|
<button
|
||||||
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
aria-label={isVideo ? "下载视频" : "下载当前图片"}
|
aria-label={isVideo ? "下载视频" : "下载当前图片"}
|
||||||
title={isVideo ? "下载视频" : "下载当前图片"}
|
title={isVideo ? "下载视频" : "下载当前图片"}
|
||||||
@@ -271,28 +413,34 @@ export function MediaControls({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 更多菜单 - 只在有按钮被折叠时显示 */}
|
{/* 更多菜单 - 只在有按钮被折叠时显示 */}
|
||||||
{/* sm屏幕以下会隐藏适配模式,md屏幕以下会隐藏循环、转录、下载、倍速 */}
|
|
||||||
<div className="sm:block md:hidden">
|
<div className="sm:block md:hidden">
|
||||||
<MoreMenu>
|
<MoreMenu>
|
||||||
{/* 小屏幕隐藏的适配模式 */}
|
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
icon={
|
||||||
|
objectFit === "contain" ? (
|
||||||
|
<Maximize2 size={18} />
|
||||||
|
) : (
|
||||||
|
<Minimize size={18} />
|
||||||
|
)
|
||||||
|
}
|
||||||
label={objectFit === "contain" ? "填充模式" : "适应模式"}
|
label={objectFit === "contain" ? "填充模式" : "适应模式"}
|
||||||
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
|
onClick={() =>
|
||||||
|
onObjectFitChange(
|
||||||
|
objectFit === "contain" ? "cover" : "contain",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 中等屏幕以下隐藏的循环模式 */}
|
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
|
icon={renderLoopIcon()}
|
||||||
label={loopMode === "loop" ? "循环播放" : "顺序播放"}
|
label={loopLabel}
|
||||||
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
|
onClick={handleLoopModeToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 中等屏幕以下隐藏的转录按钮 */}
|
|
||||||
{isVideo && hasTranscript && onShowTranscript && (
|
{isVideo && hasTranscript && onShowTranscript && (
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
@@ -303,7 +451,6 @@ export function MediaControls({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 中等屏幕以下隐藏的下载 */}
|
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={<Download size={18} />}
|
icon={<Download size={18} />}
|
||||||
@@ -312,7 +459,6 @@ export function MediaControls({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 仅在视频模式下显示的倍速(中等屏幕以下) */}
|
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
@@ -332,19 +478,20 @@ export function MediaControls({
|
|||||||
|
|
||||||
{/* 全屏 - 所有设备都显示 */}
|
{/* 全屏 - 所有设备都显示 */}
|
||||||
<button
|
<button
|
||||||
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
type="button"
|
||||||
|
className={CONTROL_BTN_CLASS}
|
||||||
onClick={onToggleFullscreen}
|
onClick={onToggleFullscreen}
|
||||||
aria-label="切换全屏"
|
aria-label="切换全屏"
|
||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
{/* 图文 BGM(隐藏控件,仅用于播放) */}
|
||||||
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null}
|
{!isVideo && musicUrl ? (
|
||||||
|
<audio ref={audioRef} src={musicUrl} loop preload="metadata" />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ export function MoreMenu({ children }: MoreMenuProps) {
|
|||||||
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"
|
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" }}
|
style={{ minWidth: "200px" }}
|
||||||
>
|
>
|
||||||
<div className="p-2 flex flex-col gap-1">
|
<div className="p-2 flex flex-col gap-1">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
MessageSquareText,
|
||||||
|
ThumbsUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { memo } from "react";
|
||||||
import type { Neighbors } from "../types.ts";
|
import type { Neighbors } from "../types.ts";
|
||||||
|
|
||||||
interface NavigationButtonsProps {
|
interface NavigationButtonsProps {
|
||||||
@@ -10,7 +16,7 @@ interface NavigationButtonsProps {
|
|||||||
onToggleComments: () => void;
|
onToggleComments: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavigationButtons({
|
export const NavigationButtons = memo(function NavigationButtons({
|
||||||
neighbors,
|
neighbors,
|
||||||
commentsCount,
|
commentsCount,
|
||||||
likesCount,
|
likesCount,
|
||||||
@@ -22,17 +28,20 @@ export function NavigationButtons({
|
|||||||
<>
|
<>
|
||||||
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
|
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2"
|
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">
|
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||||
<ThumbsUp size={40} className="" />
|
<ThumbsUp size={40} className="" />
|
||||||
|
|
||||||
<span className="text-[16px] font-semibold text-white/90 drop-shadow">{likesCount}</span>
|
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
|
||||||
|
{likesCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/* 评论开关(右侧中部) */}
|
{/* 评论开关(右侧中部) */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2 cursor-pointer"
|
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2 cursor-pointer"
|
||||||
onClick={onToggleComments}
|
onClick={onToggleComments}
|
||||||
aria-label="切换评论"
|
aria-label="切换评论"
|
||||||
@@ -40,19 +49,24 @@ export function NavigationButtons({
|
|||||||
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
<div className="grid place-items-center gap-1 drop-shadow-lg">
|
||||||
<MessageSquareText size={40} className="" />
|
<MessageSquareText size={40} className="" />
|
||||||
|
|
||||||
{commentsCount > 0 ? <span className="text-[16px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
|
{commentsCount > 0 ? (
|
||||||
<span className="text-[12px] font-semibold text-white/90 drop-shadow">暂无评论</span>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 上下切换按钮(右侧胶囊形状) */}
|
{/* 上下切换按钮(右侧胶囊形状) */}
|
||||||
<div className="absolute right-4 top-5/11 -translate-y-1/2 z-10">
|
<div className="absolute right-4 top-5/11 -translate-y-1/2 z-10">
|
||||||
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden">
|
<div className="flex flex-col rounded-full bg-black/55 border border-white/20 overflow-hidden">
|
||||||
<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"
|
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}
|
onClick={onNavigatePrev}
|
||||||
disabled={!neighbors.prev}
|
disabled={!neighbors.prev}
|
||||||
@@ -61,6 +75,7 @@ export function NavigationButtons({
|
|||||||
<ChevronUp size={24} />
|
<ChevronUp size={24} />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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}
|
onClick={onNavigateNext}
|
||||||
disabled={!neighbors.next}
|
disabled={!neighbors.next}
|
||||||
@@ -72,4 +87,4 @@ export function NavigationButtons({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,18 +1,120 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import type { AbLoop } from "../types.ts";
|
||||||
|
|
||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
onSeek: (ratio: number) => void;
|
onSeek: (ratio: number) => void;
|
||||||
|
abLoop?: AbLoop;
|
||||||
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
|
export const ProgressBar = memo(function ProgressBar({
|
||||||
|
progress,
|
||||||
|
onSeek,
|
||||||
|
abLoop,
|
||||||
|
duration = 0,
|
||||||
|
}: ProgressBarProps) {
|
||||||
|
const seekFromClientX = (clientX: number, element: HTMLElement): void => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
onSeek((clientX - rect.left) / rect.width);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAbLoop = Boolean(
|
||||||
|
abLoop && (abLoop.a !== null || abLoop.b !== null) && duration > 0,
|
||||||
|
);
|
||||||
|
const startRatio =
|
||||||
|
hasAbLoop && abLoop
|
||||||
|
? Math.max(0, Math.min(1, (abLoop.a ?? 0) / duration))
|
||||||
|
: 0;
|
||||||
|
const endRatio =
|
||||||
|
hasAbLoop && abLoop
|
||||||
|
? Math.max(startRatio, Math.min(1, (abLoop.b ?? duration) / duration))
|
||||||
|
: 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
className="relative h-1.5 cursor-pointer touch-none select-none"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
|
||||||
onSeek((e.clientX - rect.left) / rect.width);
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
seekFromClientX(e.clientX, e.currentTarget);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
||||||
|
seekFromClientX(e.clientX, e.currentTarget);
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} />
|
{/* 轨道主体 */}
|
||||||
|
<div className="relative h-full w-full rounded-full bg-white/25 overflow-hidden">
|
||||||
|
{/* AB 循环区间底色高亮 */}
|
||||||
|
{hasAbLoop && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 bg-amber-400/40"
|
||||||
|
style={{
|
||||||
|
left: `${startRatio * 100}%`,
|
||||||
|
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 播放进度填充 */}
|
||||||
|
<div
|
||||||
|
className={`origin-left h-full will-change-transform ${
|
||||||
|
hasAbLoop ? "bg-amber-300" : "bg-white"
|
||||||
|
}`}
|
||||||
|
style={{ transform: `scaleX(${progress || 0})` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* AB 循环区间顶部与底部亮边 */}
|
||||||
|
{hasAbLoop && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-0 bottom-0 border-y border-amber-300/90 bg-amber-300/20"
|
||||||
|
style={{
|
||||||
|
left: `${startRatio * 100}%`,
|
||||||
|
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A / B 端点指示标记 */}
|
||||||
|
{hasAbLoop && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
|
||||||
|
style={{ left: `${startRatio * 100}%` }}
|
||||||
|
>
|
||||||
|
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
|
||||||
|
style={{ left: `${endRatio * 100}%` }}
|
||||||
|
>
|
||||||
|
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
|
||||||
|
B
|
||||||
|
</span>
|
||||||
|
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
interface SegmentedProgressBarProps {
|
interface SegmentedProgressBarProps {
|
||||||
totalSegments: number;
|
totalSegments: number;
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
@@ -5,18 +7,64 @@ interface SegmentedProgressBarProps {
|
|||||||
onSeek: (ratio: number) => void;
|
onSeek: (ratio: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SegmentedProgressBar({
|
const SegmentBarItem = memo(function SegmentBarItem({
|
||||||
|
index,
|
||||||
|
fill,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
fill: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label={`第 ${index + 1} 段`}
|
||||||
|
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full origin-left bg-white will-change-transform"
|
||||||
|
style={{
|
||||||
|
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SegmentedProgressBar = memo(function SegmentedProgressBar({
|
||||||
totalSegments,
|
totalSegments,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
segmentProgress,
|
segmentProgress,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: SegmentedProgressBarProps) {
|
}: SegmentedProgressBarProps) {
|
||||||
|
const seekFromClientX = (clientX: number, element: HTMLElement): void => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
onSeek((clientX - rect.left) / rect.width);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-1.5 cursor-pointer"
|
className="relative h-1.5 cursor-pointer touch-none"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
|
||||||
onSeek((e.clientX - rect.left) / rect.width);
|
}}
|
||||||
|
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">
|
<div className="flex gap-1.5 h-full">
|
||||||
@@ -24,17 +72,9 @@ export function SegmentedProgressBar({
|
|||||||
let fill = 0;
|
let fill = 0;
|
||||||
if (i < currentIndex) fill = 1;
|
if (i < currentIndex) fill = 1;
|
||||||
else if (i === currentIndex) fill = segmentProgress;
|
else if (i === currentIndex) fill = segmentProgress;
|
||||||
return (
|
return <SegmentBarItem key={`seg-${i}`} index={i} fill={fill} />;
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-label={`第 ${i + 1} 段`}
|
|
||||||
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${fill})` }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { X, Copy, CheckCheck, Check } from "lucide-react";
|
import { Check, CheckCheck, Copy, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import type { VideoTranscript } from "../types";
|
import type { VideoTranscript } from "../types";
|
||||||
|
|
||||||
interface TranscriptPanelProps {
|
interface TranscriptPanelProps {
|
||||||
@@ -10,42 +10,59 @@ interface TranscriptPanelProps {
|
|||||||
transcript: VideoTranscript | null;
|
transcript: VideoTranscript | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) {
|
export const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
transcript,
|
||||||
|
}: TranscriptPanelProps) {
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
const [copiedAll, setCopiedAll] = useState(false);
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleCopy = (text: string, index: number) => {
|
const handleCopy = (text: string, index: number): void => {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
void navigator.clipboard.writeText(text).then(() => {
|
||||||
setCopiedIndex(index);
|
setCopiedIndex(index);
|
||||||
setTimeout(() => setCopiedIndex(null), 2000);
|
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current);
|
||||||
|
copyTimerRef.current = setTimeout(() => setCopiedIndex(null), 2000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyAll = () => {
|
const handleCopyAll = (): void => {
|
||||||
if (!transcript?.transcript) return;
|
if (!transcript?.transcript) return;
|
||||||
const allText = transcript.transcript.join("\n");
|
const allText = transcript.transcript.join("\n");
|
||||||
navigator.clipboard.writeText(allText).then(() => {
|
void navigator.clipboard.writeText(allText).then(() => {
|
||||||
setCopiedAll(true);
|
setCopiedAll(true);
|
||||||
setTimeout(() => setCopiedAll(false), 2000);
|
if (copyAllTimerRef.current !== null)
|
||||||
|
clearTimeout(copyAllTimerRef.current);
|
||||||
|
copyAllTimerRef.current = setTimeout(() => setCopiedAll(false), 2000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!transcript?.speech_detected || !transcript.transcript?.length) {
|
if (!transcript?.transcript?.length) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
@@ -54,9 +71,8 @@ export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelPr
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容 */}
|
|
||||||
<div className="p-8 text-center text-white/60">
|
<div className="p-8 text-center text-white/60">
|
||||||
<p>该视频暂无语音转录内容</p>
|
<p>{transcript ? "未识别到文字" : "该视频尚未转写"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,25 +81,20 @@ export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl flex flex-col"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||||
{transcript.language && (
|
|
||||||
<p className="text-xs text-white/50 mt-1">
|
|
||||||
语言: {transcript.language}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleCopyAll}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -100,6 +111,7 @@ export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelPr
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
@@ -109,11 +121,10 @@ export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 转录列表 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{transcript.transcript.map((text, index) => (
|
{transcript.transcript.map((text, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={`transcript-${index}`}
|
||||||
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
|
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
|
||||||
onClick={() => handleCopy(text, index)}
|
onClick={() => handleCopy(text, index)}
|
||||||
>
|
>
|
||||||
@@ -129,7 +140,11 @@ export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelPr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(text, index)}
|
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"
|
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all"
|
||||||
aria-label="复制"
|
aria-label="复制"
|
||||||
>
|
>
|
||||||
@@ -144,11 +159,10 @@ export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelPr
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部统计 */}
|
|
||||||
<div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50">
|
<div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50">
|
||||||
共 {transcript.transcript.length} 段转录内容
|
共 {transcript.transcript.length} 段转录内容
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef } from "react";
|
import { forwardRef, memo } from "react";
|
||||||
import type { ObjectFit } from "../types.ts";
|
import type { ObjectFit } from "../types.ts";
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
@@ -9,19 +9,18 @@ interface VideoPlayerProps {
|
|||||||
onTogglePlay: () => void;
|
onTogglePlay: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
export const VideoPlayer = memo(
|
||||||
|
forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||||
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
|
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={[
|
className={
|
||||||
// 旋转 0/180:充满容器盒子;
|
|
||||||
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
|
|
||||||
rotation % 180 === 0
|
rotation % 180 === 0
|
||||||
? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer"
|
? "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",
|
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer"
|
||||||
].join(" ")}
|
}
|
||||||
style={{
|
style={{
|
||||||
transform:
|
transform:
|
||||||
rotation % 180 === 0
|
rotation % 180 === 0
|
||||||
@@ -35,7 +34,8 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
|||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
VideoPlayer.displayName = "VideoPlayer";
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, Tag, X } from "lucide-react";
|
||||||
|
import { memo, useEffect, useState } from "react";
|
||||||
|
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||||
|
|
||||||
|
type TagUpdateResponse = {
|
||||||
|
updated?: {
|
||||||
|
aweme_id: string;
|
||||||
|
content_tags: string[];
|
||||||
|
manual_content_tags: string[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type VideoTagEditorProps = {
|
||||||
|
awemeId: string;
|
||||||
|
tags: string[];
|
||||||
|
onTagsChange: (tags: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoTagEditor = memo(function VideoTagEditor({
|
||||||
|
awemeId,
|
||||||
|
tags,
|
||||||
|
onTagsChange,
|
||||||
|
}: VideoTagEditorProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draftTags, setDraftTags] = useState<string[]>(tags);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setDraftTags(tags);
|
||||||
|
}, [open, tags]);
|
||||||
|
|
||||||
|
const toggleTag = (tag: string): void => {
|
||||||
|
setDraftTags((current) =>
|
||||||
|
current.includes(tag)
|
||||||
|
? current.filter((item) => item !== tag)
|
||||||
|
: [...current, tag],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTags = async (): Promise<void> => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/asr/tags", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
awemeId,
|
||||||
|
contentTags: draftTags,
|
||||||
|
mode: "set",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("保存标签失败");
|
||||||
|
const data: TagUpdateResponse = await response.json();
|
||||||
|
const updated = data.updated?.find((item) => item.aweme_id === awemeId);
|
||||||
|
onTagsChange(updated?.content_tags ?? draftTags);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "保存标签失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-no-swipe
|
||||||
|
className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/55 px-3 text-sm font-medium text-white shadow-lg transition-colors hover:bg-black/70"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Tag size={16} />
|
||||||
|
{tags.length > 0 ? tags.join("、") : "打标签"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/95 p-3 text-white shadow-2xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-medium">手动标签</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-full p-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
aria-label="关闭标签编辑"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{VIDEO_CONTENT_TAGS.map((tag) => {
|
||||||
|
const selected = draftTags.includes(tag);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-blue-400 bg-blue-500/25 text-blue-100"
|
||||||
|
: "border-white/10 bg-white/5 text-white/70 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected && <Check size={13} />}
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mt-3 text-xs text-rose-300">{error}</div>}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDraftTags([])}
|
||||||
|
className="rounded-full px-3 py-1.5 text-xs text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveTags}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Heart, Images } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { memo, type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import { awemeHref } from "@/lib/playback-context";
|
||||||
|
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||||
|
|
||||||
|
interface WorksGridProps {
|
||||||
|
/** 所在标签页当前可见 */
|
||||||
|
active: boolean;
|
||||||
|
list: PlaylistState;
|
||||||
|
currentId: string;
|
||||||
|
/** 点击作品跳转时带上的播放上下文查询串 */
|
||||||
|
hrefQuery: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
/** 列表只有当前这一个作品时的提示 */
|
||||||
|
singleHint?: string;
|
||||||
|
/** 网格上方的标题栏 */
|
||||||
|
header: ReactNode;
|
||||||
|
/** 标题栏下方的附加内容(如相关 tag) */
|
||||||
|
subheader?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(n: number): string {
|
||||||
|
if (n >= 10000) return `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}万`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayingBars() {
|
||||||
|
return (
|
||||||
|
<span className="flex h-4 items-end gap-0.5" aria-hidden>
|
||||||
|
{[60, 100, 40].map((height, i) => (
|
||||||
|
<span
|
||||||
|
key={height}
|
||||||
|
className="w-1 rounded-sm bg-white animate-pulse"
|
||||||
|
style={{ height: `${height}%`, animationDelay: `${i * 150}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧栏里的作品封面网格(TA的作品 / 搜索结果 / hashtag 共用):当前作品标「播放中」,点作品就地切换
|
||||||
|
export const WorksGrid = memo(function WorksGrid({
|
||||||
|
active,
|
||||||
|
list,
|
||||||
|
currentId,
|
||||||
|
hrefQuery,
|
||||||
|
onSelect,
|
||||||
|
singleHint,
|
||||||
|
header,
|
||||||
|
subheader,
|
||||||
|
}: WorksGridProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
const items = list.items ?? [];
|
||||||
|
|
||||||
|
// 让「播放中」的作品保持在可见区域(切换作品或列表加载完成时重新定位)
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: currentId / items.length 只用来触发重新定位
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const el = currentRef.current;
|
||||||
|
if (!container || !el) return;
|
||||||
|
const top = el.offsetTop - container.offsetTop;
|
||||||
|
const bottom = top + el.offsetHeight;
|
||||||
|
if (
|
||||||
|
top < container.scrollTop ||
|
||||||
|
bottom > container.scrollTop + container.clientHeight
|
||||||
|
) {
|
||||||
|
container.scrollTop =
|
||||||
|
top - (container.clientHeight - el.offsetHeight) / 2;
|
||||||
|
}
|
||||||
|
}, [active, currentId, items.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-3 border-b border-white/10 shrink-0">
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
{subheader}
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="comment-scroll flex-1 overflow-auto p-2">
|
||||||
|
{list.loading && !list.items && (
|
||||||
|
<div className="py-10 text-center text-sm text-white/60">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.error && !list.items && (
|
||||||
|
<div className="py-10 text-center text-sm text-red-300/80">
|
||||||
|
{list.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isCurrent = item.aweme_id === currentId;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.aweme_id}
|
||||||
|
ref={isCurrent ? currentRef : undefined}
|
||||||
|
href={awemeHref(item.aweme_id, hrefQuery)}
|
||||||
|
scroll={false}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={item.desc}
|
||||||
|
aria-current={isCurrent ? "true" : undefined}
|
||||||
|
className={`relative block aspect-[3/4] overflow-hidden rounded-md bg-white/5 ${
|
||||||
|
isCurrent ? "ring-2 ring-rose-400" : "hover:opacity-90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.cover_url ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: 封面来自对象存储,不走 next/image
|
||||||
|
<img
|
||||||
|
src={item.cover_url}
|
||||||
|
alt={item.desc?.slice(0, 20) || ""}
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
{item.type === "image" && (
|
||||||
|
<span className="absolute right-1 top-1 inline-flex items-center gap-0.5 rounded bg-black/55 px-1 py-0.5 text-[10px] text-white/90">
|
||||||
|
<Images size={10} />
|
||||||
|
图文
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCurrent ? (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/55 text-xs text-white">
|
||||||
|
<PlayingBars />
|
||||||
|
播放中
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="absolute bottom-1 left-1 inline-flex items-center gap-0.5 text-[11px] text-white/90 drop-shadow">
|
||||||
|
<Heart size={11} />
|
||||||
|
{formatCount(item.likes)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{list.items && items.length <= 1 && singleHint && (
|
||||||
|
<div className="py-6 text-center text-xs text-white/40">
|
||||||
|
{singleHint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.items && list.total > items.length && (
|
||||||
|
<div className="py-6 text-center text-xs text-white/40">
|
||||||
|
只列出前 {items.length} 个,共 {list.total} 个
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,216 @@
|
|||||||
export const emojiList = [
|
export const emojiList = [
|
||||||
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子"
|
"微笑",
|
||||||
]
|
"色",
|
||||||
|
"发呆",
|
||||||
|
"酷拽",
|
||||||
|
"抠鼻",
|
||||||
|
"流泪",
|
||||||
|
"捂脸",
|
||||||
|
"发怒",
|
||||||
|
"呲牙",
|
||||||
|
"尬笑",
|
||||||
|
"害羞",
|
||||||
|
"调皮",
|
||||||
|
"舔屏",
|
||||||
|
"看",
|
||||||
|
"爱心",
|
||||||
|
"比心",
|
||||||
|
"赞",
|
||||||
|
"鼓掌",
|
||||||
|
"感谢",
|
||||||
|
"抱抱你",
|
||||||
|
"玫瑰",
|
||||||
|
"尴尬流汗",
|
||||||
|
"戳手手",
|
||||||
|
"星星眼",
|
||||||
|
"杀马特",
|
||||||
|
"黄脸干杯",
|
||||||
|
"抱紧自己",
|
||||||
|
"拜拜",
|
||||||
|
"热化了",
|
||||||
|
"黄脸祈祷",
|
||||||
|
"懵",
|
||||||
|
"举手",
|
||||||
|
"加功德",
|
||||||
|
"摊手",
|
||||||
|
"无语流汗",
|
||||||
|
"续火花吧",
|
||||||
|
"点火",
|
||||||
|
"哭哭",
|
||||||
|
"吐舌小狗",
|
||||||
|
"送花",
|
||||||
|
"爱心手",
|
||||||
|
"贴贴",
|
||||||
|
"灵机一动",
|
||||||
|
"耶",
|
||||||
|
"打脸",
|
||||||
|
"大笑",
|
||||||
|
"机智",
|
||||||
|
"送心",
|
||||||
|
"666",
|
||||||
|
"闭嘴",
|
||||||
|
"来看我",
|
||||||
|
"一起加油",
|
||||||
|
"哈欠",
|
||||||
|
"震惊",
|
||||||
|
"晕",
|
||||||
|
"衰",
|
||||||
|
"困",
|
||||||
|
"疑问",
|
||||||
|
"泣不成声",
|
||||||
|
"小鼓掌",
|
||||||
|
"大金牙",
|
||||||
|
"偷笑",
|
||||||
|
"石化",
|
||||||
|
"思考",
|
||||||
|
"吐血",
|
||||||
|
"可怜",
|
||||||
|
"嘘",
|
||||||
|
"撇嘴",
|
||||||
|
"笑哭",
|
||||||
|
"奸笑",
|
||||||
|
"得意",
|
||||||
|
"憨笑",
|
||||||
|
"坏笑",
|
||||||
|
"抓狂",
|
||||||
|
"泪奔",
|
||||||
|
"钱",
|
||||||
|
"恐惧",
|
||||||
|
"愉快",
|
||||||
|
"快哭了",
|
||||||
|
"翻白眼",
|
||||||
|
"互粉",
|
||||||
|
"我想静静",
|
||||||
|
"委屈",
|
||||||
|
"鄙视",
|
||||||
|
"飞吻",
|
||||||
|
"再见",
|
||||||
|
"紫薇别走",
|
||||||
|
"听歌",
|
||||||
|
"求抱抱",
|
||||||
|
"绝望的凝视",
|
||||||
|
"不失礼貌的微笑",
|
||||||
|
"不看",
|
||||||
|
"裂开",
|
||||||
|
"干饭人",
|
||||||
|
"庆祝",
|
||||||
|
"吐舌",
|
||||||
|
"呆无辜",
|
||||||
|
"白眼",
|
||||||
|
"猪头",
|
||||||
|
"冷漠",
|
||||||
|
"暗中观察",
|
||||||
|
"二哈",
|
||||||
|
"菜狗",
|
||||||
|
"黑脸",
|
||||||
|
"展开说说",
|
||||||
|
"蜜蜂狗",
|
||||||
|
"柴犬",
|
||||||
|
"摸头",
|
||||||
|
"皱眉",
|
||||||
|
"擦汗",
|
||||||
|
"红脸",
|
||||||
|
"做鬼脸",
|
||||||
|
"强",
|
||||||
|
"如花",
|
||||||
|
"吐",
|
||||||
|
"惊喜",
|
||||||
|
"敲打",
|
||||||
|
"奋斗",
|
||||||
|
"吐彩虹",
|
||||||
|
"大哭",
|
||||||
|
"嘿哈",
|
||||||
|
"惊恐",
|
||||||
|
"囧",
|
||||||
|
"难过",
|
||||||
|
"斜眼",
|
||||||
|
"阴险",
|
||||||
|
"悠闲",
|
||||||
|
"咒骂",
|
||||||
|
"吃瓜群众",
|
||||||
|
"绿帽子",
|
||||||
|
"敢怒不敢言",
|
||||||
|
"求求了",
|
||||||
|
"眼含热泪",
|
||||||
|
"叹气",
|
||||||
|
"好开心",
|
||||||
|
"不是吧",
|
||||||
|
"鞠躬",
|
||||||
|
"躺平",
|
||||||
|
"九转大肠",
|
||||||
|
"不你不想",
|
||||||
|
"一头乱麻",
|
||||||
|
"kisskiss",
|
||||||
|
"你不大行",
|
||||||
|
"噢买尬",
|
||||||
|
"宕机",
|
||||||
|
"苦涩",
|
||||||
|
"逞强落泪",
|
||||||
|
"求机位-黄脸",
|
||||||
|
"求机位3",
|
||||||
|
"点赞",
|
||||||
|
"精选",
|
||||||
|
"强壮",
|
||||||
|
"碰拳",
|
||||||
|
"OK",
|
||||||
|
"击掌",
|
||||||
|
"左上",
|
||||||
|
"握手",
|
||||||
|
"抱拳",
|
||||||
|
"勾引",
|
||||||
|
"拳头",
|
||||||
|
"弱",
|
||||||
|
"胜利",
|
||||||
|
"右边",
|
||||||
|
"左边",
|
||||||
|
"嘴唇",
|
||||||
|
"心碎",
|
||||||
|
"凋谢",
|
||||||
|
"愤怒",
|
||||||
|
"垃圾",
|
||||||
|
"啤酒",
|
||||||
|
"咖啡",
|
||||||
|
"蛋糕",
|
||||||
|
"礼物",
|
||||||
|
"撒花",
|
||||||
|
"加一",
|
||||||
|
"减一",
|
||||||
|
"okk",
|
||||||
|
"V5",
|
||||||
|
"绝",
|
||||||
|
"给力",
|
||||||
|
"红包",
|
||||||
|
"屎",
|
||||||
|
"发",
|
||||||
|
"18禁",
|
||||||
|
"炸弹",
|
||||||
|
"西瓜",
|
||||||
|
"加鸡腿",
|
||||||
|
"握爪",
|
||||||
|
"太阳",
|
||||||
|
"月亮",
|
||||||
|
"给跪了",
|
||||||
|
"蕉绿",
|
||||||
|
"扎心",
|
||||||
|
"胡瓜",
|
||||||
|
"打call",
|
||||||
|
"栓Q",
|
||||||
|
"雪花",
|
||||||
|
"圣诞树",
|
||||||
|
"平安果",
|
||||||
|
"圣诞帽",
|
||||||
|
"气球",
|
||||||
|
"烟花",
|
||||||
|
"福",
|
||||||
|
"candy",
|
||||||
|
"糖葫芦",
|
||||||
|
"鞭炮",
|
||||||
|
"元宝",
|
||||||
|
"灯笼",
|
||||||
|
"锦鲤",
|
||||||
|
"巧克力",
|
||||||
|
"戒指",
|
||||||
|
"棒棒糖",
|
||||||
|
"纸飞机",
|
||||||
|
"粽子",
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Hooks 导出
|
// Hooks 导出
|
||||||
export { useBackgroundCanvas } from "./useBackgroundCanvas";
|
export { useBackgroundCanvas } from "./useBackgroundCanvas";
|
||||||
export { useCommentState } from "./useCommentState";
|
export { useSidePanelState } from "./useSidePanelState";
|
||||||
export { useImageCarousel } from "./useImageCarousel";
|
export { useImageCarousel } from "./useImageCarousel";
|
||||||
export { useNavigation } from "./useNavigation";
|
export { useNavigation } from "./useNavigation";
|
||||||
export { usePlayerState } from "./usePlayerState";
|
export { usePlayerState } from "./usePlayerState";
|
||||||
|
|||||||
@@ -1,78 +1,90 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface UseBackgroundCanvasProps {
|
interface UseBackgroundCanvasProps {
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
idx: number;
|
isPlaying: boolean;
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
|
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CANVAS_DRAW_INTERVAL_MS = 100; // 10fps 对 1/10 分辨率 + blur(40px) 已完全平滑,大幅降低 GPU 负载
|
||||||
|
|
||||||
export function useBackgroundCanvas({
|
export function useBackgroundCanvas({
|
||||||
isVideo,
|
isVideo,
|
||||||
idx,
|
isPlaying,
|
||||||
videoRef,
|
videoRef,
|
||||||
scrollerRef,
|
|
||||||
backgroundCanvasRef,
|
backgroundCanvasRef,
|
||||||
}: UseBackgroundCanvasProps) {
|
}: UseBackgroundCanvasProps): void {
|
||||||
|
// 初始化尺寸与卸载清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = backgroundCanvasRef.current;
|
const canvas = backgroundCanvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx || !isVideo) {
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updateCanvasSize = () => {
|
const updateCanvasSize = (): void => {
|
||||||
canvas.width = Math.floor(window.innerWidth / 10);
|
canvas.width = Math.max(1, Math.floor(window.innerWidth / 10));
|
||||||
canvas.height = Math.floor(window.innerHeight / 10);
|
canvas.height = Math.max(1, Math.floor(window.innerHeight / 10));
|
||||||
};
|
};
|
||||||
updateCanvasSize();
|
updateCanvasSize();
|
||||||
|
|
||||||
let resizeTimeout: NodeJS.Timeout;
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
const debouncedResize = () => {
|
const debouncedResize = (): void => {
|
||||||
clearTimeout(resizeTimeout);
|
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
|
||||||
resizeTimeout = setTimeout(updateCanvasSize, 300);
|
resizeTimeout = setTimeout(updateCanvasSize, 300);
|
||||||
};
|
};
|
||||||
window.addEventListener("resize", debouncedResize);
|
window.addEventListener("resize", debouncedResize);
|
||||||
|
|
||||||
const drawMediaToCanvas = () => {
|
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;
|
if (!ctx) return;
|
||||||
|
|
||||||
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
|
const drawMediaToCanvas = (): void => {
|
||||||
|
if (document.hidden) return;
|
||||||
if (isVideo) {
|
const sourceElement = videoRef.current;
|
||||||
sourceElement = videoRef.current;
|
if (
|
||||||
} else {
|
!sourceElement ||
|
||||||
const scroller = scrollerRef.current;
|
sourceElement.readyState < 2 ||
|
||||||
if (scroller) {
|
sourceElement.paused
|
||||||
// 虚拟滚动:查找所有图片容器,找到当前显示的那个
|
) {
|
||||||
const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
|
return;
|
||||||
for (const container of containers) {
|
|
||||||
const img = container.querySelector("img, video");
|
|
||||||
if (img && container.style.transform.includes(`${idx * 100}%`)) {
|
|
||||||
sourceElement = img as HTMLImageElement | HTMLVideoElement;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
|
|
||||||
|
|
||||||
const canvasWidth = canvas.width;
|
const canvasWidth = canvas.width;
|
||||||
const canvasHeight = canvas.height;
|
const canvasHeight = canvas.height;
|
||||||
const sourceWidth =
|
const sourceWidth = sourceElement.videoWidth;
|
||||||
sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
|
const sourceHeight = sourceElement.videoHeight;
|
||||||
const sourceHeight =
|
|
||||||
sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
|
|
||||||
|
|
||||||
if (!sourceWidth || !sourceHeight) return;
|
if (!canvasWidth || !canvasHeight || !sourceWidth || !sourceHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvasRatio = canvasWidth / canvasHeight;
|
const canvasRatio = canvasWidth / canvasHeight;
|
||||||
const sourceRatio = sourceWidth / sourceHeight;
|
const sourceRatio = sourceWidth / sourceHeight;
|
||||||
|
|
||||||
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
|
let drawWidth: number;
|
||||||
|
let drawHeight: number;
|
||||||
|
let offsetX: number;
|
||||||
|
let offsetY: number;
|
||||||
|
|
||||||
if (canvasRatio > sourceRatio) {
|
if (canvasRatio > sourceRatio) {
|
||||||
drawWidth = canvasWidth;
|
drawWidth = canvasWidth;
|
||||||
@@ -89,12 +101,14 @@ export function useBackgroundCanvas({
|
|||||||
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
const intervalId = setInterval(drawMediaToCanvas, 34);
|
drawMediaToCanvas();
|
||||||
|
const intervalId = window.setInterval(
|
||||||
|
drawMediaToCanvas,
|
||||||
|
CANVAS_DRAW_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalId);
|
window.clearInterval(intervalId);
|
||||||
window.removeEventListener("resize", debouncedResize);
|
|
||||||
clearTimeout(resizeTimeout);
|
|
||||||
};
|
};
|
||||||
}, [isVideo, idx, videoRef, scrollerRef, backgroundCanvasRef]);
|
}, [isVideo, isPlaying, videoRef, backgroundCanvasRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { getStringFromStorage, saveToStorage } from "../utils";
|
|
||||||
|
|
||||||
export function useCommentState() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
// 从 localStorage 恢复评论区状态
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const saved = getStringFromStorage("aweme_player_comments_open", "false");
|
|
||||||
if (saved === "true") {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
// 短暂延迟后标记为已挂载,启用动画
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setMounted(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 持久化评论区状态
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
saveToStorage("aweme_player_comments_open", open.toString());
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return { open, setOpen, mounted };
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,339 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import type { RefObject } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { 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 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 {
|
interface UseImageCarouselProps {
|
||||||
images: ImageData["images"];
|
images: ImageData["images"];
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
playbackRate?: number;
|
||||||
loopMode: LoopMode;
|
loopMode: LoopMode;
|
||||||
neighbors: Neighbors;
|
neighbors: Neighbors;
|
||||||
volume: number;
|
volume: number;
|
||||||
audioRef: RefObject<HTMLAudioElement | null>;
|
audioRef: RefObject<HTMLAudioElement | null>;
|
||||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
setProgress: (progress: number) => void;
|
setProgress: (progress: number) => void;
|
||||||
/** 单张图片显示时长(毫秒),默认 5000ms */
|
/** 单张图片显示时长(毫秒),默认 5000ms */
|
||||||
segmentMs?: number;
|
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({
|
export function useImageCarousel({
|
||||||
images,
|
images,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
playbackRate = 1,
|
||||||
loopMode,
|
loopMode,
|
||||||
neighbors,
|
neighbors,
|
||||||
volume,
|
volume,
|
||||||
audioRef,
|
audioRef,
|
||||||
scrollerRef,
|
|
||||||
setProgress,
|
setProgress,
|
||||||
segmentMs = 5000,
|
segmentMs = 5000,
|
||||||
}: UseImageCarouselProps) {
|
}: UseImageCarouselProps): UseImageCarouselReturn {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [idx, setIdx] = useState(0);
|
const [idx, setIdx] = useState(0);
|
||||||
const [segProgress, setSegProgress] = 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 segStartRef = useRef<number | null>(null);
|
||||||
const idxRef = useRef<number>(0);
|
const idxRef = useRef<number>(0);
|
||||||
const rafRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const lastUiUpdateRef = useRef(0);
|
||||||
|
const segProgressRef = useRef(0);
|
||||||
|
const totalProgressRef = useRef(0);
|
||||||
|
const prevImageKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const fallbackDuration = normalizeDuration(segmentMs, 5000);
|
||||||
|
const segmentDurations = useMemo(
|
||||||
|
() =>
|
||||||
|
images.map((img) =>
|
||||||
|
normalizeDuration(
|
||||||
|
durationOverrides[img.id] ?? img.duration,
|
||||||
|
fallbackDuration,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[durationOverrides, fallbackDuration, images],
|
||||||
|
);
|
||||||
|
const imageKey = useMemo(
|
||||||
|
() => images.map((img) => img.id).join("|"),
|
||||||
|
[images],
|
||||||
|
);
|
||||||
|
const totalDurationMs = useMemo(
|
||||||
|
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
|
||||||
|
[segmentDurations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncProgress = useCallback(
|
||||||
|
(index: number, segmentProgress: number, force = false): void => {
|
||||||
|
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
|
||||||
|
const completedMs = sumDurations(segmentDurations, index);
|
||||||
|
const totalProgress =
|
||||||
|
totalDurationMs > 0
|
||||||
|
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
|
||||||
|
: 0;
|
||||||
|
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||||
|
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
|
||||||
|
|
||||||
|
if (
|
||||||
|
force ||
|
||||||
|
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
|
||||||
|
) {
|
||||||
|
segProgressRef.current = nextSegProgress;
|
||||||
|
setSegProgress(nextSegProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
force ||
|
||||||
|
Math.abs(nextTotalProgress - totalProgressRef.current) >
|
||||||
|
PROGRESS_EPSILON
|
||||||
|
) {
|
||||||
|
totalProgressRef.current = nextTotalProgress;
|
||||||
|
setProgress(nextTotalProgress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSegmentDuration = useCallback(
|
||||||
|
(imageId: string, durationMs: number): void => {
|
||||||
|
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
|
||||||
|
const normalizedDuration = normalizeDuration(
|
||||||
|
durationMs,
|
||||||
|
fallbackDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
setDurationOverrides((current) => {
|
||||||
|
if (current[imageId] === normalizedDuration) return current;
|
||||||
|
return { ...current, [imageId]: normalizedDuration };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[fallbackDuration],
|
||||||
|
);
|
||||||
|
|
||||||
|
const markMediaReady = useCallback((imageId: string): void => {
|
||||||
|
setReadyMediaMap((current) => {
|
||||||
|
if (current[imageId]) return current;
|
||||||
|
return { ...current, [imageId]: true };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markAnimatedFailed = useCallback((imageId: string): void => {
|
||||||
|
setFailedAnimatedMap((current) => {
|
||||||
|
if (current[imageId]) return current;
|
||||||
|
return { ...current, [imageId]: true };
|
||||||
|
});
|
||||||
|
setReadyMediaMap((current) => {
|
||||||
|
if (current[imageId]) return current;
|
||||||
|
return { ...current, [imageId]: true };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const advanceSegment = useCallback(
|
||||||
|
(fromIndex: number): void => {
|
||||||
|
const currentImages = imagesRef.current;
|
||||||
|
if (!currentImages.length || idxRef.current !== fromIndex) return;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
if (loopMode === "single") {
|
||||||
|
segStartRef.current = now;
|
||||||
|
lastUiUpdateRef.current = now;
|
||||||
|
syncProgress(fromIndex, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromIndex >= currentImages.length - 1) {
|
||||||
|
if (loopMode === "sequential" && neighbors?.next) {
|
||||||
|
router.push(neighbors.next.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
idxRef.current = 0;
|
||||||
|
setIdx(0);
|
||||||
|
segStartRef.current = now;
|
||||||
|
lastUiUpdateRef.current = now;
|
||||||
|
syncProgress(0, 0, true);
|
||||||
|
if (currentImages.length === 1) {
|
||||||
|
setMediaSyncToken((token) => token + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = fromIndex + 1;
|
||||||
|
idxRef.current = nextIndex;
|
||||||
|
setIdx(nextIndex);
|
||||||
|
segStartRef.current = now;
|
||||||
|
lastUiUpdateRef.current = now;
|
||||||
|
syncProgress(nextIndex, 0, true);
|
||||||
|
},
|
||||||
|
[loopMode, neighbors?.next, router, syncProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAnimatedProgress = useCallback(
|
||||||
|
(imageId: string, progress: number): void => {
|
||||||
|
const currentImage = imagesRef.current[idxRef.current];
|
||||||
|
if (!currentImage || currentImage.id !== imageId) return;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
const clamped = Math.max(0, Math.min(1, progress));
|
||||||
|
const wrapped = clamped < segProgressRef.current - 0.2;
|
||||||
|
|
||||||
|
if (
|
||||||
|
wrapped ||
|
||||||
|
clamped >= 0.995 ||
|
||||||
|
now - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
|
||||||
|
) {
|
||||||
|
lastUiUpdateRef.current = now;
|
||||||
|
syncProgress(idxRef.current, clamped, wrapped);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[syncProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAnimatedEnded = useCallback(
|
||||||
|
(imageId: string): void => {
|
||||||
|
const currentImage = imagesRef.current[idxRef.current];
|
||||||
|
if (!currentImage || currentImage.id !== imageId) return;
|
||||||
|
advanceSegment(idxRef.current);
|
||||||
|
},
|
||||||
|
[advanceSegment],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToIndex = useCallback(
|
||||||
|
(index: number, segmentProgress = 0): void => {
|
||||||
|
const count = imagesRef.current.length;
|
||||||
|
if (!count) return;
|
||||||
|
|
||||||
|
const nextIndex = Math.max(0, Math.min(count - 1, index));
|
||||||
|
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||||
|
const now = performance.now();
|
||||||
|
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
|
||||||
|
|
||||||
|
idxRef.current = nextIndex;
|
||||||
|
setIdx(nextIndex);
|
||||||
|
segStartRef.current = now - nextSegmentProgress * segmentDuration;
|
||||||
|
lastUiUpdateRef.current = now;
|
||||||
|
syncProgress(nextIndex, nextSegmentProgress, true);
|
||||||
|
setMediaSyncToken((token) => token + 1);
|
||||||
|
},
|
||||||
|
[fallbackDuration, segmentDurations, syncProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const seekTo = useCallback(
|
||||||
|
(ratio: number): void => {
|
||||||
|
if (!imagesRef.current.length || totalDurationMs <= 0) return;
|
||||||
|
|
||||||
|
const clampedRatio = Math.max(0, Math.min(1, ratio));
|
||||||
|
const targetMs = Math.min(
|
||||||
|
totalDurationMs - 1,
|
||||||
|
clampedRatio * totalDurationMs,
|
||||||
|
);
|
||||||
|
let accumulatedMs = 0;
|
||||||
|
let targetIndex = 0;
|
||||||
|
let segmentElapsedMs = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < segmentDurations.length; i++) {
|
||||||
|
const duration = segmentDurations[i] ?? fallbackDuration;
|
||||||
|
if (
|
||||||
|
targetMs < accumulatedMs + duration ||
|
||||||
|
i === segmentDurations.length - 1
|
||||||
|
) {
|
||||||
|
targetIndex = i;
|
||||||
|
segmentElapsedMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(duration, targetMs - accumulatedMs),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
accumulatedMs += duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
|
||||||
|
const nextSegmentProgress =
|
||||||
|
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
|
||||||
|
goToIndex(targetIndex, nextSegmentProgress);
|
||||||
|
},
|
||||||
|
[fallbackDuration, goToIndex, segmentDurations, totalDurationMs],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
idxRef.current = idx;
|
idxRef.current = idx;
|
||||||
}, [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 控制
|
// BGM 控制
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = audioRef.current;
|
const el = audioRef.current;
|
||||||
@@ -51,82 +346,104 @@ export function useImageCarousel({
|
|||||||
}
|
}
|
||||||
}, [audioRef, isPlaying, volume]);
|
}, [audioRef, isPlaying, volume]);
|
||||||
|
|
||||||
// 自动切页
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!images?.length) return;
|
const el = audioRef.current;
|
||||||
|
return () => {
|
||||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
releaseMediaElement(el);
|
||||||
let lastTs = performance.now();
|
|
||||||
|
|
||||||
const tick = (ts: number) => {
|
|
||||||
if (!images?.length) return;
|
|
||||||
|
|
||||||
if (!isPlaying) segStartRef.current! += ts - lastTs;
|
|
||||||
lastTs = ts;
|
|
||||||
|
|
||||||
let start = segStartRef.current!;
|
|
||||||
let localIdx = idxRef.current;
|
|
||||||
|
|
||||||
let elapsed = ts - start;
|
|
||||||
|
|
||||||
// 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs)
|
|
||||||
const getCurrentSegmentDuration = (index: number) => {
|
|
||||||
const img = images[index];
|
|
||||||
return img?.duration ?? segmentMs;
|
|
||||||
};
|
};
|
||||||
|
}, [audioRef]);
|
||||||
|
|
||||||
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
// 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由 <video> 自身进度与 onEnded 驱动)
|
||||||
|
const currentImage = images[idx];
|
||||||
|
const isCurrentAnimated =
|
||||||
|
Boolean(currentImage?.animated) &&
|
||||||
|
!failedAnimatedMap[currentImage?.id ?? ""];
|
||||||
|
const isCurrentStaticReady = currentImage
|
||||||
|
? Boolean(readyMediaMap[currentImage.id])
|
||||||
|
: false;
|
||||||
|
|
||||||
while (elapsed >= currentSegmentDuration) {
|
useEffect(() => {
|
||||||
elapsed -= currentSegmentDuration;
|
if (!images.length || !segmentDurations.length || totalDurationMs <= 0) {
|
||||||
|
|
||||||
if (localIdx >= images.length - 1) {
|
|
||||||
if (loopMode === "sequential" && neighbors?.next) {
|
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localIdx = 0;
|
|
||||||
} else {
|
// 当前页为正常动图时,交由 ImageCarousel 内 <video> 的真实播放进度和 ended 事件推进
|
||||||
localIdx = localIdx + 1;
|
if (isCurrentAnimated) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新下一张图片的时长
|
// 静态图尚未加载完成或处于暂停态时,冻结分段进度,避免未加载完就偷跑进度裁切展示时长
|
||||||
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
if (!isPlaying || !isCurrentStaticReady) {
|
||||||
}
|
return;
|
||||||
segStartRef.current = ts - elapsed;
|
|
||||||
|
|
||||||
if (localIdx !== idxRef.current) {
|
|
||||||
idxRef.current = localIdx;
|
|
||||||
setIdx(localIdx);
|
|
||||||
// 虚拟滚动不需要实际滚动 DOM
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
|
const safeRate = playbackRate > 0 ? playbackRate : 1;
|
||||||
setSegProgress(localSeg);
|
const now = performance.now();
|
||||||
|
const currentSegmentDuration =
|
||||||
|
(segmentDurations[idx] ?? fallbackDuration) / safeRate;
|
||||||
|
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
||||||
|
|
||||||
// 计算总进度:已完成的图片 + 当前图片的进度
|
const tick = (): void => {
|
||||||
let totalProgress = 0;
|
const currentImages = imagesRef.current;
|
||||||
for (let i = 0; i < localIdx; i++) {
|
if (!currentImages.length) return;
|
||||||
totalProgress += 1;
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
totalProgress += localSeg;
|
|
||||||
setProgress(totalProgress / images.length);
|
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
|
||||||
return () => {
|
return () => {
|
||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (timerRef.current !== null) {
|
||||||
rafRef.current = null;
|
window.clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
|
}, [
|
||||||
|
advanceSegment,
|
||||||
|
fallbackDuration,
|
||||||
|
idx,
|
||||||
|
images.length,
|
||||||
|
isCurrentAnimated,
|
||||||
|
isCurrentStaticReady,
|
||||||
|
isPlaying,
|
||||||
|
playbackRate,
|
||||||
|
segmentDurations,
|
||||||
|
syncProgress,
|
||||||
|
totalDurationMs,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
idx,
|
idx,
|
||||||
setIdx,
|
setIdx,
|
||||||
segProgress,
|
segProgress,
|
||||||
|
segProgressRef,
|
||||||
|
segmentDurations,
|
||||||
|
totalDurationMs,
|
||||||
|
mediaSyncToken,
|
||||||
segStartRef,
|
segStartRef,
|
||||||
idxRef,
|
idxRef,
|
||||||
|
failedAnimatedMap,
|
||||||
|
setSegmentDuration,
|
||||||
|
markMediaReady,
|
||||||
|
markAnimatedFailed,
|
||||||
|
handleAnimatedProgress,
|
||||||
|
handleAnimatedEnded,
|
||||||
|
goToIndex,
|
||||||
|
seekTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { RefObject } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import type { Neighbors } from "../types.ts";
|
import type { Neighbors } from "../types.ts";
|
||||||
|
|
||||||
|
const LONG_PRESS_DELAY_MS = 220;
|
||||||
|
|
||||||
interface UseNavigationProps {
|
interface UseNavigationProps {
|
||||||
neighbors: Neighbors;
|
neighbors: Neighbors;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
@@ -11,6 +13,10 @@ interface UseNavigationProps {
|
|||||||
prevImg?: () => void;
|
prevImg?: () => void;
|
||||||
nextImg?: () => void;
|
nextImg?: () => void;
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
|
onFastForwardChange?: (active: boolean) => void;
|
||||||
|
onSetAbStart?: () => void;
|
||||||
|
onSetAbEnd?: () => void;
|
||||||
|
onClearAbLoop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNavigation({
|
export function useNavigation({
|
||||||
@@ -21,15 +27,31 @@ export function useNavigation({
|
|||||||
prevImg,
|
prevImg,
|
||||||
nextImg,
|
nextImg,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
}: UseNavigationProps) {
|
onFastForwardChange,
|
||||||
|
onSetAbStart,
|
||||||
|
onSetAbEnd,
|
||||||
|
onClearAbLoop,
|
||||||
|
}: UseNavigationProps): void {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const wheelCooldownRef = useRef<number>(0);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!neighbors) return;
|
if (!neighbors) return;
|
||||||
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`);
|
if (neighbors.next) router.prefetch(neighbors.next.href);
|
||||||
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`);
|
if (neighbors.prev) router.prefetch(neighbors.prev.href);
|
||||||
}, [neighbors, router]);
|
}, [neighbors, router]);
|
||||||
|
|
||||||
// 鼠标滚轮切换
|
// 鼠标滚轮切换
|
||||||
@@ -37,7 +59,7 @@ export function useNavigation({
|
|||||||
const el = mediaContainerRef.current;
|
const el = mediaContainerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent): void => {
|
||||||
if (e.ctrlKey) return;
|
if (e.ctrlKey) return;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - wheelCooldownRef.current < 700) return;
|
if (now - wheelCooldownRef.current < 700) return;
|
||||||
@@ -50,44 +72,153 @@ export function useNavigation({
|
|||||||
|
|
||||||
if (dy > 0 && neighbors?.next) {
|
if (dy > 0 && neighbors?.next) {
|
||||||
wheelCooldownRef.current = now;
|
wheelCooldownRef.current = now;
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
router.push(neighbors.next.href);
|
||||||
} else if (dy < 0 && neighbors?.prev) {
|
} else if (dy < 0 && neighbors?.prev) {
|
||||||
wheelCooldownRef.current = now;
|
wheelCooldownRef.current = now;
|
||||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
router.push(neighbors.prev.href);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("wheel", onWheel, { passive: false });
|
el.addEventListener("wheel", onWheel, { passive: false });
|
||||||
return () => el.removeEventListener("wheel", onWheel as any);
|
return () => el.removeEventListener("wheel", onWheel);
|
||||||
}, [neighbors, mediaContainerRef, router]);
|
}, [neighbors, mediaContainerRef, router]);
|
||||||
|
|
||||||
// 键盘快捷键
|
// 触屏上下滑切换(上滑下一条,下滑上一条)。只认明显的竖向手势,
|
||||||
|
// 不影响图文的左右滑动;从按钮、链接、控制栏(data-no-swipe)上开始的触摸不处理。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const el = mediaContainerRef.current;
|
||||||
const target = e.target as HTMLElement;
|
if (!el) return;
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
|
const code = e.code;
|
||||||
|
|
||||||
if (key === "arrowup" || key === "w") {
|
if (key === "arrowup" || key === "w" || code === "KeyW") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - wheelCooldownRef.current < 700) return;
|
if (now - wheelCooldownRef.current < 700) return;
|
||||||
if (neighbors?.prev) {
|
if (neighbors?.prev) {
|
||||||
wheelCooldownRef.current = now;
|
wheelCooldownRef.current = now;
|
||||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
router.push(neighbors.prev.href);
|
||||||
}
|
}
|
||||||
} else if (key === "arrowdown" || key === "s") {
|
} else if (key === "arrowdown" || key === "s" || code === "KeyS") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - wheelCooldownRef.current < 700) return;
|
if (now - wheelCooldownRef.current < 700) return;
|
||||||
if (neighbors?.next) {
|
if (neighbors?.next) {
|
||||||
wheelCooldownRef.current = now;
|
wheelCooldownRef.current = now;
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
router.push(neighbors.next.href);
|
||||||
}
|
}
|
||||||
} else if (key === "arrowleft" || key === "a") {
|
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef?.current;
|
const v = videoRef?.current;
|
||||||
@@ -97,8 +228,73 @@ export function useNavigation({
|
|||||||
} else {
|
} else {
|
||||||
prevImg?.();
|
prevImg?.();
|
||||||
}
|
}
|
||||||
} else if (key === "arrowright" || key === "d") {
|
} else if (key === "arrowright" || key === "d" || code === "KeyD") {
|
||||||
e.preventDefault();
|
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) {
|
if (isVideo) {
|
||||||
const v = videoRef?.current;
|
const v = videoRef?.current;
|
||||||
if (v && v.duration) {
|
if (v && v.duration) {
|
||||||
@@ -107,27 +303,59 @@ export function useNavigation({
|
|||||||
} else {
|
} else {
|
||||||
nextImg?.();
|
nextImg?.();
|
||||||
}
|
}
|
||||||
} else if (key === " ") {
|
};
|
||||||
e.preventDefault();
|
|
||||||
togglePlay();
|
const handleVisibilityChange = (): void => {
|
||||||
}
|
if (document.hidden) resetRightKeyPress();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
window.addEventListener("keyup", onKeyUp);
|
||||||
|
window.addEventListener("blur", resetRightKeyPress);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
|
window.removeEventListener("blur", resetRightKeyPress);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
|
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
|
||||||
|
|
||||||
// 浏览器返回事件
|
// 组件卸载时确保清理长按定时器并恢复倍速
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.history.pushState({ interceptBack: true }, "");
|
return () => {
|
||||||
|
if (longPressTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(longPressTimerRef.current);
|
||||||
|
longPressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (longPressActiveRef.current) {
|
||||||
|
longPressActiveRef.current = false;
|
||||||
|
rightPressKeyRef.current = null;
|
||||||
|
onFastForwardChangeRef.current?.(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePopState = () => {
|
// 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 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();
|
window.close();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { LoopMode, ObjectFit } from "../types.ts";
|
import type { LoopMode, ObjectFit } from "../types.ts";
|
||||||
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
|
import {
|
||||||
|
getNumberFromStorage,
|
||||||
|
getStringFromStorage,
|
||||||
|
saveToStorage,
|
||||||
|
} from "../utils";
|
||||||
|
|
||||||
export function usePlayerState() {
|
export function usePlayerState() {
|
||||||
const [isPlaying, setIsPlaying] = useState(true);
|
const [isPlaying, setIsPlaying] = useState(true);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1));
|
const [volume, setVolume] = useState(() =>
|
||||||
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1));
|
getNumberFromStorage("aweme_player_volume", 1),
|
||||||
|
);
|
||||||
|
const [rate, setRate] = useState(() =>
|
||||||
|
getNumberFromStorage("aweme_player_rate", 1),
|
||||||
|
);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
const [progressRestored, setProgressRestored] = useState(false);
|
const [progressRestored, setProgressRestored] = useState(false);
|
||||||
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
|
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
|
||||||
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
|
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
|
||||||
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
|
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
|
||||||
return saved === "sequential" ? "sequential" : "loop";
|
if (saved === "single" || saved === "sequential") return saved;
|
||||||
|
return "loop";
|
||||||
});
|
});
|
||||||
|
|
||||||
// 持久化音量
|
// 持久化音量
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { PlaylistItem, PlaylistResponse } from "@/lib/playback-context";
|
||||||
|
|
||||||
|
// 侧栏列表(作者作品 / 搜索结果 / hashtag)缓存在模块里:切换作品时播放页会重新挂载,
|
||||||
|
// 但客户端导航不会清掉 JS 内存,所以在列表里上下切换时可以立刻拿到列表,不用每次重新请求。
|
||||||
|
const STALE_MS = 60_000;
|
||||||
|
|
||||||
|
type CacheEntry = PlaylistResponse & { fetchedAt: number };
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
const inflight = new Map<string, Promise<PlaylistResponse>>();
|
||||||
|
|
||||||
|
function loadPlaylist(query: string): Promise<PlaylistResponse> {
|
||||||
|
const pending = inflight.get(query);
|
||||||
|
if (pending) return pending;
|
||||||
|
const promise = fetch(`/api/playlist${query}`)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`加载列表失败 (${res.status})`);
|
||||||
|
const data = (await res.json()) as PlaylistResponse;
|
||||||
|
cache.set(query, { ...data, fetchedAt: Date.now() });
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.finally(() => inflight.delete(query));
|
||||||
|
inflight.set(query, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaylistState = {
|
||||||
|
items: PlaylistItem[] | null;
|
||||||
|
/** 列表总数(可能多于 items:侧栏最多列出 500 个) */
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按播放上下文(/api/playlist 的查询串,含 ?)取作品列表。
|
||||||
|
* query 为空或 enabled 为 false 时不请求。
|
||||||
|
*/
|
||||||
|
export function usePlaylist(query: string, enabled: boolean): PlaylistState {
|
||||||
|
const [state, setState] = useState<PlaylistState>(() => {
|
||||||
|
const cached = query ? cache.get(query) : undefined;
|
||||||
|
return {
|
||||||
|
items: cached?.items ?? null,
|
||||||
|
total: cached?.total ?? 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query || !enabled) return;
|
||||||
|
const cached = cache.get(query);
|
||||||
|
setState({
|
||||||
|
items: cached?.items ?? null,
|
||||||
|
total: cached?.total ?? 0,
|
||||||
|
loading: !cached,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < STALE_MS) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
loadPlaylist(query)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({
|
||||||
|
items: data.items,
|
||||||
|
total: data.total,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : "加载失败",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [query, enabled]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { RelatedHashtagsResult } from "@/lib/hashtag-related.server";
|
||||||
|
|
||||||
|
const cache = new Map<string, RelatedHashtagsResult>();
|
||||||
|
|
||||||
|
/** 取某个 hashtag 的相关 tag(按共现计算);name 为空或 enabled 为 false 时不请求。 */
|
||||||
|
export function useRelatedHashtags(
|
||||||
|
name: string | null,
|
||||||
|
enabled: boolean,
|
||||||
|
): RelatedHashtagsResult | null {
|
||||||
|
const key = name?.toLowerCase() ?? "";
|
||||||
|
const [result, setResult] = useState<RelatedHashtagsResult | null>(
|
||||||
|
() => cache.get(key) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!key || !enabled) return;
|
||||||
|
const cached = cache.get(key);
|
||||||
|
setResult(cached ?? null);
|
||||||
|
if (cached) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`/api/hashtag/related?name=${encodeURIComponent(key)}`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: RelatedHashtagsResult | null) => {
|
||||||
|
if (!data) return;
|
||||||
|
cache.set(key, data);
|
||||||
|
if (!cancelled) setResult(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [key, enabled]);
|
||||||
|
|
||||||
|
return result?.name === key ? result : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** comments:评论;works:TA的作品;list:搜索结果 / hashtag / 标签列表 */
|
||||||
|
export type SidePanelTab = "comments" | "works" | "list";
|
||||||
|
|
||||||
|
// 侧栏开关沿用评论区原来的 localStorage 键;停在哪个标签页、是否处于作者模式只在本次会话内记住,
|
||||||
|
// 这样下次从首页点进来不会莫名其妙停在作者模式。
|
||||||
|
// 切换作品时播放页会整体重新挂载,靠这些存储把状态带到下一个作品。
|
||||||
|
const OPEN_KEY = "aweme_player_comments_open";
|
||||||
|
const TAB_KEY = "aweme_player_panel_tab";
|
||||||
|
const AUTHOR_MODE_KEY = "aweme_player_author_mode";
|
||||||
|
|
||||||
|
function readStorage(storage: () => Storage, key: string): string | null {
|
||||||
|
try {
|
||||||
|
return storage().getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStorage(
|
||||||
|
storage: () => Storage,
|
||||||
|
key: string,
|
||||||
|
value: string | null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (value === null) storage().removeItem(key);
|
||||||
|
else storage().setItem(key, value);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = () => window.localStorage;
|
||||||
|
const session = () => window.sessionStorage;
|
||||||
|
|
||||||
|
export function isPortraitViewport(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(orientation: portrait)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧栏状态与作者模式。
|
||||||
|
* 作者模式和侧栏开关是分开的:打开「TA的作品」进入作者模式(上下切换在该作者的作品里移动),
|
||||||
|
* 关掉侧栏不会退出,只有点顶部提示的 ✕、或切到「搜索结果 / #hashtag」列表才退出。
|
||||||
|
*/
|
||||||
|
export function useSidePanelState(authorSecUid: string | undefined) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [tab, setTab] = useState<SidePanelTab>("comments");
|
||||||
|
// 处于作者模式时记下是哪位作者;切到别的作者的作品(例如浏览器后退)时自动不生效
|
||||||
|
const [authorModeFor, setAuthorModeFor] = useState<string | null>(null);
|
||||||
|
const [restored, setRestored] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(readStorage(local, OPEN_KEY) === "true");
|
||||||
|
const savedTab = readStorage(session, TAB_KEY);
|
||||||
|
setTab(savedTab === "works" || savedTab === "list" ? savedTab : "comments");
|
||||||
|
setAuthorModeFor(readStorage(session, AUTHOR_MODE_KEY));
|
||||||
|
setRestored(true);
|
||||||
|
// 恢复完成后再开启过渡动画,避免进入页面时侧栏滑入
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setMounted(true));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restored) return;
|
||||||
|
writeStorage(local, OPEN_KEY, String(open));
|
||||||
|
writeStorage(session, TAB_KEY, tab);
|
||||||
|
writeStorage(session, AUTHOR_MODE_KEY, authorModeFor);
|
||||||
|
}, [open, tab, authorModeFor, restored]);
|
||||||
|
|
||||||
|
// 切到哪个列表标签页,上下切换就改用哪个列表的顺序;「评论」不影响顺序
|
||||||
|
const applyTabMode = useCallback(
|
||||||
|
(nextTab: SidePanelTab) => {
|
||||||
|
if (nextTab === "works" && authorSecUid) setAuthorModeFor(authorSecUid);
|
||||||
|
else if (nextTab === "list") setAuthorModeFor(null);
|
||||||
|
},
|
||||||
|
[authorSecUid],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectTab = useCallback(
|
||||||
|
(nextTab: SidePanelTab) => {
|
||||||
|
setTab(nextTab);
|
||||||
|
applyTabMode(nextTab);
|
||||||
|
},
|
||||||
|
[applyTabMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const show = useCallback(
|
||||||
|
(nextTab: SidePanelTab) => {
|
||||||
|
selectTab(nextTab);
|
||||||
|
setOpen(true);
|
||||||
|
},
|
||||||
|
[selectTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
|
/** 打开到指定标签页;已经打开在这个标签页时则关闭(关闭不影响顺序) */
|
||||||
|
const toggle = useCallback(
|
||||||
|
(nextTab: SidePanelTab) => {
|
||||||
|
if (open && tab === nextTab) close();
|
||||||
|
else show(nextTab);
|
||||||
|
},
|
||||||
|
[close, open, show, tab],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitAuthorMode = useCallback(() => setAuthorModeFor(null), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
tab,
|
||||||
|
mounted,
|
||||||
|
/** 上下切换在当前作者的作品里移动 */
|
||||||
|
authorMode: Boolean(authorSecUid) && authorModeFor === authorSecUid,
|
||||||
|
/** 已从存储里恢复完状态(之后才能安全地自动打开侧栏) */
|
||||||
|
restored,
|
||||||
|
selectTab,
|
||||||
|
show,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
exitAuthorMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { RefObject } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { LoopMode, Neighbors } from "../types.ts";
|
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 {
|
interface UseVideoPlayerProps {
|
||||||
|
enabled?: boolean;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
volume: number;
|
volume: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
loopMode: LoopMode;
|
loopMode: LoopMode;
|
||||||
|
abLoop?: AbLoop;
|
||||||
neighbors: Neighbors;
|
neighbors: Neighbors;
|
||||||
progressRestored: boolean;
|
progressRestored: boolean;
|
||||||
setIsPlaying: (playing: boolean) => void;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
@@ -17,27 +20,41 @@ interface UseVideoPlayerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useVideoPlayer({
|
export function useVideoPlayer({
|
||||||
|
enabled = true,
|
||||||
awemeId,
|
awemeId,
|
||||||
videoRef,
|
videoRef,
|
||||||
volume,
|
volume,
|
||||||
rate,
|
rate,
|
||||||
loopMode,
|
loopMode,
|
||||||
|
abLoop,
|
||||||
neighbors,
|
neighbors,
|
||||||
progressRestored,
|
progressRestored,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setProgress,
|
setProgress,
|
||||||
setProgressRestored,
|
setProgressRestored,
|
||||||
}: UseVideoPlayerProps) {
|
}: UseVideoPlayerProps): void {
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
if (progressRestored) return;
|
if (!enabled) return;
|
||||||
|
if (progressRestored && restoredAwemeIdRef.current === awemeId) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = (): void => {
|
||||||
if (progressRestored) return;
|
if (restoredAwemeIdRef.current === awemeId) return;
|
||||||
|
restoredAwemeIdRef.current = awemeId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = `aweme_progress_${awemeId}`;
|
const key = `aweme_progress_${awemeId}`;
|
||||||
@@ -47,13 +64,22 @@ export function useVideoPlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { time, timestamp } = JSON.parse(saved);
|
const parsed = JSON.parse(saved) as {
|
||||||
|
time?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
};
|
||||||
|
const time = typeof parsed.time === "number" ? parsed.time : 0;
|
||||||
|
const timestamp =
|
||||||
|
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fiveMinutes = 5 * 60 * 1000;
|
const fiveMinutes = 5 * 60 * 1000;
|
||||||
|
|
||||||
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
|
if (
|
||||||
|
now - timestamp < fiveMinutes &&
|
||||||
|
time > 1 &&
|
||||||
|
time < v.duration - 1
|
||||||
|
) {
|
||||||
v.currentTime = time;
|
v.currentTime = time;
|
||||||
console.log(`恢复播放进度: ${Math.round(time)}s`);
|
|
||||||
} else if (now - timestamp >= fiveMinutes) {
|
} else if (now - timestamp >= fiveMinutes) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
@@ -66,18 +92,20 @@ export function useVideoPlayer({
|
|||||||
|
|
||||||
if (v.readyState >= 1) {
|
if (v.readyState >= 1) {
|
||||||
onLoadedMetadata();
|
onLoadedMetadata();
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
}
|
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||||
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
|
|
||||||
|
|
||||||
// 保存播放进度
|
// 保存播放进度并在卸载/切页时主动释放媒体解码缓冲区
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
const saveProgress = () => {
|
const saveProgress = (): void => {
|
||||||
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -92,80 +120,166 @@ export function useVideoPlayer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const interval = setInterval(saveProgress, 2000);
|
const interval = window.setInterval(saveProgress, 2000);
|
||||||
const onBeforeUnload = () => saveProgress();
|
const onBeforeUnload = (): void => saveProgress();
|
||||||
window.addEventListener("beforeunload", onBeforeUnload);
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
saveProgress();
|
saveProgress();
|
||||||
|
releaseMediaElement(v);
|
||||||
};
|
};
|
||||||
}, [awemeId, videoRef]);
|
}, [awemeId, enabled, videoRef]);
|
||||||
|
|
||||||
// 监听播放状态和进度
|
// 监听播放状态和进度(含 AB 循环区间控制)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
const onTime = () => {
|
const syncVideoProgress = (): void => {
|
||||||
if (!v.duration || Number.isNaN(v.duration)) return;
|
if (!v.duration || Number.isNaN(v.duration)) return;
|
||||||
setProgress(v.currentTime / v.duration);
|
const currentAb = abLoopRef.current;
|
||||||
};
|
const hasAb = Boolean(
|
||||||
const onPlay = () => setIsPlaying(true);
|
currentAb && (currentAb.a !== null || currentAb.b !== null),
|
||||||
const onPause = () => setIsPlaying(false);
|
);
|
||||||
const onEnded = () => {
|
|
||||||
if (loopMode === "sequential" && neighbors?.next) {
|
if (hasAb && currentAb) {
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
const startTime = Math.max(0, currentAb.a ?? 0);
|
||||||
|
const endTime =
|
||||||
|
currentAb.b !== null ? Math.min(v.duration, currentAb.b) : v.duration;
|
||||||
|
|
||||||
|
if (
|
||||||
|
endTime > startTime &&
|
||||||
|
(v.currentTime >= endTime ||
|
||||||
|
(currentAb.b === null && v.currentTime >= v.duration - 0.03))
|
||||||
|
) {
|
||||||
|
v.currentTime = startTime;
|
||||||
|
if (v.paused) {
|
||||||
|
void v.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProgress = Math.max(0, Math.min(1, v.currentTime / v.duration));
|
||||||
|
if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) {
|
||||||
|
lastProgressRef.current = nextProgress;
|
||||||
|
setProgress(nextProgress);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
v.addEventListener("timeupdate", onTime);
|
const onPlay = (): void => setIsPlaying(true);
|
||||||
v.addEventListener("loadedmetadata", onTime);
|
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("play", onPlay);
|
||||||
v.addEventListener("pause", onPause);
|
v.addEventListener("pause", onPause);
|
||||||
v.addEventListener("ended", onEnded);
|
v.addEventListener("ended", onEnded);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("timeupdate", onTime);
|
v.removeEventListener("timeupdate", syncVideoProgress);
|
||||||
v.removeEventListener("loadedmetadata", onTime);
|
v.removeEventListener("loadedmetadata", syncVideoProgress);
|
||||||
v.removeEventListener("play", onPlay);
|
v.removeEventListener("play", onPlay);
|
||||||
v.removeEventListener("pause", onPause);
|
v.removeEventListener("pause", onPause);
|
||||||
v.removeEventListener("ended", onEnded);
|
v.removeEventListener("ended", onEnded);
|
||||||
};
|
};
|
||||||
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
|
}, [
|
||||||
|
enabled,
|
||||||
|
loopMode,
|
||||||
|
neighbors?.next,
|
||||||
|
router,
|
||||||
|
videoRef,
|
||||||
|
setIsPlaying,
|
||||||
|
setProgress,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 当启用 AB 循环时,使用 rAF 精确检测终点 B 并无缝跳回起点 A
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!abLoop || (abLoop.a === null && abLoop.b === null)) return;
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const checkAbLoop = (): void => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (v && !v.paused && Number.isFinite(v.duration) && v.duration > 0) {
|
||||||
|
const startTime = Math.max(0, abLoop.a ?? 0);
|
||||||
|
const endTime =
|
||||||
|
abLoop.b !== null ? Math.min(v.duration, abLoop.b) : v.duration;
|
||||||
|
|
||||||
|
if (
|
||||||
|
endTime > startTime &&
|
||||||
|
(v.currentTime >= endTime ||
|
||||||
|
(abLoop.b === null && v.currentTime >= v.duration - 0.03))
|
||||||
|
) {
|
||||||
|
v.currentTime = startTime;
|
||||||
|
const nextProgress = Math.max(0, Math.min(1, startTime / v.duration));
|
||||||
|
lastProgressRef.current = nextProgress;
|
||||||
|
setProgress(nextProgress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rafId = window.requestAnimationFrame(checkAbLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
rafId = window.requestAnimationFrame(checkAbLoop);
|
||||||
|
return () => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [abLoop, enabled, setProgress, videoRef]);
|
||||||
|
|
||||||
// 自动播放检测
|
// 自动播放检测
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
const checkAutoplay = async () => {
|
const checkAutoplay = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await v.play();
|
await v.play();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.log("自动播放被阻止,需要用户交互");
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (v.readyState >= 1) {
|
if (v.readyState >= 1) {
|
||||||
checkAutoplay();
|
void checkAutoplay();
|
||||||
} else {
|
return;
|
||||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
|
||||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
|
||||||
}
|
}
|
||||||
}, [awemeId, videoRef, setIsPlaying]);
|
|
||||||
|
const onLoaded = (): void => {
|
||||||
|
void checkAutoplay();
|
||||||
|
};
|
||||||
|
v.addEventListener("loadedmetadata", onLoaded, { once: true });
|
||||||
|
return () => v.removeEventListener("loadedmetadata", onLoaded);
|
||||||
|
}, [awemeId, enabled, videoRef, setIsPlaying]);
|
||||||
|
|
||||||
// 更新音量和倍速
|
// 更新音量和倍速
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.volume = volume;
|
if (v) v.volume = volume;
|
||||||
}, [volume, videoRef]);
|
}, [enabled, volume, videoRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.playbackRate = rate;
|
if (v) v.playbackRate = rate;
|
||||||
}, [rate, videoRef]);
|
}, [enabled, rate, videoRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import BackButton from "@/app/components/BackButton";
|
|
||||||
import AwemeDetailClient from "./Client";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import BackButton from "@/app/components/BackButton";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { AwemeData, VideoTranscript } from "./types";
|
import { parsePlaybackContext } from "@/lib/playback-context";
|
||||||
|
import { resolvePlayback } from "@/lib/playback-context.server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import AwemeDetailClient from "./Client";
|
||||||
|
import type { AwemeData, VideoTranscript } from "./types";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ awemeId: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
const id = (await params).awemeId;
|
const id = (await params).awemeId;
|
||||||
|
|
||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
@@ -16,7 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
|||||||
prisma.imagePost.findUnique({
|
prisma.imagePost.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
select: { desc: true, author: { select: { nickname: true } } },
|
select: { desc: true, author: { select: { nickname: true } } },
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data = video || post;
|
const data = video || post;
|
||||||
@@ -28,7 +34,10 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
|||||||
|
|
||||||
const desc = data.desc || "查看作品详情";
|
const desc = data.desc || "查看作品详情";
|
||||||
const author = data.author.nickname;
|
const author = data.author.nickname;
|
||||||
const title = desc.length > 50 ? `${desc.slice(0, 50)}... - ${author}` : `${desc} - ${author}`;
|
const title =
|
||||||
|
desc.length > 50
|
||||||
|
? `${desc.slice(0, 50)}... - ${author}`
|
||||||
|
: `${desc} - ${author}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -36,95 +45,101 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
export default async function AwemeDetail({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ awemeId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
const id = (await params).awemeId;
|
const 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 },
|
include: { author: true, transcript: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findUnique({
|
prisma.imagePost.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
include: { author: true, images: { orderBy: { order: "asc" } } },
|
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 commentsCount = await prisma.comment.count({
|
const commentsCount = await prisma.comment.count({
|
||||||
where: isVideo ? { videoId: id } : { imagePostId: id },
|
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||||
});
|
});
|
||||||
const aweme = isVideo ? video : post;
|
|
||||||
|
|
||||||
const data: AwemeData = {
|
let data: AwemeData;
|
||||||
aweme_id: aweme!.aweme_id,
|
let transcript: VideoTranscript | null = null;
|
||||||
desc: aweme!.desc,
|
|
||||||
created_at: aweme!.created_at,
|
if (video) {
|
||||||
likesCount: Number(aweme!.digg_count),
|
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,
|
commentsCount,
|
||||||
author: {
|
author: {
|
||||||
nickname: aweme!.author.nickname,
|
nickname: video.author.nickname,
|
||||||
avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png'),
|
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
|
||||||
sec_uid: aweme!.author.sec_uid
|
sec_uid: video.author.sec_uid,
|
||||||
},
|
},
|
||||||
...(() => {
|
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
|
||||||
if (isVideo) {
|
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
|
||||||
const aweme = video!
|
duration_ms: video.duration_ms,
|
||||||
return {
|
video_url: getFileUrl(video.video_url),
|
||||||
type: "video" as const,
|
width: video.width ?? null,
|
||||||
cover_url: getFileUrl(aweme!.cover_url ?? 'default-cover.png'),
|
height: video.height ?? null,
|
||||||
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
|
content_tags: video.manual_content_tags,
|
||||||
duration_ms: aweme!.duration_ms,
|
manual_content_tags: video.manual_content_tags,
|
||||||
video_url: getFileUrl(aweme!.video_url),
|
|
||||||
width: aweme!.width ?? null,
|
|
||||||
height: aweme!.height ?? null,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const aweme = post!
|
|
||||||
return {
|
|
||||||
type: "image" as const,
|
|
||||||
cover_url: getFileUrl(aweme!.images[0].url ?? 'default-cover.png'),
|
|
||||||
cover_size: { h: aweme!.images[0].height ?? 0, w: aweme!.images[0].width ?? 0 },
|
|
||||||
images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: img.animated? getFileUrl(img.animated) : null })),
|
|
||||||
music_url: getFileUrl(aweme!.music_url || 'default-music.mp3'),
|
|
||||||
};
|
};
|
||||||
}
|
} 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 transcript: VideoTranscript | null = isVideo ? await prisma.videoTranscript.findUnique({
|
const authorId = video?.authorId ?? post?.authorId ?? null;
|
||||||
where: { videoId: id },
|
const [playback, authorWorksCount] = await Promise.all([
|
||||||
}) : null;
|
resolvePlayback(id, parsePlaybackContext(await searchParams)),
|
||||||
|
authorId
|
||||||
// Compute prev/next neighbors by created_at across videos and image posts
|
? Promise.all([
|
||||||
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at);
|
prisma.video.count({ where: { authorId } }),
|
||||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
prisma.imagePost.count({ where: { authorId } }),
|
||||||
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
]).then(([videos, posts]) => videos + posts)
|
||||||
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
|
: 0,
|
||||||
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
|
||||||
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
|
|
||||||
]);
|
]);
|
||||||
const pickPrev = (() => {
|
|
||||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
|
||||||
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at });
|
|
||||||
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at });
|
|
||||||
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 });
|
|
||||||
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at });
|
|
||||||
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">
|
<main className="min-h-screen w-full">
|
||||||
@@ -135,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} neighbors={neighbors} transcript={transcript} />
|
<AwemeDetailClient
|
||||||
|
data={data}
|
||||||
|
neighbors={playback.neighbors}
|
||||||
|
playbackContext={playback.context}
|
||||||
|
contextQuery={playback.contextQuery}
|
||||||
|
authorWorksCount={authorWorksCount}
|
||||||
|
transcript={transcript}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export type User = { nickname: string; avatar_url: string | null; sec_uid?: string };
|
export type User = {
|
||||||
|
nickname: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
sec_uid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
cid: string;
|
cid: string;
|
||||||
@@ -13,13 +17,15 @@ export type VideoData = {
|
|||||||
type: "video";
|
type: "video";
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
cover_size: {w: number; h: number};
|
cover_size: { w: number; h: number };
|
||||||
desc: string;
|
desc: string;
|
||||||
created_at: string | Date;
|
created_at: string | Date;
|
||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
video_url: string;
|
video_url: string;
|
||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
|
content_tags: string[];
|
||||||
|
manual_content_tags: string[];
|
||||||
author: User;
|
author: User;
|
||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
likesCount: number;
|
likesCount: number;
|
||||||
@@ -29,10 +35,17 @@ export type ImageData = {
|
|||||||
type: "image";
|
type: "image";
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
cover_size: {w: number; h: number};
|
cover_size: { w: number; h: number };
|
||||||
desc: string;
|
desc: string;
|
||||||
created_at: string | Date;
|
created_at: string | Date;
|
||||||
images: { id: string; url: string; width: number | null; height: number | null; animated: string | null; duration: number | null }[];
|
images: {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
animated: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
}[];
|
||||||
music_url: string | null;
|
music_url: string | null;
|
||||||
author: User;
|
author: User;
|
||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
@@ -42,15 +55,31 @@ export type ImageData = {
|
|||||||
export type AwemeData = VideoData | ImageData;
|
export type AwemeData = VideoData | ImageData;
|
||||||
|
|
||||||
export type VideoTranscript = {
|
export type VideoTranscript = {
|
||||||
speech_detected: boolean;
|
|
||||||
language: string | null;
|
|
||||||
transcript: string[];
|
transcript: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NeighborLink = { aweme_id: string; href: string };
|
||||||
|
|
||||||
export type Neighbors = {
|
export type Neighbors = {
|
||||||
prev: { aweme_id: string } | null;
|
prev: NeighborLink | null;
|
||||||
next: { aweme_id: string } | null;
|
next: NeighborLink | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoopMode = "loop" | "sequential";
|
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 ObjectFit = "contain" | "cover";
|
||||||
|
|
||||||
|
export type AbLoop = {
|
||||||
|
a: number | null;
|
||||||
|
b: number | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export function formatTime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理评论文本中的表情占位符
|
// 处理评论文本中的表情占位符
|
||||||
export function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] {
|
export function parseCommentText(
|
||||||
|
text: string,
|
||||||
|
): (string | { type: "emoji"; name: string })[] {
|
||||||
const parts: (string | { type: "emoji"; name: string })[] = [];
|
const parts: (string | { type: "emoji"; name: string })[] = [];
|
||||||
const regex = /\[([^\]]+)\]/g;
|
const regex = /\[([^\]]+)\]/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
@@ -51,7 +53,10 @@ export function parseCommentText(text: string): (string | { type: "emoji"; name:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从 localStorage 获取数值
|
// 从 localStorage 获取数值
|
||||||
export function getNumberFromStorage(key: string, defaultValue: number): number {
|
export function getNumberFromStorage(
|
||||||
|
key: string,
|
||||||
|
defaultValue: number,
|
||||||
|
): number {
|
||||||
if (typeof window === "undefined") return defaultValue;
|
if (typeof window === "undefined") return defaultValue;
|
||||||
const saved = localStorage.getItem(key);
|
const saved = localStorage.getItem(key);
|
||||||
if (!saved) return defaultValue;
|
if (!saved) return defaultValue;
|
||||||
@@ -60,7 +65,10 @@ export function getNumberFromStorage(key: string, defaultValue: number): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从 localStorage 获取字符串
|
// 从 localStorage 获取字符串
|
||||||
export function getStringFromStorage(key: string, defaultValue: string): string {
|
export function getStringFromStorage(
|
||||||
|
key: string,
|
||||||
|
defaultValue: string,
|
||||||
|
): string {
|
||||||
if (typeof window === "undefined") return defaultValue;
|
if (typeof window === "undefined") return defaultValue;
|
||||||
return localStorage.getItem(key) || defaultValue;
|
return localStorage.getItem(key) || defaultValue;
|
||||||
}
|
}
|
||||||
@@ -83,3 +91,59 @@ export function formatAbsoluteUTC(date: string | Date): string {
|
|||||||
const s = String(d.getUTCSeconds()).padStart(2, "0");
|
const s = String(d.getUTCSeconds()).padStart(2, "0");
|
||||||
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
|
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,17 +18,24 @@ 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>>(
|
||||||
|
(e) => {
|
||||||
// Respect modifier clicks (new tab/window) and non-left clicks
|
// Respect modifier clicks (new tab/window) and non-left clicks
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
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),
|
||||||
@@ -39,7 +46,9 @@ export default function BackButton({ className, ariaLabel = '返回', hrefFallba
|
|||||||
}
|
}
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
}, [router, hrefFallback]);
|
},
|
||||||
|
[router, hrefFallback],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+276
-47
@@ -1,28 +1,54 @@
|
|||||||
"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;
|
fetchUrl?: string;
|
||||||
|
/** 附加在作品链接上的播放上下文查询串(含 ?),决定播放页上下切换的列表 */
|
||||||
|
linkQuery?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/api/feed' }: 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
|
||||||
@@ -37,8 +63,8 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
// 挂载后立即根据当前窗口宽度更新一次列数
|
// 挂载后立即根据当前窗口宽度更新一次列数
|
||||||
setColumnCount(getColumnCount());
|
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]);
|
||||||
|
|
||||||
// 估算卡片高度(用于分配到“最短列”)
|
// 估算卡片高度(用于分配到“最短列”)
|
||||||
@@ -46,12 +72,15 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
// 媒体区域高度
|
// 媒体区域高度
|
||||||
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;
|
||||||
@@ -62,12 +91,15 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
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);
|
||||||
@@ -82,24 +114,26 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
}
|
}
|
||||||
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 url = fetchUrl.includes('?') ? `${fetchUrl}&${params.toString()}` : `${fetchUrl}?${params.toString()}`;
|
const url = fetchUrl.includes("?")
|
||||||
const res = await fetch(url, { cache: 'no-store' });
|
? `${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) {
|
||||||
@@ -117,55 +151,168 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
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(
|
||||||
|
(entries) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
fetchMore();
|
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) => {
|
||||||
<article key={item.aweme_id} className="mb-4 group 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 hover:-translate-y-1">
|
setSelectedIds((current) => {
|
||||||
<Link href={`/aweme/${item.aweme_id}`} target="_blank" className="block relative w-full">
|
const next = new Set(current);
|
||||||
|
if (next.has(awemeId)) next.delete(awemeId);
|
||||||
|
else next.add(awemeId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setBatchError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setBatchError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyUpdatedTags = useCallback(
|
||||||
|
(updates: TagUpdateResponse["updated"]) => {
|
||||||
|
if (!updates?.length) return;
|
||||||
|
const tagMap = new Map(
|
||||||
|
updates.map((item) => [item.aweme_id, item.content_tags] as const),
|
||||||
|
);
|
||||||
|
setColumns((currentColumns) =>
|
||||||
|
currentColumns.map((column) =>
|
||||||
|
column.map((item) =>
|
||||||
|
tagMap.has(item.aweme_id)
|
||||||
|
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBatchAddTag = useCallback(async () => {
|
||||||
|
const awemeIds = [...selectedIds];
|
||||||
|
if (awemeIds.length === 0 || batchSaving) return;
|
||||||
|
setBatchSaving(true);
|
||||||
|
setBatchError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/asr/tags", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("批量添加标签失败");
|
||||||
|
const data: TagUpdateResponse = await response.json();
|
||||||
|
applyUpdatedTags(data.updated);
|
||||||
|
clearSelection();
|
||||||
|
} catch (error) {
|
||||||
|
setBatchError(
|
||||||
|
error instanceof Error ? error.message : "批量添加标签失败",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBatchSaving(false);
|
||||||
|
}
|
||||||
|
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
|
||||||
|
|
||||||
|
const renderCard = useCallback(
|
||||||
|
(item: FeedItem) => {
|
||||||
|
const selected = selectedIds.has(item.aweme_id);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={item.aweme_id}
|
||||||
|
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
|
||||||
|
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.type === "video" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSelection(item.aweme_id)}
|
||||||
|
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-blue-300 bg-blue-600"
|
||||||
|
: "border-white/25 bg-black/35 hover:bg-black/55"
|
||||||
|
}`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={selected ? "取消选择视频" : "选择视频"}
|
||||||
|
>
|
||||||
|
{selected && <Check size={17} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/aweme/${item.aweme_id}${linkQuery}`}
|
||||||
|
target="_blank"
|
||||||
|
className="block relative w-full"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{ aspectRatio: `${(item.width && item.height) ? `${item.width}/${item.height}` : ''}` as any }}
|
style={{
|
||||||
|
aspectRatio: getAspectRatio(item),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.type === 'video' ? (
|
{item.type === "video" ? (
|
||||||
<HoverVideo
|
<HoverVideo
|
||||||
videoUrl={(item as any).video_url}
|
videoUrl={item.video_url}
|
||||||
coverUrl={item.cover_url}
|
coverUrl={item.cover_url}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<Image
|
||||||
loading="lazy"
|
fill
|
||||||
src={item.cover_url || '/placeholder.svg'}
|
src={item.cover_url || "/placeholder.svg"}
|
||||||
alt={item.desc?.slice(0, 20) || 'image'}
|
alt={item.desc?.slice(0, 20) || "image"}
|
||||||
|
unoptimized
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
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="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="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">
|
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||||
{item.desc}
|
{item.desc}
|
||||||
</p>
|
</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">
|
<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' ? '视频' : '图文'}
|
{item.type === "video" ? "视频" : "图文"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,44 +320,126 @@ export default function FeedMasonry({ initialItems, initialCursor, fetchUrl = '/
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 p-3">
|
<div className="flex items-center gap-2 p-3">
|
||||||
{item.author.sec_uid ? (
|
{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">
|
<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">
|
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||||
{item.author.avatar_url ? (
|
{item.author.avatar_url ? (
|
||||||
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
<Image
|
||||||
|
src={item.author.avatar_url}
|
||||||
|
alt="avatar"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span>
|
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||||
|
{item.author.nickname}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<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">
|
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||||
{item.author.avatar_url ? (
|
{item.author.avatar_url ? (
|
||||||
<img src={item.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
|
<Image
|
||||||
|
src={item.author.avatar_url}
|
||||||
|
alt="avatar"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">{item.author.nickname}</span>
|
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||||
|
{item.author.nickname}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
<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)' }} />
|
{item.likes}{" "}
|
||||||
|
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
), []);
|
);
|
||||||
|
},
|
||||||
|
[selectedIds, toggleSelection],
|
||||||
|
);
|
||||||
|
|
||||||
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,防止提前加载 */}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function SearchBox() {
|
export default function SearchBox({ tag }: { tag?: string | null }) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ export default function SearchBox() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
const params = new URLSearchParams({ q: trimmed });
|
||||||
|
if (tag) params.set("tag", tag);
|
||||||
|
router.push(`/search?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+39
-5
@@ -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,11 +28,13 @@ 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 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 搜索结果高亮标记样式 */
|
/* 搜索结果高亮标记样式 */
|
||||||
mark {
|
mark {
|
||||||
@@ -37,12 +44,39 @@ mark {
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.h-screen{
|
.h-screen {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.min-h-screen{
|
.min-h-screen {
|
||||||
min-height: 100dvh;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+2
-17
@@ -1,17 +1,6 @@
|
|||||||
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: {
|
title: {
|
||||||
default: "抖歪 - 记录当下时代",
|
default: "抖歪 - 记录当下时代",
|
||||||
@@ -19,7 +8,7 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description: "记录当下时代的精彩瞬间",
|
description: "记录当下时代的精彩瞬间",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.png",
|
icon: "/favicon.webp",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,11 +19,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<body
|
<body className="antialiased">{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-17
@@ -1,9 +1,12 @@
|
|||||||
|
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 SearchBox from "./components/SearchBox";
|
||||||
import type { FeedItem } from "./types/feed";
|
import type { FeedItem } from "./types/feed";
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { getFileUrl } from "@/lib/minio";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,17 +15,37 @@ export const metadata: Metadata = {
|
|||||||
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams?: { tag?: string };
|
||||||
|
}) {
|
||||||
|
const requestedTag = searchParams?.tag?.trim() || "";
|
||||||
|
const selectedTag =
|
||||||
|
requestedTag && isVideoContentTag(requestedTag) ? requestedTag : null;
|
||||||
|
|
||||||
const [videos, posts] = await Promise.all([
|
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({
|
selectedTag
|
||||||
|
? Promise.resolve([])
|
||||||
|
: prisma.imagePost.findMany({
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: 60,
|
take: 60,
|
||||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
include: {
|
||||||
|
author: true,
|
||||||
|
images: { orderBy: { order: "asc" }, take: 1 },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -33,23 +56,35 @@ export default async function Home() {
|
|||||||
created_at: v.created_at,
|
created_at: v.created_at,
|
||||||
desc: v.desc,
|
desc: v.desc,
|
||||||
video_url: getFileUrl(v.video_url),
|
video_url: getFileUrl(v.video_url),
|
||||||
cover_url: getFileUrl(v.cover_url ?? ''),
|
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: getFileUrl(v.author.avatar_url ?? ''), sec_uid: v.author.sec_uid },
|
author: {
|
||||||
likes: Number(v.digg_count)
|
nickname: v.author.nickname,
|
||||||
|
avatar_url: getFileUrl(v.author.avatar_url ?? ""),
|
||||||
|
sec_uid: v.author.sec_uid,
|
||||||
|
},
|
||||||
|
likes: Number(v.digg_count),
|
||||||
|
content_tags: v.manual_content_tags,
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => {
|
||||||
|
const coverPath = p.images?.[0]?.url;
|
||||||
|
return {
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
aweme_id: p.aweme_id,
|
aweme_id: p.aweme_id,
|
||||||
created_at: p.created_at,
|
created_at: p.created_at,
|
||||||
desc: p.desc,
|
desc: p.desc,
|
||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||||
width: p.images?.[0]?.width ?? null,
|
width: p.images?.[0]?.width ?? null,
|
||||||
height: p.images?.[0]?.height ?? 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 },
|
author: {
|
||||||
likes: Number(p.digg_count)
|
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));
|
||||||
@@ -67,14 +102,57 @@ export default async function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-xl mb-12">
|
<div className="w-full max-w-xl mb-12">
|
||||||
<SearchBox />
|
<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>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{(() => {
|
{(() => {
|
||||||
const initial = feed.slice(0, 24);
|
const initial = feed.slice(0, 24);
|
||||||
const cursor = initial.length > 0 ? new Date(initial[initial.length - 1].created_at as any).toISOString() : null;
|
const lastItem = initial.at(-1);
|
||||||
return <FeedMasonry initialItems={initial} initialCursor={cursor} />;
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+263
-53
@@ -1,17 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
import { ArrowLeft, FileText, Mic, Search, X } from "lucide-react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Search, ArrowLeft, X, MessageSquare } from "lucide-react";
|
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)
|
// 匹配搜索结果类型(适配后端API)
|
||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
id: string;
|
id: string;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
type: 'video' | 'image';
|
type: "video" | "image";
|
||||||
rank: number;
|
score: number;
|
||||||
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
matchedIn: ("desc" | "transcript")[];
|
||||||
|
descParts: SnippetPart[]; // 完整描述,标出命中位置
|
||||||
|
snippet: SnippetPart[] | null; // 语音转写的命中片段
|
||||||
|
contentTags: string[];
|
||||||
video?: {
|
video?: {
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
@@ -35,65 +49,184 @@ type SearchResultItem = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export default function SearchClient({ initialQuery }: { initialQuery: string }) {
|
|
||||||
|
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 searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
// 只采用最近一次请求的结果,避免快速切换关键词时旧结果覆盖新结果
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
|
|
||||||
const performSearch = useCallback(async (q: string) => {
|
const buildSearchHref = useCallback((q: string, tag: string | null) => {
|
||||||
if (!q.trim()) {
|
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([]);
|
setResults([]);
|
||||||
|
setTotal(0);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
|
setPage(1);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`);
|
const data = await fetchPage(trimmed, tag, 1);
|
||||||
if (!res.ok) throw new Error("Search failed");
|
if (requestId !== requestIdRef.current) return;
|
||||||
const data = await res.json();
|
|
||||||
setResults(data.results || []);
|
setResults(data.results || []);
|
||||||
|
setTotal(data.total ?? 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== requestIdRef.current) return;
|
||||||
console.error("Search error:", err);
|
console.error("Search error:", err);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 更稳妥)
|
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get("q") || "";
|
const q = searchParams.get("q") || "";
|
||||||
|
const tagParam = searchParams.get("tag") || "";
|
||||||
|
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
|
||||||
setQuery(q);
|
setQuery(q);
|
||||||
if (q) performSearch(q);
|
setSelectedTag(tag);
|
||||||
|
if (q || tag) {
|
||||||
|
performSearch(q, tag);
|
||||||
|
} else {
|
||||||
|
requestIdRef.current++;
|
||||||
|
setResults([]);
|
||||||
|
setTotal(0);
|
||||||
|
setSearched(false);
|
||||||
|
}
|
||||||
}, [searchParams, performSearch]);
|
}, [searchParams, performSearch]);
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed && !selectedTag) return;
|
||||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
router.push(buildSearchHref(trimmed, selectedTag));
|
||||||
// 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索
|
};
|
||||||
// performSearch(trimmed);
|
|
||||||
|
const handleTagSelect = (tag: string) => {
|
||||||
|
const nextTag = selectedTag === tag ? null : tag;
|
||||||
|
setSelectedTag(nextTag);
|
||||||
|
router.push(buildSearchHref(query, nextTag));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setSelectedTag(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setTotal(0);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
router.push("/search");
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<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">
|
<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">
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
aria-label="返回首页"
|
aria-label="返回首页"
|
||||||
@@ -101,7 +234,10 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
<ArrowLeft size={24} />
|
<ArrowLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="flex-1 flex items-center gap-3">
|
<form
|
||||||
|
onSubmit={handleSearch}
|
||||||
|
className="flex-1 flex items-center gap-3"
|
||||||
|
>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -109,13 +245,12 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="搜索视频、图文描述或语音内容..."
|
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"
|
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"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<Search
|
<Search
|
||||||
size={20}
|
size={20}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{(query || selectedTag) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
@@ -129,12 +264,30 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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()}
|
disabled={loading || (!query.trim() && !selectedTag)}
|
||||||
>
|
>
|
||||||
{loading ? "搜索中..." : "搜索"}
|
{loading ? "搜索中..." : "搜索"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
{/* 结果区域 */}
|
{/* 结果区域 */}
|
||||||
@@ -156,11 +309,13 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !searched && !query && (
|
{!loading && !searched && !query && !selectedTag && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
||||||
<Search size={64} className="mb-4 opacity-20" />
|
<Search size={64} className="mb-4 opacity-20" />
|
||||||
<p className="text-xl">输入关键词开始搜索</p>
|
<p className="text-xl">输入关键词开始搜索</p>
|
||||||
<p className="text-sm mt-2">支持搜索视频描述、图文描述及语音转写内容</p>
|
<p className="text-sm mt-2">
|
||||||
|
按原文匹配描述和语音转写,多个关键词用空格分隔,以 - 开头表示排除
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -168,94 +323,149 @@ export default function SearchClient({ initialQuery }: { initialQuery: string })
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h2 className="text-lg text-white/80">
|
<h2 className="text-lg text-white/80">
|
||||||
找到 <span className="text-white font-semibold">{results.length}</span> 个结果
|
找到 <span className="text-white font-semibold">{total}</span>{" "}
|
||||||
|
个结果
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 单列列表布局 */}
|
{/* 单列列表布局 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{results.map((item) => {
|
{results.map((item) => {
|
||||||
const content = item.type === 'video' ? item.video : item.imagePost;
|
const content =
|
||||||
|
item.type === "video" ? item.video : item.imagePost;
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.awemeId}
|
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"
|
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">
|
<div className="flex gap-4 p-4">
|
||||||
{/* 左侧:封面预览 */}
|
{/* 左侧:封面预览 */}
|
||||||
<Link
|
<Link
|
||||||
href={`/aweme/${content.aweme_id}`}
|
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
||||||
>
|
>
|
||||||
<img
|
{content.cover_url ? (
|
||||||
src={content.cover_url || ""}
|
<Image
|
||||||
|
src={content.cover_url}
|
||||||
alt={content.desc}
|
alt={content.desc}
|
||||||
|
width={280}
|
||||||
|
height={384}
|
||||||
|
unoptimized
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
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>
|
</Link>
|
||||||
|
|
||||||
{/* 右侧:信息区 */}
|
{/* 右侧:信息区 */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{/* 类型标签 */}
|
{/* 类型标签 */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
<span
|
||||||
item.type === 'video'
|
className={`px-2 py-0.5 text-xs rounded ${
|
||||||
? 'bg-blue-600/20 text-blue-300'
|
item.type === "video"
|
||||||
: 'bg-purple-600/20 text-purple-300'
|
? "bg-blue-600/20 text-blue-300"
|
||||||
}`}>
|
: "bg-purple-600/20 text-purple-300"
|
||||||
{item.type === 'video' ? '视频' : '图文'}
|
}`}
|
||||||
|
>
|
||||||
|
{item.type === "video" ? "视频" : "图文"}
|
||||||
</span>
|
</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">
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-600/20 text-green-300 text-xs rounded">
|
||||||
<MessageSquare size={12} />
|
<FileText size={12} />
|
||||||
匹配度: {(item.rank * 100).toFixed(1)}%
|
描述命中
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 描述 */}
|
{/* 描述 */}
|
||||||
<Link href={`/aweme/${content.aweme_id}`} target="_blank">
|
<Link
|
||||||
<p className="text-white/90 text-sm sm:text-base line-clamp-2 mb-3 hover:text-white transition-colors">
|
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
|
||||||
{content.desc || "无描述"}
|
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>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 作者信息 */}
|
{/* 作者信息 */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<img
|
{content.author.avatar_url ? (
|
||||||
src={content.author.avatar_url || ""}
|
<Image
|
||||||
|
src={content.author.avatar_url}
|
||||||
alt={content.author.nickname}
|
alt={content.author.nickname}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
className="w-6 h-6 rounded-full ring-1 ring-white/20"
|
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">
|
<span className="text-xs text-white/60">
|
||||||
{content.author.nickname}
|
{content.author.nickname}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 匹配详情:展示后端返回的snippet(已包含<mark>标签) */}
|
{/* 语音转写片段:命中时展示命中位置附近的文字 */}
|
||||||
<div className="space-y-3">
|
{item.snippet && item.snippet.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="flex items-center gap-2 text-xs font-medium text-green-300 mb-1.5">
|
<h4 className="flex items-center gap-2 text-xs font-medium text-green-300 mb-1.5">
|
||||||
<MessageSquare size={14} />
|
<Mic size={14} />
|
||||||
匹配内容
|
{item.matchedIn.includes("transcript")
|
||||||
|
? "语音内容"
|
||||||
|
: "语音转写"}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
||||||
dangerouslySetInnerHTML={{ __html: item.snippet }}
|
style={{ wordBreak: "break-word" }}
|
||||||
style={{
|
>
|
||||||
wordBreak: 'break-word',
|
{renderParts(item.snippet)}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+5
-1
@@ -2,7 +2,11 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import SearchClient from "./SearchClient";
|
import SearchClient from "./SearchClient";
|
||||||
|
|
||||||
export default function Page({ searchParams }: { searchParams: { q?: string } }) {
|
export default function Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { q?: string };
|
||||||
|
}) {
|
||||||
const q = typeof searchParams.q === "string" ? searchParams.q : "";
|
const q = typeof searchParams.q === "string" ? searchParams.q : "";
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="p-6 text-zinc-400">Loading…</div>}>
|
<Suspense fallback={<div className="p-6 text-zinc-400">Loading…</div>}>
|
||||||
|
|||||||
+227
-71
@@ -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, Clipboard, 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));
|
||||||
};
|
};
|
||||||
@@ -43,16 +56,22 @@ export default function TasksPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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(
|
||||||
|
(urls: string[]) => {
|
||||||
if (!urls.length) return;
|
if (!urls.length) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
setTasks((prev) => {
|
setTasks((prev) => {
|
||||||
const existing = new Set(prev.map((t) => t.id));
|
const existing = new Set(prev.map((t) => t.id));
|
||||||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
const notDuplicated = urls.filter((u) => !inProgressUrls.has(u));
|
||||||
const newTasks: Task[] = [];
|
const newTasks: Task[] = [];
|
||||||
for (const url of notDuplicated) {
|
for (const url of notDuplicated) {
|
||||||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -61,25 +80,34 @@ export default function TasksPage() {
|
|||||||
// 新任务添加到最前面
|
// 新任务添加到最前面
|
||||||
return [...newTasks, ...prev];
|
return [...newTasks, ...prev];
|
||||||
});
|
});
|
||||||
}, [inProgressUrls]);
|
},
|
||||||
|
[inProgressUrls],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback((e?: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
|
(e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const urls = extractDouyinLinks(input);
|
const urls = extractDouyinLinks(input);
|
||||||
if (!urls.length) {
|
if (!urls.length) {
|
||||||
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本");
|
alert(
|
||||||
|
"未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addTasks(urls);
|
addTasks(urls);
|
||||||
setInput("");
|
setInput("");
|
||||||
}, [input, addTasks]);
|
},
|
||||||
|
[input, addTasks],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePasteAndAdd = useCallback(async () => {
|
const handlePasteAndAdd = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
const text = await navigator.clipboard.readText();
|
||||||
const urls = extractDouyinLinks(text);
|
const urls = extractDouyinLinks(text);
|
||||||
if (!urls.length) {
|
if (!urls.length) {
|
||||||
alert("剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本");
|
alert(
|
||||||
|
"剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addTasks(urls);
|
addTasks(urls);
|
||||||
@@ -94,22 +122,44 @@ export default function TasksPage() {
|
|||||||
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(`/api/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
const res = await fetch(
|
||||||
|
`/api/fetcher?url=${encodeURIComponent(task.url)}`,
|
||||||
|
{ signal: ctrl.signal, method: "GET" },
|
||||||
|
);
|
||||||
const data = await res.json().catch(() => null);
|
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);
|
||||||
}
|
}
|
||||||
@@ -117,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);
|
||||||
@@ -140,27 +190,40 @@ export default function TasksPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const retryTask = useCallback((taskId: string) => {
|
const retryTask = useCallback((taskId: string) => {
|
||||||
setTasks(prev => prev.map(t => {
|
setTasks((prev) =>
|
||||||
|
prev.map((t) => {
|
||||||
if (t.id === taskId) {
|
if (t.id === taskId) {
|
||||||
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined };
|
return {
|
||||||
|
...t,
|
||||||
|
status: "pending" as TaskStatus,
|
||||||
|
error: undefined,
|
||||||
|
result: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return t;
|
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 = (startTime?: number, endTime?: number) => {
|
const formatDuration = (startTime?: number, endTime?: number) => {
|
||||||
if (!startTime) return "";
|
if (!startTime) return "";
|
||||||
@@ -173,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 (
|
||||||
@@ -192,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" />
|
||||||
@@ -210,18 +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 flex-wrap 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={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">
|
<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" /> 粘贴并添加
|
<Clipboard 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={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>
|
||||||
@@ -231,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">用时 {formatDuration(t.startedAt, t.finishedAt)}</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>
|
||||||
@@ -296,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">
|
||||||
@@ -315,33 +447,57 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
-5
@@ -1,10 +1,12 @@
|
|||||||
export type FeedItem =
|
export type FeedItem = (
|
||||||
| ({
|
| {
|
||||||
type: "video";
|
type: "video";
|
||||||
video_url: string;
|
video_url: string;
|
||||||
} | {
|
}
|
||||||
|
| {
|
||||||
type: "image";
|
type: "image";
|
||||||
}) & {
|
}
|
||||||
|
) & {
|
||||||
likes: number;
|
likes: number;
|
||||||
author: { nickname: string; avatar_url: string | null; sec_uid?: string };
|
author: { nickname: string; avatar_url: string | null; sec_uid?: string };
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
@@ -13,7 +15,8 @@ export type FeedItem =
|
|||||||
cover_url: string | null;
|
cover_url: string | null;
|
||||||
width?: number | null;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
};
|
content_tags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
items: FeedItem[];
|
items: FeedItem[];
|
||||||
|
|||||||
@@ -1,21 +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",
|
||||||
"openai": "^6.7.0",
|
|
||||||
"playwright": "1.56.1",
|
"playwright": "1.56.1",
|
||||||
"playwright-extra": "^4.3.6",
|
"playwright-extra": "^4.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"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",
|
||||||
"zod": "^4.1.12",
|
"tsx": "^4.20.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
@@ -24,7 +25,6 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"prisma": "^6.16.3",
|
"prisma": "^6.16.3",
|
||||||
"raw-loader": "^4.0.2",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
@@ -53,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=="],
|
||||||
@@ -107,29 +159,27 @@
|
|||||||
|
|
||||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -181,14 +231,6 @@
|
|||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
|
||||||
|
|
||||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
|
||||||
|
|
||||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
"@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=="],
|
||||||
@@ -197,52 +239,8 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
|
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
|
||||||
|
|
||||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
|
|
||||||
|
|
||||||
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
|
|
||||||
|
|
||||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
|
||||||
|
|
||||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
|
||||||
|
|
||||||
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
|
|
||||||
|
|
||||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
|
||||||
|
|
||||||
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
|
|
||||||
|
|
||||||
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
|
||||||
|
|
||||||
"arr-union": ["arr-union@3.1.0", "", {}, "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q=="],
|
"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=="],
|
||||||
@@ -251,22 +249,14 @@
|
|||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ=="],
|
|
||||||
|
|
||||||
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
|
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
|
||||||
|
|
||||||
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||||
@@ -283,16 +273,12 @@
|
|||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
|
|
||||||
|
|
||||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
|
||||||
|
|
||||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
"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=="],
|
||||||
@@ -323,10 +309,6 @@
|
|||||||
|
|
||||||
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
|
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.240", "", {}, "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ=="],
|
|
||||||
|
|
||||||
"emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="],
|
|
||||||
|
|
||||||
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||||
@@ -335,32 +317,16 @@
|
|||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"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=="],
|
||||||
|
|
||||||
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
|
||||||
|
|
||||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
|
||||||
|
|
||||||
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
|
||||||
|
|
||||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||||
|
|
||||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
|
||||||
|
|
||||||
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
|
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
|
||||||
|
|
||||||
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
|
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
|
||||||
@@ -389,14 +355,10 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
|
||||||
|
|
||||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||||
|
|
||||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
@@ -429,16 +391,8 @@
|
|||||||
|
|
||||||
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
|
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
|
||||||
|
|
||||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
|
||||||
|
|
||||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
|
||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
|
||||||
|
|
||||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
"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=="],
|
"kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||||
@@ -467,10 +421,6 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||||
|
|
||||||
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
|
|
||||||
|
|
||||||
"loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="],
|
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||||
@@ -481,8 +431,6 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -501,22 +449,16 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
"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=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="],
|
|
||||||
|
|
||||||
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||||
|
|
||||||
"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=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"openai": ["openai@6.7.0", "https://registry.npmmirror.com/openai/-/openai-6.7.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A=="],
|
|
||||||
|
|
||||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
"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=="],
|
||||||
@@ -539,8 +481,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
|
||||||
|
|
||||||
"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": ["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-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=="],
|
||||||
@@ -553,10 +493,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
|
||||||
|
|
||||||
"raw-loader": ["raw-loader@4.0.2", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA=="],
|
|
||||||
|
|
||||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||||
|
|
||||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||||
@@ -567,8 +503,6 @@
|
|||||||
|
|
||||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
|
||||||
|
|
||||||
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
"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=="],
|
||||||
@@ -579,24 +513,16 @@
|
|||||||
|
|
||||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||||
|
|
||||||
"schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="],
|
|
||||||
|
|
||||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
|
|
||||||
|
|
||||||
"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=="],
|
"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": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
|
||||||
|
|
||||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||||
|
|
||||||
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
|
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
|
||||||
@@ -611,46 +537,32 @@
|
|||||||
|
|
||||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||||
|
|
||||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
|
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
|
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
|
||||||
|
|
||||||
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
|
|
||||||
|
|
||||||
"terser-webpack-plugin": ["terser-webpack-plugin@5.3.14", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw=="],
|
|
||||||
|
|
||||||
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
|
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
|
||||||
|
|
||||||
"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=="],
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="],
|
|
||||||
|
|
||||||
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
|
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
|
||||||
|
|
||||||
"webpack": ["webpack@5.102.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ=="],
|
|
||||||
|
|
||||||
"webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="],
|
|
||||||
|
|
||||||
"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=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
@@ -661,8 +573,6 @@
|
|||||||
|
|
||||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
||||||
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||||
@@ -675,10 +585,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
|
||||||
|
|
||||||
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
|
||||||
|
|
||||||
"mixin-object/for-in": ["for-in@0.1.8", "", {}, "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g=="],
|
"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=="],
|
||||||
@@ -687,22 +593,6 @@
|
|||||||
|
|
||||||
"shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="],
|
"shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="],
|
||||||
|
|
||||||
"terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
|
||||||
|
|
||||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
|
||||||
|
|
||||||
"terser-webpack-plugin/schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
|
||||||
|
|
||||||
"terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
|
||||||
|
|
||||||
"webpack/schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
|
||||||
|
|
||||||
"webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
|
||||||
|
|
||||||
"terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
|
||||||
|
|
||||||
"webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -1,12 +1,12 @@
|
|||||||
// scripts/fix-asset-urls.ts
|
// scripts/fix-asset-urls.ts
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const FROM = 'douyin-archive/';
|
const FROM = "douyin-archive/";
|
||||||
const TO = '';
|
const TO = "";
|
||||||
|
|
||||||
function escapeForPgRegex(s: string) {
|
function escapeForPgRegex(s: string) {
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串
|
const FROM_RE = `^${escapeForPgRegex(FROM)}`; // 只替换“以旧前缀开头”的字符串
|
||||||
const dryRun = false; // true: 只统计,不修改
|
const dryRun = false; // true: 只统计,不修改
|
||||||
@@ -121,12 +121,14 @@ async function main() {
|
|||||||
SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2
|
SELECT 'Video.video_url', video_url FROM "Video" WHERE video_url LIKE '${TO}%' LIMIT 2
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
console.log('Sample after update:', sample);
|
console.log("Sample after update:", sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main()
|
||||||
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}).finally(async () => {
|
})
|
||||||
|
.finally(async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
-9
@@ -1,9 +0,0 @@
|
|||||||
declare module '*.md' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.txt' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
@@ -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]+$";
|
||||||
+6
-3
@@ -1,10 +1,13 @@
|
|||||||
// lib/json.ts
|
// lib/json.ts
|
||||||
export function json(data: unknown, init?: ResponseInit) {
|
export function json(data: unknown, init?: ResponseInit) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify(data, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? v.toString() : v)),
|
||||||
{
|
{
|
||||||
...init,
|
...init,
|
||||||
headers: { 'content-type': 'application/json; charset=utf-8', ...(init?.headers || {}) },
|
headers: {
|
||||||
}
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-32
@@ -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,24 +52,24 @@ 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);
|
||||||
@@ -81,23 +81,23 @@ 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);
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,20 +142,20 @@ async function deletePostImages(postId: string) {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,14 +171,14 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,10 +228,10 @@ async function getFileDetails(path: string) {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-49
@@ -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,30 +25,28 @@ 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
|
||||||
@@ -59,7 +57,7 @@ export async function initBucket(): Promise<void> {
|
|||||||
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,27 +67,32 @@ 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 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 - 文件路径
|
||||||
@@ -112,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,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_PUBLIC_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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,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() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,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]}`;
|
||||||
@@ -319,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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { extractAudio } from "../../app/api/media";
|
||||||
|
import { getFileStream, getFileUrl, uploadTranscriptionAudio } from "../minio";
|
||||||
|
import { prisma } from "../prisma";
|
||||||
|
import { AsrError, MAX_DURATION_MS, validateMedia } from "./dashscope";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
// Whole-file transcription: download to disk, extract once, upload only audio.
|
||||||
|
export async function prepareAudio(videoId: string): Promise<string | null> {
|
||||||
|
const video = await prisma.video.findUniqueOrThrow({
|
||||||
|
where: { aweme_id: videoId },
|
||||||
|
});
|
||||||
|
if (video.duration_ms > MAX_DURATION_MS)
|
||||||
|
throw new AsrError("视频超过 ASR 的 12 小时时长限制");
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
|
||||||
|
try {
|
||||||
|
const input = path.join(dir, "video.mp4");
|
||||||
|
await pipeline(
|
||||||
|
await getFileStream(video.video_url),
|
||||||
|
createWriteStream(input),
|
||||||
|
{ signal: AbortSignal.timeout(60 * 60 * 1000) },
|
||||||
|
);
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
process.env.FFPROBE_PATH || "ffprobe",
|
||||||
|
[
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"a:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=index:format=duration",
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
input,
|
||||||
|
],
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
const probe = JSON.parse(stdout) as {
|
||||||
|
streams?: { index: number }[];
|
||||||
|
format?: { duration?: string };
|
||||||
|
};
|
||||||
|
if (!Array.isArray(probe.streams)) throw new AsrError("无法读取视频音轨");
|
||||||
|
if (!probe.streams.length) return null;
|
||||||
|
const duration = Number(probe.format?.duration) * 1000;
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0)
|
||||||
|
throw new AsrError("无法读取音频时长");
|
||||||
|
if (duration > MAX_DURATION_MS)
|
||||||
|
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||||
|
const audio = await extractAudio(input, { format: "mp3", bitrateKbps: 64 });
|
||||||
|
if (!audio) throw new AsrError("未找到 ffmpeg,请安装或配置 FFMPEG_PATH");
|
||||||
|
validateMedia(duration, audio.buffer.length);
|
||||||
|
const objectPath = `transcription-audio/${encodeURIComponent(videoId)}/${Date.now()}.mp3`;
|
||||||
|
await uploadTranscriptionAudio(objectPath, audio.buffer);
|
||||||
|
const url = getFileUrl(objectPath);
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new AsrError("请配置公网可访问的 MINIO_PUBLIC_DOMAIN");
|
||||||
|
}
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol))
|
||||||
|
throw new AsrError("音频 URL 协议无效");
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new AsrError(`音频公共地址不可访问 (HTTP ${response.status})`);
|
||||||
|
return url;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AsrError) throw error;
|
||||||
|
throw new AsrError(
|
||||||
|
"音频准备失败,请检查 MinIO、ffmpeg、ffprobe 和文件可访问性后重试",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
export class AsrError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public retryable = false,
|
||||||
|
public definitiveRejection = false,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const MAX_DURATION_MS = 12 * 60 * 60 * 1000;
|
||||||
|
export const MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
|
export function validateMedia(durationMs: number, size: number) {
|
||||||
|
if (durationMs > MAX_DURATION_MS)
|
||||||
|
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||||
|
if (!Number.isFinite(size) || size <= 0)
|
||||||
|
throw new AsrError("音频文件为空或大小未知");
|
||||||
|
if (size > MAX_BYTES) throw new AsrError("音频超过 ASR 的 2 GB 大小限制");
|
||||||
|
}
|
||||||
|
export function getAsrConfig() {
|
||||||
|
const apiKey = process.env.DASHSCOPE_API_KEY?.trim();
|
||||||
|
if (!apiKey) throw new AsrError("未配置 DASHSCOPE_API_KEY,请配置后重试");
|
||||||
|
const baseURL = (
|
||||||
|
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1"
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(baseURL);
|
||||||
|
} catch {
|
||||||
|
throw new AsrError("DASHSCOPE_BASE_URL 不是有效地址");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!["https:", "http:"].includes(url.protocol) ||
|
||||||
|
!url.pathname.endsWith("/api/v1")
|
||||||
|
) {
|
||||||
|
throw new AsrError("DASHSCOPE_BASE_URL 应为原生 API 地址,以 /api/v1 结尾");
|
||||||
|
}
|
||||||
|
const model = process.env.DASHSCOPE_ASR_MODEL || "qwen3-asr-flash-filetrans";
|
||||||
|
if (!/^qwen3-asr-flash-filetrans(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
|
||||||
|
throw new AsrError(
|
||||||
|
"DASHSCOPE_ASR_MODEL 必须是 qwen3-asr-flash-filetrans 或其日期版本",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { apiKey, baseURL, model };
|
||||||
|
}
|
||||||
|
type ObjectValue = Record<string, unknown>;
|
||||||
|
function object(value: unknown): ObjectValue {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||||
|
throw new AsrError("ASR 返回格式异常");
|
||||||
|
return value as ObjectValue;
|
||||||
|
}
|
||||||
|
function safeCode(value: unknown) {
|
||||||
|
return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value)
|
||||||
|
? value
|
||||||
|
: "UNKNOWN";
|
||||||
|
}
|
||||||
|
async function request(url: string, init: RequestInit = {}): Promise<unknown> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new AsrError("ASR 网络请求失败或超时", true);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const raw = await response.json().catch(() => null);
|
||||||
|
const body = raw && typeof raw === "object" ? (raw as ObjectValue) : {};
|
||||||
|
throw new AsrError(
|
||||||
|
`ASR HTTP ${response.status} (${safeCode(body.code)})`,
|
||||||
|
response.status === 429 || response.status >= 500,
|
||||||
|
response.status >= 400 &&
|
||||||
|
response.status < 500 &&
|
||||||
|
response.status !== 429,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new AsrError("ASR 返回非 JSON 数据", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function submitTranscription(fileURL: string): Promise<string> {
|
||||||
|
const { apiKey, baseURL, model } = getAsrConfig();
|
||||||
|
const data = object(
|
||||||
|
await request(`${baseURL}/services/audio/asr/transcription`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-DashScope-Async": "enable",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
input: { file_url: fileURL },
|
||||||
|
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const output = object(data.output);
|
||||||
|
if (typeof output.task_id !== "string" || !output.task_id)
|
||||||
|
throw new AsrError("ASR 未返回任务 ID,提交结果不确定");
|
||||||
|
return output.task_id;
|
||||||
|
}
|
||||||
|
export type AsrTask =
|
||||||
|
| { status: "PENDING" | "RUNNING" }
|
||||||
|
| { status: "SUCCEEDED"; resultURL: string };
|
||||||
|
export async function queryTranscription(taskId: string): Promise<AsrTask> {
|
||||||
|
const { apiKey, baseURL } = getAsrConfig();
|
||||||
|
const data = object(
|
||||||
|
await request(`${baseURL}/tasks/${encodeURIComponent(taskId)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const output = object(data.output);
|
||||||
|
if (output.task_status === "PENDING" || output.task_status === "RUNNING")
|
||||||
|
return { status: output.task_status };
|
||||||
|
if (output.task_status === "SUCCEEDED") {
|
||||||
|
const result = object(output.result);
|
||||||
|
if (typeof result.transcription_url !== "string")
|
||||||
|
throw new AsrError("ASR 缺少结果下载地址");
|
||||||
|
return { status: "SUCCEEDED", resultURL: result.transcription_url };
|
||||||
|
}
|
||||||
|
throw new AsrError(
|
||||||
|
`ASR 任务失败 (${safeCode(output.code || output.task_status)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function extractTranscript(value: unknown): string[] {
|
||||||
|
const data = object(value);
|
||||||
|
if (!Array.isArray(data.transcripts))
|
||||||
|
throw new AsrError("ASR 缺少 transcripts,不能作为空文字保存");
|
||||||
|
return data.transcripts.flatMap((entry: unknown) => {
|
||||||
|
const track = object(entry);
|
||||||
|
if (track.sentences !== undefined && !Array.isArray(track.sentences))
|
||||||
|
throw new AsrError("ASR 句子格式异常");
|
||||||
|
const sentences = ((track.sentences ?? []) as unknown[])
|
||||||
|
.map((sentence) => {
|
||||||
|
const text = object(sentence).text;
|
||||||
|
if (typeof text !== "string") throw new AsrError("ASR 句子缺少文字");
|
||||||
|
return text.trim();
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
if (sentences.length) return sentences;
|
||||||
|
if (typeof track.text !== "string") throw new AsrError("ASR 音轨缺少文字");
|
||||||
|
return track.text.trim() ? [track.text.trim()] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function downloadTranscript(url: string): Promise<string[]> {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new AsrError("ASR 结果地址无效");
|
||||||
|
}
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol))
|
||||||
|
throw new AsrError("ASR 结果地址协议无效");
|
||||||
|
// Result storage is not the API host: never forward the API key.
|
||||||
|
return extractTranscript(await request(url));
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Prisma, type TranscriptionTask } from "@prisma/client";
|
||||||
|
import { prisma } from "../prisma";
|
||||||
|
import { getAsrConfig } from "./dashscope";
|
||||||
|
import { isActive, transcriptionState } from "./types";
|
||||||
|
|
||||||
|
function configurationError(): string | null {
|
||||||
|
try {
|
||||||
|
getAsrConfig();
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return (error as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function enqueueTranscription(videoId: string, force = false) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const videos = await tx.$queryRaw<
|
||||||
|
{ aweme_id: string }[]
|
||||||
|
>`SELECT aweme_id FROM "Video" WHERE aweme_id = ${videoId} FOR UPDATE`;
|
||||||
|
if (!videos.length) return null;
|
||||||
|
const [transcript, task] = await Promise.all([
|
||||||
|
tx.videoTranscript.findUnique({ where: { videoId } }),
|
||||||
|
tx.transcriptionTask.findUnique({ where: { videoId } }),
|
||||||
|
]);
|
||||||
|
if ((task && isActive(task.status)) || (!force && transcript))
|
||||||
|
return transcriptionState(transcript, task);
|
||||||
|
const lastError = configurationError();
|
||||||
|
const data = {
|
||||||
|
status: lastError ? "FAILED" : "QUEUED",
|
||||||
|
lastError,
|
||||||
|
providerTaskId: null,
|
||||||
|
submittingAt: null,
|
||||||
|
submittedAt: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
leaseToken: null,
|
||||||
|
attempts: 0,
|
||||||
|
nextPollAt: new Date(),
|
||||||
|
};
|
||||||
|
const saved = await tx.transcriptionTask.upsert({
|
||||||
|
where: { videoId },
|
||||||
|
create: { videoId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return transcriptionState(transcript, saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function enqueueMissingTranscriptions() {
|
||||||
|
const error = configurationError();
|
||||||
|
if (error) throw new Error(error);
|
||||||
|
// One durable, atomic batch. Completed historical transcripts are never requeued.
|
||||||
|
return prisma.$executeRaw`
|
||||||
|
INSERT INTO "TranscriptionTask" ("videoId", status, "updatedAt")
|
||||||
|
SELECT v.aweme_id, 'QUEUED', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') FROM "Video" v
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM "VideoTranscript" t WHERE t."videoId" = v.aweme_id)
|
||||||
|
ON CONFLICT ("videoId") DO UPDATE SET status = 'QUEUED', "providerTaskId" = NULL,
|
||||||
|
"submittingAt" = NULL, "submittedAt" = NULL, "leaseUntil" = NULL, "leaseToken" = NULL,
|
||||||
|
attempts = 0, "lastError" = NULL, "nextPollAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||||
|
WHERE "TranscriptionTask".status = 'FAILED'
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
export async function readTranscription(videoId: string) {
|
||||||
|
const video = await prisma.video.findUnique({
|
||||||
|
where: { aweme_id: videoId },
|
||||||
|
include: { transcript: true, transcriptionTask: true },
|
||||||
|
});
|
||||||
|
return video
|
||||||
|
? transcriptionState(video.transcript, video.transcriptionTask)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimTask(): Promise<TranscriptionTask | null> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
// Serialize admission across all worker processes, including PM2 restarts.
|
||||||
|
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74196321)::text`;
|
||||||
|
const running = await tx.transcriptionTask.count({
|
||||||
|
where: { status: "PROCESSING" },
|
||||||
|
});
|
||||||
|
const eligible =
|
||||||
|
running < 2
|
||||||
|
? Prisma.sql`status IN ('QUEUED', 'PROCESSING')`
|
||||||
|
: Prisma.sql`status = 'PROCESSING'`;
|
||||||
|
const rows = await tx.$queryRaw<TranscriptionTask[]>`
|
||||||
|
SELECT * FROM "TranscriptionTask" WHERE ${eligible}
|
||||||
|
AND "nextPollAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AND ("leaseUntil" IS NULL OR "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
|
||||||
|
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END, "nextPollAt", "createdAt"
|
||||||
|
LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||||
|
`;
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return tx.transcriptionTask.update({
|
||||||
|
where: { videoId: rows[0].videoId },
|
||||||
|
data: {
|
||||||
|
status: "PROCESSING",
|
||||||
|
leaseToken: randomUUID(),
|
||||||
|
leaseUntil: new Date(Date.now() + 180_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function updateOwnedTask(
|
||||||
|
task: TranscriptionTask,
|
||||||
|
data: Prisma.TranscriptionTaskUpdateManyMutationInput,
|
||||||
|
) {
|
||||||
|
const result = await prisma.transcriptionTask.updateMany({
|
||||||
|
where: {
|
||||||
|
videoId: task.videoId,
|
||||||
|
leaseToken: task.leaseToken,
|
||||||
|
status: "PROCESSING",
|
||||||
|
leaseUntil: { gt: new Date() },
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (!result.count) throw new Error("转写任务租约已失效");
|
||||||
|
}
|
||||||
|
export async function completeTask(
|
||||||
|
task: TranscriptionTask,
|
||||||
|
transcript: string[],
|
||||||
|
) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.transcriptionTask.updateMany({
|
||||||
|
where: {
|
||||||
|
videoId: task.videoId,
|
||||||
|
leaseToken: task.leaseToken,
|
||||||
|
status: "PROCESSING",
|
||||||
|
leaseUntil: { gt: new Date() },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "SUCCEEDED",
|
||||||
|
lastError: null,
|
||||||
|
leaseToken: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
attempts: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!result.count) throw new Error("转写任务租约已失效");
|
||||||
|
await tx.videoTranscript.upsert({
|
||||||
|
where: { videoId: task.videoId },
|
||||||
|
create: { videoId: task.videoId, transcript },
|
||||||
|
update: { transcript },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export type TranscriptionStatus =
|
||||||
|
| "PENDING"
|
||||||
|
| "QUEUED"
|
||||||
|
| "PROCESSING"
|
||||||
|
| "SUCCEEDED"
|
||||||
|
| "FAILED";
|
||||||
|
export type Transcript = { transcript: string[] };
|
||||||
|
export type TranscriptionState = {
|
||||||
|
status: TranscriptionStatus;
|
||||||
|
error: string | null;
|
||||||
|
transcript: string[] | null;
|
||||||
|
};
|
||||||
|
export const statusLabels: Record<TranscriptionStatus, string> = {
|
||||||
|
PENDING: "待转写",
|
||||||
|
QUEUED: "排队中",
|
||||||
|
PROCESSING: "处理中",
|
||||||
|
SUCCEEDED: "已完成",
|
||||||
|
FAILED: "失败",
|
||||||
|
};
|
||||||
|
export function isActive(status: string) {
|
||||||
|
return status === "QUEUED" || status === "PROCESSING";
|
||||||
|
}
|
||||||
|
export function transcriptionState(
|
||||||
|
transcript: Transcript | null,
|
||||||
|
task: { status: string; lastError: string | null } | null,
|
||||||
|
): TranscriptionState {
|
||||||
|
return {
|
||||||
|
status: (task?.status ??
|
||||||
|
(transcript ? "SUCCEEDED" : "PENDING")) as TranscriptionStatus,
|
||||||
|
error: task?.lastError ?? null,
|
||||||
|
transcript: transcript?.transcript ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { TranscriptionTask } from "@prisma/client";
|
||||||
|
import { prepareAudio } from "./audio";
|
||||||
|
import {
|
||||||
|
AsrError,
|
||||||
|
downloadTranscript,
|
||||||
|
getAsrConfig,
|
||||||
|
queryTranscription,
|
||||||
|
submitTranscription,
|
||||||
|
} from "./dashscope";
|
||||||
|
import { claimTask, completeTask, updateOwnedTask } from "./tasks";
|
||||||
|
|
||||||
|
export const defaultServices = {
|
||||||
|
prepareAudio,
|
||||||
|
submitTranscription,
|
||||||
|
queryTranscription,
|
||||||
|
downloadTranscript,
|
||||||
|
};
|
||||||
|
export async function processTask(
|
||||||
|
task: TranscriptionTask,
|
||||||
|
services = defaultServices,
|
||||||
|
) {
|
||||||
|
let submitting = false;
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
void updateOwnedTask(task, {
|
||||||
|
leaseUntil: new Date(Date.now() + 180_000),
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 30_000);
|
||||||
|
try {
|
||||||
|
getAsrConfig();
|
||||||
|
if (!task.providerTaskId) {
|
||||||
|
if (task.submittingAt)
|
||||||
|
throw new AsrError(
|
||||||
|
"上次提交结果不确定,请核对百炼任务后手动重试,避免重复计费",
|
||||||
|
);
|
||||||
|
const url = await services.prepareAudio(task.videoId);
|
||||||
|
if (url === null) {
|
||||||
|
await completeTask(task, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateOwnedTask(task, { submittingAt: new Date() });
|
||||||
|
submitting = true;
|
||||||
|
const providerTaskId = await services.submitTranscription(url);
|
||||||
|
await updateOwnedTask(task, {
|
||||||
|
providerTaskId,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
nextPollAt: new Date(Date.now() + 10_000),
|
||||||
|
leaseToken: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Provider result URLs expire after 24 hours; do not poll forever.
|
||||||
|
if (
|
||||||
|
task.submittedAt &&
|
||||||
|
Date.now() - task.submittedAt.getTime() > 24 * 60 * 60 * 1000
|
||||||
|
) {
|
||||||
|
throw new AsrError("ASR 任务超过 24 小时,请手动重试");
|
||||||
|
}
|
||||||
|
const result = await services.queryTranscription(task.providerTaskId);
|
||||||
|
if (result.status === "SUCCEEDED") {
|
||||||
|
const transcript = await services.downloadTranscript(result.resultURL);
|
||||||
|
await completeTask(task, transcript);
|
||||||
|
} else {
|
||||||
|
await updateOwnedTask(task, {
|
||||||
|
nextPollAt: new Date(Date.now() + 10_000),
|
||||||
|
leaseToken: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
lastError: null,
|
||||||
|
attempts: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Unknown exceptions (e.g. database outage) leave the lease and submitting marker
|
||||||
|
// intact. Recovery can query a saved ID, but must never blindly submit again.
|
||||||
|
if (!(error instanceof AsrError)) throw error;
|
||||||
|
const retry = !submitting && !!task.providerTaskId && error.retryable;
|
||||||
|
await updateOwnedTask(task, {
|
||||||
|
status: retry ? "PROCESSING" : "FAILED",
|
||||||
|
lastError:
|
||||||
|
submitting && !error.definitiveRejection
|
||||||
|
? `${error.message};提交结果不确定,请核对百炼任务后手动重试`
|
||||||
|
: error.message,
|
||||||
|
attempts: { increment: 1 },
|
||||||
|
leaseToken: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
nextPollAt: new Date(
|
||||||
|
Date.now() +
|
||||||
|
Math.min(300_000, 10_000 * 2 ** Math.min(task.attempts, 5)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
console.warn(`[ASR] ${task.videoId}: ${retry ? "等待重试" : "失败"}`);
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function workerTick(services = defaultServices) {
|
||||||
|
const tasks: TranscriptionTask[] = [];
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const task = await claimTask();
|
||||||
|
if (task) tasks.push(task);
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
tasks.map(async (task) => {
|
||||||
|
try {
|
||||||
|
await processTask(task, services);
|
||||||
|
} catch {
|
||||||
|
console.error(`[ASR] ${task.videoId}: 执行中断,租约到期后恢复`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const VIDEO_CONTENT_TAGS = [
|
||||||
|
"美女",
|
||||||
|
"游戏",
|
||||||
|
"时政",
|
||||||
|
"美食",
|
||||||
|
"旅行",
|
||||||
|
"体育",
|
||||||
|
"科技",
|
||||||
|
"教育",
|
||||||
|
"宠物",
|
||||||
|
"音乐",
|
||||||
|
"影视",
|
||||||
|
"财经",
|
||||||
|
"生活",
|
||||||
|
"其他",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type VideoContentTag = (typeof VIDEO_CONTENT_TAGS)[number];
|
||||||
|
|
||||||
|
const videoContentTagSet = new Set<string>(VIDEO_CONTENT_TAGS);
|
||||||
|
|
||||||
|
export function isVideoContentTag(value: string): value is VideoContentTag {
|
||||||
|
return videoContentTagSet.has(value);
|
||||||
|
}
|
||||||
+6
-17
@@ -2,27 +2,16 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
'playwright-extra',
|
"playwright-extra",
|
||||||
'puppeteer-extra-plugin-stealth',
|
"puppeteer-extra-plugin-stealth",
|
||||||
'puppeteer-extra-plugin',
|
"puppeteer-extra-plugin",
|
||||||
], webpack: (config) => {
|
],
|
||||||
config.module.rules.push({
|
|
||||||
test: /\.(md|txt)$/i,
|
|
||||||
type: 'asset/source', // 让这些文件作为纯文本注入
|
|
||||||
});
|
|
||||||
return config;
|
|
||||||
}, turbopack: {
|
|
||||||
rules: {
|
|
||||||
'*.md': { loaders: ['raw-loader'], as: '*.js' },
|
|
||||||
'*.txt': { loaders: ['raw-loader'], as: '*.js' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
/* config options here */
|
/* config options here */
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: "https",
|
||||||
hostname: 's3l.xn--876a.net',
|
hostname: "s3l.xn--876a.net",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user