From 3ed5f603c4ec4618e07a02cb8bb4fe64e9ea1dbe Mon Sep 17 00:00:00 2001
From: liuyonghe0111 <1763195287@qq.com>
Date: Tue, 23 Dec 2025 19:35:48 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=82=B9=E8=B5=9E?=
=?UTF-8?q?=E5=92=8C=E5=AD=97=E4=BD=93=E5=8F=AF=E8=B0=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/(main)/character/[id]/page.tsx | 8 +-
src/app/(main)/chat/[id]/Drawer/index.tsx | 7 +-
src/components/features/LikedIcon.tsx | 32 ++
.../features/abandon-creation-dialog.tsx | 73 -----
.../features/ai-generate-button.tsx | 101 -------
.../features/album-delete-alert.tsx | 75 -----
.../features/album-price-setting.tsx | 274 ------------------
src/components/features/device-info.tsx | 103 -------
.../features/im-reconnect-status.tsx | 99 -------
src/components/ui/text-md.tsx | 192 ++++++------
src/hooks/services/common.ts | 36 +++
src/services/editor/index.ts | 10 +
src/services/editor/{type.d.ts => type.ts} | 15 +
13 files changed, 208 insertions(+), 817 deletions(-)
create mode 100644 src/components/features/LikedIcon.tsx
delete mode 100644 src/components/features/abandon-creation-dialog.tsx
delete mode 100644 src/components/features/ai-generate-button.tsx
delete mode 100644 src/components/features/album-delete-alert.tsx
delete mode 100644 src/components/features/album-price-setting.tsx
delete mode 100644 src/components/features/device-info.tsx
delete mode 100644 src/components/features/im-reconnect-status.tsx
create mode 100644 src/hooks/services/common.ts
rename src/services/editor/{type.d.ts => type.ts} (61%)
diff --git a/src/app/(main)/character/[id]/page.tsx b/src/app/(main)/character/[id]/page.tsx
index fd5a643..3d345b5 100644
--- a/src/app/(main)/character/[id]/page.tsx
+++ b/src/app/(main)/character/[id]/page.tsx
@@ -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 }>
-
+
{/* 内容区 */}
diff --git a/src/app/(main)/chat/[id]/Drawer/index.tsx b/src/app/(main)/chat/[id]/Drawer/index.tsx
index 33b1014..fcf3d5e 100644
--- a/src/app/(main)/chat/[id]/Drawer/index.tsx
+++ b/src/app/(main)/chat/[id]/Drawer/index.tsx
@@ -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
('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) {
{activeTab === 'profile' ? (
-
+
) : (
titleMap[activeTab]
)}
diff --git a/src/components/features/LikedIcon.tsx b/src/components/features/LikedIcon.tsx
new file mode 100644
index 0000000..70e2609
--- /dev/null
+++ b/src/components/features/LikedIcon.tsx
@@ -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;
+};
+
+const LikedIcon = React.memo((props: LikedIconProps) => {
+ const { objectId, objectType, iconProps } = props;
+ const { thumb, handleThumb, loading } = useThmubObject({ objectId, objectType });
+
+ return (
+ {
+ if (loading) return;
+ handleThumb(thumb === LikeType.Liked ? LikeType.Canceled : LikeType.Liked);
+ }}
+ />
+ );
+});
+
+export default LikedIcon;
diff --git a/src/components/features/abandon-creation-dialog.tsx b/src/components/features/abandon-creation-dialog.tsx
deleted file mode 100644
index af5bd7b..0000000
--- a/src/components/features/abandon-creation-dialog.tsx
+++ /dev/null
@@ -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 (
-
-
-
- Discard Generation
-
-
- If you choose to exit or regenerate, the current images will be lost and one generation
- attempt will be used.
-
-
- cancel
-
- {displayActionType === 'exit' ? 'Discard' : 'Regenerate'}
-
-
-
-
- )
-}
-
-export default AbandonCreationDialog
diff --git a/src/components/features/ai-generate-button.tsx b/src/components/features/ai-generate-button.tsx
deleted file mode 100644
index 6ecdce8..0000000
--- a/src/components/features/ai-generate-button.tsx
+++ /dev/null
@@ -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)
- 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 (
-
-
- Generating...
-
- )
- }
-
- return (
- <>
-
- AI Generate
-
-
-
-
- Warning
-
-
- Using AI generation will overwrite the content you have already entered. Do you want to
- continue?
-
-
- Cancel
- Continue
-
-
-
- >
- )
-}
-export default AIGenerateButton
diff --git a/src/components/features/album-delete-alert.tsx b/src/components/features/album-delete-alert.tsx
deleted file mode 100644
index e908d18..0000000
--- a/src/components/features/album-delete-alert.tsx
+++ /dev/null
@@ -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 (
-
- {children}
-
-
- Delete Image
-
-
- {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.`}
-
- {isDefaultImage ? (
-
- setIsOpen(false)}>Got it
-
- ) : (
-
- Cancel
-
-
- )}
-
-
- )
-}
-
-export default AlbumDeleteAlert
diff --git a/src/components/features/album-price-setting.tsx b/src/components/features/album-price-setting.tsx
deleted file mode 100644
index 47fbc89..0000000
--- a/src/components/features/album-price-setting.tsx
+++ /dev/null
@@ -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;
- 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({
- 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 (
-
-
- {children || (
-
-
-
- )}
-
-
-
-
- How to Unlock
-
-
-
-
-
-
-
- );
-};
-
-export default AlbumPriceSetting;
diff --git a/src/components/features/device-info.tsx b/src/components/features/device-info.tsx
deleted file mode 100644
index efc5544..0000000
--- a/src/components/features/device-info.tsx
+++ /dev/null
@@ -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('')
- const [token, setToken] = useState('')
-
- 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 (
-
-
- 📱 设备信息
-
-
-
-
Device ID (sd) Device ID (sd)
-
- {deviceId || '未生成'}
-
-
- Stored in a cookie, field named'sd ', sent as AUTH_DID' in the request header
-
-
-
-
-
- Authentication token (st) Authentication token (st)
-
-
- {token || '未登录'}
-
-
- Stored in a cookie, field named'st ', sent as AUTH_TK in the request header
-
-
-
-
-
-
-
-
-
-
💡 设备ID说明
-
- -
- Automatically generated when a user visits for the first time Automatically generated
- when a user visits for the first time
-
- -
- • Contains timestamp, random string and browser information • Contains timestamp,
- random string and browser information
-
- -
- Store in a cookie with a valid period of 365 days Store in a cookie with a valid
- period of 365 days
-
- -
- • For device identification and security verification • For device identification and
- security verification
-
- -
- • Will not be cleared when logging out (only clearAll will be cleared) • Will not be
- cleared when logging out (only clearAll will be cleared)
-
-
-
-
-
- )
-}
diff --git a/src/components/features/im-reconnect-status.tsx b/src/components/features/im-reconnect-status.tsx
deleted file mode 100644
index 9561913..0000000
--- a/src/components/features/im-reconnect-status.tsx
+++ /dev/null
@@ -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 = ({ 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 (
-
-
-
- {statusConfig.text}
-
-
- )
-}
-
-export default IMReconnectStatusBar
diff --git a/src/components/ui/text-md.tsx b/src/components/ui/text-md.tsx
index b3a46f2..87d6582 100644
--- a/src/components/ui/text-md.tsx
+++ b/src/components/ui/text-md.tsx
@@ -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;
// 定义解析后的节点类型
-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()
- let buffer = ''
+ const nodes: TextNode[] = [];
+ const activeStyles = new Set();
+ 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
+ return
;
}
- let className = ''
+ let className = '';
if (styles.length === 0) {
- return {content}
+ return {content};
}
// 叠加样式
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 (
{content}
- )
-}
+ );
+};
/**
* 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 (
- {nodes.map((node, index) => renderNode(node, index))}
- )
-})
+
+ {nodes.map((node, index) => renderNode(node, index))}
+
+ );
+});
-export default AITextRender
+export default AITextRender;
diff --git a/src/hooks/services/common.ts b/src/hooks/services/common.ts
new file mode 100644
index 0000000..817d2e6
--- /dev/null
+++ b/src/hooks/services/common.ts
@@ -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) {
+ const queryClient = useQueryClient();
+ const [thumb, setThumb] = useState();
+ 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,
+ };
+}
diff --git a/src/services/editor/index.ts b/src/services/editor/index.ts
index e26c8a7..e8d6f96 100644
--- a/src/services/editor/index.ts
+++ b/src/services/editor/index.ts
@@ -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) {
+ const { data } = await editorRequest('/api/like/getLikeStatus', { method: 'POST', data: params });
+ return data;
+}
diff --git a/src/services/editor/type.d.ts b/src/services/editor/type.ts
similarity index 61%
rename from src/services/editor/type.d.ts
rename to src/services/editor/type.ts
index c958cdd..87e8545 100644
--- a/src/services/editor/type.d.ts
+++ b/src/services/editor/type.ts
@@ -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;
+};