整体UI调整

This commit is contained in:
谢宇宁
2026-10-26 19:51:53 +07:00
parent 61d0a11dfa
commit c8895f407c
59 changed files with 0 additions and 4104 deletions
-12
View File
@@ -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 />;
}
-12
View File
@@ -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 />;
}
-12
View File
@@ -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 />;
}
-12
View File
@@ -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 />;
}
-12
View File
@@ -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 />;
}
-12
View File
@@ -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>
);
}
-82
View File
@@ -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>
);
}
-25
View File
@@ -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,
};
}
-306
View File
@@ -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]);
}
-46
View File
@@ -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(),
};
}
-221
View File
@@ -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>
);
}
-58
View File
@@ -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),
};
}
-310
View File
@@ -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>
);
}
-220
View File
@@ -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>
);
}
-235
View File
@@ -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>
);
}
-68
View File
@@ -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>
);
}
-69
View File
@@ -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>
);
}
-110
View File
@@ -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]);
}
-56
View File
@@ -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,
});
}