整体UI调整
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { FavoritesPage } from "@/components/favorites/favorites-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "我的收藏 - 海角社区",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <FavoritesPage />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { HistoryPage } from "@/components/history/history-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "观看历史 - 海角社区",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <HistoryPage />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { LikesPage } from "@/components/likes/likes-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "我的点赞 - 海角社区",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <LikesPage />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { ProfilePage } from "@/components/profile/profile-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "个人中心 - 海角社区",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <ProfilePage />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { SettingsPage } from "@/components/settings/settings-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "账号设置 - 海角社区",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <SettingsPage />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { EditProfilePage } from "@/components/settings/edit-profile-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑个人资料 - 海角社区",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <EditProfilePage />;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { Pagination } from "@/components/common/pagination";
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
|
||||
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
|
||||
|
||||
type CollectionPageProps<T extends string, S extends string> = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
breadcrumb: string;
|
||||
types: readonly T[];
|
||||
mobileTypes?: readonly T[];
|
||||
scopes: readonly S[];
|
||||
defaultScope: S;
|
||||
totalPages: number;
|
||||
getItems: (type: T, scope: S, page: number) => ShortDramaItem[];
|
||||
clearable?: boolean;
|
||||
showMobileScopes?: boolean;
|
||||
showPcScopes?: boolean;
|
||||
showVideoScopes?: boolean;
|
||||
showMobileEpisodes?: boolean;
|
||||
emptyText: string;
|
||||
};
|
||||
|
||||
type FilterPillsProps<T extends string> = {
|
||||
options: readonly T[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
itemClassName: string;
|
||||
};
|
||||
|
||||
function FilterPills<T extends string>({ options, value, onChange, className = "", itemClassName }: FilterPillsProps<T>) {
|
||||
return (
|
||||
<div className={`items-center ${className}`}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === value}
|
||||
onClick={() => onChange(option)}
|
||||
className={`cursor-pointer flex items-center justify-center rounded-2xl transition-colors ${itemClassName} ${
|
||||
option === value ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectionPage<T extends string, S extends string>({
|
||||
title,
|
||||
subtitle,
|
||||
breadcrumb,
|
||||
types,
|
||||
mobileTypes = types,
|
||||
scopes,
|
||||
defaultScope,
|
||||
totalPages,
|
||||
getItems,
|
||||
clearable = false,
|
||||
showMobileScopes = true,
|
||||
showPcScopes = true,
|
||||
showVideoScopes = true,
|
||||
showMobileEpisodes = true,
|
||||
emptyText,
|
||||
}: CollectionPageProps<T, S>) {
|
||||
const router = useRouter();
|
||||
const [type, setType] = useState<T>(types[0]);
|
||||
const [scope, setScope] = useState<S>(defaultScope);
|
||||
const [page, setPage] = useState(1);
|
||||
const [cleared, setCleared] = useState(false);
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const items = cleared ? [] : getItems(type, scope, page);
|
||||
const isVideo = type === "视频";
|
||||
const PcCard = isVideo ? VideoCard : ShortDramaCard;
|
||||
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
|
||||
|
||||
const changeType = (next: T) => {
|
||||
setType(next);
|
||||
setPage(1);
|
||||
};
|
||||
const changeScope = (next: S) => {
|
||||
setScope(next);
|
||||
setPage(1);
|
||||
};
|
||||
const changePage = (next: number) => {
|
||||
setPage(next);
|
||||
topRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.push("/profile");
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-component="collection-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
|
||||
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
|
||||
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
|
||||
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
|
||||
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-base leading-[22px] font-bold text-white">{title}</h1>
|
||||
{clearable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCleared(true)}
|
||||
disabled={cleared}
|
||||
className="cursor-pointer absolute right-4 text-xs leading-[18px] font-bold text-[#F12B9E] disabled:opacity-40"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={topRef}
|
||||
data-pc-scale
|
||||
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
|
||||
>
|
||||
<SeoBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: "个人中心", href: "/profile" },
|
||||
{ name: breadcrumb },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="hidden items-center lg:flex">
|
||||
<div className="flex flex-1 flex-col gap-px">
|
||||
<h2 className="text-[30px] leading-9 font-semibold text-white">{title}</h2>
|
||||
<p className="text-sm leading-5 text-[#9790A6]">{subtitle}</p>
|
||||
</div>
|
||||
{clearable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCleared(true)}
|
||||
disabled={cleared}
|
||||
className="cursor-pointer h-9 w-24 rounded-[18px] bg-white/6 text-xs leading-[18px] font-semibold text-[#B5AEC1] transition-colors hover:text-white disabled:opacity-40"
|
||||
>
|
||||
清空记录
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:gap-2.5">
|
||||
<FilterPills
|
||||
options={mobileTypes}
|
||||
value={type}
|
||||
onChange={changeType}
|
||||
className="flex gap-3 lg:hidden"
|
||||
itemClassName="h-8 px-4 text-sm leading-5"
|
||||
/>
|
||||
<FilterPills
|
||||
options={types}
|
||||
value={type}
|
||||
onChange={changeType}
|
||||
className="hidden gap-[9px] lg:flex"
|
||||
itemClassName="h-8 w-[88px] px-4 text-xs leading-[18px]"
|
||||
/>
|
||||
{isVideo && !showVideoScopes ? null : (
|
||||
<FilterPills
|
||||
options={scopes}
|
||||
value={scope}
|
||||
onChange={changeScope}
|
||||
className={`flex gap-2 lg:gap-[9px] ${showMobileScopes ? "" : "max-lg:hidden"} ${showPcScopes ? "" : "lg:hidden"}`}
|
||||
itemClassName="h-6 px-3 text-xs leading-[18px] lg:h-8 lg:px-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
title={emptyText}
|
||||
description="去首页发现更多好剧吧"
|
||||
onReload={null}
|
||||
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
|
||||
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<PcCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
|
||||
{items.map((item) => (
|
||||
<MobileCard key={item.id} item={item} showEpisodes={showMobileEpisodes} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
import type { CommentInfo } from "@/lib/api/types";
|
||||
|
||||
import { COMMENT_MAX_LENGTH, COMMENT_SELF_NAME, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
|
||||
import { CommentReplyPreview } from "./comment-reply-preview";
|
||||
import { HeartIcon } from "./heart-icon";
|
||||
import { InlineReplyBox } from "./inline-reply-box";
|
||||
import { useComments } from "./use-comments";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
const panelStyles = {
|
||||
detail: {
|
||||
section: "rounded-2xl bg-[rgba(24,23,46,0.88)] outline-[#383657]",
|
||||
item: "py-4",
|
||||
more: "mt-4 h-[43px] rounded-[7px]",
|
||||
},
|
||||
play: {
|
||||
section: "rounded-xl bg-[rgba(19,15,22,0.91)] outline-[rgba(199,145,89,0.7)]",
|
||||
item: "py-3",
|
||||
more: "mt-3 h-8 rounded-lg",
|
||||
},
|
||||
};
|
||||
|
||||
type ReplyTarget = {
|
||||
id: number;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
function CommentComposer({
|
||||
onSubmit,
|
||||
posting,
|
||||
error,
|
||||
}: {
|
||||
onSubmit: (content: string) => Promise<boolean>;
|
||||
posting: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const { user } = useMockUser();
|
||||
const avatar = user?.avatar || fallbackAvatar;
|
||||
const displayName = user?.name || COMMENT_SELF_NAME;
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting) return;
|
||||
const ok = await onSubmit(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<form onSubmit={submit} className="flex h-[76px] items-center gap-4 rounded-[10px] bg-[#211F38] px-4 py-3">
|
||||
<DecryptedImage
|
||||
src={avatar}
|
||||
fallback={fallbackAvatar}
|
||||
alt=""
|
||||
className="size-9 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<label className="flex h-[52px] min-w-0 flex-1 items-center gap-3 rounded-lg bg-[#110F1F] px-3 py-2.5 outline outline-1 -outline-offset-1 outline-[#383657] focus-within:outline-[#F12B9E]">
|
||||
<input
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder="说说你对这部剧的看法…"
|
||||
aria-label="评论内容"
|
||||
className="min-w-0 flex-1 bg-transparent text-xs leading-[18px] text-white outline-none placeholder:text-[#948CA8] disabled:opacity-60"
|
||||
/>
|
||||
<span className="flex shrink-0 gap-2 text-xs leading-[18px]">
|
||||
<span className="text-[#F12B9E]">已登录</span>
|
||||
<span className="text-[#948CA8]">{displayName}</span>
|
||||
<span className="text-[#736E87]">
|
||||
{text.length} / {COMMENT_MAX_LENGTH}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || posting}
|
||||
className="cursor-pointer h-[52px] w-[120px] shrink-0 rounded-[10px] bg-[#F12B9E] text-xs leading-[18px] font-semibold text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-60"
|
||||
>
|
||||
{posting ? "发布中…" : "发表评论"}
|
||||
</button>
|
||||
</form>
|
||||
{error && !posting ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** PC 评论面板(详情 / 播放页)— GET /api/app/comment/list */
|
||||
export function CommentPanel({
|
||||
variant = "detail",
|
||||
target,
|
||||
}: {
|
||||
variant?: keyof typeof panelStyles;
|
||||
target: CommentTarget | null;
|
||||
}) {
|
||||
const {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
posting,
|
||||
postError,
|
||||
reload,
|
||||
} = useComments({ target });
|
||||
const styles = panelStyles[variant];
|
||||
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||
|
||||
const startReply = (comment: CommentInfo) => {
|
||||
const id = Number(comment.id);
|
||||
if (!Number.isInteger(id) || id < 1) return;
|
||||
setReplyTo({ id, userName: (comment.userName || "").trim() || "用户" });
|
||||
};
|
||||
|
||||
const toggleExpanded = (commentId: number) => {
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(commentId)) next.delete(commentId);
|
||||
else next.add(commentId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submitRoot = async (content: string) => post(content);
|
||||
|
||||
const submitReply = async (content: string) => {
|
||||
if (!replyTo) return false;
|
||||
const ok = await post(content, { parentsId: replyTo.id });
|
||||
if (ok) setReplyTo(null);
|
||||
return ok;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-component="comment-panel"
|
||||
className={`flex w-[1196px] shrink-0 flex-col p-6 outline outline-1 -outline-offset-1 ${styles.section}`}
|
||||
>
|
||||
<div className="flex h-10 items-center justify-between">
|
||||
<h2 className="text-[22px] leading-[30px] text-white">评论 ({total})</h2>
|
||||
<div className="flex h-8 items-center gap-3 rounded-[7px] bg-[#110F1F] px-2 text-xs leading-[18px]">
|
||||
{COMMENT_SORTS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={sort === item}
|
||||
onClick={() => setSort(item)}
|
||||
className={`cursor-pointer ${sort === item ? "text-white" : "text-[#948CA8] hover:text-white"}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommentComposer
|
||||
onSubmit={submitRoot}
|
||||
posting={posting && !replyTo}
|
||||
error={!replyTo ? postError : null}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-[#948CA8]">评论加载中…</p>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<p className="text-sm text-[#948CA8]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="cursor-pointer rounded-lg bg-[#211F38] px-4 py-2 text-xs text-white hover:opacity-90"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-[#948CA8]">暂无评论</p>
|
||||
) : (
|
||||
<ul>
|
||||
{comments.map((comment) => {
|
||||
const liked = Boolean(comment.isLike);
|
||||
const commentId = Number(comment.id) || 0;
|
||||
const expanded = expandedIds.has(commentId);
|
||||
const replies = comment.subComment ?? [];
|
||||
const isReplying = replyTo?.id === commentId;
|
||||
return (
|
||||
<li key={comment.id} className={`flex gap-4 border-b border-[#2E2B47] ${styles.item}`}>
|
||||
<DecryptedImage
|
||||
src={comment.avatar}
|
||||
fallback="/images/default_avatar.svg"
|
||||
alt=""
|
||||
className="size-12 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<p className="text-sm leading-[21px] text-[#948CA8]">{comment.userName}</p>
|
||||
<p className="text-base leading-6 text-white">{comment.text}</p>
|
||||
<div className="flex items-center gap-2 text-xs leading-[18px] text-[#948CA8]">
|
||||
<span className="font-semibold">{formatCommentTime(comment.createdAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={liked ? "取消点赞" : "点赞"}
|
||||
aria-pressed={liked}
|
||||
onClick={() => void toggleLike(comment)}
|
||||
className="cursor-pointer flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
{comment.countLike ?? 0}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isReplying ? setReplyTo(null) : startReply(comment))}
|
||||
className={`cursor-pointer hover:text-white ${isReplying ? "text-[#F12B9E]" : ""}`}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
{isReplying && replyTo ? (
|
||||
<InlineReplyBox
|
||||
userName={replyTo.userName}
|
||||
posting={posting}
|
||||
error={postError}
|
||||
onSubmit={submitReply}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
) : null}
|
||||
{expanded && replies.length > 1 ? (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px] text-[#EBE8F5]">
|
||||
{replies.map((reply) => (
|
||||
<p key={reply.id}>
|
||||
{reply.replyUserName
|
||||
? `${reply.userName || ""} 回复 ${reply.replyUserName}:${reply.text || ""}`
|
||||
: `${reply.userName || ""}:${reply.text || ""}`}
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(commentId)}
|
||||
className="cursor-pointer self-start text-[#F12B9E]"
|
||||
>
|
||||
收起回复
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<CommentReplyPreview
|
||||
comment={comment}
|
||||
className="flex flex-col gap-1.5 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px]"
|
||||
textClassName="text-[#EBE8F5]"
|
||||
onMore={replies.length > 1 ? () => toggleExpanded(commentId) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!loading && !error && comments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMore || loadingMore}
|
||||
onClick={loadMore}
|
||||
className={`cursor-pointer ${styles.more} bg-[#211F38] text-xs leading-[18px] text-[#948CA8] enabled:hover:text-white disabled:opacity-60`}
|
||||
>
|
||||
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { CommentInfo } from "@/lib/api/types";
|
||||
|
||||
/** 直接展示接口 subComment / commentCount,不做字段改名 */
|
||||
export function CommentReplyPreview({
|
||||
comment,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
onMore,
|
||||
}: {
|
||||
comment: CommentInfo;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
onMore?: () => void;
|
||||
}) {
|
||||
const first = comment.subComment?.[0];
|
||||
const more = Number(comment.commentCount) || comment.subComment?.length || 0;
|
||||
if (!first && more < 1) return null;
|
||||
|
||||
const replyLabel = first
|
||||
? first.replyUserName
|
||||
? `${first.userName || ""} 回复 ${first.replyUserName}:${first.text || ""}`
|
||||
: `${first.userName || ""}:${first.text || ""}`
|
||||
: `共 ${more} 条回复`;
|
||||
|
||||
return (
|
||||
<div data-component="comment-reply-preview" className={className}>
|
||||
<p className={textClassName}>{replyLabel}</p>
|
||||
{more > 0 && onMore ? (
|
||||
<button type="button" onClick={onMore} className="cursor-pointer self-start text-[#F12B9E]">
|
||||
查看更多 {more} 条回复 ›
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { COMMENT_OBJECT_TYPE, type CommentInfo, type CommentObjectType } from "@/lib/api/types";
|
||||
|
||||
export type { CommentInfo };
|
||||
|
||||
export type CommentTarget = {
|
||||
objectId: number;
|
||||
objectType: CommentObjectType;
|
||||
};
|
||||
|
||||
export const COMMENT_SORTS = ["热门", "最新"] as const;
|
||||
export type CommentSort = (typeof COMMENT_SORTS)[number];
|
||||
|
||||
export const COMMENT_SELF_NAME = "剧迷1024";
|
||||
export const COMMENT_MAX_LENGTH = 200;
|
||||
export const COMMENT_PAGE_SIZE = 6;
|
||||
/** 列表请求超时,避免上游卡住拖住 UI */
|
||||
export const COMMENT_FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
export function sortToOrder(sort: CommentSort): 1 | 2 {
|
||||
return sort === "热门" ? 1 : 2;
|
||||
}
|
||||
|
||||
/** 过滤无效评论,保留接口原字段 */
|
||||
export function filterCommentList(list: CommentInfo[] | null | undefined): CommentInfo[] {
|
||||
return (list ?? []).filter((item) => {
|
||||
const id = Number(item?.id);
|
||||
return Number.isInteger(id) && id > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** 本地乐观插入用,字段名与 CommentInfo 一致 */
|
||||
export function createSelfComment(
|
||||
text: string,
|
||||
options?: { userName?: string; avatar?: string },
|
||||
): CommentInfo {
|
||||
return {
|
||||
id: -Date.now(),
|
||||
userName: (options?.userName || "").trim() || COMMENT_SELF_NAME,
|
||||
avatar: (options?.avatar || "").trim() || "/images/default_avatar.svg",
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
countLike: 0,
|
||||
isLike: false,
|
||||
commentCount: 0,
|
||||
subComment: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCommentCount(value: number) {
|
||||
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : String(value);
|
||||
}
|
||||
|
||||
/** 仅做展示格式化,不改字段名;空则回退原串 */
|
||||
export function formatCommentTime(value?: string | null) {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return "";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function commentTarget(
|
||||
objectId: number | string | null | undefined,
|
||||
objectType: CommentObjectType,
|
||||
): CommentTarget | null {
|
||||
const id = Number(objectId);
|
||||
if (!Number.isInteger(id) || id < 1) return null;
|
||||
return { objectId: id, objectType };
|
||||
}
|
||||
|
||||
export function playletCommentTarget(playletId: number | string | null | undefined) {
|
||||
return commentTarget(playletId, COMMENT_OBJECT_TYPE.playlet);
|
||||
}
|
||||
|
||||
export function playletChapterCommentTarget(chapterId: number | string | null | undefined) {
|
||||
return commentTarget(chapterId, COMMENT_OBJECT_TYPE.playletChapter);
|
||||
}
|
||||
|
||||
export function videoCommentTarget(videoId: number | string | null | undefined) {
|
||||
return commentTarget(videoId, COMMENT_OBJECT_TYPE.video);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
|
||||
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
|
||||
import { CommentReplyPreview } from "./comment-reply-preview";
|
||||
import { HeartIcon } from "./heart-icon";
|
||||
import { InlineReplyBox } from "./inline-reply-box";
|
||||
import { useComments } from "./use-comments";
|
||||
|
||||
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer flex size-6 items-center justify-center text-white"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
|
||||
{children ?? <span className="size-6" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** H5 短剧播放页评论底部弹层 */
|
||||
export function H5CommentSheet({ onClose, target }: { onClose: () => void; target: CommentTarget | null }) {
|
||||
const {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
posting,
|
||||
postError,
|
||||
reload,
|
||||
} = useComments({ target });
|
||||
const [text, setText] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting || replyTo) return;
|
||||
const ok = await post(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
const submitReply = async (content: string) => {
|
||||
if (!replyTo) return false;
|
||||
const ok = await post(content, { parentsId: replyTo.id });
|
||||
if (ok) setReplyTo(null);
|
||||
return ok;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-component="h5-comment-sheet" className="flex h-[70vh] flex-col">
|
||||
<div className="px-4 pt-[18px] pb-3">
|
||||
<SheetHead title={`评论 ${total}`} onClose={onClose}>
|
||||
<div className="flex gap-3 text-xs leading-[18px]">
|
||||
{COMMENT_SORTS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={sort === item}
|
||||
onClick={() => setSort(item)}
|
||||
className={`cursor-pointer ${sort === item ? "text-white" : "text-white/35"}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SheetHead>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4">
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-white/50">评论加载中…</p>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-10">
|
||||
<p className="text-sm text-white/50">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="cursor-pointer rounded-md bg-white/10 px-4 py-2 text-xs text-white"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-white/50">暂无评论</p>
|
||||
) : (
|
||||
<ul>
|
||||
{comments.map((comment) => {
|
||||
const liked = Boolean(comment.isLike);
|
||||
const commentId = Number(comment.id) || 0;
|
||||
const isReplying = replyTo?.id === commentId;
|
||||
return (
|
||||
<li key={comment.id} className="flex gap-3 border-b border-white/5 py-3">
|
||||
<DecryptedImage
|
||||
src={comment.avatar}
|
||||
fallback="/images/default_avatar.svg"
|
||||
alt=""
|
||||
className="size-8 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<p className="text-xs leading-[18px] text-white/50">{comment.userName}</p>
|
||||
<p className="text-sm leading-5 text-white">{comment.text}</p>
|
||||
<div className="flex items-center gap-3 text-[11px] leading-4 text-white/35">
|
||||
<span>{formatCommentTime(comment.createdAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={liked}
|
||||
onClick={() => void toggleLike(comment)}
|
||||
className="cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
{comment.countLike ?? 0}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isReplying) {
|
||||
setReplyTo(null);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(commentId) || commentId < 1) return;
|
||||
setReplyTo({
|
||||
id: commentId,
|
||||
userName: (comment.userName || "").trim() || "用户",
|
||||
});
|
||||
}}
|
||||
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "hover:text-white"}`}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
{isReplying && replyTo ? (
|
||||
<InlineReplyBox
|
||||
compact
|
||||
userName={replyTo.userName}
|
||||
posting={posting}
|
||||
error={postError}
|
||||
onSubmit={submitReply}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
) : null}
|
||||
<CommentReplyPreview
|
||||
comment={comment}
|
||||
className="rounded-md bg-white/5 px-2.5 py-2 text-xs leading-[18px]"
|
||||
textClassName="text-white/70"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{!loading && !error && comments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMore || loadingMore}
|
||||
onClick={loadMore}
|
||||
className="cursor-pointer my-3 h-9 w-full rounded-md bg-white/5 text-xs text-white/50 disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-t border-white/5 px-4 py-2.5">
|
||||
{!replyTo && postError ? <p className="mb-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
|
||||
<form onSubmit={submit} className="flex items-center gap-2.5">
|
||||
<input
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting || Boolean(replyTo)}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder="发表评论…"
|
||||
aria-label="评论内容"
|
||||
className="h-9 min-w-0 flex-1 rounded-full bg-white/5 px-4 text-sm text-white outline-none placeholder:text-white/35 disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || posting || Boolean(replyTo)}
|
||||
className="cursor-pointer h-9 shrink-0 rounded-full bg-[#F12B9E] px-4 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{posting && !replyTo ? "发送中" : "发送"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
|
||||
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentCount, formatCommentTime } from "./data";
|
||||
import { CommentReplyPreview } from "./comment-reply-preview";
|
||||
import { InlineReplyBox } from "./inline-reply-box";
|
||||
import type { UseCommentsResult } from "./use-comments";
|
||||
|
||||
type H5CommentsTabProps = {
|
||||
/** 由页面 useComments 托管,便于 Tab 标题同步总数 */
|
||||
state: UseCommentsResult;
|
||||
footerExtra?: ReactNode;
|
||||
};
|
||||
|
||||
/** H5 视频播放页「评论」Tab */
|
||||
export function H5CommentsTab({ state, footerExtra }: H5CommentsTabProps) {
|
||||
const {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
posting,
|
||||
postError,
|
||||
reload,
|
||||
} = state;
|
||||
const [text, setText] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting || replyTo) return;
|
||||
const ok = await post(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
const submitReply = async (content: string) => {
|
||||
if (!replyTo) return false;
|
||||
const ok = await post(content, { parentsId: replyTo.id });
|
||||
if (ok) setReplyTo(null);
|
||||
return ok;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-component="h5-comments-tab" className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base leading-[22px] font-medium text-white">{total}条评论</h2>
|
||||
<div className="flex rounded-full bg-white/5 p-0.5">
|
||||
{COMMENT_SORTS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={sort === item}
|
||||
onClick={() => setSort(item)}
|
||||
className={`cursor-pointer rounded-full px-2.5 py-1 text-[10px] leading-4 ${
|
||||
sort === item ? "bg-white/10 text-white" : "text-white/65"
|
||||
}`}
|
||||
>
|
||||
{item === "最新" ? "最新" : "最热"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-white/50">评论加载中…</p>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<p className="text-sm text-white/50">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="cursor-pointer rounded-[3px] bg-white/10 px-4 py-2 text-xs text-white"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-white/50">暂无评论</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-5">
|
||||
{comments.map((comment) => {
|
||||
const liked = Boolean(comment.isLike);
|
||||
const commentId = Number(comment.id) || 0;
|
||||
const isReplying = replyTo?.id === commentId;
|
||||
return (
|
||||
<li key={comment.id} className="flex gap-3">
|
||||
<DecryptedImage
|
||||
src={comment.avatar}
|
||||
fallback="/images/default_avatar.svg"
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
|
||||
<p className="text-sm leading-5 text-white/65">{comment.userName}</p>
|
||||
<p className="text-sm leading-5 break-words text-white">{comment.text}</p>
|
||||
<div className="flex items-center justify-between text-xs leading-[18px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white/65">{formatCommentTime(comment.createdAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "text-white"}`}
|
||||
onClick={() => {
|
||||
if (isReplying) {
|
||||
setReplyTo(null);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(commentId) || commentId < 1) return;
|
||||
setReplyTo({
|
||||
id: commentId,
|
||||
userName: (comment.userName || "").trim() || "用户",
|
||||
});
|
||||
}}
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={liked}
|
||||
onClick={() => void toggleLike(comment)}
|
||||
className="cursor-pointer flex items-center gap-1 text-white/65"
|
||||
>
|
||||
<svg viewBox="0 0 18 18" className="size-[18px]" aria-hidden="true">
|
||||
<path
|
||||
d="M9 15.5c-.22 0-.45-.08-.62-.23C5.97 13.25 2.25 10.65 2.25 7.2c0-2.25 1.6-4.05 3.6-4.05 1.24 0 2.37.68 3.15 1.7.78-1.02 1.9-1.7 3.15-1.7 2 0 3.6 1.8 3.6 4.05 0 3.45-3.72 6.05-6.13 8.07-.17.15-.4.23-.62.23Z"
|
||||
fill={liked ? "#F52C56" : "none"}
|
||||
stroke="#F52C56"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
{formatCommentCount(Number(comment.countLike) || 0)}
|
||||
</button>
|
||||
</div>
|
||||
{isReplying && replyTo ? (
|
||||
<InlineReplyBox
|
||||
compact
|
||||
userName={replyTo.userName}
|
||||
posting={posting}
|
||||
error={postError}
|
||||
onSubmit={submitReply}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
) : null}
|
||||
<CommentReplyPreview
|
||||
comment={comment}
|
||||
className="flex flex-col gap-1 rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px]"
|
||||
textClassName="text-white/80"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!loading && !error && comments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMore || loadingMore}
|
||||
onClick={loadMore}
|
||||
className="cursor-pointer h-9 w-full rounded-[3px] bg-white/5 text-xs text-white/50 disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-10 bg-[#1A1A1A]">
|
||||
{!replyTo && postError ? <p className="px-4 pt-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
|
||||
<form onSubmit={submit} className="flex h-[58px] items-center px-4 py-1.5">
|
||||
<label className="flex h-full flex-1 items-center gap-2 rounded-[3px] bg-white/5 px-3 py-1.5">
|
||||
<input
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting || Boolean(replyTo)}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={posting && !replyTo ? "发布中…" : "发表评论…"}
|
||||
enterKeyHint="send"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-white/35 disabled:opacity-60"
|
||||
/>
|
||||
<svg viewBox="0 0 24 24" className="size-6 shrink-0" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,0.65)" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8.5 14a4 4 0 0 0 7 0"
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="9" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
|
||||
<circle cx="15" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
|
||||
</svg>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
{footerExtra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export function HeartIcon({ filled, className = "size-[18px]" }: { filled: boolean; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 14 14" fill="none" className={className} aria-hidden="true">
|
||||
<path
|
||||
d="M7 11.5c-.2 0-.4-.07-.55-.2C4.6 9.8 2.5 8.2 2.5 5.9 2.5 4.5 3.5 3.5 4.8 3.5c.9 0 1.7.5 2.2 1.2.5-.7 1.3-1.2 2.2-1.2 1.3 0 2.3 1 2.3 2.4 0 2.3-2.1 3.9-3.95 5.4-.15.13-.35.2-.55.2Z"
|
||||
fill={filled ? "#F12B9E" : "none"}
|
||||
stroke={filled ? "#F12B9E" : "rgba(255,255,255,0.55)"}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export {
|
||||
COMMENT_FETCH_TIMEOUT_MS,
|
||||
COMMENT_MAX_LENGTH,
|
||||
COMMENT_PAGE_SIZE,
|
||||
COMMENT_SELF_NAME,
|
||||
COMMENT_SORTS,
|
||||
commentTarget,
|
||||
createSelfComment,
|
||||
filterCommentList,
|
||||
formatCommentCount,
|
||||
formatCommentTime,
|
||||
playletChapterCommentTarget,
|
||||
playletCommentTarget,
|
||||
sortToOrder,
|
||||
videoCommentTarget,
|
||||
type CommentSort,
|
||||
type CommentTarget,
|
||||
} from "./data";
|
||||
export { CommentPanel } from "./comment-panel";
|
||||
export { CommentReplyPreview } from "./comment-reply-preview";
|
||||
export { H5CommentSheet } from "./h5-comment-sheet";
|
||||
export { H5CommentsTab } from "./h5-comments-tab";
|
||||
export { HeartIcon } from "./heart-icon";
|
||||
export { useComments, type UseCommentsOptions, type UseCommentsResult } from "./use-comments";
|
||||
export type { CommentInfo } from "@/lib/api/types";
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
|
||||
import { COMMENT_MAX_LENGTH } from "./data";
|
||||
|
||||
/** 挂在单条评论下方的回复输入框 */
|
||||
export function InlineReplyBox({
|
||||
userName,
|
||||
posting,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
compact = false,
|
||||
}: {
|
||||
userName: string;
|
||||
posting: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (content: string) => Promise<boolean>;
|
||||
onCancel: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const content = text.trim();
|
||||
if (!content || posting) return;
|
||||
const ok = await onSubmit(content);
|
||||
if (ok) setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="inline-reply-box"
|
||||
className={`flex flex-col gap-2 ${
|
||||
compact
|
||||
? "mt-1 rounded-md bg-white/[0.06] p-2.5 outline outline-1 -outline-offset-1 outline-white/15"
|
||||
: "mt-2 rounded-xl bg-[#211F38] p-3 outline outline-1 -outline-offset-1 outline-[#4A4668]"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between leading-[18px] ${
|
||||
compact ? "text-[11px] text-white/55" : "text-xs text-[#948CA8]"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
回复 <span className="font-medium text-[#F12B9E]">@{userName}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<label
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 transition-[outline-color] ${
|
||||
compact
|
||||
? "h-10 rounded-[3px] bg-[#0F0F0F] px-3 outline outline-1 -outline-offset-1 outline-white/20 focus-within:outline-[#F12B9E]"
|
||||
: "h-11 rounded-lg bg-[#110F1F] px-3 outline outline-1 -outline-offset-1 outline-[#5A5678] focus-within:outline-[#F12B9E]"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
disabled={posting}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
placeholder={`回复 @${userName}…`}
|
||||
aria-label={`回复 ${userName}`}
|
||||
className={`min-w-0 flex-1 bg-transparent text-white outline-none placeholder:text-white/40 disabled:opacity-60 ${
|
||||
compact ? "text-sm leading-5" : "text-sm leading-5"
|
||||
}`}
|
||||
/>
|
||||
<span className="shrink-0 text-[10px] leading-4 text-white/30">
|
||||
{text.length}/{COMMENT_MAX_LENGTH}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim() || posting}
|
||||
className={`cursor-pointer shrink-0 font-medium text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-45 ${
|
||||
compact
|
||||
? "h-10 rounded-[3px] bg-[#F12B9E] px-3.5 text-xs"
|
||||
: "h-11 rounded-lg bg-[#F12B9E] px-4 text-sm"
|
||||
}`}
|
||||
>
|
||||
{posting ? "发送中" : "发送"}
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "@heroui/react";
|
||||
|
||||
import { openLoginDialog } from "@/components/auth/login-dialog";
|
||||
import { getCommentList, sendComment, setCommentLike } from "@/lib/api/comment";
|
||||
import type { CommentInfo } from "@/lib/api/types";
|
||||
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
import {
|
||||
COMMENT_FETCH_TIMEOUT_MS,
|
||||
COMMENT_PAGE_SIZE,
|
||||
filterCommentList,
|
||||
sortToOrder,
|
||||
type CommentSort,
|
||||
type CommentTarget,
|
||||
} from "./data";
|
||||
|
||||
export type UseCommentsOptions = {
|
||||
target: CommentTarget | null;
|
||||
initialSort?: CommentSort;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export type UseCommentsResult = {
|
||||
sort: CommentSort;
|
||||
setSort: (sort: CommentSort) => void;
|
||||
comments: CommentInfo[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
posting: boolean;
|
||||
error: string | null;
|
||||
postError: string | null;
|
||||
loadMore: () => void;
|
||||
toggleLike: (comment: CommentInfo) => Promise<void>;
|
||||
post: (text: string, options?: { parentsId?: number }) => Promise<boolean>;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
function formatCommentError(err: unknown, fallback = "评论加载失败") {
|
||||
if (err instanceof DOMException && err.name === "TimeoutError") return "加载超时,请重试";
|
||||
if (err instanceof Error) {
|
||||
if (/aborted|timeout|超时/i.test(err.message) || err.name === "AbortError") return "加载超时,请重试";
|
||||
return err.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function abortAfter(ms: number) {
|
||||
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
||||
return AbortSignal.timeout(ms);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function mergeSignals(a: AbortSignal, b: AbortSignal) {
|
||||
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
|
||||
return AbortSignal.any([a, b]);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
if (a.aborted || b.aborted) {
|
||||
controller.abort();
|
||||
return controller.signal;
|
||||
}
|
||||
a.addEventListener("abort", onAbort, { once: true });
|
||||
b.addEventListener("abort", onAbort, { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
async function resolveAuthToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function patchComment(list: CommentInfo[], id: number, patch: Partial<CommentInfo>): CommentInfo[] {
|
||||
return list.map((item) => (Number(item.id) === id ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
export function useComments({
|
||||
target,
|
||||
initialSort = "热门",
|
||||
pageSize = COMMENT_PAGE_SIZE,
|
||||
}: UseCommentsOptions): UseCommentsResult {
|
||||
const [sort, setSort] = useState<CommentSort>(initialSort);
|
||||
const [comments, setComments] = useState<CommentInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
const [loading, setLoading] = useState(Boolean(target));
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [postError, setPostError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const fetchSeq = useRef(0);
|
||||
const likingIds = useRef(new Set<number>());
|
||||
|
||||
const objectId = target?.objectId ?? 0;
|
||||
const objectType = target?.objectType;
|
||||
const enabled = Boolean(target && objectId > 0 && objectType != null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
|
||||
if (!enabled || objectType == null) {
|
||||
setComments([]);
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++fetchSeq.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPageNum(1);
|
||||
likingIds.current.clear();
|
||||
|
||||
try {
|
||||
const data = await getCommentList(
|
||||
{
|
||||
objectId,
|
||||
objectType,
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
sortOrder: sortToOrder(sort),
|
||||
},
|
||||
{
|
||||
token: getAuthToken(),
|
||||
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
|
||||
},
|
||||
);
|
||||
if (cancelled || seq !== fetchSeq.current) return;
|
||||
setComments(filterCommentList(data?.list));
|
||||
setTotal(Number(data?.totalCount) || 0);
|
||||
setPageNum(1);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled || seq !== fetchSeq.current || controller.signal.aborted) return;
|
||||
setComments([]);
|
||||
setTotal(0);
|
||||
setError(formatCommentError(err));
|
||||
} finally {
|
||||
if (!cancelled && seq === fetchSeq.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [enabled, objectId, objectType, sort, pageSize, reloadKey]);
|
||||
|
||||
const hasMore = comments.length < total;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!enabled || objectType == null || loading || loadingMore || !hasMore) return;
|
||||
|
||||
const nextPage = pageNum + 1;
|
||||
const controller = new AbortController();
|
||||
setLoadingMore(true);
|
||||
|
||||
void getCommentList(
|
||||
{
|
||||
objectId,
|
||||
objectType,
|
||||
pageNum: nextPage,
|
||||
pageSize,
|
||||
sortOrder: sortToOrder(sort),
|
||||
},
|
||||
{
|
||||
token: getAuthToken(),
|
||||
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
|
||||
},
|
||||
)
|
||||
.then((data) => {
|
||||
const mapped = filterCommentList(data?.list);
|
||||
setComments((current) => {
|
||||
const seen = new Set(current.map((item) => Number(item.id)));
|
||||
return [...current, ...mapped.filter((item) => !seen.has(Number(item.id)))];
|
||||
});
|
||||
setTotal(Number(data?.totalCount) || total);
|
||||
setPageNum(nextPage);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(formatCommentError(err, "加载更多失败"));
|
||||
})
|
||||
.finally(() => setLoadingMore(false));
|
||||
}, [enabled, objectId, objectType, loading, loadingMore, hasMore, pageNum, pageSize, sort, total]);
|
||||
|
||||
const toggleLike = useCallback(async (comment: CommentInfo) => {
|
||||
const commentId = Number(comment.id);
|
||||
if (!Number.isInteger(commentId) || commentId < 1) return;
|
||||
if (likingIds.current.has(commentId)) return;
|
||||
|
||||
const token = await resolveAuthToken();
|
||||
if (!token) {
|
||||
openLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIsLike = !Boolean(comment.isLike);
|
||||
const nextCountLike = Math.max(0, (Number(comment.countLike) || 0) + (nextIsLike ? 1 : -1));
|
||||
const snapshot = { isLike: comment.isLike, countLike: comment.countLike };
|
||||
|
||||
likingIds.current.add(commentId);
|
||||
setComments((current) => patchComment(current, commentId, { isLike: nextIsLike, countLike: nextCountLike }));
|
||||
|
||||
try {
|
||||
const result = await setCommentLike(
|
||||
{ commentId, isLike: nextIsLike },
|
||||
token,
|
||||
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
|
||||
);
|
||||
setComments((current) =>
|
||||
patchComment(current, commentId, {
|
||||
isLike: result?.isLike ?? nextIsLike,
|
||||
countLike: typeof result?.countLike === "number" ? result.countLike : nextCountLike,
|
||||
}),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
setComments((current) => patchComment(current, commentId, snapshot));
|
||||
handleUnauthorized(err);
|
||||
} finally {
|
||||
likingIds.current.delete(commentId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const post = useCallback(
|
||||
async (text: string, options?: { parentsId?: number }) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || posting) return false;
|
||||
if (!enabled || objectType == null) {
|
||||
setPostError("无法发布评论");
|
||||
return false;
|
||||
}
|
||||
|
||||
setPostError(null);
|
||||
const token = await resolveAuthToken();
|
||||
if (!token) {
|
||||
openLoginDialog();
|
||||
return false;
|
||||
}
|
||||
|
||||
setPosting(true);
|
||||
|
||||
try {
|
||||
await sendComment(
|
||||
{
|
||||
objectId,
|
||||
objectType,
|
||||
text: trimmed,
|
||||
parentsId: options?.parentsId ?? 0,
|
||||
},
|
||||
token,
|
||||
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
// 审核通过前不写入列表(对齐 1045)
|
||||
toast.success("发布成功,评论审核后才会展示");
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (handleUnauthorized(err)) {
|
||||
setPostError("登录已过期或没有权限,请重新登录");
|
||||
return false;
|
||||
}
|
||||
const message = formatCommentError(err, "发表失败,请稍后重试");
|
||||
setPostError(message);
|
||||
toast.danger(message);
|
||||
return false;
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
},
|
||||
[enabled, objectId, objectType, posting],
|
||||
);
|
||||
|
||||
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
|
||||
|
||||
return {
|
||||
sort,
|
||||
setSort,
|
||||
comments,
|
||||
total,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
posting,
|
||||
error,
|
||||
postError,
|
||||
loadMore,
|
||||
toggleLike,
|
||||
post,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { CornerFlagBadge, resolveContentBadge } from "@/components/home/section-heading";
|
||||
import { dramaHref } from "@/components/short-drama/data";
|
||||
|
||||
import {
|
||||
getEpisodeChunks,
|
||||
getEpisodeRanges,
|
||||
getEpisodeState,
|
||||
totalEpisodeOf,
|
||||
updateStatusLabel,
|
||||
type DramaDetail,
|
||||
type RelatedDrama,
|
||||
} from "./data";
|
||||
|
||||
type EpisodeProps = {
|
||||
detail: DramaDetail;
|
||||
current: number;
|
||||
onSelect: (episode: number) => void;
|
||||
};
|
||||
|
||||
function LockIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 18 18" fill="none" className={className} aria-hidden="true">
|
||||
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M6 7.5V5.25a3 3 0 0 1 6 0V7.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M9 10.5v2.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 免费/未解锁均进播放页;未解锁由播放页在播放器上盖下载遮罩 */
|
||||
function useEpisodeClick(_detail: DramaDetail, onSelect: (episode: number) => void) {
|
||||
return (episode: number) => onSelect(episode);
|
||||
}
|
||||
|
||||
/** 与播放页「更多好剧」一致的三列推荐卡片 */
|
||||
export function MorePlayletsGrid({ items }: { items: RelatedDrama[] }) {
|
||||
if (!items.length) {
|
||||
return <p className="py-8 text-center text-xs text-white/35">暂无更多推荐</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-[5px] gap-y-3">
|
||||
{items.map((item) => {
|
||||
const badge = resolveContentBadge(item);
|
||||
return (
|
||||
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex flex-col gap-1">
|
||||
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
|
||||
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
|
||||
<CornerFlagBadge
|
||||
tone={badge}
|
||||
className="absolute top-2 right-2 z-[1]"
|
||||
badgeClassName="h-[18px]"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-sm leading-5 text-white">{item.title}</span>
|
||||
<span className="truncate text-xs leading-[18px] text-white/50">
|
||||
{item.tagNames?.[0]}
|
||||
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
|
||||
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EpisodePanel({ detail, current, onSelect }: EpisodeProps) {
|
||||
const totalEpisode = totalEpisodeOf(detail);
|
||||
const ranges = getEpisodeRanges(totalEpisode, 20);
|
||||
const [rangeIndex, setRangeIndex] = useState(-1);
|
||||
const range = ranges[rangeIndex];
|
||||
const start = range?.start ?? 1;
|
||||
const end = range?.end ?? totalEpisode;
|
||||
const handleClick = useEpisodeClick(detail, onSelect);
|
||||
|
||||
return (
|
||||
<section
|
||||
data-component="episode-panel"
|
||||
data-episodes
|
||||
className="flex flex-col gap-4 rounded-2xl bg-[rgba(20,23,46,0.9)] p-6 outline outline-1 -outline-offset-1 outline-[#333354]"
|
||||
>
|
||||
<div className="flex h-10 items-center gap-6">
|
||||
<h2 className="text-[22px] leading-[30px] text-white">选集</h2>
|
||||
<p className="flex-1 text-sm text-[#8C879C]">共 {totalEpisode} 集</p>
|
||||
<div className="flex items-center gap-8 text-sm">
|
||||
{[{ label: "全部", index: -1 }, ...ranges.map((item, index) => ({ label: item.label, index }))].map(
|
||||
(item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
aria-pressed={rangeIndex === item.index}
|
||||
onClick={() => setRangeIndex(item.index)}
|
||||
className={`cursor-pointer ${rangeIndex === item.index ? "text-[#F12B9E]" : "text-[#8C879C] hover:text-white"}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(10,154px)] justify-between gap-y-3">
|
||||
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
|
||||
const locked = getEpisodeState(episode, detail) === "locked";
|
||||
const playing = episode === current;
|
||||
return (
|
||||
<button
|
||||
key={episode}
|
||||
type="button"
|
||||
aria-current={playing || undefined}
|
||||
aria-label={`第${episode}集${locked ? "(未解锁)" : ""}`}
|
||||
onClick={() => handleClick(episode)}
|
||||
className={`cursor-pointer relative flex h-[58px] items-center justify-center gap-0.5 overflow-hidden rounded-lg text-lg leading-6 font-semibold transition-colors ${
|
||||
playing
|
||||
? "bg-[rgba(241,43,158,0.1)] text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
|
||||
: locked
|
||||
? "bg-white/10 text-[#828186] outline outline-1 -outline-offset-1 outline-white/5 hover:bg-white/15"
|
||||
: "bg-[#3A3D53] text-white hover:bg-[#454866]"
|
||||
}`}
|
||||
>
|
||||
第{episode}集
|
||||
{locked ? <LockIcon className="size-[18px]" /> : null}
|
||||
{!locked ? (
|
||||
<span className="absolute top-1 right-1 flex h-5 w-9 items-center justify-center rounded bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[10px] font-normal leading-4 text-[#002D27]">
|
||||
免费
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function H5EpisodeStrip({ detail, onSelect, current }: Omit<EpisodeProps, "current"> & { current?: number }) {
|
||||
const totalEpisode = totalEpisodeOf(detail);
|
||||
const ranges = getEpisodeChunks(totalEpisode, 30);
|
||||
const [rangeIndex, setRangeIndex] = useState(0);
|
||||
const { start, end } = ranges[rangeIndex] ?? ranges[0];
|
||||
const handleClick = useEpisodeClick(detail, onSelect);
|
||||
const hasPreview = Boolean(detail.preview?.trim());
|
||||
const previewChapter = detail.chapterList?.find(
|
||||
(item) => item.title?.trim() === "预告" || Number(item.currentEpisode) === 0,
|
||||
);
|
||||
const showPreview = hasPreview || Boolean(previewChapter);
|
||||
|
||||
return (
|
||||
<section data-component="h5-episode-strip" data-episodes className="inline-flex w-full flex-col items-start gap-3">
|
||||
<div className="inline-flex w-full items-center justify-between">
|
||||
<h2 className="text-sm leading-5 font-medium text-white/90">剧集</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-0.5 text-xs leading-[18px] text-[#656565]">
|
||||
{updateStatusLabel(detail.updateStatus)} 共{totalEpisode}集
|
||||
<img src="/images/arrow_rught.svg" alt="" className="size-[18px] opacity-50" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-full items-center gap-6 overflow-x-auto text-sm leading-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{ranges.map((item, index) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
aria-pressed={rangeIndex === index}
|
||||
onClick={() => setRangeIndex(index)}
|
||||
className={`cursor-pointer shrink-0 ${rangeIndex === index ? "text-white" : "text-white/35"}`}
|
||||
>
|
||||
{item.start}-{item.end}
|
||||
</button>
|
||||
))}
|
||||
<Link href="/short-drama/hot" className="shrink-0 text-white/35">
|
||||
更多好剧
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-full items-center gap-1.5 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{showPreview && rangeIndex === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="预告"
|
||||
onClick={() => {
|
||||
if (previewChapter && Number(previewChapter.currentEpisode) > 0) {
|
||||
handleClick(Number(previewChapter.currentEpisode));
|
||||
return;
|
||||
}
|
||||
handleClick(1);
|
||||
}}
|
||||
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
|
||||
>
|
||||
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
|
||||
预告
|
||||
</span>
|
||||
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
|
||||
const locked = getEpisodeState(episode, detail) === "locked";
|
||||
const playing = current != null && episode === current;
|
||||
|
||||
if (playing) {
|
||||
return (
|
||||
<button
|
||||
key={episode}
|
||||
type="button"
|
||||
aria-current="true"
|
||||
aria-label={`第${episode}集`}
|
||||
onClick={() => handleClick(episode)}
|
||||
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
|
||||
>
|
||||
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
|
||||
{episode}
|
||||
</span>
|
||||
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
|
||||
{!locked ? (
|
||||
<img
|
||||
src="/images/tag_mf.png"
|
||||
alt="免费"
|
||||
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<button
|
||||
key={episode}
|
||||
type="button"
|
||||
aria-label={`第${episode}集(未解锁)`}
|
||||
onClick={() => handleClick(episode)}
|
||||
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5 outline outline-1 -outline-offset-1 outline-[#222222]"
|
||||
>
|
||||
<span className="absolute top-[21px] left-2.5 inline-flex w-10 items-center justify-center text-base leading-[22px] font-medium text-[#828186]">
|
||||
<span className="min-w-0 flex-1 text-center">{episode}</span>
|
||||
<LockIcon className="size-[18px] shrink-0" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={episode}
|
||||
type="button"
|
||||
aria-label={`第${episode}集`}
|
||||
onClick={() => handleClick(episode)}
|
||||
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5"
|
||||
>
|
||||
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-white">
|
||||
{episode}
|
||||
</span>
|
||||
<img
|
||||
src="/images/tag_mf.png"
|
||||
alt="免费"
|
||||
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** H5 详情:热门推荐 — 一行横向滚动 */
|
||||
export function H5MorePlaylets({ items }: { items: RelatedDrama[] }) {
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<section id="more-playlets" data-component="h5-more-playlets" className="flex scroll-mt-4 flex-col gap-3">
|
||||
<h2 className="text-sm leading-5 font-medium text-white/90">热门推荐</h2>
|
||||
<div className="-mx-4 flex gap-[9px] overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{items.map((item) => {
|
||||
const badge = resolveContentBadge(item);
|
||||
return (
|
||||
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex w-[98px] shrink-0 flex-col gap-1">
|
||||
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
|
||||
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
|
||||
<CornerFlagBadge
|
||||
tone={badge}
|
||||
className="absolute top-2 right-2 z-[1]"
|
||||
badgeClassName="h-[18px]"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-xs leading-[18px] font-medium text-white">{item.title}</span>
|
||||
<span className="truncate text-xs leading-[18px] text-white/65">
|
||||
{item.tagNames?.[0]}
|
||||
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
|
||||
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { formatHeat } from "@/components/ranking/data";
|
||||
import { dramaHref } from "@/components/short-drama/data";
|
||||
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
|
||||
|
||||
import type { RelatedDrama } from "./data";
|
||||
|
||||
export function RelatedAside({ items }: { items: RelatedDrama[] }) {
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<aside data-component="drama-related-aside" className="flex min-w-0 flex-1 flex-col gap-4 rounded-2xl bg-[rgba(24,23,46,0.88)] p-6 outline outline-1 -outline-offset-1 outline-[#383657]">
|
||||
<div className="flex h-9 items-center justify-between">
|
||||
<h2 className="text-[22px] font-bold text-white">热门推荐</h2>
|
||||
<Link href="/short-drama/hot" className="flex items-center gap-1 text-xs leading-[18px] text-[#B8B2C2] hover:text-white">
|
||||
更多
|
||||
<img src="/images/more_arrow.svg" alt="" className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={dramaHref(String(item.id), item.slug)}
|
||||
className="group flex h-24 items-center gap-4"
|
||||
>
|
||||
<DecryptedImage
|
||||
src={item.cover}
|
||||
fallback={IMG_PLACEHOLDER}
|
||||
alt=""
|
||||
className="h-24 w-[72px] shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h3 className="truncate text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="truncate text-sm leading-[21px] text-[#8C859C]">{item.desc}</p>
|
||||
<div className="flex items-center justify-between text-xs leading-[18px]">
|
||||
<span className="text-[#A89EB5]">{item.tagNames?.[0]}</span>
|
||||
<span className="text-[#ABA1B8]">{formatHeat(item.heatValue ?? item.countBrowse)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
dramaDetailHref,
|
||||
getEpisodeChunks,
|
||||
getEpisodeState,
|
||||
playHref,
|
||||
totalEpisodeOf,
|
||||
type DramaDetail,
|
||||
} from "@/components/drama-detail/data";
|
||||
|
||||
import { ChevronIcon } from "./icons";
|
||||
|
||||
export const EPISODE_CHUNK = 30;
|
||||
|
||||
function LockIcon({ className = "size-[18px]" }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 18 18" fill="none" className={className} aria-hidden="true">
|
||||
<path
|
||||
d="M5.29 7.65V5.25a3.71 3.71 0 0 1 7.42 0v2.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.35"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.35" />
|
||||
<path d="M9 10.09v2.79" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FreeBadge() {
|
||||
return (
|
||||
<span className="absolute top-[5px] left-11 flex h-5 w-9 items-center justify-center rounded bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[10px] leading-4 text-[#002D27]">
|
||||
免费
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEpisodeRange(detail: DramaDetail, current: number) {
|
||||
const ranges = getEpisodeChunks(totalEpisodeOf(detail), EPISODE_CHUNK);
|
||||
const [rangeIndex, setRangeIndex] = useState(() => Math.floor((current - 1) / EPISODE_CHUNK));
|
||||
const { start, end } = ranges[rangeIndex] ?? ranges[0];
|
||||
const episodes = Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
||||
return { ranges, rangeIndex, setRangeIndex, episodes };
|
||||
}
|
||||
|
||||
export function PlayEpisodePanel({
|
||||
detail,
|
||||
current,
|
||||
wide = false,
|
||||
}: {
|
||||
detail: DramaDetail;
|
||||
current: number;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const { ranges, rangeIndex, setRangeIndex, episodes } = useEpisodeRange(detail, current);
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-component="play-episode-panel"
|
||||
className={`inline-flex shrink-0 flex-col items-start justify-start gap-3 overflow-hidden rounded-xl px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)] ${
|
||||
wide ? "w-full" : "h-[648px] w-[528px]"
|
||||
} bg-[rgba(19,15,22,0.79)]`}
|
||||
>
|
||||
<div className="inline-flex h-9 w-full shrink-0 items-center justify-between gap-3">
|
||||
<h2 className="min-w-0 text-[22px] leading-[30px] font-normal text-white">选集</h2>
|
||||
<Link
|
||||
href={dramaDetailHref(detail)}
|
||||
className="inline-flex w-auto shrink-0 items-center gap-2 whitespace-nowrap text-xs leading-[18px] text-[#9991AB] hover:text-white"
|
||||
>
|
||||
<ChevronIcon direction="left" />
|
||||
返回详情
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative inline-flex h-8 w-full shrink-0 items-center ${
|
||||
wide ? "justify-start gap-20" : "justify-between"
|
||||
}`}
|
||||
>
|
||||
{ranges.map((range, index) => {
|
||||
const active = index === rangeIndex;
|
||||
return (
|
||||
<button
|
||||
key={range.label}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setRangeIndex(index)}
|
||||
className={`cursor-pointer relative h-8 text-base leading-[22px] ${
|
||||
active ? "font-medium text-[#F12B9E]" : "font-normal text-[#6E6B75] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
{active ? (
|
||||
<span className="absolute top-[30px] left-0 h-0.5 w-[39px] rounded-sm bg-[#FF1A8C]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`inline-flex w-full flex-wrap content-start items-start justify-start gap-3 overflow-hidden ${
|
||||
wide ? "" : "min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin]"
|
||||
}`}
|
||||
>
|
||||
{episodes.map((episode) => {
|
||||
const playing = episode === current;
|
||||
const locked = getEpisodeState(episode, detail) === "locked";
|
||||
|
||||
if (playing) {
|
||||
return (
|
||||
<Link
|
||||
key={episode}
|
||||
href={playHref(detail.id, episode, detail.slug)}
|
||||
replace
|
||||
scroll={false}
|
||||
aria-current="true"
|
||||
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-[rgba(241,43,158,0.1)] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
|
||||
>
|
||||
<span className="absolute top-[29px] left-[14px] flex w-[58px] items-end justify-center text-center text-lg leading-6 text-[#F12B9E]">
|
||||
{episode}
|
||||
</span>
|
||||
<img src="/images/playing.svg" alt="" className="absolute top-2 left-[58px] size-5" />
|
||||
{!locked ? <FreeBadge /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (!locked) {
|
||||
return (
|
||||
<Link
|
||||
key={episode}
|
||||
href={playHref(detail.id, episode, detail.slug)}
|
||||
replace
|
||||
scroll={false}
|
||||
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-[#3A3D53] transition-colors hover:bg-[#454866]"
|
||||
>
|
||||
<span className="absolute top-[29px] left-[14px] flex w-[58px] items-end justify-center text-center text-lg leading-6 text-white">
|
||||
{episode}
|
||||
</span>
|
||||
<FreeBadge />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={episode}
|
||||
href={playHref(detail.id, episode, detail.slug)}
|
||||
replace
|
||||
scroll={false}
|
||||
aria-label={`第${episode}集(未解锁)`}
|
||||
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-white/10 outline outline-1 -outline-offset-1 outline-white/5 transition-colors hover:bg-white/15"
|
||||
>
|
||||
<span className="absolute top-[29px] left-[14px] inline-flex w-[58px] items-center justify-center gap-0.5 text-lg leading-6 text-[#828186]">
|
||||
<span className="min-w-0 flex-1 text-center">{episode}</span>
|
||||
<LockIcon className="size-[18px] shrink-0" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import { reportPlayletHistory } from "@/lib/api/playlet-actions";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
async function ensureToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 播放成功后上报观看历史;同一 playletId+chapterId 只报一次 */
|
||||
export function useReportPlayletHistory(playletId: number, chapterId?: number) {
|
||||
const reportedKeyRef = useRef<string | null>(null);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (!Number.isInteger(playletId) || playletId < 1) return;
|
||||
if (!Number.isInteger(chapterId) || !chapterId || chapterId < 1) return;
|
||||
|
||||
const key = `${playletId}:${chapterId}`;
|
||||
if (reportedKeyRef.current === key) return;
|
||||
reportedKeyRef.current = key;
|
||||
|
||||
const token = await ensureToken();
|
||||
if (!token) {
|
||||
reportedKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await reportPlayletHistory({ playletId, chapterId }, token);
|
||||
} catch (error) {
|
||||
reportedKeyRef.current = null;
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[playlet/history] report failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}, [playletId, chapterId]);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
|
||||
import { resolveContentBadge } from "@/components/home/section-heading";
|
||||
import { formatHeat } from "@/components/ranking/data";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import type { FavoriteVideoListItem, PlayletData } from "@/lib/api/types";
|
||||
|
||||
export const favoriteTypes = ["短剧", "视频"] as const;
|
||||
|
||||
export type FavoriteType = (typeof favoriteTypes)[number];
|
||||
|
||||
export const FAVORITES_PAGE_SIZE = 24;
|
||||
|
||||
/** 短剧收藏列表项 → 卡片(接口 PlayletData) */
|
||||
export function mapFavoritePlayletItem(item: PlayletData): ShortDramaItem | null {
|
||||
const id = Number(item.id);
|
||||
if (!Number.isInteger(id) || id < 1) return null;
|
||||
return {
|
||||
kind: "drama",
|
||||
id: String(id),
|
||||
slug: item.slug,
|
||||
title: (item.title || "").trim() || "未命名短剧",
|
||||
subtitle: (item.desc || "").trim(),
|
||||
tags: pickPlayletCardTags(item),
|
||||
heat: formatHeat(item.heatValue ?? item.countBrowse),
|
||||
episodes: item.totalEpisode ? `${item.totalEpisode}集` : "",
|
||||
badge: resolveContentBadge(item),
|
||||
cover: (item.cover || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 视频收藏列表项 → 卡片 */
|
||||
export function mapFavoriteVideoItem(item: FavoriteVideoListItem): ShortDramaItem | null {
|
||||
const id = Number(item.id);
|
||||
if (!Number.isInteger(id) || id < 1) return null;
|
||||
return {
|
||||
kind: "drama",
|
||||
id: String(id),
|
||||
slug: item.slug,
|
||||
title: (item.title || "").trim() || "未命名视频",
|
||||
subtitle: "",
|
||||
tags: pickVideoCardTags(item),
|
||||
heat: formatHeat(item.watchNum),
|
||||
episodes: "",
|
||||
cover: (item.coverImg || "").trim(),
|
||||
};
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { Pagination } from "@/components/common/pagination";
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
|
||||
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { getPlayletFavoriteList } from "@/lib/api/playlet-actions";
|
||||
import { getVideoFavoriteList } from "@/lib/api/video-actions";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
import {
|
||||
FAVORITES_PAGE_SIZE,
|
||||
favoriteTypes,
|
||||
mapFavoritePlayletItem,
|
||||
mapFavoriteVideoItem,
|
||||
type FavoriteType,
|
||||
} from "./data";
|
||||
|
||||
async function resolveToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 我的收藏:短剧 GET /api/app/playlet/favorite/list · 视频 GET /api/app/video/favorite/list */
|
||||
export function FavoritesPage() {
|
||||
const router = useRouter();
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const [type, setType] = useState<FavoriteType>("短剧");
|
||||
const [page, setPage] = useState(1);
|
||||
const [items, setItems] = useState<ShortDramaItem[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await resolveToken();
|
||||
if (cancelled) return;
|
||||
if (!token) {
|
||||
setItems([]);
|
||||
setTotalPages(1);
|
||||
setError("请先登录后查看收藏");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "短剧") {
|
||||
const data = await getPlayletFavoriteList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
|
||||
if (cancelled) return;
|
||||
const list = (data?.list ?? [])
|
||||
.map(mapFavoritePlayletItem)
|
||||
.filter((item): item is ShortDramaItem => Boolean(item));
|
||||
const total = Number(data?.totalCount) || 0;
|
||||
setItems(list);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
|
||||
} else {
|
||||
const data = await getVideoFavoriteList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
|
||||
if (cancelled) return;
|
||||
const list = (data?.list ?? [])
|
||||
.map(mapFavoriteVideoItem)
|
||||
.filter((item): item is ShortDramaItem => Boolean(item));
|
||||
const total = Number(data?.totalCount) || 0;
|
||||
setItems(list);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setItems([]);
|
||||
setTotalPages(1);
|
||||
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [type, page, reloadKey]);
|
||||
|
||||
const changeType = (next: FavoriteType) => {
|
||||
setType(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changePage = (next: number) => {
|
||||
setPage(next);
|
||||
topRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.push("/profile");
|
||||
};
|
||||
|
||||
const isVideo = type === "视频";
|
||||
const PcCard = isVideo ? VideoCard : ShortDramaCard;
|
||||
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
|
||||
const empty = !loading && !error && items.length === 0;
|
||||
|
||||
return (
|
||||
<main data-component="favorites-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
|
||||
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
|
||||
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
|
||||
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
|
||||
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-base leading-[22px] font-bold text-white">我的收藏</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={topRef}
|
||||
data-pc-scale
|
||||
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
|
||||
>
|
||||
<SeoBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: "个人中心", href: "/profile" },
|
||||
{ name: "追剧与收藏" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="hidden flex-col gap-px lg:flex">
|
||||
<h2 className="text-[30px] leading-9 font-semibold text-white">我的收藏</h2>
|
||||
<p className="text-sm leading-5 text-[#9790A6]">继续关注喜欢的内容</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 lg:hidden">
|
||||
{favoriteTypes.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === type}
|
||||
onClick={() => changeType(option)}
|
||||
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors ${
|
||||
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden gap-[9px] lg:flex">
|
||||
{favoriteTypes.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === type}
|
||||
onClick={() => changeType(option)}
|
||||
className={`cursor-pointer flex h-8 w-[88px] items-center justify-center rounded-2xl px-4 text-xs leading-[18px] transition-colors ${
|
||||
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
title={error}
|
||||
description="请检查网络后重试"
|
||||
onReload={() => setReloadKey((value) => value + 1)}
|
||||
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
|
||||
/>
|
||||
) : empty ? (
|
||||
<EmptyState
|
||||
title="暂无收藏内容"
|
||||
description="去首页发现更多好剧吧"
|
||||
onReload={null}
|
||||
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
|
||||
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<PcCard key={`${type}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
|
||||
{items.map((item) => (
|
||||
<MobileCard key={`${type}-${item.id}`} item={item} showEpisodes={false} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
|
||||
import { playHref } from "@/components/drama-detail/data";
|
||||
import { formatHeat } from "@/components/ranking/data";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { videoHref } from "@/components/video/video-card";
|
||||
import type { PlayletHistoryItem, VideoHistoryItem } from "@/lib/api/types";
|
||||
|
||||
export const historyTypes = ["短剧", "视频"] as const;
|
||||
export type HistoryType = (typeof historyTypes)[number];
|
||||
|
||||
export const HISTORY_PAGE_SIZE = 24;
|
||||
|
||||
export function formatWatchedAt(value?: string | null) {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return "";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 短剧历史 → 卡片展示(保留接口字段语义,仅做展示文案) */
|
||||
export function mapPlayletHistoryItem(item: PlayletHistoryItem): ShortDramaItem | null {
|
||||
const id = Number(item.id);
|
||||
if (!Number.isInteger(id) || id < 1) return null;
|
||||
const episode = Number(item.episode) || 0;
|
||||
const href = episode > 0 ? playHref(id, episode, item.slug) : undefined;
|
||||
return {
|
||||
kind: "drama",
|
||||
id: String(id),
|
||||
slug: item.slug,
|
||||
title: (item.title || "").trim() || "未命名短剧",
|
||||
subtitle: (item.desc || "").trim(),
|
||||
tags: pickPlayletCardTags(item),
|
||||
heat: formatHeat(item.heatValue ?? item.countBrowse),
|
||||
episodes: episode > 0 ? `看到第${episode}集` : item.totalEpisode ? `全${item.totalEpisode}集` : "",
|
||||
cover: (item.cover || "").trim(),
|
||||
href,
|
||||
};
|
||||
}
|
||||
|
||||
/** 视频历史 → 卡片展示 */
|
||||
export function mapVideoHistoryItem(item: VideoHistoryItem): ShortDramaItem | null {
|
||||
const id = Number(item.id);
|
||||
if (!Number.isInteger(id) || id < 1) return null;
|
||||
return {
|
||||
kind: "drama",
|
||||
id: String(id),
|
||||
slug: item.slug,
|
||||
title: (item.title || "").trim() || "未命名视频",
|
||||
subtitle: (item.desc || "").trim(),
|
||||
tags: pickVideoCardTags(item),
|
||||
heat: formatHeat(item.watchNum),
|
||||
episodes: formatWatchedAt(item.watchedAt),
|
||||
cover: (item.coverImg || "").trim(),
|
||||
href: videoHref(String(id), item.slug),
|
||||
};
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertDialog, Button, toast } from "@heroui/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { Pagination } from "@/components/common/pagination";
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { clearPlayletHistory, getPlayletHistoryList } from "@/lib/api/playlet-actions";
|
||||
import { clearVideoHistory, getVideoHistoryList } from "@/lib/api/video-actions";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
import {
|
||||
HISTORY_PAGE_SIZE,
|
||||
historyTypes,
|
||||
mapPlayletHistoryItem,
|
||||
mapVideoHistoryItem,
|
||||
type HistoryType,
|
||||
} from "./data";
|
||||
|
||||
async function resolveToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 观看历史:短剧 GET /api/app/playlet/history/list · 视频 GET /api/app/video/history/list */
|
||||
export function HistoryPage() {
|
||||
const router = useRouter();
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const [type, setType] = useState<HistoryType>("短剧");
|
||||
const [page, setPage] = useState(1);
|
||||
const [items, setItems] = useState<ShortDramaItem[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await resolveToken();
|
||||
if (cancelled) return;
|
||||
if (!token) {
|
||||
setItems([]);
|
||||
setTotalPages(1);
|
||||
setError("请先登录后查看观看历史");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "短剧") {
|
||||
const data = await getPlayletHistoryList({ pageNum: page, pageSize: HISTORY_PAGE_SIZE }, token);
|
||||
if (cancelled) return;
|
||||
const list = (data?.list ?? [])
|
||||
.map(mapPlayletHistoryItem)
|
||||
.filter((item): item is ShortDramaItem => Boolean(item));
|
||||
const total = Number(data?.totalCount) || 0;
|
||||
setItems(list);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE) || 1));
|
||||
} else {
|
||||
const data = await getVideoHistoryList({ pageNum: page, pageSize: HISTORY_PAGE_SIZE }, token);
|
||||
if (cancelled) return;
|
||||
const list = (data?.list ?? [])
|
||||
.map(mapVideoHistoryItem)
|
||||
.filter((item): item is ShortDramaItem => Boolean(item));
|
||||
const total = Number(data?.totalCount) || 0;
|
||||
setItems(list);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE) || 1));
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setItems([]);
|
||||
setTotalPages(1);
|
||||
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [type, page, reloadKey]);
|
||||
|
||||
const changeType = (next: HistoryType) => {
|
||||
setType(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changePage = (next: number) => {
|
||||
setPage(next);
|
||||
topRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const openClearConfirm = () => {
|
||||
if (clearing || items.length === 0) return;
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const clearHistory = async () => {
|
||||
if (clearing || items.length === 0) return;
|
||||
const label = type === "短剧" ? "短剧" : "视频";
|
||||
const token = await resolveToken();
|
||||
if (!token) {
|
||||
setConfirmOpen(false);
|
||||
toast.danger("请先登录后再清空");
|
||||
return;
|
||||
}
|
||||
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
try {
|
||||
// DELETE /api/app/playlet/history · DELETE /api/app/video/history
|
||||
const result = type === "短剧" ? await clearPlayletHistory(token) : await clearVideoHistory(token);
|
||||
const deleted = Number(result?.deletedCount) || 0;
|
||||
setItems([]);
|
||||
setTotalPages(1);
|
||||
setPage(1);
|
||||
setConfirmOpen(false);
|
||||
setReloadKey((value) => value + 1);
|
||||
toast.success(deleted > 0 ? `已清空${deleted}条${label}历史` : `${label}历史已清空`);
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : "清空失败,请稍后重试";
|
||||
setError(message);
|
||||
toast.danger(message);
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.push("/profile");
|
||||
};
|
||||
|
||||
const isVideo = type === "视频";
|
||||
const PcCard = isVideo ? VideoCard : ShortDramaCard;
|
||||
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
|
||||
const empty = !loading && !error && items.length === 0;
|
||||
const canClear = !clearing && items.length > 0;
|
||||
const clearLabel = type === "短剧" ? "短剧" : "视频";
|
||||
|
||||
return (
|
||||
<main data-component="history-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
|
||||
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
|
||||
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
|
||||
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
|
||||
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-base leading-[22px] font-bold text-white">观看历史</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openClearConfirm}
|
||||
disabled={!canClear}
|
||||
className="cursor-pointer absolute right-4 text-xs leading-[18px] font-bold text-[#F12B9E] disabled:opacity-40"
|
||||
>
|
||||
{clearing ? "清空中" : "清空"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={topRef}
|
||||
data-pc-scale
|
||||
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
|
||||
>
|
||||
<SeoBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: "个人中心", href: "/profile" },
|
||||
{ name: "观看历史" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="hidden items-center lg:flex">
|
||||
<div className="flex flex-1 flex-col gap-px">
|
||||
<h2 className="text-[30px] leading-9 font-semibold text-white">观看历史</h2>
|
||||
<p className="text-sm leading-5 text-[#9790A6]">找回最近观看的剧集</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openClearConfirm}
|
||||
disabled={!canClear}
|
||||
className="cursor-pointer h-9 w-24 rounded-[18px] bg-white/6 text-xs leading-[18px] font-semibold text-[#B5AEC1] transition-colors hover:text-white disabled:opacity-40"
|
||||
>
|
||||
{clearing ? "清空中" : "清空记录"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 lg:gap-[9px]">
|
||||
{historyTypes.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === type}
|
||||
onClick={() => changeType(option)}
|
||||
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors lg:w-[88px] lg:text-xs lg:leading-[18px] ${
|
||||
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
title={error}
|
||||
description="请检查网络后重试"
|
||||
onReload={() => setReloadKey((value) => value + 1)}
|
||||
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
|
||||
/>
|
||||
) : empty ? (
|
||||
<EmptyState
|
||||
title="暂无观看记录"
|
||||
description="去首页发现更多好剧吧"
|
||||
onReload={null}
|
||||
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
|
||||
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<PcCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
|
||||
{items.map((item) => (
|
||||
<MobileCard key={item.id} item={item} showEpisodes />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
isOpen={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (clearing) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Backdrop variant="opaque" className="bg-black/70">
|
||||
<AlertDialog.Container placement="center" size="sm">
|
||||
<AlertDialog.Dialog className="w-[min(100vw-32px,360px)] rounded-2xl bg-[#211F2E] p-5 text-white shadow-[0_12px_40px_rgba(241,43,158,0.18)] outline outline-1 -outline-offset-1 outline-[#3A3652]">
|
||||
<AlertDialog.Header className="mb-3 flex flex-col items-start gap-3">
|
||||
<AlertDialog.Icon
|
||||
status="danger"
|
||||
className="size-9 rounded-full bg-[#F12B9E]/15 text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]/55"
|
||||
/>
|
||||
<AlertDialog.Heading className="text-lg leading-6 font-semibold text-white">
|
||||
清空观看历史
|
||||
</AlertDialog.Heading>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Body className="mb-5">
|
||||
<p className="text-sm leading-5 text-[#B8B0C8]">
|
||||
确定清空全部{clearLabel}观看历史?此操作不可恢复。
|
||||
</p>
|
||||
</AlertDialog.Body>
|
||||
<AlertDialog.Footer className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
isDisabled={clearing}
|
||||
onPress={() => setConfirmOpen(false)}
|
||||
className="h-9 min-w-[72px] rounded-full px-4 text-sm text-[#C8C0D6] hover:bg-white/8 hover:text-white"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={clearing}
|
||||
onPress={() => void clearHistory()}
|
||||
className="h-9 min-w-[96px] rounded-full bg-[#F12B9E] px-4 text-sm font-medium text-white hover:bg-[#F23DA8] disabled:opacity-60"
|
||||
>
|
||||
{clearing ? "清空中…" : "确定清空"}
|
||||
</Button>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Dialog>
|
||||
</AlertDialog.Container>
|
||||
</AlertDialog.Backdrop>
|
||||
</AlertDialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
export function HeaderUserChip() {
|
||||
const { user } = useMockUser();
|
||||
const avatar = user?.avatar || fallbackAvatar;
|
||||
const displayName = user?.name || "游客";
|
||||
|
||||
return (
|
||||
<Link href="/profile" className="flex shrink-0 items-center gap-2">
|
||||
<DecryptedImage src={avatar} fallback={fallbackAvatar} priority alt="" className="size-10 rounded-full object-cover" />
|
||||
<span className="hidden max-w-[96px] truncate text-sm leading-5 text-white xl:inline">{displayName}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { Pagination } from "@/components/common/pagination";
|
||||
import {
|
||||
FAVORITES_PAGE_SIZE,
|
||||
favoriteTypes,
|
||||
mapFavoritePlayletItem,
|
||||
mapFavoriteVideoItem,
|
||||
type FavoriteType,
|
||||
} from "@/components/favorites/data";
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
|
||||
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { getPlayletLikeList } from "@/lib/api/playlet-actions";
|
||||
import { getVideoLikeList } from "@/lib/api/video-actions";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
async function resolveToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 我的点赞:短剧 GET /api/app/playlet/like/list · 视频 GET /api/app/video/like/list */
|
||||
export function LikesPage() {
|
||||
const router = useRouter();
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const [type, setType] = useState<FavoriteType>("短剧");
|
||||
const [page, setPage] = useState(1);
|
||||
const [items, setItems] = useState<ShortDramaItem[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await resolveToken();
|
||||
if (cancelled) return;
|
||||
if (!token) {
|
||||
setItems([]);
|
||||
setTotalPages(1);
|
||||
setError("请先登录后查看点赞");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "短剧") {
|
||||
const data = await getPlayletLikeList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
|
||||
if (cancelled) return;
|
||||
const list = (data?.list ?? [])
|
||||
.map(mapFavoritePlayletItem)
|
||||
.filter((item): item is ShortDramaItem => Boolean(item));
|
||||
const total = Number(data?.totalCount) || 0;
|
||||
setItems(list);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
|
||||
} else {
|
||||
const data = await getVideoLikeList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
|
||||
if (cancelled) return;
|
||||
const list = (data?.list ?? [])
|
||||
.map(mapFavoriteVideoItem)
|
||||
.filter((item): item is ShortDramaItem => Boolean(item));
|
||||
const total = Number(data?.totalCount) || 0;
|
||||
setItems(list);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setItems([]);
|
||||
setTotalPages(1);
|
||||
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [type, page, reloadKey]);
|
||||
|
||||
const changeType = (next: FavoriteType) => {
|
||||
setType(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changePage = (next: number) => {
|
||||
setPage(next);
|
||||
topRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.push("/profile");
|
||||
};
|
||||
|
||||
const isVideo = type === "视频";
|
||||
const PcCard = isVideo ? VideoCard : ShortDramaCard;
|
||||
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
|
||||
const empty = !loading && !error && items.length === 0;
|
||||
|
||||
return (
|
||||
<main data-component="likes-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
|
||||
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
|
||||
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
|
||||
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
|
||||
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-base leading-[22px] font-bold text-white">我的点赞</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={topRef}
|
||||
data-pc-scale
|
||||
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
|
||||
>
|
||||
<SeoBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: "个人中心", href: "/profile" },
|
||||
{ name: "我的点赞" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="hidden flex-col gap-px lg:flex">
|
||||
<h2 className="text-[30px] leading-9 font-semibold text-white">我的点赞</h2>
|
||||
<p className="text-sm leading-5 text-[#9790A6]">我喜欢的我都点赞</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 lg:hidden">
|
||||
{favoriteTypes.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === type}
|
||||
onClick={() => changeType(option)}
|
||||
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors ${
|
||||
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden gap-[9px] lg:flex">
|
||||
{favoriteTypes.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === type}
|
||||
onClick={() => changeType(option)}
|
||||
className={`cursor-pointer flex h-8 w-[88px] items-center justify-center rounded-2xl px-4 text-xs leading-[18px] transition-colors ${
|
||||
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
title={error}
|
||||
description="请检查网络后重试"
|
||||
onReload={() => setReloadKey((value) => value + 1)}
|
||||
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
|
||||
/>
|
||||
) : empty ? (
|
||||
<EmptyState
|
||||
title="暂无点赞内容"
|
||||
description="去首页发现更多好剧吧"
|
||||
onReload={null}
|
||||
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
|
||||
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<PcCard key={`${type}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
|
||||
{items.map((item) => (
|
||||
<MobileCard key={`${type}-${item.id}`} item={item} showEpisodes={false} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { openLoginDialog } from "@/components/auth/login-dialog";
|
||||
import { getPlayletFavoriteList, getPlayletLikeList } from "@/lib/api/playlet-actions";
|
||||
import { getVideoFavoriteList, getVideoLikeList } from "@/lib/api/video-actions";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
import { useMockUser } from "./use-mock-user";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
extra?: string;
|
||||
small?: boolean;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
const staticMenuItems: MenuItem[] = [
|
||||
{ label: "我的点赞", icon: "/images/icon_like.svg", href: "/likes", small: true },
|
||||
{ label: "我的收藏", icon: "/images/collect.svg", href: "/favorites" },
|
||||
{ label: "观看历史", icon: "/images/history.svg", href: "/history", extra: "继续观看" },
|
||||
{ label: "回家的路", icon: "/images/home.svg", href: "/way-home", highlight: true },
|
||||
{ label: "账号设置", icon: "/images/profile.svg", href: "/settings" },
|
||||
{ label: "问题反馈", icon: "/images/wtfk.svg", href: "/feedback" },
|
||||
];
|
||||
|
||||
function formatBuCount(count: number | null) {
|
||||
if (count == null) return "";
|
||||
return `${count}部`;
|
||||
}
|
||||
|
||||
async function resolveToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function readTotal(
|
||||
loader: (query: { pageNum: number; pageSize: number }, token: string) => Promise<{ totalCount?: number } | null>,
|
||||
token: string,
|
||||
) {
|
||||
try {
|
||||
const data = await loader({ pageNum: 1, pageSize: 1 }, token);
|
||||
return Number(data?.totalCount) || 0;
|
||||
} catch (error) {
|
||||
console.warn("[profile/counts]", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function Avatar({ src }: { src: string }) {
|
||||
return (
|
||||
<DecryptedImage
|
||||
src={src}
|
||||
fallback={fallbackAvatar}
|
||||
priority
|
||||
alt=""
|
||||
className="size-[60px] shrink-0 rounded-full object-cover outline-2 -outline-offset-2 outline-[#FFC590]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const guestActionBtn =
|
||||
"cursor-pointer flex h-7 shrink-0 items-center justify-center gap-1 rounded-full bg-white px-2 text-[#120F1C]";
|
||||
|
||||
function GuestCard({ name, avatar, id }: { name: string; avatar: string; id?: string }) {
|
||||
return (
|
||||
<section className="flex items-start gap-3 bg-[#121117] px-2 py-3">
|
||||
<Avatar src={avatar} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h2 className="truncate text-lg leading-[30px] font-semibold text-white">{name}</h2>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
<span className="shrink-0 text-sm leading-5 text-[#AEA8BC]">
|
||||
{id ? `ID:${id}` : "游客登录中…"}
|
||||
</span>
|
||||
<button type="button" onClick={() => openLoginDialog()} className={guestActionBtn}>
|
||||
<span className="text-sm leading-5 font-semibold">→</span>
|
||||
<span className="text-xs leading-[18px] font-medium">登录账号</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => openLoginDialog()} className={guestActionBtn}>
|
||||
<span className="text-sm leading-5 font-semibold">→</span>
|
||||
<span className="text-xs leading-[18px] font-medium">绑定账号</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs leading-[18px] text-[#F12B9E]">
|
||||
登录正式账号后可同步收藏、追剧、观看历史和金币余额。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCard({ name, avatar, id, onSwitch }: { name: string; avatar: string; id: string; onSwitch: () => void }) {
|
||||
return (
|
||||
<section className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar src={avatar} />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="truncate text-lg leading-[30px] font-semibold text-white">{name}</h2>
|
||||
<div className="flex items-center gap-2 text-xs leading-[18px] text-[#948CA6]">
|
||||
<span>ID:{id}</span>
|
||||
<button type="button" onClick={() => navigator.clipboard?.writeText(id)} className="cursor-pointer underline">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitch}
|
||||
className="cursor-pointer flex h-7 shrink-0 items-center rounded-full bg-white/10 px-[13px] text-xs leading-[18px] text-white/90"
|
||||
>
|
||||
切换账号
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileH5() {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useMockUser({ refreshOnMount: true });
|
||||
const [likeCount, setLikeCount] = useState<number | null>(null);
|
||||
const [favoriteCount, setFavoriteCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const token = await resolveToken();
|
||||
if (!token || cancelled) {
|
||||
if (!cancelled) {
|
||||
setLikeCount(0);
|
||||
setFavoriteCount(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [playletLike, videoLike, playletFav, videoFav] = await Promise.all([
|
||||
readTotal(getPlayletLikeList, token),
|
||||
readTotal(getVideoLikeList, token),
|
||||
readTotal(getPlayletFavoriteList, token),
|
||||
readTotal(getVideoFavoriteList, token),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setLikeCount(playletLike + videoLike);
|
||||
setFavoriteCount(playletFav + videoFav);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.push("/");
|
||||
};
|
||||
|
||||
const switchAccount = () => {
|
||||
logout();
|
||||
openLoginDialog();
|
||||
};
|
||||
|
||||
const menuItems = staticMenuItems.map((item) => {
|
||||
if (item.label === "我的点赞") return { ...item, extra: formatBuCount(likeCount) };
|
||||
if (item.label === "我的收藏") return { ...item, extra: formatBuCount(favoriteCount) };
|
||||
return item;
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-component="profile-h5" className="flex flex-col lg:hidden">
|
||||
<div className="relative flex h-12 items-center justify-center px-4">
|
||||
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
|
||||
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
|
||||
<path
|
||||
d="M15 5l-7 7 7 7"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-base leading-[22px] font-bold text-white">我的</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||
{user?.isRegister ? (
|
||||
<UserCard name={user.name} avatar={user.avatar} id={user.id} onSwitch={switchAccount} />
|
||||
) : (
|
||||
<GuestCard
|
||||
name={user?.name ?? "游客"}
|
||||
avatar={user?.avatar ?? "/images/default_avatar.svg"}
|
||||
id={user?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<nav className="flex flex-col overflow-hidden rounded-2xl bg-[#121117]" aria-label="个人中心菜单">
|
||||
{menuItems.map((item) => (
|
||||
<Link key={item.label} href={item.href} className="flex h-12 items-center justify-between px-3.5">
|
||||
<span
|
||||
className={`flex items-center gap-1 text-sm leading-5 ${item.highlight ? "text-white" : "text-[#EBE5F2]"}`}
|
||||
>
|
||||
<img src={item.icon} alt="" className={`size-4 object-contain ${item.small ? "p-px" : ""}`} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs leading-[18px] text-[#857D94]">
|
||||
{item.extra}
|
||||
<img src="/images/arrow_rught.svg" alt="" className="size-[18px]" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { openLoginDialog } from "@/components/auth/login-dialog";
|
||||
|
||||
import { useMockUser } from "./use-mock-user";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
const primaryBtn =
|
||||
"cursor-pointer flex h-[42px] items-center justify-center gap-2 rounded-full bg-white px-4 text-[#120F1C]";
|
||||
const secondaryBtn =
|
||||
"cursor-pointer flex h-[42px] items-center justify-center rounded-full bg-white/10 px-4 text-sm leading-5 font-semibold text-white outline outline-1 -outline-offset-1 outline-white/20 hover:bg-white/15";
|
||||
|
||||
export function ProfileHero() {
|
||||
const { user, logout } = useMockUser({ refreshOnMount: true });
|
||||
const isGuest = !user || !user.isRegister;
|
||||
|
||||
const switchAccount = () => {
|
||||
logout();
|
||||
openLoginDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-component="profile-hero" className="flex items-center gap-4 overflow-hidden rounded-3xl bg-[linear-gradient(90deg,rgba(241,43,158,0.17)_0%,rgba(62,102,255,0.18)_100%)] p-5 outline outline-white/14 -outline-offset-1 lg:h-44 lg:gap-5 lg:px-7 lg:py-6">
|
||||
<DecryptedImage
|
||||
src={user?.avatar}
|
||||
fallback={fallbackAvatar}
|
||||
priority
|
||||
alt=""
|
||||
className="size-16 shrink-0 rounded-full object-cover lg:size-[84px]"
|
||||
/>
|
||||
{isGuest ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h2 className="truncate text-xl leading-7 font-semibold text-white lg:text-2xl lg:leading-[30px]">
|
||||
{user?.name ?? "游客"}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-x-4 text-sm leading-5">
|
||||
<span className="text-[#AEA8BC]">{user ? `ID:${user.id}` : "游客登录中…"}</span>
|
||||
<span className="text-[#F12B9E]">登录正式账号后可同步收藏、追剧、观看历史和金币余额。</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 lg:mt-0">
|
||||
<button type="button" onClick={() => openLoginDialog()} className={primaryBtn}>
|
||||
<span className="text-base leading-[22px] font-semibold">→</span>
|
||||
<span className="text-sm leading-5 font-semibold">登录账号</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => openLoginDialog()} className={primaryBtn}>
|
||||
<span className="text-base leading-[22px] font-semibold">→</span>
|
||||
<span className="text-sm leading-5 font-semibold">绑定账号</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="truncate text-xl leading-7 font-semibold text-white lg:text-2xl lg:leading-[30px]">
|
||||
{user.name}
|
||||
</h2>
|
||||
<p className="truncate text-sm leading-5 text-[#AEA8BC]">{user.accountNo || user.email}</p>
|
||||
</div>
|
||||
<button type="button" onClick={switchAccount} className={`shrink-0 ${secondaryBtn}`}>
|
||||
切换账号
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
|
||||
import { ProfileH5 } from "./profile-h5";
|
||||
import { ProfileHero } from "./profile-hero";
|
||||
|
||||
const entries = [
|
||||
{
|
||||
title: "我的点赞",
|
||||
description: "我喜欢的我都点赞",
|
||||
href: "/likes",
|
||||
image: "/images/my_dz.png",
|
||||
imageClassName: "h-[93px] w-[98px]",
|
||||
},
|
||||
{
|
||||
title: "追剧与收藏",
|
||||
description: "继续关注喜欢的内容",
|
||||
href: "/favorites",
|
||||
image: "/images/my_sc.png",
|
||||
imageClassName: "h-[100px] w-[110px]",
|
||||
},
|
||||
{
|
||||
title: "账号设置",
|
||||
description: "资料与安全设置",
|
||||
href: "/settings",
|
||||
image: "/images/my_sz.png",
|
||||
imageClassName: "h-[100px] w-[98px]",
|
||||
},
|
||||
];
|
||||
|
||||
export function ProfilePage() {
|
||||
return (
|
||||
<main data-component="profile-page" className="flex flex-1 flex-col lg:bg-[linear-gradient(90deg,#1F081D_0%,#070714_48%,#09183A_100%)]">
|
||||
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: "个人中心" }]} />
|
||||
<ProfileH5 />
|
||||
<div className="mx-auto hidden w-full max-w-[1748px] flex-col gap-[18px] px-6 py-[26px] lg:flex">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-xs leading-[18px] font-semibold text-[#F160B1]">ACCOUNT</p>
|
||||
<p className="text-2xl leading-8 font-semibold text-white lg:text-[34px] lg:leading-10">个人中心</p>
|
||||
<p className="text-sm leading-5 text-[#9790A6]">欢迎回来,继续你的追剧旅程</p>
|
||||
</div>
|
||||
|
||||
<ProfileHero />
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
{entries.map((entry) => (
|
||||
<Link
|
||||
key={entry.title}
|
||||
href={entry.href}
|
||||
className="group relative flex h-[132px] items-center justify-between rounded-[32px_64px_32px_32px] bg-[linear-gradient(180deg,#241929_0%,rgba(30,16,34,0.31)_100%)] pt-[15px] pr-[46px] pb-[17px] pl-[31px] outline-1 outline-transparent -outline-offset-1 backdrop-blur-[1px] transition-[outline-color,box-shadow] duration-300 hover:shadow-[0_0_4px_rgba(191,0,101,0.3),0_0_5px_rgba(255,55,161,0.4),0_0_3px_rgba(255,55,161,0.6),0.25px_0.25px_1px_#C40068,-0.25px_-0.25px_1px_#C40068] hover:outline-[#FF37A1]"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-[5px]">
|
||||
<h3 className="text-lg leading-6 font-semibold text-white">{entry.title}</h3>
|
||||
<p className="text-xs leading-[18px] text-[#9790A6] transition-colors duration-300 group-hover:text-[#D6D6D6]">
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative shrink-0 overflow-hidden">
|
||||
<span className="absolute top-1/2 left-1/2 h-[71px] w-[73px] -translate-1/2 rounded-full bg-[#66103D] blur-[20px] transition-colors duration-300 group-hover:bg-[#A6225F]" />
|
||||
<img src={entry.image} alt="" className={`relative object-contain ${entry.imageClassName}`} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import {
|
||||
CREDENTIAL_MAX_LEN,
|
||||
isValidCredential,
|
||||
PASSWORD_RULE_HINT,
|
||||
} from "@/lib/auth/credentials";
|
||||
import { updateUserProfile } from "@/lib/auth/session";
|
||||
|
||||
type Field = "oldPassword" | "newPassword" | "confirmPassword";
|
||||
|
||||
const fields: { key: Field; label: string; placeholder: string }[] = [
|
||||
{ key: "oldPassword", label: "旧密码", placeholder: "请输入旧密码" },
|
||||
{ key: "newPassword", label: "新密码", placeholder: "请输入新密码" },
|
||||
{ key: "confirmPassword", label: "再次确认新密码", placeholder: "请再次输入新密码" },
|
||||
];
|
||||
|
||||
const emptyValues: Record<Field, string> = { oldPassword: "", newPassword: "", confirmPassword: "" };
|
||||
|
||||
function validate(values: Record<Field, string>) {
|
||||
if (!values.oldPassword) return "请输入旧密码";
|
||||
if (!isValidCredential(values.newPassword)) return PASSWORD_RULE_HINT;
|
||||
if (values.newPassword === values.oldPassword) return "新密码不能与旧密码相同";
|
||||
if (values.confirmPassword !== values.newPassword) return "两次输入的新密码不一致";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [values, setValues] = useState(emptyValues);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && !saving && onClose();
|
||||
const { overflow } = document.body.style;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = overflow;
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open, onClose, saving]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => {
|
||||
if (saving) return;
|
||||
setValues(emptyValues);
|
||||
setError("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const message = validate(values);
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateUserProfile({
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
setValues(emptyValues);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "修改密码失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-component="change-password-dialog"
|
||||
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
|
||||
onClick={(event) => event.target === event.currentTarget && close()}
|
||||
>
|
||||
<div className="my-auto flex flex-col items-center gap-2.5 lg:gap-6">
|
||||
<form
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="change-password-title"
|
||||
onSubmit={submit}
|
||||
className="flex w-[310px] flex-col items-center gap-4 rounded-3xl bg-[#0A0915]/90 px-8 py-7 shadow-[0_20px_50px_rgba(0,0,0,0.35)] outline outline-1 -outline-offset-1 outline-white/13 lg:w-[400px] lg:gap-3.5"
|
||||
>
|
||||
<img src="/logo.svg" alt="海角社区" className="h-12 w-auto" />
|
||||
<div className="flex flex-col gap-2 self-stretch">
|
||||
<h2 id="change-password-title" className="text-[30px] leading-9 font-semibold text-white">
|
||||
修改密码
|
||||
</h2>
|
||||
<p className="text-sm leading-5 text-[#A8A1B6]">新密码需为 4-20 位,不能包含空格,且不同于旧密码</p>
|
||||
</div>
|
||||
{fields.map((field) => (
|
||||
<PasswordField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={values[field.key]}
|
||||
onChange={(value) => {
|
||||
setValues((current) => ({ ...current, [field.key]: value }));
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{error ? <p className="-my-1 self-stretch text-xs leading-[18px] text-[#FF5A7A]">{error}</p> : null}
|
||||
<div className="flex gap-3.5 self-stretch">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
disabled={saving}
|
||||
className="cursor-pointer h-12 flex-1 rounded-xl bg-[#1B1A26] text-sm leading-5 font-semibold text-white transition-colors hover:bg-[#252333] disabled:opacity-60"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="cursor-pointer h-12 flex-1 rounded-xl bg-[#F12B9E] text-sm leading-5 font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "提交中…" : "确定"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<button type="button" aria-label="关闭" onClick={close} className="cursor-pointer size-11 lg:size-9">
|
||||
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
|
||||
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
|
||||
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordField({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-2 self-stretch">
|
||||
<span className="text-sm leading-5 font-semibold text-[#E0DDE8]">{label}</span>
|
||||
<span className="flex h-12 items-center gap-2 rounded-xl bg-white/7 px-3.5 outline outline-1 -outline-offset-1 outline-white/10 focus-within:outline-[#F12B9E]/60">
|
||||
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
|
||||
<rect x="4" y="8.5" width="12" height="8.5" rx="2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
|
||||
<path d="M7 8.5V6.5a3 3 0 0 1 6 0v2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type={visible ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
maxLength={CREDENTIAL_MAX_LEN}
|
||||
autoComplete={label === "旧密码" ? "current-password" : "new-password"}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-[#746E85]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
className="cursor-pointer flex size-5 shrink-0 items-center justify-center"
|
||||
>
|
||||
<svg viewBox="0 0 20 16" className="h-[13.33px] w-[17.5px]" aria-hidden="true">
|
||||
<path
|
||||
d="M1 8s3.3-6 9-6 9 6 9 6-3.3 6-9 6-9-6-9-6z"
|
||||
fill="none"
|
||||
stroke="#A8A1B8"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="10" cy="8" r="2.6" fill="#A8A1B8" />
|
||||
{visible ? null : <path d="M3 15L17 1" stroke="#A8A1B8" strokeWidth="1.6" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||
|
||||
import { BackButton } from "@/components/common/back-button";
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
import { useMockProfile } from "@/components/settings/use-mock-profile";
|
||||
import { updateUserProfile } from "@/lib/auth/session";
|
||||
|
||||
const fallbackAvatar = "/images/default_avatar.svg";
|
||||
|
||||
type ProfileEditFormProps = {
|
||||
onSaved?: () => void;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProfileEditForm({ onSaved, actions, className }: ProfileEditFormProps) {
|
||||
const { user } = useMockUser({ refreshOnMount: true });
|
||||
const { profile, saveProfile } = useMockProfile();
|
||||
const [nicknameDraft, setNicknameDraft] = useState<string | null>(null);
|
||||
const [bioDraft, setBioDraft] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null);
|
||||
|
||||
const nickname = nicknameDraft ?? user?.name ?? profile.nickname;
|
||||
const bio = bioDraft ?? profile.bio;
|
||||
const avatar = user?.avatar ?? profile.avatar ?? fallbackAvatar;
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmed = nickname.trim();
|
||||
if (!trimmed) {
|
||||
setMessage({ text: "请输入昵称", error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const nickChanged = trimmed !== (user?.name || "");
|
||||
const bioChanged = bio.trim() !== (profile.bio || "").trim();
|
||||
|
||||
if (nickChanged && !user?.accountNo) {
|
||||
setMessage({ text: "请先注册账号后再修改资料", error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nickChanged && !bioChanged) {
|
||||
setMessage({ text: "暂无需要保存的修改", error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
if (nickChanged) {
|
||||
await updateUserProfile({ nickName: trimmed });
|
||||
}
|
||||
saveProfile({
|
||||
nickname: trimmed,
|
||||
bio: bio.trim(),
|
||||
avatar: null,
|
||||
});
|
||||
setNicknameDraft(null);
|
||||
setBioDraft(null);
|
||||
setMessage({ text: "已保存", error: false });
|
||||
onSaved?.();
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
text: error instanceof Error ? error.message : "保存失败",
|
||||
error: true,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
data-component="profile-edit-form"
|
||||
onSubmit={submit}
|
||||
className={className ?? "flex flex-col gap-3.5 p-4"}
|
||||
>
|
||||
<div className="flex h-28 items-center justify-between rounded-2xl bg-[#13111C] px-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-base leading-[22px] font-bold text-[#FAF7FF]">个人头像</span>
|
||||
<span className="text-xs leading-[18px] text-[#9E94AD]">当前头像</span>
|
||||
</div>
|
||||
<DecryptedImage
|
||||
src={avatar}
|
||||
fallback={fallbackAvatar}
|
||||
priority
|
||||
alt="头像"
|
||||
className="size-16 shrink-0 rounded-full object-cover outline outline-1 -outline-offset-1 outline-white/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex h-[76px] flex-col gap-2 rounded-2xl bg-[#13111C] px-3.5 py-3">
|
||||
<span className="text-xs leading-[18px] font-bold text-[#9E94AD]">昵称</span>
|
||||
<input
|
||||
value={nickname}
|
||||
onChange={(event) => setNicknameDraft(event.target.value)}
|
||||
maxLength={20}
|
||||
placeholder="请输入昵称"
|
||||
className="h-5 bg-transparent text-sm leading-5 text-[#FAF7FF] outline-none placeholder:text-[#5F586E]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex h-24 flex-col gap-2 rounded-2xl bg-[#13111C] px-3.5 py-3">
|
||||
<span className="text-xs leading-[18px] font-bold text-[#9E94AD]">个人简介</span>
|
||||
<textarea
|
||||
value={bio}
|
||||
onChange={(event) => setBioDraft(event.target.value)}
|
||||
maxLength={60}
|
||||
rows={2}
|
||||
placeholder="分享你的追剧偏好"
|
||||
className="min-h-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[#FAF7FF] outline-none placeholder:text-[#5F586E]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{message ? (
|
||||
<p className={`text-xs leading-[18px] ${message.error ? "text-[#FF5A7A]" : "text-[#4ADE80]"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{actions}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="cursor-pointer h-[50px] rounded-[14px] bg-[#F12B9E] text-base leading-[22px] font-bold text-[#FAF7FF] active:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "保存中…" : "保存修改"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditProfilePage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(min-width: 1024px)");
|
||||
const redirect = () => media.matches && router.replace("/settings");
|
||||
redirect();
|
||||
media.addEventListener("change", redirect);
|
||||
return () => media.removeEventListener("change", redirect);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<main data-component="edit-profile-page" className="flex-1 bg-[#07060D] lg:hidden">
|
||||
<SeoBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: "个人中心", href: "/profile" },
|
||||
{ name: "账号设置", href: "/settings" },
|
||||
{ name: "编辑个人资料" },
|
||||
]}
|
||||
/>
|
||||
<div className="relative flex h-12 items-center justify-center px-4">
|
||||
<BackButton fallback="/settings" className="absolute left-4" />
|
||||
<h1 className="text-lg leading-6 font-bold text-[#FAF7FF]">编辑个人资料</h1>
|
||||
</div>
|
||||
<ProfileEditForm
|
||||
onSaved={() =>
|
||||
window.setTimeout(() => (window.history.length > 1 ? router.back() : router.replace("/settings")), 600)
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { openLoginDialog } from "@/components/auth/login-dialog";
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
import { ChangePasswordDialog } from "@/components/settings/change-password-dialog";
|
||||
import { ProfileEditForm } from "@/components/settings/edit-profile-page";
|
||||
|
||||
const panelClass =
|
||||
"flex flex-col gap-3.5 rounded-[20px] bg-white/5 p-6 outline outline-1 -outline-offset-1 outline-white/10";
|
||||
|
||||
export function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { logout } = useMockUser({ refreshOnMount: true });
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const closePassword = useCallback(() => setPasswordOpen(false), []);
|
||||
|
||||
const switchAccount = () => {
|
||||
logout();
|
||||
openLoginDialog();
|
||||
};
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) router.back();
|
||||
else router.push("/profile");
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-component="settings-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
|
||||
<div className="lg:hidden">
|
||||
<div className="relative flex h-12 items-center justify-center px-4">
|
||||
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
|
||||
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
|
||||
<path
|
||||
d="M15 5l-7 7 7 7"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-lg leading-6 font-bold text-[#FAF7FF]">账号设置</h1>
|
||||
</div>
|
||||
|
||||
<ProfileEditForm
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordOpen(true)}
|
||||
className="cursor-pointer h-12 rounded-[14px] bg-[#13111C] text-sm leading-5 font-bold text-[#F12B9E] outline-1 -outline-offset-1 outline-[#F12B9E]"
|
||||
>
|
||||
修改密码
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto hidden w-full flex-col gap-[18px] lg:flex lg:w-[calc(100%-64px)] lg:max-w-[1700px] lg:py-[30px]">
|
||||
<SeoBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: "个人中心", href: "/profile" },
|
||||
{ name: "账号设置" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<p className="text-2xl leading-8 font-semibold text-white lg:text-[30px] lg:leading-9">账号设置</p>
|
||||
<p className="text-sm leading-5 text-[#9790A6]">管理个人资料、账号安全和通知偏好</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-[18px] lg:grid-cols-[minmax(0,1fr)_490px] lg:items-start">
|
||||
<section className={panelClass}>
|
||||
<h2 className="text-xl leading-[26px] font-semibold text-white">个人资料</h2>
|
||||
<ProfileEditForm className="flex flex-col gap-3.5 p-0" />
|
||||
</section>
|
||||
|
||||
<section className={`${panelClass} lg:min-h-[510px]`}>
|
||||
<h2 className="text-xl leading-[26px] font-semibold text-white">账号安全</h2>
|
||||
<div className="flex flex-col justify-center gap-4 rounded-2xl bg-[#222033]/41 p-4 lg:h-[110px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl leading-7 font-medium text-[#DDD9E5]">登录密码</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordOpen(true)}
|
||||
className="cursor-pointer text-xl leading-7 font-medium text-[#F12B9E] hover:opacity-80"
|
||||
>
|
||||
修改密码
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-base leading-[22px] text-[#A49AB6]">建议定期更新登录密码</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchAccount}
|
||||
className="cursor-pointer h-10 w-[130px] rounded-[20px] bg-white/6 text-sm leading-5 font-semibold text-[#F160B1] outline outline-1 -outline-offset-1 outline-[#F160B1]/35 transition-colors hover:bg-white/10"
|
||||
>
|
||||
切换账号
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<ChangePasswordDialog open={passwordOpen} onClose={closePassword} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
|
||||
export type MockProfile = {
|
||||
nickname: string;
|
||||
bio: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "hjsq-mock-profile";
|
||||
const CHANGE_EVENT = "hjsq-mock-profile-change";
|
||||
|
||||
export const defaultProfile: MockProfile = {
|
||||
nickname: "爽剧网用户",
|
||||
bio: "热爱短剧,记录每一次心动",
|
||||
avatar: null,
|
||||
};
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
window.addEventListener("storage", callback);
|
||||
window.addEventListener(CHANGE_EVENT, callback);
|
||||
return () => {
|
||||
window.removeEventListener("storage", callback);
|
||||
window.removeEventListener(CHANGE_EVENT, callback);
|
||||
};
|
||||
}
|
||||
|
||||
const getSnapshot = () => localStorage.getItem(STORAGE_KEY);
|
||||
const getServerSnapshot = () => null;
|
||||
|
||||
export function useMockProfile() {
|
||||
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const profile = useMemo<MockProfile>(() => {
|
||||
if (!raw) return defaultProfile;
|
||||
try {
|
||||
return { ...defaultProfile, ...(JSON.parse(raw) as Partial<MockProfile>) };
|
||||
} catch {
|
||||
return defaultProfile;
|
||||
}
|
||||
}, [raw]);
|
||||
|
||||
const saveProfile = useCallback((next: MockProfile) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...next, avatar: null }));
|
||||
}
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}, []);
|
||||
|
||||
return { profile, saveProfile };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { videoHref } from "@/components/video/video-card";
|
||||
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
|
||||
|
||||
export function VideoRelatedAside({ items }: { items: ShortDramaItem[] }) {
|
||||
const list = items.filter((item) => item.kind === "drama");
|
||||
if (!list.length) return null;
|
||||
|
||||
return (
|
||||
<aside data-component="video-related-aside" className="flex min-w-0 flex-1 flex-col gap-4 rounded-2xl bg-[rgba(24,23,46,0.88)] p-6 outline outline-1 -outline-offset-1 outline-[#383657]">
|
||||
<div className="flex h-9 items-center justify-between">
|
||||
<h2 className="text-[22px] font-bold text-white">热门推荐</h2>
|
||||
<Link href="/video/hot" className="flex items-center gap-1 text-xs leading-[18px] text-[#B8B2C2] hover:text-white">
|
||||
更多
|
||||
<img src="/images/more_arrow.svg" alt="" className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
{list.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href || videoHref(item.id, item.slug)}
|
||||
className="group flex h-24 items-center gap-4"
|
||||
>
|
||||
<DecryptedImage
|
||||
src={item.cover}
|
||||
fallback={IMG_PLACEHOLDER}
|
||||
alt=""
|
||||
className="h-24 w-[128px] shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h3 className="line-clamp-2 text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between text-xs leading-[18px]">
|
||||
<span className="truncate text-[#A89EB5]">{item.tags[0]?.name}</span>
|
||||
<span className="shrink-0 text-[#ABA1B8]">{item.heat}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import { reportVideoHistory } from "@/lib/api/video-actions";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
async function ensureToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 播放成功后上报观看历史;同一视频 id 只报一次 */
|
||||
export function useReportVideoHistory(videoId: number) {
|
||||
const reportedIdRef = useRef<number | null>(null);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (!Number.isInteger(videoId) || videoId < 1) return;
|
||||
if (reportedIdRef.current === videoId) return;
|
||||
reportedIdRef.current = videoId;
|
||||
|
||||
const token = await ensureToken();
|
||||
if (!token) {
|
||||
reportedIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await reportVideoHistory({ id: videoId }, token);
|
||||
} catch (error) {
|
||||
reportedIdRef.current = null;
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[video/history] report failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}, [videoId]);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { apiRequest } from "./client";
|
||||
import type {
|
||||
CommentLikeSetReq,
|
||||
CommentLikeSetResp,
|
||||
CommentListQuery,
|
||||
CommentListResp,
|
||||
CommentSendReq,
|
||||
} from "./types";
|
||||
|
||||
/** 客户端:评论列表 GET /api/app/comment/list */
|
||||
export function getCommentList(
|
||||
query: CommentListQuery,
|
||||
options?: { token?: string | null; signal?: AbortSignal },
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
objectId: String(query.objectId),
|
||||
objectType: String(query.objectType),
|
||||
pageNum: String(query.pageNum ?? 1),
|
||||
pageSize: String(query.pageSize ?? 6),
|
||||
sortOrder: String(query.sortOrder ?? 2),
|
||||
});
|
||||
|
||||
return apiRequest<CommentListResp>(`/api/app/comment/list?${params}`, {
|
||||
method: "GET",
|
||||
token: options?.token,
|
||||
signal: options?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:发布评论 POST /api/app/comment/send */
|
||||
export function sendComment(payload: CommentSendReq, token: string, signal?: AbortSignal) {
|
||||
return apiRequest<string>("/api/app/comment/send", {
|
||||
method: "POST",
|
||||
body: {
|
||||
objectId: payload.objectId,
|
||||
objectType: payload.objectType,
|
||||
text: payload.text,
|
||||
parentsId: payload.parentsId ?? 0,
|
||||
},
|
||||
token,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户端:评论点赞/取消点赞 POST /api/app/comment/like/set */
|
||||
export function setCommentLike(payload: CommentLikeSetReq, token: string, signal?: AbortSignal) {
|
||||
return apiRequest<CommentLikeSetResp>("/api/app/comment/like/set", {
|
||||
method: "POST",
|
||||
body: {
|
||||
commentId: payload.commentId,
|
||||
isLike: payload.isLike,
|
||||
},
|
||||
token,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user