feat: 增加点赞和字体可调
This commit is contained in:
parent
a2c9f86df9
commit
3ed5f603c4
|
|
@ -5,6 +5,8 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { IconButton } from '@/components/ui/button';
|
import { IconButton } from '@/components/ui/button';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import LikedIcon from '@/components/features/LikedIcon';
|
||||||
|
import { LikeTargetType } from '@/services/editor/type';
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
@ -18,7 +20,11 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||||
<Link href="/home">
|
<Link href="/home">
|
||||||
<IconButton variant="ghost" size="large" iconfont="icon-arrow-left" />
|
<IconButton variant="ghost" size="large" iconfont="icon-arrow-left" />
|
||||||
</Link>
|
</Link>
|
||||||
<IconButton variant="tertiary" size="large" iconfont="icon-Like" />
|
<LikedIcon
|
||||||
|
iconProps={{ size: 'large' }}
|
||||||
|
objectId={id}
|
||||||
|
objectType={LikeTargetType.Character}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<div className="mx-auto flex-1 overflow-auto pb-4 w-full">
|
<div className="mx-auto flex-1 overflow-auto pb-4 w-full">
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import { useStreamChatStore } from '../stream-chat';
|
||||||
import IconFont from '@/components/ui/iconFont';
|
import IconFont from '@/components/ui/iconFont';
|
||||||
import MaskCreate from './MaskCreate';
|
import MaskCreate from './MaskCreate';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import LikedIcon from '@/components/features/LikedIcon';
|
||||||
|
import { LikeTargetType } from '@/services/editor/type';
|
||||||
|
|
||||||
type SettingProps = {
|
type SettingProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -42,6 +45,8 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
||||||
const t = useTranslations('chat.drawer');
|
const t = useTranslations('chat.drawer');
|
||||||
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
||||||
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const characterId = id.split('-')[2];
|
||||||
|
|
||||||
const titleMap = {
|
const titleMap = {
|
||||||
mask_list: t('maskedIdentityMode'),
|
mask_list: t('maskedIdentityMode'),
|
||||||
|
|
@ -66,7 +71,7 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
||||||
<AlertDialogContent className="max-w-[500px]" showCloseButton={activeTab === 'profile'}>
|
<AlertDialogContent className="max-w-[500px]" showCloseButton={activeTab === 'profile'}>
|
||||||
<AlertDialogTitle className="flex justify-between">
|
<AlertDialogTitle className="flex justify-between">
|
||||||
{activeTab === 'profile' ? (
|
{activeTab === 'profile' ? (
|
||||||
<IconButton variant="tertiary" size="small" iconfont="icon-Like" />
|
<LikedIcon objectId={characterId} objectType={LikeTargetType.Character} />
|
||||||
) : (
|
) : (
|
||||||
titleMap[activeTab]
|
titleMap[activeTab]
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { LikeTargetType, LikeType } from '@/services/editor/type';
|
||||||
|
import { IconButton } from '../ui/button';
|
||||||
|
import { useThmubObject } from '@/hooks/services/common';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type LikedIconProps = {
|
||||||
|
objectId: string;
|
||||||
|
objectType: LikeTargetType;
|
||||||
|
iconProps?: React.ComponentProps<typeof IconButton>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LikedIcon = React.memo((props: LikedIconProps) => {
|
||||||
|
const { objectId, objectType, iconProps } = props;
|
||||||
|
const { thumb, handleThumb, loading } = useThmubObject({ objectId, objectType });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
variant="tertiary"
|
||||||
|
size="small"
|
||||||
|
{...iconProps}
|
||||||
|
iconfont={thumb === LikeType.Liked ? 'icon-Like-fill' : 'icon-Like'}
|
||||||
|
onClick={() => {
|
||||||
|
if (loading) return;
|
||||||
|
handleThumb(thumb === LikeType.Liked ? LikeType.Canceled : LikeType.Liked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LikedIcon;
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useLayoutEffect, useRef } from 'react'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
|
|
||||||
interface AbandonCreationDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onCancel: () => void
|
|
||||||
onRegenerate: () => void
|
|
||||||
actionType?: 'exit' | 'regenerate'
|
|
||||||
}
|
|
||||||
|
|
||||||
const AbandonCreationDialog = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onCancel,
|
|
||||||
onRegenerate,
|
|
||||||
actionType = 'regenerate',
|
|
||||||
}: AbandonCreationDialogProps) => {
|
|
||||||
const router = useRouter()
|
|
||||||
// 保存对话框打开时的 actionType,确保关闭动画期间按钮文案不变
|
|
||||||
const actionTypeRef = useRef(actionType)
|
|
||||||
const prevOpenRef = useRef(open)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
// 当对话框打开时,持续更新 ref 为当前的 actionType
|
|
||||||
// 这样可以确保即使 actionType 在对话框打开后才更新,也能被捕获
|
|
||||||
if (open) {
|
|
||||||
actionTypeRef.current = actionType
|
|
||||||
}
|
|
||||||
prevOpenRef.current = open
|
|
||||||
}, [open, actionType])
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
onRegenerate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对话框打开时使用当前的 actionType(确保显示最新值),关闭动画期间使用 ref 保持文案不变
|
|
||||||
const displayActionType = open ? actionType : actionTypeRef.current
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Discard Generation</AlertDialogTitle>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
If you choose to exit or regenerate, the current images will be lost and one generation
|
|
||||||
attempt will be used.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={onCancel}>cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleConfirm}>
|
|
||||||
{displayActionType === 'exit' ? 'Discard' : 'Regenerate'}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AbandonCreationDialog
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '../ui/alert-dialog'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
const AIGenerateButton = ({
|
|
||||||
loading,
|
|
||||||
showAlert,
|
|
||||||
onConfirm,
|
|
||||||
className,
|
|
||||||
getNickName,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
loading?: boolean
|
|
||||||
showAlert?: boolean
|
|
||||||
onConfirm: (() => Promise<void> | void) | (() => void)
|
|
||||||
className?: string
|
|
||||||
getNickName?: () => string
|
|
||||||
disabled?: boolean
|
|
||||||
}) => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (disabled) return
|
|
||||||
if (getNickName && getNickName() === '') {
|
|
||||||
toast.error('Please enter nickname')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (showAlert) {
|
|
||||||
setOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
await onConfirm()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContinue = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
setOpen(false)
|
|
||||||
await onConfirm()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading || isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<i
|
|
||||||
className={cn(
|
|
||||||
'iconfont icon-loading !text-primary-variant-normal animate-spin !text-[14px] leading-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="text-primary-variant-normal txt-label-m">Generating...</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-1" onClick={handleConfirm}>
|
|
||||||
<span className="text-primary-variant-normal txt-label-m cursor-pointer">AI Generate</span>
|
|
||||||
</div>
|
|
||||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Warning</AlertDialogTitle>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Using AI generation will overwrite the content you have already entered. Do you want to
|
|
||||||
continue?
|
|
||||||
</AlertDialogDescription>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleContinue}>Continue</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export default AIGenerateButton
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useDeleteAlbumImage } from '@/hooks/aiUser'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '../ui/alert-dialog'
|
|
||||||
import { Button } from '../ui/button'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
const AlbumDeleteAlert = ({
|
|
||||||
aiId,
|
|
||||||
albumId,
|
|
||||||
isDefaultImage,
|
|
||||||
onDeleted,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
aiId: number
|
|
||||||
albumId: number
|
|
||||||
isDefaultImage: boolean
|
|
||||||
onDeleted?: () => void
|
|
||||||
children: React.ReactNode
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const deleteMutation = useDeleteAlbumImage()
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync({ aiId, albumId })
|
|
||||||
onDeleted?.()
|
|
||||||
setIsOpen(false)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Image</AlertDialogTitle>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{isDefaultImage
|
|
||||||
? 'The default cover image cannot be deleted. It will appear as your profile header, card cover, and chat background.'
|
|
||||||
: `Once deleted, the image cannot be restored. Users who have already unlocked this image will still be able to view it in the character's album.`}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
{isDefaultImage ? (
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogAction onClick={() => setIsOpen(false)}>Got it</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
) : (
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<Button variant="primary" loading={deleteMutation.isPending} onClick={handleDelete}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
)}
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AlbumDeleteAlert
|
|
||||||
|
|
@ -1,274 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '../ui/alert-dialog';
|
|
||||||
import { IconButton, Button } from '../ui/button';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
|
||||||
import { Input } from '../ui/input';
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
|
||||||
import Decimal from 'decimal.js';
|
|
||||||
|
|
||||||
export interface AlbumPriceFormData {
|
|
||||||
unlockMethod: 'pay' | 'free';
|
|
||||||
price?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = z
|
|
||||||
.object({
|
|
||||||
unlockMethod: z.enum(['pay', 'free']),
|
|
||||||
price: z.string().optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
// 如果选择了 "pay",price 必须填写且有效
|
|
||||||
if (data.unlockMethod === 'pay') {
|
|
||||||
if (!data.price || data.price.trim() === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (Number(data.price) < 20) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'At least 20 diamonds',
|
|
||||||
path: ['price'],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
interface AlbumPriceSettingProps {
|
|
||||||
defaultUnlockPrice?: number;
|
|
||||||
onConfirm: (data: AlbumPriceFormData) => Promise<void>;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AlbumPriceSetting = ({ defaultUnlockPrice, onConfirm, children }: AlbumPriceSettingProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
console.log('defaultUnlockPrice', defaultUnlockPrice);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
if (defaultUnlockPrice === undefined) {
|
|
||||||
form.setValue('unlockMethod', 'pay');
|
|
||||||
form.setValue('price', '20');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (defaultUnlockPrice) {
|
|
||||||
form.setValue('unlockMethod', 'pay');
|
|
||||||
form.setValue('price', new Decimal(defaultUnlockPrice).div(100).toString());
|
|
||||||
} else {
|
|
||||||
form.setValue('unlockMethod', 'free');
|
|
||||||
form.setValue('price', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen, defaultUnlockPrice]);
|
|
||||||
|
|
||||||
const form = useForm<AlbumPriceFormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
unlockMethod: 'pay',
|
|
||||||
price: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const unlockMethod = form.watch('unlockMethod');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (unlockMethod === 'free') {
|
|
||||||
const priceValue = form.getValues('price');
|
|
||||||
if (!priceValue) {
|
|
||||||
form.setValue('price', '20');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [unlockMethod]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: AlbumPriceFormData) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
console.log('保存数据:', data);
|
|
||||||
const result = {
|
|
||||||
unlockMethod: data.unlockMethod,
|
|
||||||
price: data.unlockMethod === 'pay' ? data.price : undefined,
|
|
||||||
};
|
|
||||||
await onConfirm(result);
|
|
||||||
setIsOpen(false);
|
|
||||||
form.reset();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存失败:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
form.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
{children || (
|
|
||||||
<IconButton variant="tertiary" size="small">
|
|
||||||
<i className="iconfont icon-icon-setting" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent className="bg-surface-base-normal w-[400px] rounded-2xl p-6">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="txt-title-l text-txt-primary-normal pl-10 text-center">
|
|
||||||
How to Unlock
|
|
||||||
</AlertDialogTitle>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 方法选择器 */}
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="unlockMethod"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="txt-label-m text-txt-primary-normal">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div>How to Unlock</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
<div>
|
|
||||||
<p className="break-words">
|
|
||||||
Users can unlock your character's images, giving you a share of
|
|
||||||
the revenue.
|
|
||||||
</p>
|
|
||||||
<p className="break-words">
|
|
||||||
Spicyxx.AI keeps 20% of each image's sales as a service fee.
|
|
||||||
</p>
|
|
||||||
<p className="break-words">
|
|
||||||
Offering a few free images can encourage users to interact with
|
|
||||||
your character.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="pay">Paid Unlock</SelectItem>
|
|
||||||
<SelectItem value="free">Free</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 价格输入框 */}
|
|
||||||
{form.watch('unlockMethod') === 'pay' && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="price"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="txt-label-m text-txt-primary-normal">
|
|
||||||
Unlock Price
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
value={field.value || ''}
|
|
||||||
type="text"
|
|
||||||
placeholder="20~9999"
|
|
||||||
onInput={(e) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
// 只允许输入数字
|
|
||||||
let value = target.value.replace(/[^0-9]/g, '');
|
|
||||||
|
|
||||||
// 如果为空,允许继续输入
|
|
||||||
if (value === '') {
|
|
||||||
target.value = '';
|
|
||||||
field.onChange('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为数字并限制范围
|
|
||||||
const numValue = parseInt(value);
|
|
||||||
if (numValue > 99999) {
|
|
||||||
value = '99999';
|
|
||||||
}
|
|
||||||
|
|
||||||
target.value = value;
|
|
||||||
field.onChange(value);
|
|
||||||
}}
|
|
||||||
prefixIcon={
|
|
||||||
<div className="flex items-center justify-center p-1">
|
|
||||||
<Image
|
|
||||||
src="/icons/diamond.svg"
|
|
||||||
alt="Diamond"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
error={!!form.formState.errors.price}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
|
|
||||||
<AlertDialogFooter className="flex gap-4 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="tertiary"
|
|
||||||
size="large"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
size="large"
|
|
||||||
className="flex-1"
|
|
||||||
loading={isLoading}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AlbumPriceSetting;
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { tokenManager } from '@/lib/auth/token'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
export function DeviceInfo() {
|
|
||||||
const [deviceId, setDeviceId] = useState<string>('')
|
|
||||||
const [token, setToken] = useState<string>('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 获取当前的设备ID和token
|
|
||||||
const currentDeviceId = tokenManager.getDeviceId()
|
|
||||||
const currentToken = tokenManager.getToken() || ''
|
|
||||||
|
|
||||||
setDeviceId(currentDeviceId)
|
|
||||||
setToken(currentToken)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const refreshDeviceId = () => {
|
|
||||||
// 生成新的设备ID
|
|
||||||
const newDeviceId = tokenManager.getDeviceId()
|
|
||||||
setDeviceId(newDeviceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAllData = () => {
|
|
||||||
tokenManager.clearAll()
|
|
||||||
setDeviceId('')
|
|
||||||
setToken('')
|
|
||||||
// 重新生成设备ID
|
|
||||||
setTimeout(() => {
|
|
||||||
const newDeviceId = tokenManager.getDeviceId()
|
|
||||||
setDeviceId(newDeviceId)
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>📱 设备信息</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Device ID (sd) Device ID (sd)</h4>
|
|
||||||
<div className="rounded border bg-gray-50 p-3 font-mono text-sm break-all">
|
|
||||||
{deviceId || '未生成'}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Stored in a cookie, field named'sd ', sent as AUTH_DID' in the request header
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-sm font-medium text-gray-700">
|
|
||||||
Authentication token (st) Authentication token (st)
|
|
||||||
</h4>
|
|
||||||
<div className="rounded border bg-gray-50 p-3 font-mono text-sm break-all">
|
|
||||||
{token || '未登录'}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Stored in a cookie, field named'st ', sent as AUTH_TK in the request header
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={refreshDeviceId} variant="secondary" size="small">
|
|
||||||
Refresh device ID
|
|
||||||
</Button>
|
|
||||||
<Button onClick={clearAllData} variant="secondary" size="small">
|
|
||||||
Clear all data
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm">
|
|
||||||
<h5 className="mb-1 font-medium text-blue-800">💡 设备ID说明</h5>
|
|
||||||
<ul className="space-y-1 text-xs text-blue-700">
|
|
||||||
<li>
|
|
||||||
Automatically generated when a user visits for the first time Automatically generated
|
|
||||||
when a user visits for the first time
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• Contains timestamp, random string and browser information • Contains timestamp,
|
|
||||||
random string and browser information
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Store in a cookie with a valid period of 365 days Store in a cookie with a valid
|
|
||||||
period of 365 days
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• For device identification and security verification • For device identification and
|
|
||||||
security verification
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• Will not be cleared when logging out (only clearAll will be cleared) • Will not be
|
|
||||||
cleared when logging out (only clearAll will be cleared)
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useAtomValue } from 'jotai'
|
|
||||||
import { imReconnectStatusAtom, IMReconnectStatus } from '@/atoms/im'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
interface IMReconnectStatusProps {
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMReconnectStatusBar: React.FC<IMReconnectStatusProps> = ({ className }) => {
|
|
||||||
const reconnectStatus = useAtomValue(imReconnectStatusAtom)
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
|
|
||||||
// 控制状态条的显示/隐藏
|
|
||||||
useEffect(() => {
|
|
||||||
if (reconnectStatus === IMReconnectStatus.CONNECTED) {
|
|
||||||
// 连接成功后,延迟隐藏状态条
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsVisible(false)
|
|
||||||
}, 2000)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
} else if (reconnectStatus !== IMReconnectStatus.DISCONNECTED) {
|
|
||||||
// 断线、重连中、失败时立即显示
|
|
||||||
setIsVisible(true)
|
|
||||||
}
|
|
||||||
}, [reconnectStatus])
|
|
||||||
|
|
||||||
// 不显示时返回null
|
|
||||||
if (!isVisible) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusConfig = () => {
|
|
||||||
switch (reconnectStatus) {
|
|
||||||
case IMReconnectStatus.DISCONNECTED:
|
|
||||||
return {
|
|
||||||
text: '连接已断开',
|
|
||||||
bgColor: 'bg-error-default',
|
|
||||||
textColor: 'text-white',
|
|
||||||
icon: 'icon-status-error',
|
|
||||||
}
|
|
||||||
case IMReconnectStatus.RECONNECTING:
|
|
||||||
return {
|
|
||||||
text: '正在重连...',
|
|
||||||
bgColor: 'bg-warning-default',
|
|
||||||
textColor: 'text-white',
|
|
||||||
icon: 'icon-loading',
|
|
||||||
animate: true,
|
|
||||||
}
|
|
||||||
case IMReconnectStatus.FAILED:
|
|
||||||
return {
|
|
||||||
text: '重连失败,请刷新页面',
|
|
||||||
bgColor: 'bg-error-default',
|
|
||||||
textColor: 'text-white',
|
|
||||||
icon: 'icon-status-error',
|
|
||||||
}
|
|
||||||
case IMReconnectStatus.CONNECTED:
|
|
||||||
return {
|
|
||||||
text: '连接已恢复',
|
|
||||||
bgColor: 'bg-success-default',
|
|
||||||
textColor: 'text-white',
|
|
||||||
icon: 'icon-check',
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusConfig = getStatusConfig()
|
|
||||||
|
|
||||||
if (!statusConfig) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed top-0 right-0 left-0 z-50 flex items-center justify-center px-4 py-2 text-sm font-medium transition-all duration-300 ease-in-out',
|
|
||||||
'bg-positive-normal',
|
|
||||||
// statusConfig.bgColor,
|
|
||||||
// statusConfig.textColor,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<i
|
|
||||||
className={cn(
|
|
||||||
'iconfont text-base',
|
|
||||||
statusConfig.icon,
|
|
||||||
statusConfig.animate && 'animate-spin'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{statusConfig.text}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IMReconnectStatusBar
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
|
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
// import './index.css'
|
// import './index.css'
|
||||||
|
|
||||||
type AITextRenderProps = {
|
type AITextRenderProps = {
|
||||||
text: string
|
text: string;
|
||||||
}
|
} & React.HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
// 定义解析后的节点类型
|
// 定义解析后的节点类型
|
||||||
type TextStyle = 'dialogue' | 'emphasis' | 'emphasis2' | 'supplement'
|
type TextStyle = 'dialogue' | 'emphasis' | 'emphasis2' | 'supplement';
|
||||||
|
|
||||||
type TextNode = {
|
type TextNode = {
|
||||||
styles: TextStyle[]
|
styles: TextStyle[];
|
||||||
content: string
|
content: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析文本为节点数组
|
* 解析文本为节点数组
|
||||||
|
|
@ -19,129 +21,129 @@ type TextNode = {
|
||||||
* @returns 解析后的节点数组
|
* @returns 解析后的节点数组
|
||||||
*/
|
*/
|
||||||
const parseText = (text: string): TextNode[] => {
|
const parseText = (text: string): TextNode[] => {
|
||||||
const nodes: TextNode[] = []
|
const nodes: TextNode[] = [];
|
||||||
const activeStyles = new Set<TextStyle>()
|
const activeStyles = new Set<TextStyle>();
|
||||||
let buffer = ''
|
let buffer = '';
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (!buffer) return
|
if (!buffer) return;
|
||||||
nodes.push({ styles: Array.from(activeStyles), content: buffer })
|
nodes.push({ styles: Array.from(activeStyles), content: buffer });
|
||||||
buffer = ''
|
buffer = '';
|
||||||
}
|
};
|
||||||
|
|
||||||
let i = 0
|
let i = 0;
|
||||||
while (i < text.length) {
|
while (i < text.length) {
|
||||||
const char = text[i]
|
const char = text[i];
|
||||||
|
|
||||||
// 换行符: \n
|
// 换行符: \n
|
||||||
if (char === '\n') {
|
if (char === '\n') {
|
||||||
flush()
|
flush();
|
||||||
nodes.push({ styles: [], content: '\n' })
|
nodes.push({ styles: [], content: '\n' });
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一级强调: *
|
// 一级强调: *
|
||||||
// 不保留标记符号
|
// 不保留标记符号
|
||||||
if (char === '*') {
|
if (char === '*') {
|
||||||
flush()
|
flush();
|
||||||
if (activeStyles.has('emphasis')) {
|
if (activeStyles.has('emphasis')) {
|
||||||
activeStyles.delete('emphasis')
|
activeStyles.delete('emphasis');
|
||||||
} else {
|
} else {
|
||||||
activeStyles.add('emphasis')
|
activeStyles.add('emphasis');
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 二级强调: ^
|
// 二级强调: ^
|
||||||
// 不保留标记符号
|
// 不保留标记符号
|
||||||
if (char === '^') {
|
if (char === '^') {
|
||||||
flush()
|
flush();
|
||||||
if (activeStyles.has('emphasis2')) {
|
if (activeStyles.has('emphasis2')) {
|
||||||
activeStyles.delete('emphasis2')
|
activeStyles.delete('emphasis2');
|
||||||
} else {
|
} else {
|
||||||
activeStyles.add('emphasis2')
|
activeStyles.add('emphasis2');
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对话: "" (英文双引号)
|
// 对话: "" (英文双引号)
|
||||||
if (char === '"') {
|
if (char === '"') {
|
||||||
if (activeStyles.has('dialogue')) {
|
if (activeStyles.has('dialogue')) {
|
||||||
// Closing: include quote, flush, remove style
|
// Closing: include quote, flush, remove style
|
||||||
buffer += char
|
buffer += char;
|
||||||
flush()
|
flush();
|
||||||
activeStyles.delete('dialogue')
|
activeStyles.delete('dialogue');
|
||||||
} else {
|
} else {
|
||||||
// Opening: flush prev, add style, include quote
|
// Opening: flush prev, add style, include quote
|
||||||
flush()
|
flush();
|
||||||
activeStyles.add('dialogue')
|
activeStyles.add('dialogue');
|
||||||
buffer += char
|
buffer += char;
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对话: “” (中文双引号)
|
// 对话: “” (中文双引号)
|
||||||
if (char === '“') {
|
if (char === '“') {
|
||||||
flush()
|
flush();
|
||||||
activeStyles.add('dialogue')
|
activeStyles.add('dialogue');
|
||||||
buffer += char
|
buffer += char;
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (char === '”') {
|
if (char === '”') {
|
||||||
buffer += char
|
buffer += char;
|
||||||
flush()
|
flush();
|
||||||
activeStyles.delete('dialogue')
|
activeStyles.delete('dialogue');
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对话:「」
|
// 对话:「」
|
||||||
if (char === '「') {
|
if (char === '「') {
|
||||||
flush()
|
flush();
|
||||||
activeStyles.add('dialogue')
|
activeStyles.add('dialogue');
|
||||||
buffer += char
|
buffer += char;
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (char === '」') {
|
if (char === '」') {
|
||||||
buffer += char
|
buffer += char;
|
||||||
flush()
|
flush();
|
||||||
activeStyles.delete('dialogue')
|
activeStyles.delete('dialogue');
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 补充: () or ()
|
// 补充: () or ()
|
||||||
if (char === '(' || char === '(') {
|
if (char === '(' || char === '(') {
|
||||||
flush()
|
flush();
|
||||||
activeStyles.add('supplement')
|
activeStyles.add('supplement');
|
||||||
buffer += char
|
buffer += char;
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (char === ')' || char === ')') {
|
if (char === ')' || char === ')') {
|
||||||
buffer += char
|
buffer += char;
|
||||||
flush()
|
flush();
|
||||||
activeStyles.delete('supplement')
|
activeStyles.delete('supplement');
|
||||||
i += 1
|
i += 1;
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer += char
|
buffer += char;
|
||||||
i += 1
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
flush()
|
flush();
|
||||||
|
|
||||||
return nodes
|
return nodes;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染单个节点
|
* 渲染单个节点
|
||||||
|
|
@ -150,55 +152,65 @@ const parseText = (text: string): TextNode[] => {
|
||||||
* @returns React 元素
|
* @returns React 元素
|
||||||
*/
|
*/
|
||||||
const renderNode = (node: TextNode, index: number): React.ReactNode => {
|
const renderNode = (node: TextNode, index: number): React.ReactNode => {
|
||||||
const { styles, content } = node
|
const { styles, content } = node;
|
||||||
|
|
||||||
// 处理换行符
|
// 处理换行符
|
||||||
if (content === '\n') {
|
if (content === '\n') {
|
||||||
return <br key={index} />
|
return <br key={index} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let className = ''
|
let className = '';
|
||||||
|
|
||||||
if (styles.length === 0) {
|
if (styles.length === 0) {
|
||||||
return <span key={index}>{content}</span>
|
return <span key={index}>{content}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 叠加样式
|
// 叠加样式
|
||||||
if (styles.includes('dialogue')) {
|
if (styles.includes('dialogue')) {
|
||||||
className += ' text-[rgba(255,236,159,1)]'
|
className += ' text-[rgba(255,236,159,1)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styles.includes('emphasis')) {
|
if (styles.includes('emphasis')) {
|
||||||
className += ' font-semibold'
|
className += ' font-semibold';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styles.includes('emphasis2')) {
|
if (styles.includes('emphasis2')) {
|
||||||
className += ' underline'
|
className += ' underline';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styles.includes('supplement')) {
|
if (styles.includes('supplement')) {
|
||||||
className += ' text-[rgba(181,177,207,1)]'
|
className += ' text-[rgba(181,177,207,1)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span key={index} className={className.trim()}>
|
<span key={index} className={className.trim()}>
|
||||||
{content}
|
{content}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 文本渲染组件
|
* AI 文本渲染组件
|
||||||
*/
|
*/
|
||||||
const AITextRender = React.memo(({ text }: AITextRenderProps) => {
|
const AITextRender = React.memo(({ text, ...props }: AITextRenderProps) => {
|
||||||
// 1. 解析文本
|
// 1. 解析文本
|
||||||
const nodes = parseText(text)
|
const nodes = parseText(text);
|
||||||
|
const font = useStreamChatStore((store) => store.chatSetting.font);
|
||||||
|
const fontClass =
|
||||||
|
{
|
||||||
|
12: 'text-xs',
|
||||||
|
14: 'text-sm',
|
||||||
|
16: 'text-base',
|
||||||
|
18: 'text-lg',
|
||||||
|
20: 'text-xl',
|
||||||
|
}[font] || 'text-base';
|
||||||
|
|
||||||
// 2. 渲染节点
|
// 2. 渲染节点
|
||||||
// 这里的 className "ai-text-render" 对应 CSS 中的根类,用于控制全局字体、行高等
|
|
||||||
return (
|
return (
|
||||||
<span className="ai-text-render">{nodes.map((node, index) => renderNode(node, index))}</span>
|
<span {...props} className={cn(fontClass, props.className)}>
|
||||||
)
|
{nodes.map((node, index) => renderNode(node, index))}
|
||||||
})
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default AITextRender
|
export default AITextRender;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { getLikeStatus, thmubObject } from '@/services/editor';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { authKeys } from '@/lib/query-keys';
|
||||||
|
import { LikeObjectParamsType, LikeType } from '@/services/editor/type';
|
||||||
|
import { useAsyncFn } from '../tools';
|
||||||
|
|
||||||
|
export function useThmubObject(props: Pick<LikeObjectParamsType, 'objectId' | 'objectType'>) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [thumb, setThumb] = useState<LikeType>();
|
||||||
|
const user = queryClient.getQueryData(authKeys.currentUser()) as any;
|
||||||
|
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['likeStatus', props.objectId, user.userId],
|
||||||
|
enabled: !!props.objectId && !!user.userId,
|
||||||
|
queryFn: () => {
|
||||||
|
return getLikeStatus({ objectId: props.objectId, userId: user.userId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { run: handleThumb, loading } = useAsyncFn(async (likeType: LikeType) => {
|
||||||
|
setThumb(likeType);
|
||||||
|
const user = queryClient.getQueryData(authKeys.currentUser()) as any;
|
||||||
|
const { data } = await thmubObject({ ...props, likeType, userId: user?.userId });
|
||||||
|
if (data.code === 200) {
|
||||||
|
} else {
|
||||||
|
setThumb(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumb,
|
||||||
|
loading,
|
||||||
|
handleThumb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { editorRequest } from '@/lib/client';
|
import { editorRequest } from '@/lib/client';
|
||||||
|
import { LikeObjectParamsType } from './type';
|
||||||
|
|
||||||
export async function fetchCharacters({ index, limit, query }: any) {
|
export async function fetchCharacters({ index, limit, query }: any) {
|
||||||
const { data } = await editorRequest('/api/character/list', {
|
const { data } = await editorRequest('/api/character/list', {
|
||||||
|
|
@ -16,3 +17,12 @@ export async function fetchCharacter(params: any) {
|
||||||
export async function fetchCharacterTags(params: any = {}) {
|
export async function fetchCharacterTags(params: any = {}) {
|
||||||
return editorRequest('/api/tag/list', { method: 'POST', data: params });
|
return editorRequest('/api/tag/list', { method: 'POST', data: params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function thmubObject(params: LikeObjectParamsType) {
|
||||||
|
return editorRequest('/api/like/likeOrCancel', { method: 'POST', data: params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLikeStatus(params: Pick<LikeObjectParamsType, 'objectId' | 'userId'>) {
|
||||||
|
const { data } = await editorRequest('/api/like/getLikeStatus', { method: 'POST', data: params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,18 @@ export type CharacterType = {
|
||||||
}[];
|
}[];
|
||||||
[x: string]: any;
|
[x: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum LikeTargetType {
|
||||||
|
Character = 0,
|
||||||
|
Story = 1,
|
||||||
|
}
|
||||||
|
export enum LikeType {
|
||||||
|
Liked = 0,
|
||||||
|
Canceled = 1,
|
||||||
|
}
|
||||||
|
export type LikeObjectParamsType = {
|
||||||
|
objectId?: string;
|
||||||
|
objectType?: LikeTargetType;
|
||||||
|
userId?: number;
|
||||||
|
likeType?: LikeType;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue