import { createRichTextDocument, renderRichTextToReact } from '@kkk/richtext'
import { PlayIcon } from '@phosphor-icons/react'
import { differenceInSeconds, format, formatDistanceToNow, fromUnixTime } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { Search } from 'lucide-react'
import React, { type ReactNode } from 'react'
import { cn } from '../../../../utils/cn'
import { isDark } from '../../../../utils/theme'
import { DefaultLayout } from '../../../components/DefaultLayout'
import { QRCodeWithAvatar } from '../../../components/QRCodeWithAvatar'
import type { PosterProps } from '../../../types/ctx'
import { DouyinCommentIcon, DouyinFavoriteIcon, DouyinLikeIcon, DouyinMoreIcon, DouyinShareIcon } from '../../components/Icons'
import type { DouyinSubComment } from '../../components/types'
import type { DouyinCommentData } from './types'
const douyinMentionClassName = 'text-[#04498d] dark:text-[#face15]'
const douyinSearchKeywordClassName = 'font-medium text-[#04498d] dark:text-[#face15]'
const douyinSearchKeywordIconClassName = 'opacity-90'
// 注意不能提升到模块顶层调用:打包后注册表与共享 chunk 存在循环引用,
// 模块求值期调用会在「未初始化」的绑定上炸出 is not a function,必须惰性创建。
const createEmptyDouyinRichText = () => createRichTextDocument([], { platform: 'douyin' })
const renderDouyinCommentRichText = (content: DouyinSubComment['text'] | DouyinCommentData['CommentsData'][number]['text']): ReactNode => {
return renderRichTextToReact(content, {
mention: { className: douyinMentionClassName },
searchKeyword: { className: douyinSearchKeywordClassName, iconClassName: douyinSearchKeywordIconClassName }
})
}
const formatDouyinCommentTime = (timestamp: number): string => {
if (!timestamp) {
return ''
}
const commentDate = fromUnixTime(timestamp)
const diffSeconds = differenceInSeconds(new Date(), commentDate)
if (diffSeconds < 30) {
return '刚刚'
}
if (diffSeconds < 7776000) {
return formatDistanceToNow(commentDate, {
locale: zhCN,
addSuffix: true
})
}
return format(commentDate, 'yyyy-MM-dd')
}
const formatDouyinCommentDiggCount = (count: number): string => {
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}w`
}
return String(count)
}
const formatPublishTime = (timestamp: number): string => {
if (!timestamp) return ''
return format(fromUnixTime(timestamp), 'yyyy年MM月dd日 HH:mm', { locale: zhCN })
}
// const DouyinLogo: React.FC<{ useDarkTheme?: boolean }> = ({ useDarkTheme }) => {
// const [hasError, setHasError] = React.useState(false)
// if (hasError) {
// return (
//
// 抖音
//
// )
// }
// return (
//
setHasError(true)}
// />
// )
// }
/**
* 二维码组件
* @param props 组件属性
* @returns JSX元素
*/
const QRCodeSection: React.FC & { useDarkTheme?: boolean }> = (props) => {
return (
)
}
/**
* 视频信息头部组件
* @param props 组件属性
* @returns JSX元素
*/
const VideoInfoHeader: React.FC & { useDarkTheme?: boolean }> = (props) => {
return (
{/* 左侧内容区 */}
{/* 作者信息 + Logo */}
{/* 作者头像 */}
{/* 作者名 + 时间 */}
{props.Author}
{formatPublishTime(props.CreateTime)}
{/* 抖音 Logo */}
{/*
*/}
{/* 数据统计 */}
{formatDouyinCommentDiggCount(props.Statistics.digg_count)}
{formatDouyinCommentDiggCount(props.Statistics.comment_count)}
{formatDouyinCommentDiggCount(props.Statistics.share_count)}
{formatDouyinCommentDiggCount(props.Statistics.collect_count)}
{/* 规格信息 */}
{props.Type}
{props.Type === '视频' && props.Resolution && (
<>
·
{props.Resolution}
>
)}
{props.Type === '视频' ? (
<>
·
{props.VideoSize}MB
·
{props.VideoFPS}Hz
>
) : (
<>
·
{props.Region}
·
{props.ImageLength}张
>
)}
{/* 右侧二维码 */}
)
}
interface ReplyNode extends DouyinSubComment {
children: ReplyNode[]
hiddenCount?: number
}
const organizeReplies = (replies: DouyinSubComment[], rootCid: string, maxDepth: number = 6): ReplyNode[] => {
const map = new Map()
const roots: ReplyNode[] = []
// 第一遍:创建节点
replies.forEach((r) => {
map.set(r.cid, { ...r, children: [] })
})
// 第二遍:构建树
replies.forEach((r) => {
const node = map.get(r.cid)!
const parentId = r.reply_to_reply_id
if (parentId && map.has(parentId) && parentId !== rootCid && parentId !== '0') {
map.get(parentId)!.children.push(node)
} else {
roots.push(node)
}
})
// 计算深度并修剪树的函数
const pruneTree = (nodes: ReplyNode[], currentDepth: number): ReplyNode[] => {
if (currentDepth > maxDepth) {
const count = nodes.length + nodes.reduce((acc, node) => acc + countChildren(node), 0)
if (count > 0) {
return [
{
cid: `more-${Date.now()}-${Math.random()}`,
text: createEmptyDouyinRichText(),
digg_count: 0,
create_time: 0,
nickname: '',
userimageurl: '',
ip_label: '',
text_extra: [],
label_text: '',
image_list: null,
reply_to_reply_id: '',
reply_to_username: '',
children: [],
hiddenCount: count
}
]
}
return []
}
return nodes.map((node) => {
node.children = pruneTree(node.children, currentDepth + 1)
return node
})
}
const countChildren = (node: ReplyNode): number => {
return node.children.length + node.children.reduce((acc, child) => acc + countChildren(child), 0)
}
return pruneTree(roots, 1)
}
const ReplyItemComponent: React.FC<{ reply: ReplyNode; depth?: number; isLast?: boolean; maxDepth?: number }> = ({
reply,
depth = 0,
isLast,
maxDepth = 6
}) => {
const nicknameLength = reply.nickname.length
const replyToLength = reply.reply_to_username?.length || 0
const isNicknameLonger = nicknameLength >= replyToLength
if (reply.hiddenCount) {
return (
{/*
外部网格:处理缩进和树连接
第1列:父级线程线(脊柱) + 连接到此评论的线(曲线)
第2列:评论本身
*/}
{/* 第1列:树连接区域 */}
{/* 1. 脊柱:来自父级的垂直线 */}
{/* 如果不是最后一个子节点则穿过 */}
{!isLast &&
}
{/* 脊柱延伸用于边距间隙 */}
{/* 连接到下一个兄弟节点的 mb-6 间隙 */}
{!isLast &&
}
{/* 2. 曲线:L形连接到当前评论 */}
{/* 第2列:显示更多文本 */}
另外 {reply.hiddenCount} 条回复
)
}
return (
{/*
外部网格:处理缩进和树连接
第1列:父级线程线(脊柱) + 连接到此评论的线(曲线)
第2列:评论本身
*/}
{/* 第1列:树连接区域 */}
{/* 1. 脊柱:来自父级的垂直线 */}
{/* 如果不是最后一个子节点则穿过 */}
{!isLast &&
}
{/* 脊柱延伸用于边距间隙 */}
{/* 连接到下一个兄弟节点的 mb-6 间隙 */}
{!isLast &&
}
{/* 2. 曲线:L形连接到当前评论 */}
{/* 第2列:评论主体(六宫格) */}
{/* 内部网格:头像 | 内容 */}
{/* 内部第1列:头像 & 子线程线 */}
{/* 添加 h-full 以确保其拉伸以匹配内容高度 */}
{/* 头像 - 固定高度 */}
{/* 子线程线 - 从头像下方开始并延伸到此单元格底部 */}
{reply.children.length > 0 &&
}
{/* 内部第2列:头部、内容、操作 */}
{/* 第1行:头部 */}
{reply.nickname}
{reply.label_text !== '' && (
{reply.label_text}
)}
{reply.reply_to_username && (
{reply.reply_to_username}
)}
{/* 第2行:内容 */}
{renderDouyinCommentRichText(reply.text)}
{reply.image_list &&
reply.image_list.length > 0 &&
reply.image_list.filter(Boolean).map((img, idx) => (
))}
{/* 第3行:操作 */}
{formatDouyinCommentTime(reply.create_time)}
{reply.ip_label}
{formatDouyinCommentDiggCount(reply.digg_count)}
{/* 子容器 - 递归 */}
{reply.children.length > 0 && (
{reply.children.map((child, index) => (
))}
)}
)
}
/**
* 单个评论组件
* @param props 组件属性
* @returns JSX元素
* @description Root comment component
*/
const CommentItemComponent: React.FC = (props) => {
return (
{/* 根网格 - 单列主体(根节点没有连接列) */}
{/* 内部网格:头像 | 内容 */}
{/* 内部第1列:头像 & 子线程线 */}
{/* 头像 - 根节点更大 */}
{/* 子线程线 */}
{props.replyComment && props.replyComment.length > 0 && (
)}
{/* 内部第2列:内容 */}
{/* 头部 */}
{props.nickname}
{props.label_type === 1 && (
作者
)}
{props.is_author_digged && props.status_label !== '作者赞过' && (
作者赞过
)}
{props.status_label && (
{props.status_label}
)}
{renderDouyinCommentRichText(props.text)}
{/* 评论图片 */}
{(props.commentimage || props.sticker) && (
)}
{formatDouyinCommentTime(props.create_time)}
{props.ip_label}
{formatDouyinCommentDiggCount(props.digg_count)}
{/* 回复容器 */}
{/*
根缩进调整:
根头像列为 140px。中心点为 70px。
子外部列为 100px。中心点为 50px。
我们需要子中心点(50px)与根中心点(70px)对齐。
所以我们需要将子元素向右推 20px。
容器上使用 pl-5。
*/}
{props.replyComment && props.replyComment.length > 0 && (
{organizeReplies(props.replyComment, props.cid || '', props.maxDepth).map((reply, index, arr) => (
))}
)}
)
}
/**
* 抖音评论组件
* @param props 组件属性
* @returns JSX元素
*/
export const DouyinComment: React.FC> = React.memo((props) => {
// 随机选择一个搜索词
const randomSuggestWord = () => {
if (props.data.suggestWrod && props.data.suggestWrod.length > 0) {
const randomIndex = Math.floor(Math.random() * props.data.suggestWrod.length)
return props.data.suggestWrod[randomIndex]
}
return null
}
return (
{/* 视频信息头部 */}
{/* 推荐搜索词 */}
{randomSuggestWord() && (
大家都在搜:
{randomSuggestWord()}
)}
{/* {randomSuggestWord && (
)} */}
{/* 评论列表 */}
{props.data.CommentsData.length > 0 ? (
<>
{props.data.CommentsData.map((comment, index) => (
))}
>
) : (
)}
)
})
export default DouyinComment