全新popiart网站 react项目重构
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

593 lines
25 KiB

import { useCopyText } from "@/hooks/useCopyText";
import "./index.scss";
import { addCharacterLike, addUserFollow, delCharacterLike, deleteCharacter, deleteUserFollow, getCharacterDetail, updateCharacter } from "@/api/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dropdown, Input, Message, Modal } from "@arco-design/web-react";
import AvatarCropModal from "@/components/AvatarCropModal";
import { formatLocalDate } from "@/utils";
import { useUserStore } from "@/store/useUserStore";
import { useGlobalPromptImageStore } from "@/store/useMakeSameStore";
function formatTaskCreateDate(task: any): string {
const raw = task?.createTime;
if (raw == null || raw === "") return "";
const numeric = Number(raw);
const millis = Number.isFinite(numeric) ? (String(raw).length === 10 ? numeric * 1000 : numeric) : Number.NaN;
const date = Number.isFinite(millis) ? new Date(millis) : new Date(String(raw));
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
const hour = `${date.getHours()}`.padStart(2, "0");
const minute = `${date.getMinutes()}`.padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}`;
}
export interface CharacterDetailDialogProps {
id: string | number;
onClose?: () => void;
onUseCharacter?: () => void;
onDeleted?: (id: string | number) => void;
onUpdated?: (payload: {
id: string | number;
title: string;
description: string;
isPublic: boolean;
avatar: string;
}) => void;
}
export default function CharacterDetailDialog({
id: characterId,
onClose,
onUseCharacter,
onDeleted,
onUpdated,
}: CharacterDetailDialogProps) {
const { t } = useTranslation();
const user: any = useUserStore((s) => s.user);
const [railMenuOpen, setRailMenuOpen] = useState(false);
const [isEditingCharacterName, setIsEditingCharacterName] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [isSavingCharacterName, setIsSavingCharacterName] = useState(false);
const [isSavingDescription, setIsSavingDescription] = useState(false);
const [characterName, setCharacterName] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [cropModalImage, setCropModalImage] = useState("");
const [characterDetail, setCharacterDetail] = useState<any>({});
const skipNextNameBlurRef = useRef(false);
const skipNextIntroBlurRef = useRef(false);
const requestAppendImageUrl = useGlobalPromptImageStore((s) => s.requestAppendImageUrl);
const placeCaretAtEnd = useCallback((eventTarget: EventTarget | null) => {
if (!(eventTarget instanceof HTMLInputElement) && !(eventTarget instanceof HTMLTextAreaElement)) return;
const target = eventTarget;
const valueLength = target.value.length;
requestAnimationFrame(() => {
try {
target.setSelectionRange(valueLength, valueLength);
} catch {
// Some input modes may not support selection APIs.
}
});
}, []);
// 是否是自己的角色
const isOwnCharacter = useMemo(() => {
const uploadUserId = characterDetail?.uploadUserId;
const currentUserId = user?.id;
if (uploadUserId == null || currentUserId == null) return false;
return String(uploadUserId) === String(currentUserId);
}, [characterDetail?.uploadUserId, user?.id]);
// 是否用户上传角色(非官方、用户自创)
const isUserUploadCharacter = useMemo(() => {
return characterDetail?.characterType === 2;
}, [characterDetail?.characterType]);
const characterNameInputWidth = useMemo(() => {
const textLength = Array.from(characterName || "").length;
const computedWidth = textLength * 14 + 28;
return Math.min(200, Math.max(69, computedWidth));
}, [characterName]);
useEffect(() => {
if (characterId == null || characterId === "") return;
let cancelled = false;
void getCharacterDetail(characterId)
.then((res: any) => {
if (cancelled) return;
const detail = res?.data ?? {};
setCharacterDetail(detail);
setCharacterName(detail?.title ?? "");
setDescription(detail?.description ?? "");
setIsPublic(Boolean(detail?.isPublic));
})
.catch(() => {
if (cancelled) return;
setCharacterDetail({});
setCharacterName("");
setDescription("");
setIsPublic(false);
});
setIsEditingCharacterName(false);
setIsEditingDescription(false);
return () => {
cancelled = true;
};
}, [characterId]);
const copyText = useCopyText();
const addFollow = (userId: any) => {
void addUserFollow(userId).then((res: any) => {
if (res?.data) {
setCharacterDetail((prev: any) => ({
...prev,
userInfo: { ...prev.userInfo, userFollowed: true },
}));
}
});
};
const delFollow = (userId: any) => {
void deleteUserFollow(userId).then((res: any) => {
if (res?.data) {
setCharacterDetail((prev: any) => ({
...prev,
userInfo: { ...prev.userInfo, userFollowed: false },
}));
}
});
};
// 点赞
const toggleLike = () => {
if (!characterDetail.userLiked) {
void addCharacterLike(characterDetail.id).then((res: any) => {
if (res?.data) {
setCharacterDetail((prev: any) => ({
...prev,
userLiked: true,
likeNum: res?.data.likeNum,
}));
}
});
} else {
void delCharacterLike(characterDetail.id).then((res: any) => {
if (res?.data) {
setCharacterDetail((prev: any) => ({
...prev,
userLiked: false,
likeNum: res?.data.likeNum,
}));
}
});
}
};
// 下载
const handleDownload = () => {
window.open(characterDetail.threeViewImage, '_blank');
};
const handleDeleteCharacter = () => {
const id = characterDetail?.id ?? characterId;
if (id == null || id === "") {
Message.error(t("pages.charactersCreate.roleIdMissing"));
return;
}
if (!isUserUploadCharacter || !isOwnCharacter) return;
Modal.confirm({
title: t("pages.charactersCreate.confirmDeleteTitle"),
content: t("pages.charactersCreate.confirmDeleteContent"),
okText: t("pages.assetDetails.delete"),
cancelText: t("button.cancel"),
okButtonProps: { status: "danger" },
onOk: async () => {
try {
await deleteCharacter(id);
Message.success(t("pages.assetDetails.deleteSuccess"));
onDeleted?.(id);
onClose?.();
} catch {
Message.error(t("pages.assetDetails.deleteFailed"));
}
},
});
};
// 复制描述
const handleCopyDescription = () => {
void copyText(characterDetail?.description);
};
const submitCharacterUpdate = async (overrides?: Partial<{ characterName: string; description: string; isPublic: boolean; avatar: string }>) => {
const id = characterDetail?.id ?? characterId;
if (id == null || id === "") return false;
const payload = {
id,
characterName: overrides?.characterName ?? characterDetail?.title ?? "",
description: overrides?.description ?? characterDetail?.description ?? "",
isPublic: overrides?.isPublic ?? Boolean(characterDetail?.isPublic),
avatar: overrides?.avatar ?? characterDetail?.avatar ?? characterDetail?.headImage ?? "",
};
try {
await updateCharacter(payload);
setCharacterDetail((prev: any) => ({
...prev,
title: payload.characterName,
description: payload.description,
isPublic: payload.isPublic,
avatar: payload.avatar,
}));
onUpdated?.({
id,
title: payload.characterName,
description: payload.description,
isPublic: payload.isPublic,
avatar: payload.avatar,
});
setCharacterName(payload.characterName);
setDescription(payload.description);
setIsPublic(payload.isPublic);
return true;
} catch {
Message.error(t("pages.charactersCreate.updateFailed"));
return false;
}
};
const handleStartEditName = () => {
setCharacterName(characterDetail?.title ?? "");
setIsEditingCharacterName(true);
};
const handleCancelEditName = () => {
setCharacterName(characterDetail?.title ?? "");
setIsEditingCharacterName(false);
};
const handleFinishEditName = async () => {
if (!isEditingCharacterName || isSavingCharacterName) return;
const trimmedName = characterName.trim();
const fallbackName = characterDetail?.title ?? "";
const nextName = trimmedName || fallbackName;
if (nextName === fallbackName) {
setCharacterName(nextName);
setIsEditingCharacterName(false);
return;
}
setIsSavingCharacterName(true);
const ok = await submitCharacterUpdate({ characterName: nextName });
setIsSavingCharacterName(false);
if (ok) setIsEditingCharacterName(false);
};
const handleStartEditIntro = () => {
setDescription(characterDetail?.description ?? "");
setIsEditingDescription(true);
};
const handleCancelEditIntro = () => {
setDescription(characterDetail?.description ?? "");
setIsEditingDescription(false);
};
const handleFinishEditIntro = async () => {
if (!isEditingDescription || isSavingDescription) return;
const trimmedIntro = description.trim();
const fallbackIntro = characterDetail?.description ?? "";
const nextIntro = trimmedIntro || fallbackIntro;
if (nextIntro === fallbackIntro) {
setDescription(nextIntro);
setIsEditingDescription(false);
return;
}
setIsSavingDescription(true);
const ok = await submitCharacterUpdate({ description: nextIntro });
setIsSavingDescription(false);
if (ok) setIsEditingDescription(false);
};
const handleTogglePublic = () => {
setIsPublic((prev) => {
const next = !prev;
void submitCharacterUpdate({ isPublic: next });
return next;
});
};
const handleOpenAvatarCrop = () => {
if (!isOwnCharacter || !isUserUploadCharacter) return;
const sourceImage = String(characterDetail?.threeViewImage ?? "").trim();
if (!sourceImage) return;
setCropModalImage(sourceImage);
};
const promptQuickActions = ["5s 虚实结合 Vlog", "拿起角色手办", "剧情九宫格", "趣味表情包", "喝咖啡", "逛公园", "和爱丽丝聚餐~", "一张臭美照~"];
const mediaActions = ["生成视频", "超清", "细节修复"];
return (
<>
<div
className={`character_detail_dialog${isOwnCharacter ? " character_detail_dialog--own" : ""}${isUserUploadCharacter ? " character_detail_dialog--user-created" : ""
}`}
>
<div className="character_detail_dialog__left">
<div className="character_detail_dialog__previewInner">
<div className="character_detail_dialog__gradient">
<div className="character_detail_dialog__gradientHeader">
<p className="character_detail_dialog__gradientTitle">{t("character_detail.card_title")}</p>
<img className="character_detail_dialog__brandMark" src={new URL('/src/assets/images/logo-full-white.png', import.meta.url).href} alt="" width={63} height={16} />
</div>
<div className="character_detail_dialog__cardFrame">
<img className="character_detail_dialog__cardImage" src={characterDetail.threeViewImage} alt="" />
</div>
</div>
</div>
<div className="character_detail_dialog__railTriggerBlock">
<img
className="character_detail_dialog__railTriggerIcon"
src={new URL("/src/assets/images/ic_close.png", import.meta.url).href}
alt=""
onClick={() => onClose?.()}
/>
</div>
</div>
<aside className="character_detail_dialog__right">
<div className="character_detail_dialog__rightColumn">
<div className="character_detail_dialog__mainContent">
<div className="character_detail_dialog__authorRow">
{characterDetail.userInfo && isUserUploadCharacter ? (
<div className="character_detail_dialog__authorInfo">
<img className="character_detail_dialog__authorAvatar" src={characterDetail.userInfo.avatar} alt="" />
<p className="character_detail_dialog__authorName">{characterDetail.userInfo.name}</p>
{!characterDetail.userInfo.userFollowed ? (
<button type="button" className="character_detail_dialog__authorFollowBtn" onClick={() => addFollow(characterDetail.userInfo.id)}>
+{t("pages.profile.stats.following")}
</button>
) : (
<button
type="button"
className="character_detail_dialog__authorFollowBtn character_detail_dialog__authorFollowBtn_active"
onClick={() => delFollow(characterDetail.userInfo.userId)}
>
{t("pages.charactersCreate.followed")}
</button>
)}
</div>
) : (
<div />
)}
<div className="character_detail_dialog__authorActions">
<button type="button" className="character_detail_dialog__likeRow" onClick={() => { toggleLike() }}>
<img src={new URL(`/src/assets/images/${characterDetail.userLiked ? "ic_like_active" : "ic_like"}.png`, import.meta.url).href} alt="" />
{characterDetail.likeNum || 0}
</button>
<Dropdown
trigger="click"
position="bl"
popupVisible={railMenuOpen}
onVisibleChange={setRailMenuOpen}
droplist={
<div id="character-details-rail-menu" className="character_detail_dialog__floatMenu" role="menu" aria-label={t("character_detail.download")}>
<button type="button" className="character_detail_dialog__floatRow" role="menuitem" onClick={handleDownload}>
<img className="character_detail_dialog__floatRowIcon" src={new URL("/src/assets/images/ic_download.png", import.meta.url).href} alt="" />
{t("character_detail.download")}
</button>
{isUserUploadCharacter && isOwnCharacter ? (
<button type="button" className="character_detail_dialog__floatRow" role="menuitem" onClick={handleDeleteCharacter}>
<img className="character_detail_dialog__floatRowIcon" src={new URL("/src/assets/images/ic_delete.png", import.meta.url).href} alt="" />
{t("pages.assetDetails.delete")}
</button>
) : null}
</div>
}
>
<button
type="button"
className="character_detail_dialog__moreBtn"
aria-label={t("character_detail.download")}
aria-haspopup="menu"
aria-expanded={railMenuOpen}
aria-controls="character-details-rail-menu"
>
<img src={new URL("/src/assets/images/ic_more.png", import.meta.url).href} alt="" />
</button>
</Dropdown>
</div>
</div>
<div className="character_detail_dialog__metaHintRow">
<span className="character_detail_dialog__metaHintText">{formatLocalDate(characterDetail.createTime ?? characterDetail.taskInfo?.createTime)}</span>
<span className="character_detail_dialog__metaHintDivider" />
<span className="character_detail_dialog__metaHintText">AI生成</span>
</div>
<div className="character_detail_dialog__divider" role="presentation" />
<div className="character_detail_dialog__avatarSection">
<div className="character_detail_dialog__avatarPreview">
<img className="character_detail_dialog__avatarPreviewImage" src={characterDetail.avatar || characterDetail.headImage || characterDetail.threeViewImage} alt="" />
</div>
</div>
{false && isOwnCharacter && isUserUploadCharacter && (
<div className="character_detail_dialog__avatarUpdate">
<button type="button" className="character_detail_dialog__avatarUpdateBtn" onClick={handleOpenAvatarCrop}>
</button>
</div>
)}
<div className="character_detail_dialog__nameRow">
<div className="character_detail_dialog__nameValue">
{!isEditingCharacterName ? (
<>
<div className="character_detail_dialog__nameValueText">{characterDetail.title}</div>
{isOwnCharacter && <img
className="character_detail_dialog__edit"
src={new URL("/src/assets/images/ic_character_edit.png", import.meta.url).href}
alt=""
onClick={handleStartEditName}
/>}
</>
) : (
<div className="character_detail_dialog__nameEditField">
<Input
autoFocus
className="character_detail_dialog__nameInput"
style={{ width: `${characterNameInputWidth}px` }}
value={characterName}
onChange={setCharacterName}
onFocus={(event) => placeCaretAtEnd(event.target)}
onBlur={() => {
if (skipNextNameBlurRef.current) {
skipNextNameBlurRef.current = false;
return;
}
void handleFinishEditName();
}}
onPressEnter={() => { void handleFinishEditName(); }}
onKeyDown={(event) => {
if (event.key === "Escape") {
skipNextNameBlurRef.current = true;
handleCancelEditName();
}
}}
/>
</div>
)}
</div>
</div>
<div className="character_detail_dialog__intro">
<p className="character_detail_dialog__introTitle">{t("character_detail.role_intro_title")}</p>
<div className="character_detail_dialog__introBody">
{!isEditingDescription ? (
<>
<p className="character_detail_dialog__introText">{characterDetail.description}</p>
{isOwnCharacter && <img
className="character_detail_dialog__edit"
src={new URL("/src/assets/images/ic_character_edit.png", import.meta.url).href}
alt=""
onClick={handleStartEditIntro}
/>}
</>
) : (
<Input.TextArea
autoFocus
className="character_detail_dialog__introInput"
value={description}
onChange={setDescription}
onFocus={(event) => placeCaretAtEnd(event.target)}
onBlur={() => {
if (skipNextIntroBlurRef.current) {
skipNextIntroBlurRef.current = false;
return;
}
void handleFinishEditIntro();
}}
autoSize={{ minRows: 3, maxRows: 8 }}
onKeyDown={(event) => {
if (event.key === "Escape") {
skipNextIntroBlurRef.current = true;
handleCancelEditIntro();
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void handleFinishEditIntro();
}}
/>
)}
</div>
</div>
{isOwnCharacter && (
<div className="character_detail_dialog__toggleRow">
<span className="character_detail_dialog__toggleLabel">{t("pages.charactersCreate.createRoleModal.publicDisplay")}</span>
<button
type="button"
className="character_detail_dialog__toggleStatus"
onClick={handleTogglePublic}
aria-label={t("pages.charactersCreate.createRoleModal.publicDisplaySwitchAria")}
aria-pressed={isPublic}
>
<span className={`character_detail_dialog__toggleStatusPart${isPublic ? " character_detail_dialog__toggleStatusPart_active" : ""}`}></span>
<span className="character_detail_dialog__toggleStatusSlash"> / </span>
<span className={`character_detail_dialog__toggleStatusPart${!isPublic ? " character_detail_dialog__toggleStatusPart_active" : ""}`}></span>
</button>
</div>
)}
{characterDetail.taskInfo != null && (
<div className="character_detail_dialog__taskInlineMeta">
<span className="character_detail_dialog__taskInlineMetaItem">{characterDetail.taskInfo?.aiModelName ?? ""}</span>
<span className="character_detail_dialog__taskInlineMetaDivider" />
<span className="character_detail_dialog__taskInlineMetaItem">
{characterDetail.taskInfo?.width && characterDetail.taskInfo?.height
? `${characterDetail.taskInfo.width}P`
: ""}
</span>
<span className="character_detail_dialog__taskInlineMetaDivider" />
<span className="character_detail_dialog__taskInlineMetaItem">{characterDetail.taskInfo?.aspectRatio ?? ""}</span>
<span className="character_detail_dialog__taskInlineMetaDivider" />
<span className="character_detail_dialog__taskInlineMetaItem"></span>
</div>
)}
</div>
<div className="character_detail_dialog__sidebarFoot">
<div className="character_detail_dialog__actionPanel character_detail_dialog__actionPanel_prompt">
{promptQuickActions.map((label) => (
<button key={label} type="button" className="character_detail_dialog__panelActionBtn">
<span className="character_detail_dialog__panelActionIcon" />
<span className="character_detail_dialog__panelActionText">{label}</span>
</button>
))}
</div>
<div className="character_detail_dialog__actionPanel character_detail_dialog__actionPanel_media">
{mediaActions.map((label) => (
<button key={label} type="button" className="character_detail_dialog__panelActionBtn">
<span className="character_detail_dialog__panelActionIcon" />
<span className="character_detail_dialog__panelActionText">{label}</span>
</button>
))}
</div>
<div className="character_detail_dialog__actionPanel character_detail_dialog__actionPanel_primary">
<div
className="character_detail_dialog__panelActionBtn character_detail_dialog__panelActionBtn_active"
onClick={() => {
const refImage = String(characterDetail?.threeViewImage ?? "").trim();
const refTitle = String(characterDetail?.title ?? "").trim();
if (!refImage) return;
requestAppendImageUrl(refImage, refTitle, 103);
onClose?.();
}}
>
<span className="character_detail_dialog__panelActionIcon" />
<span className="character_detail_dialog__panelActionText">{t("character_detail.use_character")}</span>
</div>
<button type="button" className="character_detail_dialog__panelActionBtn" onClick={onUseCharacter}>
<span className="character_detail_dialog__panelActionIcon" />
<span className="character_detail_dialog__panelActionText"></span>
</button>
</div>
</div>
</div>
</aside>
</div>
<AvatarCropModal
image={cropModalImage}
onUploaded={(uploadedAvatarUrl) => {
setCharacterDetail((prev: any) => ({
...prev,
avatar: uploadedAvatarUrl,
}));
void submitCharacterUpdate({ avatar: uploadedAvatarUrl });
setCropModalImage("");
}}
onClose={() => setCropModalImage("")}
/>
</>
);
}