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 Link from 'next/link';
|
||||
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 }> }) {
|
||||
const { id } = await params;
|
||||
|
|
@ -18,7 +20,11 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||
<Link href="/home">
|
||||
<IconButton variant="ghost" size="large" iconfont="icon-arrow-left" />
|
||||
</Link>
|
||||
<IconButton variant="tertiary" size="large" iconfont="icon-Like" />
|
||||
<LikedIcon
|
||||
iconProps={{ size: 'large' }}
|
||||
objectId={id}
|
||||
objectType={LikeTargetType.Character}
|
||||
/>
|
||||
</div>
|
||||
{/* 内容区 */}
|
||||
<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 MaskCreate from './MaskCreate';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useParams } from 'next/navigation';
|
||||
import LikedIcon from '@/components/features/LikedIcon';
|
||||
import { LikeTargetType } from '@/services/editor/type';
|
||||
|
||||
type SettingProps = {
|
||||
open: boolean;
|
||||
|
|
@ -42,6 +45,8 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
|||
const t = useTranslations('chat.drawer');
|
||||
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
||||
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const characterId = id.split('-')[2];
|
||||
|
||||
const titleMap = {
|
||||
mask_list: t('maskedIdentityMode'),
|
||||
|
|
@ -66,7 +71,7 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
|||
<AlertDialogContent className="max-w-[500px]" showCloseButton={activeTab === 'profile'}>
|
||||
<AlertDialogTitle className="flex justify-between">
|
||||
{activeTab === 'profile' ? (
|
||||
<IconButton variant="tertiary" size="small" iconfont="icon-Like" />
|
||||
<LikedIcon objectId={characterId} objectType={LikeTargetType.Character} />
|
||||
) : (
|
||||
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'
|
||||
|
||||
type AITextRenderProps = {
|
||||
text: string
|
||||
}
|
||||
text: string;
|
||||
} & React.HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
// 定义解析后的节点类型
|
||||
type TextStyle = 'dialogue' | 'emphasis' | 'emphasis2' | 'supplement'
|
||||
type TextStyle = 'dialogue' | 'emphasis' | 'emphasis2' | 'supplement';
|
||||
|
||||
type TextNode = {
|
||||
styles: TextStyle[]
|
||||
content: string
|
||||
}
|
||||
styles: TextStyle[];
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析文本为节点数组
|
||||
|
|
@ -19,129 +21,129 @@ type TextNode = {
|
|||
* @returns 解析后的节点数组
|
||||
*/
|
||||
const parseText = (text: string): TextNode[] => {
|
||||
const nodes: TextNode[] = []
|
||||
const activeStyles = new Set<TextStyle>()
|
||||
let buffer = ''
|
||||
const nodes: TextNode[] = [];
|
||||
const activeStyles = new Set<TextStyle>();
|
||||
let buffer = '';
|
||||
|
||||
const flush = () => {
|
||||
if (!buffer) return
|
||||
nodes.push({ styles: Array.from(activeStyles), content: buffer })
|
||||
buffer = ''
|
||||
}
|
||||
if (!buffer) return;
|
||||
nodes.push({ styles: Array.from(activeStyles), content: buffer });
|
||||
buffer = '';
|
||||
};
|
||||
|
||||
let i = 0
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const char = text[i]
|
||||
const char = text[i];
|
||||
|
||||
// 换行符: \n
|
||||
if (char === '\n') {
|
||||
flush()
|
||||
nodes.push({ styles: [], content: '\n' })
|
||||
i += 1
|
||||
continue
|
||||
flush();
|
||||
nodes.push({ styles: [], content: '\n' });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 一级强调: *
|
||||
// 不保留标记符号
|
||||
if (char === '*') {
|
||||
flush()
|
||||
flush();
|
||||
if (activeStyles.has('emphasis')) {
|
||||
activeStyles.delete('emphasis')
|
||||
activeStyles.delete('emphasis');
|
||||
} else {
|
||||
activeStyles.add('emphasis')
|
||||
activeStyles.add('emphasis');
|
||||
}
|
||||
i += 1
|
||||
continue
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 二级强调: ^
|
||||
// 不保留标记符号
|
||||
if (char === '^') {
|
||||
flush()
|
||||
flush();
|
||||
if (activeStyles.has('emphasis2')) {
|
||||
activeStyles.delete('emphasis2')
|
||||
activeStyles.delete('emphasis2');
|
||||
} else {
|
||||
activeStyles.add('emphasis2')
|
||||
activeStyles.add('emphasis2');
|
||||
}
|
||||
i += 1
|
||||
continue
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对话: "" (英文双引号)
|
||||
if (char === '"') {
|
||||
if (activeStyles.has('dialogue')) {
|
||||
// Closing: include quote, flush, remove style
|
||||
buffer += char
|
||||
flush()
|
||||
activeStyles.delete('dialogue')
|
||||
buffer += char;
|
||||
flush();
|
||||
activeStyles.delete('dialogue');
|
||||
} else {
|
||||
// Opening: flush prev, add style, include quote
|
||||
flush()
|
||||
activeStyles.add('dialogue')
|
||||
buffer += char
|
||||
flush();
|
||||
activeStyles.add('dialogue');
|
||||
buffer += char;
|
||||
}
|
||||
i += 1
|
||||
continue
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对话: “” (中文双引号)
|
||||
if (char === '“') {
|
||||
flush()
|
||||
activeStyles.add('dialogue')
|
||||
buffer += char
|
||||
i += 1
|
||||
continue
|
||||
flush();
|
||||
activeStyles.add('dialogue');
|
||||
buffer += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '”') {
|
||||
buffer += char
|
||||
flush()
|
||||
activeStyles.delete('dialogue')
|
||||
i += 1
|
||||
continue
|
||||
buffer += char;
|
||||
flush();
|
||||
activeStyles.delete('dialogue');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对话:「」
|
||||
if (char === '「') {
|
||||
flush()
|
||||
activeStyles.add('dialogue')
|
||||
buffer += char
|
||||
i += 1
|
||||
continue
|
||||
flush();
|
||||
activeStyles.add('dialogue');
|
||||
buffer += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '」') {
|
||||
buffer += char
|
||||
flush()
|
||||
activeStyles.delete('dialogue')
|
||||
i += 1
|
||||
continue
|
||||
buffer += char;
|
||||
flush();
|
||||
activeStyles.delete('dialogue');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 补充: () or ()
|
||||
if (char === '(' || char === '(') {
|
||||
flush()
|
||||
activeStyles.add('supplement')
|
||||
buffer += char
|
||||
i += 1
|
||||
continue
|
||||
flush();
|
||||
activeStyles.add('supplement');
|
||||
buffer += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ')' || char === ')') {
|
||||
buffer += char
|
||||
flush()
|
||||
activeStyles.delete('supplement')
|
||||
i += 1
|
||||
continue
|
||||
buffer += char;
|
||||
flush();
|
||||
activeStyles.delete('supplement');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += char
|
||||
i += 1
|
||||
buffer += char;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
flush()
|
||||
flush();
|
||||
|
||||
return nodes
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染单个节点
|
||||
|
|
@ -150,55 +152,65 @@ const parseText = (text: string): TextNode[] => {
|
|||
* @returns React 元素
|
||||
*/
|
||||
const renderNode = (node: TextNode, index: number): React.ReactNode => {
|
||||
const { styles, content } = node
|
||||
const { styles, content } = node;
|
||||
|
||||
// 处理换行符
|
||||
if (content === '\n') {
|
||||
return <br key={index} />
|
||||
return <br key={index} />;
|
||||
}
|
||||
|
||||
let className = ''
|
||||
let className = '';
|
||||
|
||||
if (styles.length === 0) {
|
||||
return <span key={index}>{content}</span>
|
||||
return <span key={index}>{content}</span>;
|
||||
}
|
||||
|
||||
// 叠加样式
|
||||
if (styles.includes('dialogue')) {
|
||||
className += ' text-[rgba(255,236,159,1)]'
|
||||
className += ' text-[rgba(255,236,159,1)]';
|
||||
}
|
||||
|
||||
if (styles.includes('emphasis')) {
|
||||
className += ' font-semibold'
|
||||
className += ' font-semibold';
|
||||
}
|
||||
|
||||
if (styles.includes('emphasis2')) {
|
||||
className += ' underline'
|
||||
className += ' underline';
|
||||
}
|
||||
|
||||
if (styles.includes('supplement')) {
|
||||
className += ' text-[rgba(181,177,207,1)]'
|
||||
className += ' text-[rgba(181,177,207,1)]';
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={index} className={className.trim()}>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 文本渲染组件
|
||||
*/
|
||||
const AITextRender = React.memo(({ text }: AITextRenderProps) => {
|
||||
const AITextRender = React.memo(({ text, ...props }: AITextRenderProps) => {
|
||||
// 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. 渲染节点
|
||||
// 这里的 className "ai-text-render" 对应 CSS 中的根类,用于控制全局字体、行高等
|
||||
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 { LikeObjectParamsType } from './type';
|
||||
|
||||
export async function fetchCharacters({ index, limit, query }: any) {
|
||||
const { data } = await editorRequest('/api/character/list', {
|
||||
|
|
@ -16,3 +17,12 @@ export async function fetchCharacter(params: any) {
|
|||
export async function fetchCharacterTags(params: any = {}) {
|
||||
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;
|
||||
};
|
||||
|
||||
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