feat: 增加点赞和字体可调

This commit is contained in:
liuyonghe0111 2025-12-23 19:35:48 +08:00
parent a2c9f86df9
commit 3ed5f603c4
13 changed files with 208 additions and 817 deletions

View File

@ -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">

View File

@ -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]
)} )}

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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>
)
}

View File

@ -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

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;
};