feat(project): 调整项目到A18

This commit is contained in:
liuyonghe0111 2025-12-09 17:13:46 +08:00
parent f19f3e2541
commit 4f028ed72b
234 changed files with 12935 additions and 33644 deletions

4
.env
View File

@ -6,6 +6,10 @@ NEXT_PUBLIC_SHARK_API_URL=https://test-shark.crushlevel.ai
NEXT_PUBLIC_COW_API_URL=https://test-cow.crushlevel.ai
NEXT_PUBLIC_PIGEON_API_URL=https://test-pigeon.crushlevel.ai
# 自建服务
NEXT_PUBLIC_EDIT_API_URL=http://54.223.196.180
NEXT_PUBLIC_CHAT_API_URL=http://54.223.196.180
# 三方登录
NEXT_PUBLIC_DISCORD_CLIENT_ID=1396735872459866233

5
.npmrc Normal file
View File

@ -0,0 +1,5 @@
# pnpm 配置
shamefully-hoist=true
strict-peer-dependencies=false
auto-install-peers=true

View File

@ -3,6 +3,5 @@
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
"printWidth": 100
}

View File

@ -1,26 +1,21 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
import prettier from 'eslint-config-prettier'
import importQuotes from 'eslint-plugin-import-quotes'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
prettier,
{
plugins: { 'import-quotes': importQuotes },
rules: {
// 👇 import 使用双引号
'import-quotes/import-quotes': ['error', 'double'],
},
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, prettier, {
plugins: { 'import-quotes': importQuotes },
rules: {
// 👇 import 使用双引号
'import-quotes/import-quotes': ['error', 'double'],
},
]
}, {
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
}]
export default eslintConfig

View File

@ -2,9 +2,6 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
/* config options here */
eslint: {
ignoreDuringBuilds: true,
},
images: {
remotePatterns: [
{

12521
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,10 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"i18n:scan": "i18next-scanner",
"i18n:scan-custom": "tsx scripts/i18n-scan.ts",
"i18n:convert": "node scripts/convert-to-i18n.js",
@ -47,40 +47,39 @@
"embla-carousel-react": "^8.6.0",
"jotai": "^2.13.1",
"js-cookie": "^3.0.5",
"keen-slider": "^6.8.6",
"lamejs": "^1.2.1",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next": "16.0.8",
"next-themes": "^0.4.6",
"nim-web-sdk-ng": "^10.9.41",
"numeral": "^2.0.6",
"qs": "^6.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-easy-crop": "^5.5.0",
"react-hook-form": "^7.60.0",
"react-photo-view": "^1.2.7",
"react-stickynode": "^5.0.2",
"react-virtuoso": "^4.17.0",
"sonner": "^2.0.6",
"swiper": "^12.0.3",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.0.5"
"zod": "^4.0.5",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/numeral": "^2.0.5",
"@types/qs": "^6.14.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"acorn-typescript": "^1.4.13",
"eslint": "^9",
"eslint-config-next": "15.3.5",
"eslint-config-next": "16.0.8",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-quotes": "^0.0.1",
"eslint-plugin-prettier": "^5.5.4",
@ -101,5 +100,9 @@
"workerDirectory": [
"public"
]
},
"overrides": {
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
}

8881
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 429 KiB

539
public/font-v2/demo.css Normal file
View File

@ -0,0 +1,539 @@
/* Logo 字体 */
@font-face {
font-family: "iconfont logo";
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
}
.logo {
font-family: "iconfont logo";
font-size: 160px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* tabs */
.nav-tabs {
position: relative;
}
.nav-tabs .nav-more {
position: absolute;
right: 0;
bottom: 0;
height: 42px;
line-height: 42px;
color: #666;
}
#tabs {
border-bottom: 1px solid #eee;
}
#tabs li {
cursor: pointer;
width: 100px;
height: 40px;
line-height: 40px;
text-align: center;
font-size: 16px;
border-bottom: 2px solid transparent;
position: relative;
z-index: 1;
margin-bottom: -1px;
color: #666;
}
#tabs .active {
border-bottom-color: #f00;
color: #222;
}
.tab-container .content {
display: none;
}
/* 页面布局 */
.main {
padding: 30px 100px;
width: 960px;
margin: 0 auto;
}
.main .logo {
color: #333;
text-align: left;
margin-bottom: 30px;
line-height: 1;
height: 110px;
margin-top: -50px;
overflow: hidden;
*zoom: 1;
}
.main .logo a {
font-size: 160px;
color: #333;
}
.helps {
margin-top: 40px;
}
.helps pre {
padding: 20px;
margin: 10px 0;
border: solid 1px #e7e1cd;
background-color: #fffdef;
overflow: auto;
}
.icon_lists {
width: 100% !important;
overflow: hidden;
*zoom: 1;
}
.icon_lists li {
width: 100px;
margin-bottom: 10px;
margin-right: 20px;
text-align: center;
list-style: none !important;
cursor: default;
}
.icon_lists li .code-name {
line-height: 1.2;
}
.icon_lists .icon {
display: block;
height: 100px;
line-height: 100px;
font-size: 42px;
margin: 10px auto;
color: #333;
-webkit-transition: font-size 0.25s linear, width 0.25s linear;
-moz-transition: font-size 0.25s linear, width 0.25s linear;
transition: font-size 0.25s linear, width 0.25s linear;
}
.icon_lists .icon:hover {
font-size: 100px;
}
.icon_lists .svg-icon {
/* 通过设置 font-size 来改变图标大小 */
width: 1em;
/* 图标和文字相邻时,垂直对齐 */
vertical-align: -0.15em;
/* 通过设置 color 来改变 SVG 的颜色/fill */
fill: currentColor;
/* path stroke 溢出 viewBox 部分在 IE 下会显示
normalize.css 中也包含这行 */
overflow: hidden;
}
.icon_lists li .name,
.icon_lists li .code-name {
color: #666;
}
/* markdown 样式 */
.markdown {
color: #666;
font-size: 14px;
line-height: 1.8;
}
.highlight {
line-height: 1.5;
}
.markdown img {
vertical-align: middle;
max-width: 100%;
}
.markdown h1 {
color: #404040;
font-weight: 500;
line-height: 40px;
margin-bottom: 24px;
}
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
color: #404040;
margin: 1.6em 0 0.6em 0;
font-weight: 500;
clear: both;
}
.markdown h1 {
font-size: 28px;
}
.markdown h2 {
font-size: 22px;
}
.markdown h3 {
font-size: 16px;
}
.markdown h4 {
font-size: 14px;
}
.markdown h5 {
font-size: 12px;
}
.markdown h6 {
font-size: 12px;
}
.markdown hr {
height: 1px;
border: 0;
background: #e9e9e9;
margin: 16px 0;
clear: both;
}
.markdown p {
margin: 1em 0;
}
.markdown>p,
.markdown>blockquote,
.markdown>.highlight,
.markdown>ol,
.markdown>ul {
width: 80%;
}
.markdown ul>li {
list-style: circle;
}
.markdown>ul li,
.markdown blockquote ul>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown>ul li p,
.markdown>ol li p {
margin: 0.6em 0;
}
.markdown ol>li {
list-style: decimal;
}
.markdown>ol li,
.markdown blockquote ol>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown code {
margin: 0 3px;
padding: 0 5px;
background: #eee;
border-radius: 3px;
}
.markdown strong,
.markdown b {
font-weight: 600;
}
.markdown>table {
border-collapse: collapse;
border-spacing: 0px;
empty-cells: show;
border: 1px solid #e9e9e9;
width: 95%;
margin-bottom: 24px;
}
.markdown>table th {
white-space: nowrap;
color: #333;
font-weight: 600;
}
.markdown>table th,
.markdown>table td {
border: 1px solid #e9e9e9;
padding: 8px 16px;
text-align: left;
}
.markdown>table th {
background: #F7F7F7;
}
.markdown blockquote {
font-size: 90%;
color: #999;
border-left: 4px solid #e9e9e9;
padding-left: 0.8em;
margin: 1em 0;
}
.markdown blockquote p {
margin: 0;
}
.markdown .anchor {
opacity: 0;
transition: opacity 0.3s ease;
margin-left: 8px;
}
.markdown .waiting {
color: #ccc;
}
.markdown h1:hover .anchor,
.markdown h2:hover .anchor,
.markdown h3:hover .anchor,
.markdown h4:hover .anchor,
.markdown h5:hover .anchor,
.markdown h6:hover .anchor {
opacity: 1;
display: inline-block;
}
.markdown>br,
.markdown>p>br {
clear: both;
}
.hljs {
display: block;
background: white;
padding: 0.5em;
color: #333333;
overflow-x: auto;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: #df5000;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #a71d5d;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}
/* 代码高亮 */
/* PrismJS 1.15.0
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection,
pre[class*="language-"] ::selection,
code[class*="language-"]::selection,
code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre)>code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre)>code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View File

@ -0,0 +1,651 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>iconfont Demo</title>
<link rel="shortcut icon" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg" type="image/x-icon"/>
<link rel="icon" type="image/svg+xml" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg"/>
<link rel="stylesheet" href="https://g.alicdn.com/thx/cube/1.3.2/cube.min.css">
<link rel="stylesheet" href="demo.css">
<link rel="stylesheet" href="iconfont.css">
<script src="iconfont.js"></script>
<!-- jQuery -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/7bfddb60-08e8-11e9-9b04-53e73bb6408b.js"></script>
<!-- 代码高亮 -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/a3f714d0-08e6-11e9-8a15-ebf944d7534c.js"></script>
<style>
.main .logo {
margin-top: 0;
height: auto;
}
.main .logo a {
display: flex;
align-items: center;
}
.main .logo .sub-title {
margin-left: 0.5em;
font-size: 22px;
color: #fff;
background: linear-gradient(-45deg, #3967FF, #B500FE);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>
</head>
<body>
<div class="main">
<h1 class="logo"><a href="https://www.iconfont.cn/" title="iconfont 首页" target="_blank">
<img width="200" src="https://img.alicdn.com/imgextra/i3/O1CN01Mn65HV1FfSEzR6DKv_!!6000000000514-55-tps-228-59.svg">
</a></h1>
<div class="nav-tabs">
<ul id="tabs" class="dib-box">
<li class="dib active"><span>Unicode</span></li>
<li class="dib"><span>Font class</span></li>
<li class="dib"><span>Symbol</span></li>
</ul>
<a href="https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=5076160" target="_blank" class="nav-more">查看项目</a>
</div>
<div class="tab-container">
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xe620;</span>
<div class="name">挂断电话</div>
<div class="code-name">&amp;#xe620;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61f;</span>
<div class="name">电话</div>
<div class="code-name">&amp;#xe61f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61d;</span>
<div class="name">艾特</div>
<div class="code-name">&amp;#xe61d;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61e;</span>
<div class="name">性别</div>
<div class="code-name">&amp;#xe61e;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61c;</span>
<div class="name">Frame 247</div>
<div class="code-name">&amp;#xe61c;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61b;</span>
<div class="name">编辑</div>
<div class="code-name">&amp;#xe61b;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe615;</span>
<div class="name">展开</div>
<div class="code-name">&amp;#xe615;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe616;</span>
<div class="name">Frame 195</div>
<div class="code-name">&amp;#xe616;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe617;</span>
<div class="name">展开-1</div>
<div class="code-name">&amp;#xe617;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe618;</span>
<div class="name">生成</div>
<div class="code-name">&amp;#xe618;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe619;</span>
<div class="name">复制</div>
<div class="code-name">&amp;#xe619;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61a;</span>
<div class="name">Frame 194</div>
<div class="code-name">&amp;#xe61a;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe614;</span>
<div class="name">gender-female-line</div>
<div class="code-name">&amp;#xe614;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe613;</span>
<div class="name">gender-male-line</div>
<div class="code-name">&amp;#xe613;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe612;</span>
<div class="name">刷新</div>
<div class="code-name">&amp;#xe612;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe610;</span>
<div class="name">箭头</div>
<div class="code-name">&amp;#xe610;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe611;</span>
<div class="name">关闭</div>
<div class="code-name">&amp;#xe611;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe60d;</span>
<div class="name">搜索</div>
<div class="code-name">&amp;#xe60d;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe60e;</span>
<div class="name">折叠</div>
<div class="code-name">&amp;#xe60e;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe60f;</span>
<div class="name">展开</div>
<div class="code-name">&amp;#xe60f;</div>
</li>
</ul>
<div class="article markdown">
<h2 id="unicode-">Unicode 引用</h2>
<hr>
<p>Unicode 是字体在网页端最原始的应用方式,特点是:</p>
<ul>
<li>支持按字体的方式去动态调整图标大小,颜色等等。</li>
<li>默认情况下不支持多色,直接添加多色图标会自动去色。</li>
</ul>
<blockquote>
<p>注意:新版 iconfont 支持两种方式引用多色图标SVG symbol 引用方式和彩色字体图标模式。(使用彩色字体图标需要在「编辑项目」中开启「彩色」选项后并重新生成。)</p>
</blockquote>
<p>Unicode 使用步骤如下:</p>
<h3 id="-font-face">第一步:拷贝项目下面生成的 <code>@font-face</code></h3>
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.eot?t=1765173841330'); /* IE9 */
src: url('iconfont.eot?t=1765173841330#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('iconfont.woff2?t=1765173841330') format('woff2'),
url('iconfont.woff?t=1765173841330') format('woff'),
url('iconfont.ttf?t=1765173841330') format('truetype'),
url('iconfont.svg?t=1765173841330#iconfont') format('svg');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
<pre><code class="language-css"
>.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取字体编码,应用于页面</h3>
<pre>
<code class="language-html"
>&lt;span class="iconfont"&gt;&amp;#x33;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"iconfont" 是你项目下的 font-family。可以通过编辑项目查看默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont icon-guaduandianhua"></span>
<div class="name">
挂断电话
</div>
<div class="code-name">.icon-guaduandianhua
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-dianhua"></span>
<div class="name">
电话
</div>
<div class="code-name">.icon-dianhua
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-aite"></span>
<div class="name">
艾特
</div>
<div class="code-name">.icon-aite
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-xingbie"></span>
<div class="name">
性别
</div>
<div class="code-name">.icon-xingbie
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-Frame247"></span>
<div class="name">
Frame 247
</div>
<div class="code-name">.icon-a-Frame247
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-bianji"></span>
<div class="name">
编辑
</div>
<div class="code-name">.icon-bianji
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zhankai1"></span>
<div class="name">
展开
</div>
<div class="code-name">.icon-zhankai1
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-Frame195"></span>
<div class="name">
Frame 195
</div>
<div class="code-name">.icon-a-Frame195
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zhankai-1"></span>
<div class="name">
展开-1
</div>
<div class="code-name">.icon-zhankai-1
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-shengcheng"></span>
<div class="name">
生成
</div>
<div class="code-name">.icon-shengcheng
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-fuzhi"></span>
<div class="name">
复制
</div>
<div class="code-name">.icon-fuzhi
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-Frame194"></span>
<div class="name">
Frame 194
</div>
<div class="code-name">.icon-a-Frame194
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-gender-female-line"></span>
<div class="name">
gender-female-line
</div>
<div class="code-name">.icon-gender-female-line
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-gender-male-line"></span>
<div class="name">
gender-male-line
</div>
<div class="code-name">.icon-gender-male-line
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-shuaxin"></span>
<div class="name">
刷新
</div>
<div class="code-name">.icon-shuaxin
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-jiantou"></span>
<div class="name">
箭头
</div>
<div class="code-name">.icon-jiantou
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-guanbi"></span>
<div class="name">
关闭
</div>
<div class="code-name">.icon-guanbi
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-sousuo"></span>
<div class="name">
搜索
</div>
<div class="code-name">.icon-sousuo
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zhedie"></span>
<div class="name">
折叠
</div>
<div class="code-name">.icon-zhedie
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zhankai"></span>
<div class="name">
展开
</div>
<div class="code-name">.icon-zhankai
</div>
</li>
</ul>
<div class="article markdown">
<h2 id="font-class-">font-class 引用</h2>
<hr>
<p>font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。</p>
<p>与 Unicode 使用方式相比,具有如下特点:</p>
<ul>
<li>相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。</li>
<li>因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-fontclass-">第一步:引入项目下面生成的 fontclass 代码:</h3>
<pre><code class="language-html">&lt;link rel="stylesheet" href="./iconfont.css"&gt;
</code></pre>
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;span class="iconfont icon-xxx"&gt;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"
iconfont" 是你项目下的 font-family。可以通过编辑项目查看默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-guaduandianhua"></use>
</svg>
<div class="name">挂断电话</div>
<div class="code-name">#icon-guaduandianhua</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-dianhua"></use>
</svg>
<div class="name">电话</div>
<div class="code-name">#icon-dianhua</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-aite"></use>
</svg>
<div class="name">艾特</div>
<div class="code-name">#icon-aite</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xingbie"></use>
</svg>
<div class="name">性别</div>
<div class="code-name">#icon-xingbie</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-Frame247"></use>
</svg>
<div class="name">Frame 247</div>
<div class="code-name">#icon-a-Frame247</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-bianji"></use>
</svg>
<div class="name">编辑</div>
<div class="code-name">#icon-bianji</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zhankai1"></use>
</svg>
<div class="name">展开</div>
<div class="code-name">#icon-zhankai1</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-Frame195"></use>
</svg>
<div class="name">Frame 195</div>
<div class="code-name">#icon-a-Frame195</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zhankai-1"></use>
</svg>
<div class="name">展开-1</div>
<div class="code-name">#icon-zhankai-1</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-shengcheng"></use>
</svg>
<div class="name">生成</div>
<div class="code-name">#icon-shengcheng</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-fuzhi"></use>
</svg>
<div class="name">复制</div>
<div class="code-name">#icon-fuzhi</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-Frame194"></use>
</svg>
<div class="name">Frame 194</div>
<div class="code-name">#icon-a-Frame194</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-gender-female-line"></use>
</svg>
<div class="name">gender-female-line</div>
<div class="code-name">#icon-gender-female-line</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-gender-male-line"></use>
</svg>
<div class="name">gender-male-line</div>
<div class="code-name">#icon-gender-male-line</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-shuaxin"></use>
</svg>
<div class="name">刷新</div>
<div class="code-name">#icon-shuaxin</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-jiantou"></use>
</svg>
<div class="name">箭头</div>
<div class="code-name">#icon-jiantou</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-guanbi"></use>
</svg>
<div class="name">关闭</div>
<div class="code-name">#icon-guanbi</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-sousuo"></use>
</svg>
<div class="name">搜索</div>
<div class="code-name">#icon-sousuo</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zhedie"></use>
</svg>
<div class="name">折叠</div>
<div class="code-name">#icon-zhedie</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zhankai"></use>
</svg>
<div class="name">展开</div>
<div class="code-name">#icon-zhankai</div>
</li>
</ul>
<div class="article markdown">
<h2 id="symbol-">Symbol 引用</h2>
<hr>
<p>这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇<a href="">文章</a>
这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:</p>
<ul>
<li>支持多色图标了,不再受单色限制。</li>
<li>通过一些技巧,支持像字体那样,通过 <code>font-size</code>, <code>color</code> 来调整样式。</li>
<li>兼容性较差,支持 IE9+,及现代浏览器。</li>
<li>浏览器渲染 SVG 的性能一般,还不如 png。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-symbol-">第一步:引入项目下面生成的 symbol 代码:</h3>
<pre><code class="language-html">&lt;script src="./iconfont.js"&gt;&lt;/script&gt;
</code></pre>
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
<pre><code class="language-html">&lt;style&gt;
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
&lt;/style&gt;
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;svg class="icon" aria-hidden="true"&gt;
&lt;use xlink:href="#icon-xxx"&gt;&lt;/use&gt;
&lt;/svg&gt;
</code></pre>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$('.tab-container .content:first').show()
$('#tabs li').click(function (e) {
var tabContent = $('.tab-container .content')
var index = $(this).index()
if ($(this).hasClass('active')) {
return
} else {
$('#tabs li').removeClass('active')
$(this).addClass('active')
tabContent.hide().eq(index).fadeIn()
}
})
})
</script>
</body>
</html>

View File

@ -0,0 +1,98 @@
@font-face {
font-family: "iconfont"; /* Project id 5076160 */
src: url('iconfont.eot?t=1765173841330'); /* IE9 */
src: url('iconfont.eot?t=1765173841330#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('iconfont.woff2?t=1765173841330') format('woff2'),
url('iconfont.woff?t=1765173841330') format('woff'),
url('iconfont.ttf?t=1765173841330') format('truetype'),
url('iconfont.svg?t=1765173841330#iconfont') format('svg');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-guaduandianhua:before {
content: "\e620";
}
.icon-dianhua:before {
content: "\e61f";
}
.icon-aite:before {
content: "\e61d";
}
.icon-xingbie:before {
content: "\e61e";
}
.icon-a-Frame247:before {
content: "\e61c";
}
.icon-bianji:before {
content: "\e61b";
}
.icon-zhankai1:before {
content: "\e615";
}
.icon-a-Frame195:before {
content: "\e616";
}
.icon-zhankai-1:before {
content: "\e617";
}
.icon-shengcheng:before {
content: "\e618";
}
.icon-fuzhi:before {
content: "\e619";
}
.icon-a-Frame194:before {
content: "\e61a";
}
.icon-gender-female-line:before {
content: "\e614";
}
.icon-gender-male-line:before {
content: "\e613";
}
.icon-shuaxin:before {
content: "\e612";
}
.icon-jiantou:before {
content: "\e610";
}
.icon-guanbi:before {
content: "\e611";
}
.icon-sousuo:before {
content: "\e60d";
}
.icon-zhedie:before {
content: "\e60e";
}
.icon-zhankai:before {
content: "\e60f";
}

BIN
public/font-v2/iconfont.eot Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,149 @@
{
"id": "5076160",
"name": "spicyxx.ai",
"font_family": "iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "46281959",
"name": "挂断电话",
"font_class": "guaduandianhua",
"unicode": "e620",
"unicode_decimal": 58912
},
{
"icon_id": "46281954",
"name": "电话",
"font_class": "dianhua",
"unicode": "e61f",
"unicode_decimal": 58911
},
{
"icon_id": "46261887",
"name": "艾特",
"font_class": "aite",
"unicode": "e61d",
"unicode_decimal": 58909
},
{
"icon_id": "46261886",
"name": "性别",
"font_class": "xingbie",
"unicode": "e61e",
"unicode_decimal": 58910
},
{
"icon_id": "46252831",
"name": "Frame 247",
"font_class": "a-Frame247",
"unicode": "e61c",
"unicode_decimal": 58908
},
{
"icon_id": "46252115",
"name": "编辑",
"font_class": "bianji",
"unicode": "e61b",
"unicode_decimal": 58907
},
{
"icon_id": "46252119",
"name": "展开",
"font_class": "zhankai1",
"unicode": "e615",
"unicode_decimal": 58901
},
{
"icon_id": "46252114",
"name": "Frame 195",
"font_class": "a-Frame195",
"unicode": "e616",
"unicode_decimal": 58902
},
{
"icon_id": "46252118",
"name": "展开-1",
"font_class": "zhankai-1",
"unicode": "e617",
"unicode_decimal": 58903
},
{
"icon_id": "46252117",
"name": "生成",
"font_class": "shengcheng",
"unicode": "e618",
"unicode_decimal": 58904
},
{
"icon_id": "46252116",
"name": "复制",
"font_class": "fuzhi",
"unicode": "e619",
"unicode_decimal": 58905
},
{
"icon_id": "46252113",
"name": "Frame 194",
"font_class": "a-Frame194",
"unicode": "e61a",
"unicode_decimal": 58906
},
{
"icon_id": "46234515",
"name": "gender-female-line",
"font_class": "gender-female-line",
"unicode": "e614",
"unicode_decimal": 58900
},
{
"icon_id": "46234139",
"name": "gender-male-line",
"font_class": "gender-male-line",
"unicode": "e613",
"unicode_decimal": 58899
},
{
"icon_id": "46211262",
"name": "刷新",
"font_class": "shuaxin",
"unicode": "e612",
"unicode_decimal": 58898
},
{
"icon_id": "46211223",
"name": "箭头",
"font_class": "jiantou",
"unicode": "e610",
"unicode_decimal": 58896
},
{
"icon_id": "46211222",
"name": "关闭",
"font_class": "guanbi",
"unicode": "e611",
"unicode_decimal": 58897
},
{
"icon_id": "46211227",
"name": "搜索",
"font_class": "sousuo",
"unicode": "e60d",
"unicode_decimal": 58893
},
{
"icon_id": "46211226",
"name": "折叠",
"font_class": "zhedie",
"unicode": "e60e",
"unicode_decimal": 58894
},
{
"icon_id": "46211225",
"name": "展开",
"font_class": "zhankai",
"unicode": "e60f",
"unicode_decimal": 58895
}
]
}

View File

@ -0,0 +1,59 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Created by iconfont</metadata>
<defs>
<font id="iconfont" horiz-adv-x="1024">
<font-face
font-family="iconfont"
font-weight="400"
font-stretch="normal"
units-per-em="1024"
ascent="896"
descent="-128"
/>
<missing-glyph />
<glyph glyph-name="guaduandianhua" unicode="&#58912;" d="M710.186667 405.504c0 29.994667-24.32 54.314667-54.314667 54.314667H366.72c-29.994667 0-54.314667-24.32-54.314667-54.314667v-77.653333A148.650667 148.650667 0 0 0 163.754667 179.2h-6.485334a140.970667 140.970667 0 0 0-139.093333 117.802667 211.498667 211.498667 0 0 0 176.896 243.882666l42.922667 6.485334a1917.354667 1917.354667 0 0 0 613.802666-6.229334 191.744 191.744 0 0 0 156.544-220.501333l-3.712-22.101333a142.848 142.848 0 0 0-140.885333-119.338667h-4.949333a148.650667 148.650667 0 0 0-148.608 148.650667v77.653333z" horiz-adv-x="1024" />
<glyph glyph-name="dianhua" unicode="&#58911;" d="M339.797333 488.8576a54.306133 54.306133 0 0 1 0-76.8l204.458667-204.458667a54.306133 54.306133 0 0 1 76.8 0l54.920533 54.920534c58.026667 58.026667 152.1664 58.026667 210.2272 0l4.573867-4.608a140.970667 140.970667 0 0 0 15.018667-181.623467 211.490133 211.490133 0 0 0-297.506134-47.377067l-34.952533 25.770667A1917.371733 1917.371733 0 0 0 143.735467 493.124267a191.726933 191.726933 0 0 0 45.226666 266.581333l18.2272 12.970667a142.848 142.848 0 0 0 184.046934-15.223467l3.4816-3.4816c58.026667-58.026667 58.026667-152.1664 0-210.193067l-54.920534-54.954666zM606.549333 482.645333a126.7712 126.7712 0 0 0 36.522667-103.082666 25.6 25.6 0 0 0-50.926933 5.393066 75.537067 75.537067 0 0 1-21.777067 61.508267 75.537067 75.537067 0 0 1-61.508267 21.742933 25.6 25.6 0 0 0-5.358933 50.926934 126.737067 126.737067 0 0 0 103.082667-36.4544zM734.8224 588.3904a190.941867 190.941867 0 0 0 54.954667-155.306667 25.6 25.6 0 0 0-50.926934 5.3248 139.741867 139.741867 0 0 1-40.277333 113.7664 139.741867 139.741867 0 0 1-113.732267 40.277334 25.6 25.6 0 0 0-5.358933 50.8928 190.941867 190.941867 0 0 0 155.306667-54.954667z" horiz-adv-x="1024" />
<glyph glyph-name="aite" unicode="&#58909;" d="M557.888 576c34.656 0 64.64-6.4 90.048-19.264 25.376-12.832 44.8-30.912 58.24-54.208 13.76-23.296 20.608-50.176 20.608-80.64 0-24.48-4.32-47.648-12.992-69.44-8.352-21.792-20.608-39.424-36.736-52.864-16.128-13.44-35.232-20.16-57.344-20.16-15.52 0-28.064 3.136-37.632 9.408a44.128 44.128 0 0 0-18.368 27.776c-8.64-11.648-19.84-20.896-33.6-27.776a99.52 99.52 0 0 0-43.456-9.856c-22.72 0-40.48 6.72-53.312 20.16-12.832 13.76-19.264 32.416-19.264 56 0 22.4 4.64 43.296 13.888 62.72 9.28 19.424 22.08 34.944 38.528 46.592a92.16 92.16 0 0 0 55.552 17.92c25.696 0 43.296-8.96 52.864-26.88l4.032 23.296h52.416l-21.056-120.96c-0.896-5.664-1.344-10.88-1.344-15.68 0-7.168 1.504-12.544 4.48-16.128 2.976-3.584 8.064-5.376 15.232-5.376 10.752 0 19.712 5.088 26.88 15.232 7.456 10.144 12.992 22.848 16.576 38.08 3.872 15.232 5.824 30.464 5.824 45.696 0 36.16-11.04 64.064-33.152 83.776-22.08 20-53.312 30.016-93.632 30.016-34.336 0-65.408-8.352-93.184-25.088a175.808 175.808 0 0 1-65.408-67.648c-15.52-28.384-23.296-60.032-23.296-94.976 0-37.024 10.88-65.568 32.704-85.568 22.08-20 53.312-30.016 93.632-30.016 28.96 0 54.496 5.216 76.608 15.68l8.96-41.216c-28.384-11.936-59.872-17.92-94.528-17.92-33.44 0-62.72 6.4-87.808 19.264a137.152 137.152 0 0 0-58.24 53.76c-13.76 23.296-20.608 50.336-20.608 81.088 0 43.904 10.144 83.776 30.464 119.616a224.96 224.96 0 0 0 85.12 84.672c36.448 20.608 77.216 30.912 122.304 30.912z m-51.52-250.432c10.752 0 20.608 3.136 29.568 9.408 8.96 6.272 16 14.624 21.056 25.088 5.376 10.464 8.064 21.664 8.064 33.6 0 12.544-3.424 22.72-10.304 30.464-6.88 7.776-16.416 11.648-28.672 11.648-11.04 0-20.768-3.424-29.12-10.304a66.816 66.816 0 0 1-19.712-25.984c-4.48-10.752-6.72-22.08-6.72-34.048 0-12.256 2.976-21.952 8.96-29.12 5.984-7.168 14.944-10.752 26.88-10.752z" horiz-adv-x="1024" />
<glyph glyph-name="xingbie" unicode="&#58910;" d="M640 537.6A128 128 0 1 1 512 409.6V332.8a204.8 204.8 0 1 0 0 409.6 204.8 204.8 0 0 0 0-409.6V409.6A128 128 0 0 1 640 537.6zM514.048 268.8a396.9536 396.9536 0 0 0 370.7392-255.0784 38.4 38.4 0 1 0-71.7312-27.4432 320.1536 320.1536 0 0 1-598.016 0 38.4 38.4 0 1 0-71.68 27.4432 396.9024 396.9024 0 0 0 370.688 255.0784z" horiz-adv-x="1024" />
<glyph glyph-name="a-Frame247" unicode="&#58908;" d="M170.666667 384a42.666667 42.666667 0 0 0 42.666666 42.666667h597.333334a42.666667 42.666667 0 1 0 0-85.333334H213.333333a42.666667 42.666667 0 0 0-42.666666 42.666667zM512 725.333333a42.666667 42.666667 0 0 0 42.666667-42.666666v-597.333334a42.666667 42.666667 0 1 0-85.333334 0V682.666667a42.666667 42.666667 0 0 0 42.666667 42.666666z" horiz-adv-x="1024" />
<glyph glyph-name="bianji" unicode="&#58907;" d="M224 192v384A96 96 0 0 0 320 672H512a32 32 0 0 0 0-64H320a32 32 0 0 1-32-32v-384c0-17.664 14.336-32 32-32h384a32 32 0 0 1 32 32V384a32 32 0 0 0 64 0v-192a96 96 0 0 0-96-96h-384a96 96 0 0 0-96 96zM745.386667 662.613333a32 32 0 1 0 45.226666-45.226666l-341.333333-341.333334a32 32 0 1 0-45.226667 45.226667l341.333334 341.333333z" horiz-adv-x="1024" />
<glyph glyph-name="zhankai1" unicode="&#58901;" d="M672 536a24 24 0 0 0 0-48H352a24 24 0 0 0 0 48h320zM672 408a24 24 0 0 0 0-48H352a24 24 0 0 0 0 48h320zM367.04 304.96a24 24 0 0 0 39.52-24.96H672a24 24 0 0 0 0-48h-265.44a23.936 23.936 0 0 0-39.52-24.96L318.08 256l48.96 48.96z" horiz-adv-x="1024" />
<glyph glyph-name="a-Frame195" unicode="&#58902;" d="M338.752 685.248a64 64 0 0 0 90.496 0l256-256a64 64 0 0 0 0-90.496l-256-256a64 64 0 1 0-90.496 90.496L549.504 384 338.752 594.752a64 64 0 0 0 0 90.496z" horiz-adv-x="1024" />
<glyph glyph-name="zhankai-1" unicode="&#58903;" d="M672 536a24 24 0 0 0 0-48H352a24 24 0 0 0 0 48h320zM672 408a24 24 0 0 0 0-48H352a24 24 0 0 0 0 48h320zM656.96 304.96a24 24 0 0 1-39.52-24.96H352a24 24 0 0 1 0-48h265.44a23.936 23.936 0 0 1 39.52-24.96L705.92 256l-48.96 48.96z" horiz-adv-x="1024" />
<glyph glyph-name="shengcheng" unicode="&#58904;" d="M506.837333 693.333333c125.44 0 232.064-80.213333 271.573334-192a32 32 0 1 0-60.330667-21.333333 224 224 0 0 1-431.914667-36.266667l87.424 14.506667a32 32 0 0 0 10.496-63.146667l-165.248-27.562666V405.333333a288 288 0 0 0 288 288zM517.162667 74.666667a288.085333 288.085333 0 0 0-271.573334 192 32 32 0 1 0 60.330667 21.333333 224.085333 224.085333 0 0 1 431.914667 36.266667l-87.424-14.506667a32 32 0 0 0-10.496 63.146667l165.248 27.562666v-37.802666a288 288 0 0 0-288-288z" horiz-adv-x="1024" />
<glyph glyph-name="fuzhi" unicode="&#58905;" d="M597.333333 277.333333V213.333333H298.666667v64h298.666666z m21.333334 21.333334V597.333333a21.333333 21.333333 0 0 1-21.333334 21.333334H298.666667a21.333333 21.333333 0 0 1-21.333334-21.333334v-298.666666a21.333333 21.333333 0 0 1 21.333334-21.333334V213.333333a85.333333 85.333333 0 0 0-85.333334 85.333334V597.333333a85.333333 85.333333 0 0 0 85.333334 85.333334h298.666666a85.333333 85.333333 0 0 0 85.333334-85.333334v-298.666666a85.333333 85.333333 0 0 0-85.333334-85.333334v64a21.333333 21.333333 0 0 1 21.333334 21.333334zM736 256v213.333333a32 32 0 0 0 64 0v-213.333333A160 160 0 0 0 640 96h-213.333333a32 32 0 0 0 0 64h213.333333A96 96 0 0 1 736 256z" horiz-adv-x="1024" />
<glyph glyph-name="a-Frame194" unicode="&#58906;" d="M685.248 685.248a64 64 0 0 1-90.496 0l-256-256a64 64 0 0 1 0-90.496l256-256a64 64 0 1 1 90.496 90.496L474.496 384l210.752 210.752a64 64 0 0 1 0 90.496z" horiz-adv-x="1024" />
<glyph glyph-name="gender-female-line" unicode="&#58900;" d="M725.333333 554.666667a213.333333 213.333333 0 1 1-213.333333-213.333334v-128a341.333333 341.333333 0 1 0 0 682.666667 341.333333 341.333333 0 0 0 0-682.666667v128a213.333333 213.333333 0 0 1 213.333333 213.333334zM469.333333-85.333333v341.333333a42.666667 42.666667 0 1 0 85.333334 0v-341.333333a42.666667 42.666667 0 1 0-85.333334 0zM299.093333 128.426667l426.282667-0.426667a42.666667 42.666667 0 1 0-0.085333-85.333333l-426.24 0.426666a42.666667 42.666667 0 1 0 0.085333 85.333334z" horiz-adv-x="1024" />
<glyph glyph-name="gender-male-line" unicode="&#58899;" d="M493.866667-119.381333C241.493333-175.104 16.853333 49.493333 72.576 301.909333 128.256 554.24 426.154667 663.722667 631.68 506.88l166.954667 166.954667h-169.173334a47.061333 47.061333 0 0 0 0 94.122666H960v-330.538666a47.061333 47.061333 0 1 0-94.122667 0V606.72l-166.954666-166.954667c156.757333-205.482667 47.36-503.381333-205.056-559.104z m182.613333 345.088A258.432 258.432 0 0 1 417.706667 484.522667c-143.232 0-259.925333-115.541333-259.925334-258.816a260.352 260.352 0 0 1 259.925334-259.925334c143.274667 0 258.858667 116.650667 258.858666 259.925334z" horiz-adv-x="1024" />
<glyph glyph-name="shuaxin" unicode="&#58898;" d="M512 768a384 384 0 0 0 42.666667-765.525333v-78.976a21.333333 21.333333 0 0 0-36.437334-15.104l-119.125333 119.168a21.333333 21.333333 0 0 0 0 30.165333l119.168 119.168a21.333333 21.333333 0 0 0 36.394667-15.061333v-73.386667A298.666667 298.666667 0 1 1 213.333333 384a42.666667 42.666667 0 1 0-85.333333 0 384 384 0 0 0 384 384z" horiz-adv-x="1024" />
<glyph glyph-name="jiantou" unicode="&#58896;" d="M545.0752 606.9248a25.6 25.6 0 0 0 36.1984 0L804.1984 384l-222.9248-222.9248a25.6 25.6 0 0 0-36.1984 36.2496l161.1264 161.0752H204.8a25.6 25.6 0 0 0 0 51.2h501.4016l-161.0752 161.1264a25.6 25.6 0 0 0 0 36.1984z" horiz-adv-x="1024" />
<glyph glyph-name="guanbi" unicode="&#58897;" d="M801.5872 166.7584a51.2 51.2 0 0 0-72.3968-72.3968l-506.88 506.88a51.2 51.2 0 1 0 72.448 72.3968l506.88-506.88zM294.8096 94.3616a51.2 51.2 0 0 0-72.448 72.3968l506.88 506.88a51.2 51.2 0 0 0 72.3968-72.3968l-506.88-506.88z" horiz-adv-x="1024" />
<glyph glyph-name="sousuo" unicode="&#58893;" d="M768 426.666667a298.666667 298.666667 0 1 1-298.666667-298.666667v-85.333333a384 384 0 1 0 0 768 384 384 0 0 0 0-768v85.333333a298.666667 298.666667 0 0 1 298.666667 298.666667zM695.168 200.832a42.666667 42.666667 0 0 0 60.330667 0l128-128a42.666667 42.666667 0 1 0-60.330667-60.330667l-128 128a42.666667 42.666667 0 0 0 0 60.330667z" horiz-adv-x="1024" />
<glyph glyph-name="zhedie" unicode="&#58894;" d="M320 279.424a10.666667 10.666667 0 0 0 16.32 9.045333l54.528-34.090666a10.666667 10.666667 0 0 0 0-18.090667l-54.528-34.090667a10.666667 10.666667 0 0 0-16.32 9.045334v68.181333zM682.666667 256a21.333333 21.333333 0 1 0 0-42.666667H469.333333a21.333333 21.333333 0 1 0 0 42.666667h213.333334z m0 149.333333a21.333333 21.333333 0 1 0 0-42.666666H341.333333a21.333333 21.333333 0 1 0 0 42.666666h341.333334z m0 149.333334a21.333333 21.333333 0 1 0 0-42.666667H341.333333a21.333333 21.333333 0 1 0 0 42.666667h341.333334z" horiz-adv-x="1024" />
<glyph glyph-name="zhankai" unicode="&#58895;" d="M687.68 288.448a10.666667 10.666667 0 0 0 16.32-9.024v-68.181333a10.666667 10.666667 0 0 0-16.32-9.045334l-54.528 34.090667a10.666667 10.666667 0 0 0 0 18.090667l54.528 34.090666zM554.666667 256a21.333333 21.333333 0 1 0 0-42.666667H341.333333a21.333333 21.333333 0 1 0 0 42.666667h213.333334z m128 149.333333a21.333333 21.333333 0 1 0 0-42.666666H341.333333a21.333333 21.333333 0 1 0 0 42.666666h341.333334z m0 149.333334a21.333333 21.333333 0 1 0 0-42.666667H341.333333a21.333333 21.333333 0 1 0 0 42.666667h341.333334z" horiz-adv-x="1024" />
</font>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/font-v2/iconfont.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 994 B

View File

@ -0,0 +1,19 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 9.5L7 4.5L13 3L23.5 6L28 10.5L24 13.5L16 13L9.5 9.5Z" fill="url(#paint0_linear_77_8903)"/>
<path d="M1 10L7 4.5L9.5 9.5L1 10Z" fill="#938951"/>
<path d="M1 10L9.5 18V9.5L1 10Z" fill="#8A651A"/>
<path d="M16 13L9.5 18V9.5L16 13Z" fill="#93691A"/>
<path d="M16 13L9.5 18L19.5 20.5L16 13Z" fill="#937833"/>
<path d="M24 13.5L19.5 20.5L16 13L24 13.5Z" fill="#938951"/>
<path d="M30.5 18L19.5 20.5L24 13.5L30.5 18Z" fill="#8A651A"/>
<path d="M30.5 18L28 10.5L24 13.5L30.5 18Z" fill="#85551C"/>
<path d="M12 29L1 10L9.5 18L12 29Z" fill="#85551C"/>
<path d="M19.5 20.5L9.5 18L12 29L19.5 20.5Z" fill="#938951"/>
<path d="M12 29L30.5 18L19.5 20.5L12 29Z" fill="#937732"/>
<defs>
<linearGradient id="paint0_linear_77_8903" x1="17.5" y1="3" x2="17.5" y2="13.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#937532"/>
<stop offset="1" stop-color="#934A1C"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 994 B

View File

@ -1,21 +1,4 @@
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" fill="none" customFrame="url(#clipPath_3)">
<defs>
<clipPath id="clipPath_3">
<rect width="20" height="20" x="0" y="0" rx="4" fill="rgb(255,255,255)" />
</clipPath>
<clipPath id="clipPath_4">
<rect width="20" height="20" x="0" y="0" rx="9.5" fill="rgb(255,255,255)" />
</clipPath>
<clipPath id="clipPath_5">
<rect width="12" height="12" x="4" y="4" fill="rgb(255,255,255)" />
</clipPath>
</defs>
<rect id="Checkbox&amp;Radiobtn" width="20" height="20" x="0" y="0" rx="4" />
<g id="radiobtn" customFrame="url(#clipPath_4)">
<rect id="radiobtn" width="20" height="20" x="0" y="0" rx="9.5" fill="rgb(210,31,119)" style="mix-blend-mode:normal" />
<g id="icon/select" customFrame="url(#clipPath_5)">
<rect id="icon/select" width="12" height="12" x="4" y="4" />
<path id="路径 18" d="M15.0878 7.21296C15.242 7.05769 15.3286 6.84769 15.3286 6.6288C15.3286 6.17109 14.9576 5.80005 14.4999 5.80005C14.2471 5.80005 14.0081 5.91547 13.8508 6.11348L8.46446 12.1732L6.11757 9.82626C5.96035 9.6505 5.7357 9.55005 5.49989 9.55005C5.04219 9.55005 4.67114 9.92109 4.67114 10.3788C4.67114 10.6146 4.7716 10.8393 4.94735 10.9965L7.91478 13.9639C7.92624 13.9754 7.93804 13.9865 7.95015 13.9973C8.29172 14.3009 8.81474 14.2701 9.11835 13.9285L15.0878 7.21296Z" fill="rgb(255,255,255)" style="mix-blend-mode:normal" fill-rule="evenodd" />
</g>
</g>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="9" fill="#9A2AFF"/>
<path d="M6 9.14286L8.33333 12L13 7" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@ -1,5 +1,5 @@
'use client'
import { SocialButton } from './SocialButton'
// import { SocialButton } from './SocialButton'
import Link from 'next/link'
import { toast } from 'sonner'
import DiscordButton from './DiscordButton'
@ -14,12 +14,11 @@ export function LoginForm() {
return (
<div className="w-full space-y-3 sm:space-y-4">
<div className="mb-4 text-center sm:mb-6">
<h2 className="txt-title-m sm:txt-title-l">Log in/Sign up</h2>
<p className="text-gradient mt-3 text-sm sm:mt-4 sm:text-base">Chat, Crush, AI Date</p>
<div className="mb-12 text-center">
<h2 className="txt-headline-m">Login or Sign up</h2>
{/* <p className="text-gradient mt-3 text-sm sm:mt-4 sm:text-base">Chat, Crush, AI Date</p> */}
</div>
<div className="mt-4 space-y-3 sm:mt-6 sm:space-y-4">
<div className="space-y-3 sm:space-y-4">
<DiscordButton />
<GoogleButton />
@ -34,9 +33,9 @@ export function LoginForm() {
<div className="mt-4 text-center sm:mt-6">
<p className="txt-body-s sm:txt-body-m text-txt-secondary-normal">
By continuing, you agree to CrushLevel's{' '}
By continuing, you agree to Crush Level's{' '}
<Link href="/policy/tos" target="_blank" className="text-primary-variant-normal">
User Agreement
Terms of Service
</Link>{' '}
and{' '}
<Link href="/policy/privacy" target="_blank" className="text-primary-variant-normal">

View File

@ -27,32 +27,16 @@ export default function LoginPage() {
}
return (
<div className="flex h-screen w-screen overflow-hidden">
{/* 左侧 - 滚动背景 + 图片轮播 (桌面端显示) */}
<div className="relative hidden lg:block lg:w-1/2">
<LeftPanel scrollBg={scrollBg} images={images} />
{/* 关闭按钮 - 桌面端 */}
<IconButton
iconfont="icon-close"
variant="tertiaryDark"
size="large"
className="absolute top-6 left-6 z-20"
onClick={handleClose}
/>
</div>
{/* 右侧 - 登录表单 */}
<div className="relative flex w-full flex-col items-center justify-center px-6 sm:px-12 lg:w-1/2">
{/* 关闭按钮 - 移动端 */}
<IconButton
iconfont="icon-close"
variant="tertiary"
size="large"
className="absolute top-4 right-4 z-20 lg:hidden"
onClick={handleClose}
/>
<div className="flex h-screen realoative w-screen overflow-hidden">
<IconButton
iconfont="icon-close"
variant="tertiaryDark"
size="large"
className="absolute top-6 left-6 z-20"
onClick={handleClose}
/>
<div className="relative flex w-full flex-col items-center justify-center px-6">
{/* Logo */}
<div className="relative mb-8 h-[48px] w-[120px] sm:mb-12 sm:h-[64px] sm:w-[160px]">
<Image src="/logo.svg" alt="Crush Level" fill className="object-contain" priority />

View File

@ -0,0 +1,13 @@
'use client'
import AITextRender from '@/components/ui/text-md'
export default function AIMessage({ data }: { data: any }) {
return (
<div className="mb-8 max-w-[90%]">
<div className="bg-surface-element-normal inline-block rounded-lg p-4 backdrop-blur-2xl">
<AITextRender text={data.text} />
</div>
</div>
)
}

View File

@ -1,67 +1,51 @@
'use client'
import { useParams } from 'next/navigation'
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Tag } from '@/components/ui/tag'
import { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import CrushLevelAvatar from './CrushLevelAvatar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
// import CrushLevelAvatar from './CrushLevelAvatar'
export const CharacterAvatorAndName = () => {
const { introduction, characterName, tagName } = {
introduction: 'introduction introduction introduction introduction introduction',
characterName: 'characterName',
tagName: 'tagName',
}
return (
<div className="flex flex-col items-center gap-6">
<Avatar className="h-20 w-20">
<AvatarImage src="https://picsum.photos/200/300" />
<AvatarFallback>{characterName?.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="txt-headline-s text-center text-white">Honey Snow</div>
</div>
)
}
const ChatMessageUserHeader = () => {
const [isFullIntroduction, setIsFullIntroduction] = useState(false)
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
const textRef = useRef<HTMLDivElement>(null)
const { aiId } = useParams()
const { data } = useGetAIUserBaseInfo({ aiId: Number(aiId) })
const { headImg, nickname, introduction, characterName, tagName } = data || {}
const { introduction, characterName, tagName } = {
introduction: 'introduction introduction introduction introduction introduction',
characterName: 'characterName',
tagName: 'tagName',
}
// 检测文本是否超过三行
useEffect(() => {
if (textRef.current && introduction) {
// 创建一个临时元素来测量文本高度
const tempElement = document.createElement('div')
tempElement.style.cssText = `
position: absolute;
visibility: hidden;
width: ${textRef.current.offsetWidth}px;
font-size: 14px;
font-family: inherit;
line-height: 20px;
white-space: normal;
word-break: break-word;
`
tempElement.className = textRef.current.className
.replace('line-clamp-3', '')
.replace('line-clamp-none', '')
tempElement.textContent = introduction
document.body.appendChild(tempElement)
// 计算单行高度
const singleLineElement = document.createElement('div')
singleLineElement.style.cssText = tempElement.style.cssText
singleLineElement.className = tempElement.className
singleLineElement.textContent = 'A'
document.body.appendChild(singleLineElement)
const singleLineHeight = singleLineElement.offsetHeight
const fullTextHeight = tempElement.offsetHeight
// 清理临时元素
document.body.removeChild(tempElement)
document.body.removeChild(singleLineElement)
// 判断是否超过三行(考虑一些容差)
const threeLineHeight = singleLineHeight * 3
console.log('fullTextHeight', fullTextHeight, threeLineHeight)
setShouldShowExpandButton(fullTextHeight > threeLineHeight + 2)
// 直接比较滚动高度和可见高度
// 如果内容的实际高度大于容器的可见高度,说明内容被截断了
const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight
setShouldShowExpandButton(isOverflowing)
}
}, [introduction])
return (
<div className="flex flex-col items-center gap-6">
<CrushLevelAvatar showAnimation={true} />
<CharacterAvatorAndName />
<div className="bg-surface-element-normal border-outline-normal flex w-full flex-col gap-2 rounded-lg border border-solid p-4 backdrop-blur-2xl">
<div
@ -74,7 +58,7 @@ const ChatMessageUserHeader = () => {
wordBreak: 'break-word',
}}
>
{introduction}
{introduction.repeat(10)}
</div>
<div className="flex items-center justify-between">

View File

@ -0,0 +1,104 @@
'use client'
import { IconButton } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useState, useRef, useEffect } from 'react'
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
const { maxHeight = 200, className, value, onChange, ...restProps } = props
const textareaRef = useRef<HTMLTextAreaElement>(null)
// 调整高度的函数
const adjustHeight = () => {
const textarea = textareaRef.current
if (!textarea) return
// 先重置高度为 0这样才能获取真实的 scrollHeight
textarea.style.height = '0px'
// 获取内容实际需要的高度
const scrollHeight = textarea.scrollHeight
// 计算新高度:取 scrollHeight 和 maxHeight 的较小值
const newHeight = Math.min(scrollHeight, maxHeight)
textarea.style.height = `${newHeight}px`
// 如果内容超过最大高度,显示滚动条
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
}
// 监听内容变化,自动调整高度
useEffect(() => {
adjustHeight()
}, [value, maxHeight])
return (
<textarea
ref={textareaRef}
value={value}
onChange={onChange}
className={cn(
'w-full resize-none',
// 移除所有默认样式
'border-none outline-none focus:outline-none',
'bg-transparent',
// 自定义滚动条样式(可选)
'scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent',
className
)}
style={{
minHeight: '24px', // 最小高度,一行文字的高度
height: '24px', // 初始高度
overflow: 'hidden', // 初始隐藏滚动条
}}
{...restProps}
/>
)
}
export default function Input() {
const [isRecording, setIsRecording] = useState(false)
const [inputValue, setInputValue] = useState('')
return (
<div className="flex mb-6 items-end gap-4">
<div></div>
<div className="flex w-full items-end gap-4">
{/* 打电话按钮 */}
<IconButton onClick={() => {}} iconfont="icon-gift-border" />
<div className="flex-1 flex items-end gap-2 min-h-12 py-2 px-2 bg-white/15 rounded-3xl">
{/* 语音录制按钮 */}
<IconButton
variant="ghost"
size="small"
iconfont="icon-voice_msg"
className="flex-shrink-0"
/>
<AuthHeightTextarea
placeholder="Chat"
maxHeight={70}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="py-1"
/>
{/* 提示词提示按钮 */}
<IconButton
variant="ghost"
size="small"
onClick={() => {}}
className={cn('bg-surface-element-hover flex-shrink-0')}
iconfont="icon-prompt"
/>
</div>
<IconButton
size="large"
loading={false}
iconfont="icon-icon-send"
onClick={() => {}}
disabled={false}
className="flex-shrink-0"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,45 @@
'use client'
import CharacterHeader from './CharacterHeader'
import AIMessage from './AIMessage'
import UserMessage from './UserMessage'
import VirtualList from '@/components/ui/virtual-list'
export default function MessageList() {
const itemList = [
{
type: 'header',
},
{
type: 'aiMessage',
data: {
text: '"Hello, how are you?", *Long long ago*, ^there was a beautiful princess...^ (She was the most beautiful princess in the world)',
},
},
{
type: 'userMessage',
data: {
text: '"Hello, how are you?", Long long ago, there was a beautiful princess... (She was the most beautiful princess in the world)',
},
},
]
const itemContent = (index: number, item: { type: string; data: any }) => {
switch (item.type) {
case 'aiMessage':
return <AIMessage data={item.data} />
case 'userMessage':
return <UserMessage data={item.data} />
case 'header':
return <CharacterHeader />
default:
return null
}
}
return (
<div className="flex-1 replative">
<VirtualList className="h-full" data={itemList} itemContent={itemContent} />
</div>
)
}

View File

@ -0,0 +1,225 @@
'use client'
import { SiderHeader } from '.'
import { useChatStore } from '../store'
import { Button, IconButton } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import Image from 'next/image'
import { Checkbox } from '@/components/ui/checkbox'
import React, { useState } from 'react'
import { Tag } from '@/components/ui/tag'
import { ImageViewer } from '@/components/ui/image-viewer'
import { useImageViewer } from '@/hooks/useImageViewer'
type BackgroundItem = {
backgroundId: number
imgUrl: string
isDefault: boolean
inUse?: boolean
}
const BackgroundImageViewerAction = ({
datas,
backgroundId,
isSelected,
onChange,
}: {
datas: BackgroundItem[]
backgroundId: number
isSelected: boolean
onChange: (backgroundId: number) => void
}) => {
const handleSelect = () => {
// 如果只有一张背景且当前已选中,不允许取消选中
if (datas.length === 1 && isSelected) {
return
}
onChange(backgroundId)
}
return (
<>
<div className="bg-outline-normal h-6 w-px" />
<div
className="bg-surface-element-light-normal flex h-8 cursor-pointer items-center justify-center gap-2 rounded-full px-3 backdrop-blur-lg"
onClick={() => handleSelect()}
>
<Checkbox shape="round" checked={isSelected} />
<div className="txt-label-s">Select</div>
</div>
</>
)
}
const BackgroundItemCard = ({
item,
selected,
inUse,
onClick,
onImagePreview,
totalCount,
}: {
item: BackgroundItem
selected: boolean
inUse: boolean
onClick: () => void
onImagePreview: () => void
totalCount: number
}) => {
const handleClick = () => {
// 如果只有一张背景且当前已选中,不允许取消选中
if (totalCount === 1 && selected) {
return
}
onClick()
}
return (
<div className="group relative cursor-pointer" onClick={handleClick}>
<div
className={cn(
'bg-surface-element-normal relative aspect-[3/4] overflow-hidden rounded-lg',
selected && 'border-primary-normal border-2 border-solid'
)}
>
<Image src={item.imgUrl || ''} alt={''} fill className="object-cover" />
</div>
{item.isDefault && (
<Tag className="absolute top-2 left-2" variant="dark" size="small">
Default
</Tag>
)}
{inUse && <Checkbox shape="round" checked className="absolute top-2 right-2" />}
<IconButton
className="absolute right-2 bottom-2 opacity-0 transition-opacity group-hover:opacity-100"
size="xs"
variant="contrast"
onClick={(e) => {
e.stopPropagation()
onImagePreview()
}}
>
<i className="iconfont icon-icon-fullImage" />
</IconButton>
</div>
)
}
export default function Background() {
const setSideBar = useChatStore((store) => store.setSideBar)
const [selectId, setSelectId] = useState<number | undefined>(1)
const [loading, setLoading] = useState(false)
// 静态数据:模拟背景图片列表
const backgroundList: BackgroundItem[] = [
{
backgroundId: 0,
imgUrl: 'https://picsum.photos/400/600?random=1',
isDefault: true,
inUse: true,
},
{
backgroundId: 1,
imgUrl: 'https://picsum.photos/400/600?random=2',
isDefault: false,
},
{
backgroundId: 2,
imgUrl: 'https://picsum.photos/400/600?random=3',
isDefault: false,
},
{
backgroundId: 3,
imgUrl: 'https://picsum.photos/400/600?random=4',
isDefault: false,
},
]
// 当前使用的背景
const currentBackgroundImg = backgroundList.find((item) => item.inUse)?.imgUrl
// 静态数据:是否解锁生成图片功能
const isUnlock = true
// 图片查看器
const {
isOpen: isViewerOpen,
currentIndex: viewerIndex,
openViewer,
closeViewer,
handleIndexChange,
} = useImageViewer()
const handleConfirm = async () => {
setLoading(true)
try {
// TODO: 调用实际的 API
// await updateChatBackground({ aiId, backgroundId: selectId })
console.log('Selected background:', selectId)
// 模拟延迟
await new Promise((resolve) => setTimeout(resolve, 500))
setSideBar('profile')
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
const handleImagePreview = (index: number) => {
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index)
}
return (
<>
<div className="flex h-full flex-col">
<SiderHeader title="Chat Background" />
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
{backgroundList?.map((item, index) => (
<BackgroundItemCard
key={item.backgroundId}
selected={selectId === item.backgroundId}
inUse={item.imgUrl === currentBackgroundImg}
item={item}
onClick={() => setSelectId(item.backgroundId)}
onImagePreview={() => handleImagePreview(index)}
totalCount={backgroundList?.length || 0}
/>
))}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
Cancel
</Button>
<Button size="large" variant="primary" loading={loading} onClick={handleConfirm}>
Confirm
</Button>
</div>
</div>
<ImageViewer
images={backgroundList?.map((item) => item.imgUrl || '') || []}
currentIndex={viewerIndex}
open={isViewerOpen}
onClose={closeViewer}
onIndexChange={handleIndexChange}
showChooseButton={false}
ActionComponent={() => {
return (
<BackgroundImageViewerAction
datas={backgroundList || []}
backgroundId={backgroundList?.[viewerIndex]?.backgroundId || 0}
isSelected={selectId === backgroundList?.[viewerIndex]?.backgroundId}
onChange={setSelectId}
/>
)
}}
/>
</>
)
}

View File

@ -0,0 +1,79 @@
'use client'
import { SiderHeader } from '.'
import { useChatStore } from '../store'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Button, IconButton } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import Image from 'next/image'
export default function ChatModel() {
const setSideBar = useChatStore((store) => store.setSideBar)
return (
<div className="flex flex-col h-full">
<SiderHeader title="Chat Model" />
<div className="flex-1">
<div className="bg-surface-element-normal overflow-hidden rounded-lg p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="txt-title-s">Role-Playing Model</div>
<Tooltip>
<TooltipTrigger asChild>
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<div className="space-y-2">
<p className="break-words">
Text Message Price: Refers to the cost of chatting with the character via text
messages, including sending text, images, or gifts. Charged per message.
</p>
<p className="break-words">
Voice Message Price: Refers to the cost of sending a voice message to the
character or playing the characters voice. Charged per use.
</p>
<p className="break-words">
Voice Call Price: Refers to the cost of having a voice call with the
character. Charged per minute.
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<Checkbox checked={true} shape="round" />
</div>
<div className="txt-body-m text-txt-secondary-normal mt-1">
Role-play a conversation with AI
</div>
<div className="bg-surface-district-normal mt-3 rounded-sm p-3">
<div className="flex items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal">1/Text Message</span>
</div>
<div className="mt-3 flex items-center justify-between gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal">10/Send or play voice</span>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal">20/min Voice call</span>
</div>
</div>
</div>
</div>
<div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div>
</div>
<div className="flex gap-4 justify-end">
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
Cancel
</Button>
<Button onClick={() => setSideBar('profile')}>Select</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,85 @@
'use client'
import { SiderHeader } from '.'
import { useChatStore } from '../store'
import { useState } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type FontOption = {
value: number
label: string
isStandard?: boolean
}
export default function Font() {
const setSideBar = useChatStore((store) => store.setSideBar)
// 字体大小选项
const fontOptions: FontOption[] = [
{ value: 12, label: 'A 12' },
{ value: 14, label: 'A 14' },
{ value: 16, label: 'A 16', isStandard: true },
{ value: 18, label: 'A 18' },
{ value: 20, label: 'A 20' },
]
const [selectedFont, setSelectedFont] = useState(16)
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
// TODO: 调用实际的 API 保存字体设置
// await updateFontSize({ fontSize: selectedFont })
console.log('Selected font size:', selectedFont)
// 模拟延迟
await new Promise((resolve) => setTimeout(resolve, 500))
setSideBar('profile')
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
return (
<div className="flex h-full flex-col">
<SiderHeader title="Font" />
<div className="flex-1">
<div className="flex flex-col gap-3">
{fontOptions.map((option) => (
<div
key={option.value}
className={cn(
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
selectedFont === option.value && 'bg-surface-element-hover'
)}
onClick={() => setSelectedFont(option.value)}
>
<div className="txt-title-s flex items-center gap-2">
{option.label}
{option.isStandard && (
<span className="txt-body-m text-txt-secondary-normal">(standard)</span>
)}
</div>
<Checkbox shape="round" checked={selectedFont === option.value} />
</div>
))}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
Cancel
</Button>
<Button size="large" variant="primary" loading={loading} onClick={handleConfirm}>
Select
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
'use client'
import { SiderHeader } from '.'
import { useChatStore } from '../store'
import { useState } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type TokenOption = {
value: number
label: string
}
export default function MaxToken() {
const setSideBar = useChatStore((store) => store.setSideBar)
// 最大回复数选项
const tokenOptions: TokenOption[] = [
{ value: 800, label: '800' },
{ value: 1000, label: '1000' },
{ value: 1200, label: '1200' },
{ value: 1500, label: '1500' },
]
const [selectedToken, setSelectedToken] = useState(800)
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
// TODO: 调用实际的 API 保存最大回复数设置
// await updateMaxToken({ maxToken: selectedToken })
console.log('Selected max token:', selectedToken)
// 模拟延迟
await new Promise((resolve) => setTimeout(resolve, 500))
setSideBar('profile')
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
return (
<div className="flex h-full flex-col">
<SiderHeader title="Maximum Replies" />
<div className="flex-1">
<div className="flex flex-col gap-3">
{tokenOptions.map((option) => (
<div
key={option.value}
className={cn(
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
selectedToken === option.value && 'bg-surface-element-hover'
)}
onClick={() => setSelectedToken(option.value)}
>
<div className="txt-title-s">{option.label}</div>
<Checkbox shape="round" checked={selectedToken === option.value} />
</div>
))}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
Cancel
</Button>
<Button size="large" variant="primary" loading={loading} onClick={handleConfirm}>
Select
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,372 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { SiderHeader } from '.'
import { useChatStore } from '../store'
import { z } from 'zod'
import dayjs from 'dayjs'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { Gender } from '@/types/user'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { calculateAge, getDaysInMonth } from '@/lib/utils'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
const currentYear = dayjs().year()
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`)
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'))
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'))
const characterFormSchema = z
.object({
nickname: z
.string()
.trim()
.min(1, 'Please Enter nickname')
.min(2, 'Nickname must be between 2 and 20 characters')
.max(20, 'Nickname must be less than 20 characters'),
sex: z.enum(Gender, { message: 'Please select gender' }),
year: z.string().min(1, 'Please select year'),
month: z.string().min(1, 'Please select month'),
day: z.string().min(1, 'Please select day'),
profile: z.string().trim().optional(),
})
.refine(
(data) => {
const age = calculateAge(data.year, data.month, data.day)
return age >= 18
},
{
message: 'Character age must be at least 18 years old',
path: ['year'],
}
)
.refine(
(data) => {
if (data.profile) {
if (data.profile.trim().length > 300) {
return false
}
return data.profile.trim().length >= 10
}
return true
},
{
message: 'At least 10 characters',
path: ['profile'],
}
)
export default function Personal() {
const setSideBar = useChatStore((store) => store.setSideBar)
const [loading, setLoading] = useState(false)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
// 静态数据,模拟从接口获取的数据
const chatSettingData = {
nickname: 'John',
sex: Gender.MALE,
birthday: dayjs('1995-06-15').valueOf(),
whoAmI: 'A creative and passionate developer',
}
const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined
const form = useForm<z.infer<typeof characterFormSchema>>({
resolver: zodResolver(characterFormSchema),
defaultValues: {
nickname: chatSettingData?.nickname || '',
sex: chatSettingData?.sex,
year: birthday?.year().toString() || undefined,
month:
birthday?.month() !== undefined
? (birthday.month() + 1).toString().padStart(2, '0')
: undefined,
day: birthday?.date().toString().padStart(2, '0') || undefined,
profile: chatSettingData?.whoAmI || '',
},
})
// 处理返回的逻辑
const handleGoBack = useCallback(() => {
if (form.formState.isDirty) {
setShowConfirmDialog(true)
} else {
setSideBar('profile')
}
}, [form.formState.isDirty, setSideBar])
// 确认放弃修改
const handleConfirmDiscard = useCallback(() => {
form.reset()
setShowConfirmDialog(false)
setSideBar('profile')
}, [form, setSideBar])
async function onSubmit(data: z.infer<typeof characterFormSchema>) {
if (!form.formState.isDirty) {
setSideBar('profile')
return
}
setLoading(true)
try {
// TODO: 这里应该调用实际的 API
// 模拟检查昵称是否存在
const isExist = false // await checkNickname({ nickname: data.nickname.trim() })
if (isExist) {
form.setError('nickname', {
message: 'This nickname is already taken',
})
return
}
// TODO: 这里应该调用实际的保存 API
// await setMyChatSetting({
// aiId,
// nickname: data.nickname,
// birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
// whoAmI: data.profile || '',
// })
console.log('Saved data:', {
nickname: data.nickname,
birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
whoAmI: data.profile || '',
})
setSideBar('profile')
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
const selectedYear = form.watch('year')
const selectedMonth = form.watch('month')
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
const genderTexts = [
{
value: Gender.MALE,
label: 'Male',
},
{
value: Gender.FEMALE,
label: 'Female',
},
{
value: Gender.OTHER,
label: 'Other',
},
]
const gender = form.watch('sex')
const genderText = genderTexts.find((text) => text.value === gender)?.label
return (
<>
<div className="flex flex-col gap-6">
<SiderHeader title="My Chat Persona" />
<Form {...form}>
<form className="space-y-8">
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel>Nickname</FormLabel>
<FormControl>
<Input
placeholder="Enter nickname"
maxLength={20}
error={!!form.formState.errors.nickname}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="">
<div className="txt-label-m text-txt-secondary-normal">Gender</div>
<div className="mt-3">
<div className="bg-surface-element-normal rounded-m txt-body-l text-txt-secondary-disabled flex h-12 items-center px-4 py-3">
{genderText}
</div>
<div className="txt-body-s text-txt-secondary-disabled mt-1">
Please note: gender cannot be changed after setting
</div>
</div>
</div>
<div>
<Label className="txt-label-m mb-3 block">Birthday</Label>
<div className="flex gap-2">
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{months.map((m, index) => (
<SelectItem key={m} value={m}>
{monthTexts[index]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="day"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent>
{days.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<FormMessage>
{form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage>
</div>
<FormField
control={form.control}
name="profile"
render={({ field }) => (
<FormItem>
<FormLabel>
My Persona
<span className="txt-label-m text-txt-secondary-normal">(Optional)</span>
</FormLabel>
<FormControl>
<Textarea
{...field}
maxLength={300}
error={!!form.formState.errors.profile}
placeholder="Set your own persona in CrushLevel"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<div className="flex gap-3">
<Button variant="tertiary" size="large" className="flex-1" onClick={handleGoBack}>
Cancel
</Button>
<Button
type="submit"
size="large"
className="flex-1"
onClick={form.handleSubmit(onSubmit)}
loading={loading}
>
Save
</Button>
</div>
</div>
{/* 确认放弃修改的对话框 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
<AlertDialogDescription>
The edited content will not be saved after exiting. Please confirm whether to continue
exiting?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowConfirmDialog(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}>
Exit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -0,0 +1,183 @@
'use client'
import { getAge } from '@/lib/utils'
import { CharacterAvatorAndName } from '../CharacterHeader'
import { Tag } from '@/components/ui/tag'
import Image from 'next/image'
import { cn } from '@/lib/utils'
import { useChatStore } from '../store'
import { IconButton } from '@/components/ui/button'
import React from 'react'
import { Switch } from '@/components/ui/switch'
const genderMap = {
0: '/icons/male.svg',
1: '/icons/female.svg',
2: '/icons/gender-neutral.svg',
}
const ChatProfilePersona = React.memo(() => {
const setSideBar = useChatStore((store) => store.setSideBar)
const whoAmI = 'whoAmI'
return (
<div className="flex w-full flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="txt-title-s">My Chat Persona</div>
<div
className="txt-label-m text-primary-variant-normal cursor-pointer"
onClick={() => setSideBar('personal')}
>
Edit
</div>
</div>
<div className="bg-surface-base-normal rounded-m py-1">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Nickname</div>
<div className="txt-body-l text-txt-primary-normal flex-1 truncate text-right">{''}</div>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Gender</div>
<div className="txt-body-l text-txt-primary-normal">
{genderMap[0 as keyof typeof genderMap]}
</div>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Age</div>
<div className="txt-body-l text-txt-primary-normal">{getAge(Number(23))}</div>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Who am I</div>
<div
className={cn(
'txt-body-l text-txt-primary-normal flex-1 truncate text-right',
whoAmI ? 'text-txt-primary-normal' : 'text-txt-secondary-normal'
)}
>
{whoAmI || 'Unfilled'}
</div>
</div>
</div>
</div>
)
})
type SettingItem = {
onClick: () => void
label: string
value?: React.ReactNode
}
export default function Profile() {
const setSideBar = useChatStore((store) => store.setSideBar)
const chatSettingItems: SettingItem[][] = [
[
{
onClick: () => setSideBar('model'),
label: 'Chat Model',
value: 'Role-Playing',
},
{
onClick: () => null,
label: 'Long text',
value: <Switch checked={true} />,
},
],
[
{
onClick: () => setSideBar('max_token'),
label: 'Maximum Replies',
value: '1200',
},
],
[
{
onClick: () => setSideBar('font'),
label: 'Font',
value: '17px',
},
{
onClick: () => setSideBar('background'),
label: 'Chat Background',
value: '17px',
},
],
]
const voiceSettingItems: SettingItem[][] = [
[
{
onClick: () => setSideBar('voice_actor'),
label: 'Voice Artist',
value: 'Default',
},
],
]
const bundleRender = (title: string, items: SettingItem[][]) => {
return (
<div className="flex w-full flex-col items-start justify-start gap-3">
<div className="txt-title-s w-full text-left">{title}</div>
<div className="flex w-full flex-col items-start justify-start gap-4">
{items.map((list, index) => (
<div key={`group_${index}`} className="bg-surface-base-normal rounded-m w-full py-1">
{list.map((item, itemIndex) => (
<div
key={`item_${itemIndex}`}
className={cn(
'flex h-12 cursor-pointer items-center justify-between mx-4 py-3',
itemIndex < list.length - 1 && 'border-b border-outline-normal'
)}
onClick={item.onClick}
>
<div className="txt-label-l flex-1">{item.label}</div>
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
{item.value && (
<div className="txt-body-l flex items-center text-txt-primary-normal truncate">
{item.value}
</div>
)}
{typeof item.value === 'string' && (
<IconButton iconfont="icon-arrow-right-border" size="small" variant="ghost" />
)}
</div>
</div>
))}
</div>
))}
</div>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<CharacterAvatorAndName />
{/* Tags */}
<div className="flex w-full flex-col items-start justify-start gap-4">
<div className="flex w-full flex-wrap items-start justify-center gap-2">
<Tag>
<Image
src={genderMap[0 as keyof typeof genderMap]}
alt="Gender"
width={16}
height={16}
/>
<div>{getAge(Number(24))}</div>
</Tag>
<Tag>{'Sensibility'}</Tag>
<Tag>{'Romantic'}</Tag>
</div>
</div>
<ChatProfilePersona />
{bundleRender('Chat Setting', chatSettingItems)}
{bundleRender('Voice Setting', voiceSettingItems)}
</div>
)
}

View File

@ -0,0 +1,170 @@
'use client'
import { SiderHeader } from '.'
import { useChatStore } from '../store'
import { useState } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
type VoiceGender = 'all' | 'male' | 'female'
type VoiceActorItem = {
id: number
name: string
description: string
avatarUrl: string
gender: 'male' | 'female'
}
export default function VoiceActor() {
const setSideBar = useChatStore((store) => store.setSideBar)
// 语音演员列表(静态数据)
const voiceActors: VoiceActorItem[] = [
{
id: 1,
name: 'Voice Actor 1',
description: 'Have a role-playing conversation with AI',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
gender: 'female',
},
{
id: 2,
name: 'Voice Actor 2',
description: 'Have a role-playing conversation with AI',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
gender: 'female',
},
{
id: 3,
name: 'Voice Actor 3',
description: 'Have a role-playing conversation with AI',
avatarUrl: 'https://i.pravatar.cc/150?img=3',
gender: 'male',
},
{
id: 4,
name: 'Voice Actor 4',
description: 'Have a role-playing conversation with AI',
avatarUrl: 'https://i.pravatar.cc/150?img=4',
gender: 'female',
},
{
id: 5,
name: 'Voice Actor 5',
description: 'Have a role-playing conversation with AI',
avatarUrl: 'https://i.pravatar.cc/150?img=5',
gender: 'male',
},
{
id: 6,
name: 'Voice Actor 6',
description: 'Have a role-playing conversation with AI',
avatarUrl: 'https://i.pravatar.cc/150?img=6',
gender: 'female',
},
{
id: 7,
name: 'Voice Actor 7',
description: 'Have a role-playing conversation with AI',
avatarUrl: 'https://i.pravatar.cc/150?img=7',
gender: 'male',
},
]
const [selectedGender, setSelectedGender] = useState<VoiceGender>('all')
const [selectedActorId, setSelectedActorId] = useState(1)
const [loading, setLoading] = useState(false)
// 根据性别过滤演员列表
const filteredActors = voiceActors.filter((actor) => {
if (selectedGender === 'all') return true
return actor.gender === selectedGender
})
const handleConfirm = async () => {
setLoading(true)
try {
// TODO: 调用实际的 API 保存语音演员设置
// await updateVoiceActor({ voiceActorId: selectedActorId })
console.log('Selected voice actor:', selectedActorId)
// 模拟延迟
await new Promise((resolve) => setTimeout(resolve, 500))
setSideBar('profile')
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
return (
<div className="flex h-full flex-col">
<SiderHeader title="Voice Artist" />
{/* Gender Tabs */}
<div className="mb-6 flex gap-6">
{[
{ value: 'all' as const, label: 'All' },
{ value: 'male' as const, label: 'Male' },
{ value: 'female' as const, label: 'Female' },
].map((tab) => (
<button
key={tab.value}
className={cn(
'txt-title-s relative pb-2 transition-colors',
selectedGender === tab.value ? 'text-txt-primary-normal' : 'text-txt-secondary-normal'
)}
onClick={() => setSelectedGender(tab.value)}
>
{tab.label}
{selectedGender === tab.value && (
<div className="bg-primary-normal absolute bottom-0 left-0 right-0 h-0.5 rounded-full" />
)}
</button>
))}
</div>
{/* Voice Actor List */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3">
{filteredActors.map((actor) => (
<div
key={actor.id}
className={cn(
'bg-surface-element-normal flex cursor-pointer items-center gap-3 rounded-lg p-4 transition-colors',
selectedActorId === actor.id && 'bg-surface-element-hover'
)}
onClick={() => setSelectedActorId(actor.id)}
>
<Avatar className="h-14 w-14">
<AvatarImage src={actor.avatarUrl} alt={actor.name} />
<AvatarFallback>{actor.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="txt-title-s text-txt-primary-normal">{actor.name}</div>
<div className="txt-body-s text-txt-secondary-normal mt-1">{actor.description}</div>
</div>
<Checkbox shape="round" checked={selectedActorId === actor.id} />
</div>
))}
</div>
</div>
{/* Footer Buttons */}
<div className="mt-6 flex justify-end gap-3">
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
Cancel
</Button>
<Button size="large" variant="primary" loading={loading} onClick={handleConfirm}>
Select
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,38 @@
'use client'
import { useChatStore } from '../store'
import Profile from './Profile'
import Personal from './Personal'
import VoiceActor from './VoiceActor'
import Font from './Font'
import MaxToken from './MaxToken'
import Background from './Background'
import ChatModel from './ChatModel'
import { IconButton } from '@/components/ui/button'
import React from 'react'
export const SiderHeader = React.memo(({ title }: { title: string }) => {
const setSideBar = useChatStore((store) => store.setSideBar)
return (
<div className="flex gap-2 mb-7 items-center">
<IconButton variant="ghost" size="small" onClick={() => setSideBar('profile')}>
<i className="iconfont-v2 iconv2-jiantou" />
</IconButton>
<div className="txt-title-m">{title}</div>
</div>
)
})
export default function Sider() {
const sideBar = useChatStore((store) => store.sideBar)
return (
<div className="w-100 h-full overflow-y-auto border-outline-normal border-l bg-[rgba(17,16,38,1)] p-6">
{sideBar === 'profile' && <Profile />}
{sideBar === 'personal' && <Personal />}
{sideBar === 'voice_actor' && <VoiceActor />}
{sideBar === 'font' && <Font />}
{sideBar === 'max_token' && <MaxToken />}
{sideBar === 'background' && <Background />}
{sideBar === 'model' && <ChatModel />}
</div>
)
}

View File

@ -0,0 +1,10 @@
'use client'
export default function UserMessage({ data }: { data: any }) {
return (
<div className="mb-8 flex justify-end">
<div className="bg-primary-normal/20 inline-block max-w-[90%] rounded-lg p-4 backdrop-blur-2xl">
{data.text}
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
'use client'
import { IconButton } from '@/components/ui/button'
import Input from './Input'
import MessageList from './MessageList'
import { useChatStore } from './store'
import Sider from './Sider'
export default function ChatPage() {
const isSidebarOpen = useChatStore((store) => store.isSidebarOpen)
const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen)
return (
<div className="flex h-full">
<div className="relative w-full flex-1 flex justify-center">
<div className="max-w-[752px] w-full h-full flex flex-col">
<MessageList />
<Input />
</div>
<IconButton
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="absolute top-1 right-1"
variant="ghost"
size="small"
>
<i className="iconfont-v2 iconv2-zhedie" />
</IconButton>
</div>
{isSidebarOpen && <Sider />}
</div>
)
}

View File

@ -0,0 +1,24 @@
import { create } from 'zustand'
type SideBar =
| 'profile'
| 'personal'
| 'history'
| 'voice_actor'
| 'font'
| 'max_token'
| 'background'
| 'model'
interface ChatStore {
isSidebarOpen: boolean
setIsSidebarOpen: (isSidebarOpen: boolean) => void
sideBar: SideBar
setSideBar: (sideBar: SideBar) => void
}
export const useChatStore = create<ChatStore>((set) => ({
isSidebarOpen: false,
setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }),
sideBar: 'profile',
setSideBar: (sideBar: SideBar) => set({ sideBar }),
}))

View File

@ -0,0 +1,8 @@
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<div>
<h1>Character: {id}</h1>
</div>
)
}

View File

@ -0,0 +1,6 @@
'use client'
import { redirect } from 'next/navigation'
export default function CharacterPage() {
redirect('/home')
}

View File

@ -1,76 +0,0 @@
'use client'
import ChatMessageList from './components/ChatMessageList'
import ChatBackground from './components/ChatBackground'
import ChatMessageAction from './components/ChatMessageAction'
import ChatDrawers from './components/ChatDrawers'
import { ChatConfigProvider } from './context/chatConfig'
import { DrawerLayerProvider } from './components/ChatDrawers/InlineDrawer'
import { IconButton } from '@/components/ui/button'
import { useSetAtom } from 'jotai'
import { isChatProfileDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import ChatCall from './components/ChatCall'
import { Badge } from '@/components/ui/badge'
import { RED_DOT_KEYS, useRedDot } from '@/hooks/useRedDot'
import { useS3TokenCache } from '@/hooks/useS3TokenCache'
import { BizTypeEnum } from '@/services/common/types'
import ChatFirstGuideDialog from './components/ChatFirstGuideDialog'
import CoinInsufficientDialog from '@/components/features/coin-insufficient-dialog'
const ChatPage = () => {
const setDrawerState = useSetAtom(isChatProfileDrawerOpenAtom)
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { hasRedDot } = useRedDot()
// 预加载S3 Token提升上传速度
useS3TokenCache({
preloadBizTypes: [BizTypeEnum.SOUND_PATH],
refreshBeforeExpireMinutes: 5,
})
const handleOpenChatProfileDrawer = () => {
setIsChatProfileDrawerOpen(true)
}
const isShowRedDot =
hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE)
return (
<ChatConfigProvider>
<div className="flex overflow-hidden">
<div className="bg-background-default absolute inset-0" />
<div className="border-outline-normal relative flex-1 border-t border-solid transition-all">
<ChatBackground />
{/* 消息列表区域 */}
<div className="relative flex h-[calc(100vh-64px)] flex-col px-6">
<ChatMessageList />
<ChatMessageAction />
<div className="absolute top-6 right-6 h-8 w-8">
<IconButton
iconfont="icon-icon_chatroom_more"
variant="ghost"
size="small"
onClick={handleOpenChatProfileDrawer}
/>
{isShowRedDot && <Badge variant="dot" className="absolute top-0 right-0" />}
</div>
</div>
</div>
<div className="border-outline-normal relative border-t border-solid transition-all">
<DrawerLayerProvider>
<ChatDrawers />
</DrawerLayerProvider>
</div>
</div>
<ChatCall />
<ChatFirstGuideDialog />
<CoinInsufficientDialog />
</ChatConfigProvider>
)
}
export default ChatPage

View File

@ -1,52 +0,0 @@
'use client'
import Image from 'next/image'
import * as React from 'react'
import { useChatConfig } from '../context/chatConfig'
const ChatBackground = () => {
const { aiInfo } = useChatConfig()
const { backgroundImg } = aiInfo || {}
return (
<div className="bg-background-default absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<div className="absolute top-0 bottom-0 left-1/2 w-[752px] -translate-x-1/2">
{backgroundImg && (
<Image
src={backgroundImg}
alt="Background"
className="pointer-events-none h-full w-full object-cover"
width={720}
height={1280}
style={{ objectPosition: 'center -48px' }}
/>
)}
{/* <div className="absolute h-full bottom-0 left-0 right-0 top-1/2 -translate-y-1/2 pointer-events-none min-h-[1280px]" style={{ background: 'radial-gradient(48.62% 48.62% at 50% 50%, rgba(33, 26, 43, 0.35) 0%, #211A2B 100%)' }} /> */}
{/* todo */}
<div
className="absolute top-0 bottom-0 left-0 w-[240px]"
style={{
background:
'linear-gradient(-90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 11.9%, rgba(33, 26, 43, 0.0324148) 21.59%, rgba(33, 26, 43, 0.0704) 29.39%, rgba(33, 26, 43, 0.120652) 35.67%, rgba(33, 26, 43, 0.181481) 40.75%, rgba(33, 26, 43, 0.2512) 44.98%, rgba(33, 26, 43, 0.328119) 48.7%, rgba(33, 26, 43, 0.410548) 52.25%, rgba(33, 26, 43, 0.4968) 55.96%, rgba(33, 26, 43, 0.585185) 60.19%, rgba(33, 26, 43, 0.674015) 65.27%, rgba(33, 26, 43, 0.7616) 71.55%, rgba(33, 26, 43, 0.846252) 79.36%, rgba(33, 26, 43, 0.926281) 89.04%, #211A2B 100.94%)',
}}
/>
<div
className="absolute top-0 right-0 bottom-0 w-[240px]"
style={{
background:
'linear-gradient(90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 12.01%, rgba(33, 26, 43, 0.0324148) 21.79%, rgba(33, 26, 43, 0.0704) 29.67%, rgba(33, 26, 43, 0.120652) 36%, rgba(33, 26, 43, 0.181481) 41.13%, rgba(33, 26, 43, 0.2512) 45.4%, rgba(33, 26, 43, 0.328119) 49.15%, rgba(33, 26, 43, 0.410548) 52.73%, rgba(33, 26, 43, 0.4968) 56.49%, rgba(33, 26, 43, 0.585185) 60.75%, rgba(33, 26, 43, 0.674015) 65.88%, rgba(33, 26, 43, 0.7616) 72.22%, rgba(33, 26, 43, 0.846252) 80.1%, rgba(33, 26, 43, 0.926281) 89.87%, #211A2B 101.89%)',
}}
/>
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(157.27% 64.69% at 50% 37.71%, rgba(33, 26, 43, 0.35) 0%, rgba(33, 26, 43, 0.35545) 11.79%, rgba(33, 26, 43, 0.37107) 21.38%, rgba(33, 26, 43, 0.39576) 29.12%, rgba(33, 26, 43, 0.428424) 35.34%, rgba(33, 26, 43, 0.467963) 40.37%, rgba(33, 26, 43, 0.51328) 44.56%, rgba(33, 26, 43, 0.563277) 48.24%, rgba(33, 26, 43, 0.616856) 51.76%, rgba(33, 26, 43, 0.67292) 55.44%, rgba(33, 26, 43, 0.73037) 59.63%, rgba(33, 26, 43, 0.78811) 64.66%, rgba(33, 26, 43, 0.84504) 70.88%, rgba(33, 26, 43, 0.900064) 78.62%, rgba(33, 26, 43, 0.952083) 88.21%, #211A2B 100%)',
}}
></div>
</div>
</div>
)
}
export default ChatBackground

View File

@ -1,906 +0,0 @@
'use client'
import { useCurrentUser } from '@/hooks/auth'
import { useChatConfig } from '../../context/chatConfig'
import CrushLevelAvatar from '../CrushLevelAvatar'
import { useDoRtcOperation, useGetRtcToken, useStartVoiceChat } from '@/hooks/useIm'
import { useCallback, useEffect, useRef, useState } from 'react'
import { RTCClient } from './rtc-types'
import RtcComponent from './RtcComponent'
import {
AutoPlayFailedEvent,
LocalAudioPropertiesInfo,
MediaType,
onUserJoinedEvent,
onUserLeaveEvent,
PlayerEvent,
} from '@byteplus/rtc'
import { RtcOperation } from '@/services/im'
import ChatEndButton from './ChatEndButton'
import ChatCallStatus from './ChatCallStatus'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import {
hasReceivedAiGreetingAtom,
hasStartAICallAtom,
isCallAtom,
isCoinInsufficientAtom,
selectedConversationIdAtom,
} from '@/atoms/im'
import { COIN_INSUFFICIENT_ERROR_CODE } from '@/hooks/useWallet'
import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat'
import { CustomMessageType } from '@/types/im'
import { useQueryClient } from '@tanstack/react-query'
import { walletKeys } from '@/lib/query-keys'
import { useAudioActivityDetection } from '@/hooks/useAudioActivityDetection'
// 字幕数据结构定义
interface SubtitleData {
text: string
language: string
userId: string
sequence: number
definite: boolean
paragraph: boolean
roundId?: number
}
interface SubtitleMessage {
type: string
data: SubtitleData[]
}
export interface SubtitleState {
userSubtitle: string // 用户字幕
aiSubtitle?: string // AI 字幕(显示用)
aiCompleteMessage: string // AI完整消息拼接
currentAiSentence: string // 当前AI句子缓冲
isUserSpeaking: boolean
isAiSpeaking: boolean
isAiThinking: boolean
hideInterrupt: boolean
}
const durationText = (duration: number) => {
const hours = Math.floor(duration / 3600000)
const minutes = Math.floor((duration % 3600000) / 60000)
const seconds = Math.floor((duration % 60000) / 1000)
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
} else if (minutes > 0) {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
} else {
return `00:${seconds.toString().padStart(2, '0')}`
}
}
const ChatCallContainer = () => {
const [isConnected, setIsConnected] = useState(false)
const [remoteStreams, setRemoteStreams] = useState<{
[key: string]: {
playerComp: React.ReactNode
}
}>({})
const [autoPlayFailUser, setAutoPlayFailUser] = useState<string[]>([])
const [isUserMicSpeaking, setisUserMicSpeaking] = useState<boolean>(false)
const autoPlayFailUserdRef = useRef<string[]>([])
const rtc = useRef<RTCClient>(null)
const rtcComponentRef = useRef<any>(null) // 添加这个ref来存储RtcComponent的引用
const playStatus = useRef<{ [key: string]: { audio: boolean; video: boolean } }>({})
const isJoiningRef = useRef<boolean>(false)
const callStartTimeRef = useRef<number | null>(null) // 通话开始时间
const rtcTimingRef = useRef<{
rtcJoinStart?: number
rtcJoinEnd?: number
localStreamStart?: number
localStreamEnd?: number
startApiCall?: number
startApiSuccess?: number
aiGreetingReceived?: number
}>({}) // RTC各阶段时间记录
const setHasReceivedAiGreeting = useSetAtom(hasReceivedAiGreetingAtom)
const lastCallbackMessageRef = useRef<string>('')
const { nim } = useNimChat()
const { sendMessageActive } = useNimMsgContext()
const selectedConversationId = useAtomValue(selectedConversationIdAtom)
const queryClient = useQueryClient()
// 字幕状态管理 - 使用 ref 来同步保存状态避免异步setState问题
const subtitleStateRef = useRef<SubtitleState>({
userSubtitle: '',
aiSubtitle: '',
aiCompleteMessage: '',
currentAiSentence: '',
isUserSpeaking: false,
isAiSpeaking: false,
isAiThinking: false,
hideInterrupt: false,
})
// 用于触发页面更新的状态
const [subtitleState, setSubtitleState] = useState<SubtitleState>(subtitleStateRef.current)
// 音频播放相关
const connectingAudioRef = useRef<HTMLAudioElement | null>(null)
// 用于跟踪最新的sequence确保按序处理
const lastSequenceRef = useRef<{ [userId: string]: number }>({})
// AI说话结束的防抖定时器
const aiSpeechEndTimerRef = useRef<NodeJS.Timeout | null>(null)
// 用户麦克风说话状态延迟定时器
const userMicSpeakingTimerRef = useRef<NodeJS.Timeout | null>(null)
// 本地音频活动检测
const audioActivityDetection = useAudioActivityDetection({
threshold: 35, // 音频阈值,可以根据需要调整
sampleRate: 50, // 50ms 采样率,更快的响应
})
const setHasStartAICall = useSetAtom(hasStartAICallAtom)
// 用于存储当前的音频流,以便传递给音频检测
const currentAudioStreamRef = useRef<MediaStream | null>(null)
const { aiId } = useChatConfig()
const { data: user } = useCurrentUser()
const userId = user?.userId
const roomId = `${user?.userId}-${aiId}`
const [isCall, setIsCall] = useAtom(isCallAtom)
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom)
// 使用 ref 来存储当前通话的 taskId确保每次开始新通话时生成新的 ID
const taskIdRef = useRef<string | null>(null)
// 用于管理请求取消的 AbortController
const abortControllerRef = useRef<AbortController | null>(null)
// 生成新的 taskId 的函数
const generateNewTaskId = useCallback(() => {
const newTaskId = `${roomId}-${new Date().getTime()}`
taskIdRef.current = newTaskId
return newTaskId
}, [roomId])
const { data } = useGetRtcToken({ roomId })
const { mutateAsync: doRtcOperation } = useDoRtcOperation()
const { mutate: startVoiceChat } = useStartVoiceChat()
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom)
// AI说话结束的防抖处理函数
const handleAiSpeechEndDebounced = useCallback(() => {
// 清除之前的定时器
if (aiSpeechEndTimerRef.current) {
clearTimeout(aiSpeechEndTimerRef.current)
}
// 设置新的防抖定时器
aiSpeechEndTimerRef.current = setTimeout(() => {
console.log('AI说话结束防抖触发启用用户麦克风')
// 更新状态
const currentState = subtitleStateRef.current
const newState = {
...currentState,
hideInterrupt: true,
}
// 启用用户麦克风
rtc.current?.changeAudioState(true)
// 更新状态
updateSubtitleState(newState)
}, 300) // 200毫秒防抖延迟
}, [])
// 清理防抖定时器的函数
const clearAiSpeechEndTimer = useCallback(() => {
if (aiSpeechEndTimerRef.current) {
clearTimeout(aiSpeechEndTimerRef.current)
aiSpeechEndTimerRef.current = null
}
}, [])
// 打断功能
const handleInterrupt = useCallback(async () => {
if (!taskIdRef.current) return
// 清除AI说话结束的防抖定时器
clearAiSpeechEndTimer()
// 用户打断时重新启用麦克风
console.log('用户打断AI重新启用麦克风')
rtc.current?.changeAudioState(true)
await doRtcOperation({
data: {
roomId,
optType: RtcOperation.INTERRUPT,
aiId,
taskId: taskIdRef.current,
},
})
setSubtitleState({
userSubtitle: '',
// aiSubtitle: '',
aiCompleteMessage: '',
currentAiSentence: '',
isUserSpeaking: false,
isAiSpeaking: false,
isAiThinking: false,
hideInterrupt: true,
})
}, [doRtcOperation, roomId, aiId])
const rtcToken = data?.token
const handleUserPublishStream = useCallback(
(stream: { userId: string; mediaType: MediaType }) => {
const userId = stream.userId
if (stream.mediaType & MediaType.VIDEO) {
if (remoteStreams[userId]) {
rtc.current?.setRemoteVideoPlayer(userId, `remoteStream_${userId}`)
}
}
},
[remoteStreams]
)
const handleUserUnpublishStream = (event: { userId: string; mediaType: MediaType }) => {
const { userId, mediaType } = event
if (mediaType & MediaType.VIDEO) {
rtc.current?.setRemoteVideoPlayer(userId, undefined)
}
}
const handleUserJoin = (e: onUserJoinedEvent) => {
console.log('handleUserJoin', e)
}
const handleUserLeave = (e: onUserLeaveEvent) => {
const { userInfo } = e
const remoteUserId = userInfo.userId
if (remoteStreams[remoteUserId]) {
delete remoteStreams[remoteUserId]
}
setRemoteStreams({
...remoteStreams,
})
}
const addFailUser = (userId: string) => {
const index = autoPlayFailUser.findIndex((item) => item === userId)
if (index === -1) {
autoPlayFailUser.push(userId)
}
setAutoPlayFailUser([...autoPlayFailUser])
}
const handleAutoPlayFail = (event: AutoPlayFailedEvent) => {
console.log('handleAutoPlayFail', event.userId, event)
const { userId, kind } = event
let playUser = playStatus.current?.[userId] || {}
playUser = { ...playUser, [kind]: false }
playStatus.current[userId] = playUser
addFailUser(userId)
}
const handleEventError = (e: any, VERTC: any) => {
if (e.errorCode === VERTC.ErrorCode.DUPLICATE_LOGIN) {
// message.error('你的账号被其他人顶下线了');
leaveRoom()
setIsCall(false)
}
}
const playerFail = (params: { type: 'audio' | 'video'; userId: string }) => {
const { type, userId } = params
let playUser = playStatus.current?.[userId] || {}
playUser = { ...playUser, [type]: false }
const { audio, video } = playUser
if (audio === false || video === false) {
addFailUser(userId)
}
}
const handlePlayerEvent = (event: PlayerEvent) => {
const { userId, rawEvent, type } = event
let playUser = playStatus.current?.[userId] || {}
if (!playStatus.current) return
if (rawEvent.type === 'playing') {
playUser = { ...playUser, [type]: true }
const { audio, video } = playUser
if (audio !== false && video !== false) {
const _autoPlayFailUser = autoPlayFailUserdRef.current.filter((item) => item !== userId)
setAutoPlayFailUser([..._autoPlayFailUser])
}
} else if (rawEvent.type === 'pause') {
playerFail({ userId, type })
}
playStatus.current[userId] = playUser
}
// 解包二进制数据并验证
const unpackSubtitleMessage = (message: ArrayBuffer): string | null => {
const kSubtitleHeaderSize = 8
if (message.byteLength < kSubtitleHeaderSize) {
console.warn('Message size too small')
return null
}
const uint8Array = new Uint8Array(message)
// 验证魔数 "subv" (0x73756276)
const magicNumber =
(uint8Array[0] << 24) | (uint8Array[1] << 16) | (uint8Array[2] << 8) | uint8Array[3]
if (magicNumber !== 0x73756276) {
console.warn('Invalid magic number, not a subtitle message')
return null
}
// 获取内容长度
const length =
(uint8Array[4] << 24) | (uint8Array[5] << 16) | (uint8Array[6] << 8) | uint8Array[7]
if (message.byteLength - kSubtitleHeaderSize !== length) {
console.warn('Message length mismatch')
return null
}
if (length === 0) {
return ''
}
// 提取字幕内容
const subtitleBytes = uint8Array.slice(kSubtitleHeaderSize)
return new TextDecoder('utf-8').decode(subtitleBytes)
}
// 解析字幕数据
const parseSubtitleData = (jsonString: string): SubtitleMessage | null => {
try {
const parsed = JSON.parse(jsonString) as SubtitleMessage
if (parsed.type !== 'subtitle' || !Array.isArray(parsed.data)) {
console.warn('Invalid subtitle message format')
return null
}
return parsed
} catch (error) {
console.error('Failed to parse subtitle JSON:', error)
return null
}
}
// 更新状态的辅助函数
const updateSubtitleState = (newState: SubtitleState) => {
if (newState.isAiThinking) {
setTimeout(() => {
setSubtitleState({ ...newState })
rtc.current?.changeAudioState(false)
}, 200)
return
}
subtitleStateRef.current = newState
setSubtitleState({ ...newState })
}
// 结合本地音频检测和服务器回调的用户说话状态
// const combinedUserSpeaking = subtitleState.isUserSpeaking || audioActivityDetection.isSpeaking;
const combinedUserSpeaking = isUserMicSpeaking
// 处理字幕消息
const handleSubtitleMessage = (subtitleMessage: SubtitleMessage) => {
subtitleMessage.data.forEach((subtitle) => {
console.log('Subtitle:', {
text: subtitle.text,
language: subtitle.language,
userId: subtitle.userId,
sequence: subtitle.sequence,
isComplete: subtitle.definite,
isParagraphEnd: subtitle.paragraph,
roundId: subtitle.roundId,
})
const isCurrentUser = subtitle.userId === String(`${userId}`)
const userKey = isCurrentUser ? 'user' : 'ai'
// 检查sequence序列确保按序处理
const lastSequence = lastSequenceRef.current[userKey] || 0
if (subtitle.sequence <= lastSequence) {
console.log('跳过过期的字幕消息:', subtitle.sequence, '当前最新:', lastSequence)
return
}
// 更新sequence
lastSequenceRef.current[userKey] = subtitle.sequence
// 获取当前状态从ref中获取最新状态
const currentState = subtitleStateRef.current
const newState = { ...currentState }
if (isCurrentUser) {
// 处理用户字幕
if (subtitle.paragraph === false && subtitle.definite === false) {
// 用户实时说话
console.log('用户实时字幕:', subtitle.text)
newState.userSubtitle = subtitle.text
newState.isUserSpeaking = true
// 用户开始说话时清空AI字幕和完整消息准备接收新的AI回复
// newState.aiSubtitle = '';
newState.aiCompleteMessage = ''
newState.currentAiSentence = ''
newState.isAiSpeaking = false // 现在明确设置为false切换到listening状态
newState.isAiThinking = false
} else if (subtitle.paragraph === true) {
// 用户说话结束
console.log('用户说话结束:', subtitle.text)
newState.isUserSpeaking = false
newState.isAiThinking = true
}
} else {
// 处理AI字幕
if (subtitle.paragraph === false && subtitle.definite === false) {
// AI实时字幕 - 显示当前句子的实时内容
console.log('AI实时字幕:', subtitle.text)
// AI开始说话时清除防抖定时器
clearAiSpeechEndTimer()
newState.currentAiSentence = subtitle.text
newState.aiSubtitle = newState.aiCompleteMessage + subtitle.text
// AI开始说话时禁用用户麦克风
// if (!currentState.isAiSpeaking) {
// console.log('AI1开始说话禁用用户麦克风');
// rtc.current?.changeAudioState(false);
// }
rtc.current?.changeAudioState(false)
newState.isAiSpeaking = true
newState.isAiThinking = false
newState.userSubtitle = ''
newState.isUserSpeaking = false
newState.hideInterrupt = false
} else if (subtitle.paragraph === false && subtitle.definite === true) {
// AI确定字幕 - 将确定的句子添加到完整消息中
console.log('AI确定字幕:', subtitle.text)
// 防重复处理
if (lastCallbackMessageRef.current === subtitle.text) {
return
}
lastCallbackMessageRef.current = subtitle.text
// 将确定的文本添加到完整消息中
newState.aiCompleteMessage = newState.aiCompleteMessage + subtitle.text
newState.currentAiSentence = '' // 清空当前句子缓冲
newState.aiSubtitle = newState.aiCompleteMessage // 更新显示
newState.isAiSpeaking = true
newState.isAiThinking = false
newState.hideInterrupt = false
} else if (subtitle.paragraph === true) {
// AI段落结束 - 完整回复结束
console.log('AI回复完成:', subtitle.text)
// 如果还有剩余文本,添加到完整消息中
if (subtitle.text && !newState.aiCompleteMessage.includes(subtitle.text)) {
newState.aiCompleteMessage = newState.aiCompleteMessage + subtitle.text
newState.aiSubtitle = newState.aiCompleteMessage
}
newState.currentAiSentence = ''
// 不立即设置 hideInterrupt 和 changeAudioState而是使用防抖处理
// newState.hideInterrupt = true;
// rtc.current?.changeAudioState(true);
// AI说完话后保持显示状态不立即切换到listening
// newState.isAiSpeaking = false; // 注释掉这行让AI字幕继续显示
newState.isAiThinking = false
// 输出完整的AI回复用于调试
console.log('AI完整回复:', newState.aiCompleteMessage)
// 使用防抖处理AI说话结束
handleAiSpeechEndDebounced()
}
}
// 更新状态并触发页面刷新
updateSubtitleState(newState)
// 判断字幕来源
if (isCurrentUser) {
console.log('Human user subtitle:', subtitle.text)
} else {
console.log('AI agent subtitle:', subtitle.text)
}
})
}
const handleRoomBinaryMessageReceived = (event: { userId: string; message: ArrayBuffer }) => {
console.log('handleRoomBinaryMessageReceived', event)
const { message } = event
// 记录收到AI开场白时间
if (!rtcTimingRef.current.aiGreetingReceived) {
rtcTimingRef.current.aiGreetingReceived = Date.now()
const totalDuration =
rtcTimingRef.current.aiGreetingReceived - rtcTimingRef.current.rtcJoinStart!
const startToGreetingDuration =
rtcTimingRef.current.aiGreetingReceived - rtcTimingRef.current.startApiSuccess!
console.log('🎉 收到AI开场白:', new Date().toLocaleString())
console.log('📊 RTC完整流程耗时统计:')
console.log(
` - RTC加入房间: ${rtcTimingRef.current.rtcJoinEnd! - rtcTimingRef.current.rtcJoinStart!}ms`
)
console.log(
` - 创建本地流: ${rtcTimingRef.current.localStreamEnd! - rtcTimingRef.current.localStreamStart!}ms`
)
console.log(
` - START接口调用: ${rtcTimingRef.current.startApiSuccess! - rtcTimingRef.current.startApiCall!}ms`
)
console.log(` - START接口到AI开场白: ${startToGreetingDuration}ms`)
console.log(` - 总耗时: ${totalDuration}ms`)
}
setHasReceivedAiGreeting(true)
if (connectingAudioRef.current) {
connectingAudioRef.current.pause()
connectingAudioRef.current.currentTime = 0
connectingAudioRef.current = null
}
// 尝试解包字幕消息
const subtitleJson = unpackSubtitleMessage(message)
if (subtitleJson !== null) {
// 是字幕消息,进行解析
const subtitleMessage = parseSubtitleData(subtitleJson)
if (subtitleMessage) {
handleSubtitleMessage(subtitleMessage)
return
}
}
}
const handleLocalAudioPropertiesReport = (event: LocalAudioPropertiesInfo[]) => {
console.log('handleLocalAudioPropertiesReport', event)
// 如果用户麦克风正在说话则设置isUserMicSpeaking为true
if (event.some((item) => item.audioPropertiesInfo.linearVolume > 25)) {
setisUserMicSpeaking(true)
} else {
setisUserMicSpeaking(false)
}
}
const leaveRoom = useCallback(async () => {
if (!rtc.current) return
// off the event
rtc.current.removeEventListener()
try {
await rtc.current.leave()
} catch (error) {
console.log('leaveRoom error', error)
}
setAutoPlayFailUser([])
setSubtitleState({
userSubtitle: '',
aiSubtitle: '',
aiCompleteMessage: '',
currentAiSentence: '',
isUserSpeaking: false,
isAiSpeaking: false,
isAiThinking: false,
hideInterrupt: false,
})
// 重置通话开始时间和AI开场白状态
callStartTimeRef.current = null
setHasReceivedAiGreeting(false)
isJoiningRef.current = false
rtcComponentRef.current = null // 清理RtcComponent引用
taskIdRef.current = null // 清理taskId为下次通话做准备
// 重置RTC时间记录
rtcTimingRef.current = {}
// 清理 AbortController
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
// 清理连接音频
if (connectingAudioRef.current) {
connectingAudioRef.current.pause()
connectingAudioRef.current.currentTime = 0
connectingAudioRef.current = null
}
// 停止本地音频活动检测
audioActivityDetection.stopDetection()
// 清理音频流引用
if (currentAudioStreamRef.current) {
currentAudioStreamRef.current.getTracks().forEach((track) => track.stop())
currentAudioStreamRef.current = null
}
// 清理用户麦克风说话状态定时器
if (userMicSpeakingTimerRef.current) {
clearTimeout(userMicSpeakingTimerRef.current)
userMicSpeakingTimerRef.current = null
}
setIsConnected(false)
}, [setHasReceivedAiGreeting])
useEffect(() => {
;(async () => {
if (!roomId || !userId || !rtc.current || !rtcToken || isJoiningRef.current) return
// 在每次开始新通话时生成新的 taskId
const currentTaskId = generateNewTaskId()
isJoiningRef.current = true
// 重置AI开场白状态
setHasReceivedAiGreeting(false)
// 记录RTC加入开始时间
rtcTimingRef.current.rtcJoinStart = Date.now()
console.log('🚀 RTC加入房间开始:', new Date().toLocaleString())
try {
await rtc.current.join((rtcToken as any) || null, roomId, `${userId}`)
// 记录RTC加入成功时间
rtcTimingRef.current.rtcJoinEnd = Date.now()
const rtcJoinDuration = rtcTimingRef.current.rtcJoinEnd - rtcTimingRef.current.rtcJoinStart!
console.log(
'✅ RTC加入房间成功:',
new Date().toLocaleString(),
`耗时: ${rtcJoinDuration}ms`
)
// 记录通话开始时间
callStartTimeRef.current = Date.now()
rtc?.current?.createLocalStream(`${userId}`, async (res: any) => {
// 记录创建本地流开始时间
rtcTimingRef.current.localStreamStart = Date.now()
console.log('🎤 创建本地流开始:', new Date().toLocaleString())
// rtc.current?.changeAudioState(true);
// 启动本地音频活动检测
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
currentAudioStreamRef.current = stream
audioActivityDetection.startDetection(stream)
console.log('本地音频活动检测已启动')
} catch (error) {
console.error('启动本地音频检测失败:', error)
}
try {
// 记录创建本地流结束时间
rtcTimingRef.current.localStreamEnd = Date.now()
const localStreamDuration =
rtcTimingRef.current.localStreamEnd - rtcTimingRef.current.localStreamStart!
console.log(
'✅ 创建本地流完成:',
new Date().toLocaleString(),
`耗时: ${localStreamDuration}ms`
)
// 创建新的 AbortController 用于这次请求
const abortController = new AbortController()
abortControllerRef.current = abortController
// 记录START接口调用开始时间
rtcTimingRef.current.startApiCall = Date.now()
console.log('📞 调用START接口开始:', new Date().toLocaleString())
await doRtcOperation({
data: {
roomId,
optType: RtcOperation.START,
aiId,
taskId: currentTaskId,
},
signal: abortController.signal,
})
await rtc.current?.enableAudioPropertiesReport({
interval: 300,
})
// 记录START接口调用成功时间
rtcTimingRef.current.startApiSuccess = Date.now()
const startApiDuration =
rtcTimingRef.current.startApiSuccess - rtcTimingRef.current.startApiCall!
console.log(
'✅ START接口调用成功:',
new Date().toLocaleString(),
`耗时: ${startApiDuration}ms`
)
setHasStartAICall(true)
// 请求成功完成,清理 AbortController
abortControllerRef.current = null
} catch (error: any) {
// 清理 AbortController
abortControllerRef.current = null
// 如果是用户主动取消的请求,不需要处理错误
if (error.name === 'AbortError' || error.name === 'CanceledError') {
console.log('START 请求已被用户取消')
return
}
if (error.errorCode && error.errorCode === COIN_INSUFFICIENT_ERROR_CODE) {
setIsCall(false)
leaveRoom()
setIsCoinInsufficient(true)
}
}
// startVoiceChat({
// roomId,
// aiId,
// taskId: currentTaskId,
// userId,
// });
setIsConnected(true)
})
} catch (error: any) {
console.log('join error', error)
isJoiningRef.current = false
leaveRoom()
setIsCall(false)
}
})()
}, [
roomId,
userId,
rtcToken,
doRtcOperation,
aiId,
generateNewTaskId,
leaveRoom,
setIsCall,
setHasReceivedAiGreeting,
setHasStartAICall,
])
useEffect(() => {
if (!isCall) return
const handleCallCoinInsufficient = () => {
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
leaveRoom()
setIsCall(false)
setIsCoinInsufficient(true)
}
window.addEventListener('call-coin-insufficient', handleCallCoinInsufficient)
return () => {
window.removeEventListener('call-coin-insufficient', handleCallCoinInsufficient)
}
}, [isCall])
// 监听 isCall 变化,当用户关闭通话时取消正在进行的请求
useEffect(() => {
if (!isCall && abortControllerRef.current) {
console.log('用户关闭通话,取消正在进行的 START 请求')
abortControllerRef.current.abort()
abortControllerRef.current = null
}
}, [isCall])
// 控制连接音频播放
useEffect(() => {
if (!hasReceivedAiGreeting && isCall) {
// 创建或获取音频元素
if (!connectingAudioRef.current) {
connectingAudioRef.current = new Audio('/voice/connecting.mp3')
connectingAudioRef.current.loop = true
}
// 播放音频
connectingAudioRef.current.play().catch((error) => {
console.log('音频播放失败:', error)
})
} else {
// 停止音频播放
if (connectingAudioRef.current) {
connectingAudioRef.current.pause()
connectingAudioRef.current.currentTime = 0
}
}
return () => {
if (connectingAudioRef.current) {
connectingAudioRef.current.pause()
connectingAudioRef.current.currentTime = 0
connectingAudioRef.current = null
}
}
}, [hasReceivedAiGreeting, isCall])
// 组件卸载时清理防抖定时器
useEffect(() => {
return () => {
clearAiSpeechEndTimer()
// 清理用户麦克风说话状态定时器
if (userMicSpeakingTimerRef.current) {
clearTimeout(userMicSpeakingTimerRef.current)
userMicSpeakingTimerRef.current = null
}
}
}, [clearAiSpeechEndTimer])
return (
<>
<div className="fixed inset-0 z-50 backdrop-blur-2xl">
<div className="bg-background-default absolute inset-0 opacity-65" />
<div
className="absolute right-[50px] -bottom-[500px] left-[50px] h-[612px] rounded-full opacity-30 blur-[100px]"
style={{
background: 'linear-gradient(117deg, #FF9CB5 16.41%, #BC97EF 46.49%, #8BE6F0 77.76%)',
}}
/>
<div className="absolute inset-0 flex items-center justify-center overflow-auto py-[156px]">
<div className="flex min-h-[712px] flex-col items-center justify-between">
<div>
<CrushLevelAvatar size="large" noLink showAnimation hideIcon />
</div>
<ChatCallStatus
isConnected={isConnected}
subtitleState={{
...subtitleState,
isUserSpeaking: combinedUserSpeaking, // 使用结合后的状态
}}
onInterrupt={handleInterrupt}
/>
<ChatEndButton
roomId={roomId}
taskId={taskIdRef.current || ''}
onLeave={leaveRoom}
callStartTime={callStartTimeRef.current}
abortController={abortControllerRef.current}
/>
</div>
</div>
</div>
{!rtcComponentRef.current && (
<RtcComponent
onRef={(ref) => {
rtc.current = ref
rtcComponentRef.current = ref
}}
config={{
appId: process.env.NEXT_PUBLIC_RTC_APP_ID,
roomId,
}}
streamOptions={{
audio: true,
video: false,
}}
handleUserPublishStream={handleUserPublishStream}
handleUserUnpublishStream={handleUserUnpublishStream}
handleUserJoin={handleUserJoin}
handleUserLeave={handleUserLeave}
handleAutoPlayFail={handleAutoPlayFail}
handleEventError={handleEventError}
handlePlayerEvent={handlePlayerEvent}
handleRoomBinaryMessageReceived={handleRoomBinaryMessageReceived}
handleLocalAudioPropertiesReport={handleLocalAudioPropertiesReport}
/>
)}
</>
)
}
export default ChatCallContainer

View File

@ -1,137 +0,0 @@
'use client'
import { Button } from '@/components/ui/button'
import { SubtitleState } from './ChatCallContainer'
import { useAtomValue } from 'jotai'
import { hasReceivedAiGreetingAtom } from '@/atoms/im'
import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation'
const ChatCallStatus = ({
isConnected,
subtitleState,
onInterrupt,
}: {
isConnected: boolean
subtitleState: SubtitleState
onInterrupt?: () => void
}) => {
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom)
const renderAction = () => {
if (!subtitleState.hideInterrupt) {
return (
<Button variant="tertiary" size="large" onClick={onInterrupt}>
Click to interrupt
</Button>
)
}
if (subtitleState.isAiThinking) {
return (
<div className="flex flex-col items-center justify-center gap-6">
{/* 三个圆点动画 */}
<div className="flex items-center justify-center gap-2">
<div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" />
<div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" />
<div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" />
</div>
<div className="txt-label-m text-txt-secondary-normal text-center">Thinking...</div>
</div>
)
}
return (
<div className="flex flex-col justify-center gap-3">
<VoiceWaveAnimation animated={subtitleState.isUserSpeaking} barCount={22} />
<div className="txt-label-m text-txt-secondary-normal text-center">Listening...</div>
</div>
)
}
if (!hasReceivedAiGreeting) {
return (
<div className="flex flex-col items-center justify-center gap-6">
{/* 三个圆点动画 */}
<div className="flex items-center justify-center gap-2">
<div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" />
<div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" />
<div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" />
</div>
<div className="txt-label-m text-txt-secondary-normal text-center">
Waiting to be connected
</div>
</div>
)
}
return (
<div className="relative my-4 flex size-full min-h-[220px] flex-col content-stretch items-center justify-between gap-4">
<div className="txt-body-l max-w-[60vw] text-center">{subtitleState.aiSubtitle}</div>
{renderAction()}
</div>
)
// if (subtitleState.isAiSpeaking) {
// return (
// <div className="content-stretch flex flex-col gap-4 items-center justify-center relative size-full">
// {/* AI字幕显示区域 */}
// <div className="flex flex-col font-['Poppins',_sans-serif] justify-center relative shrink-0 w-full max-w-[496px] px-6">
// <div className="text-center">
// <p className="break-words">
// {/* 解析字幕文本,分离内心想法和实际对话 */}
// {(() => {
// const subtitle = subtitleState.aiSubtitle;
// const thoughtMatch = subtitle?.match(/^[\(](.*?)[\)]\s*(.*)$/);
// if (thoughtMatch) {
// const [, thought, speech] = thoughtMatch;
// return (
// <>
// <span className="txt-body-l text-txt-secondary-normal">
// ({thought})
// </span>
// <span className="txt-body-l text-txt-primary-normal">
// {speech}
// </span>
// </>
// );
// } else {
// return (
// <span className="txt-body-l text-txt-primary-normal">
// {subtitle}
// </span>
// );
// }
// })()}
// </p>
// </div>
// </div>
// {/* 打断按钮 */}
// {!subtitleState.hideInterrupt && <Button variant="tertiary" size="large" onClick={onInterrupt}>Click to interrupt</Button>}
// </div>
// )
// }
// if (subtitleState.isAiThinking) {
// return (
// <div className="txt-label-l text-txt-primary-specialmap-normal flex items-center">
// <span>Thinking</span>
// <span className="animate-listening-dot-1">.</span>
// <span className="animate-listening-dot-2">.</span>
// <span className="animate-listening-dot-3">.</span>
// </div>
// )
// }
// return (
// <div className="txt-label-l text-txt-primary-specialmap-normal flex items-center">
// <span>Listening</span>
// <span className="animate-listening-dot-1">.</span>
// <span className="animate-listening-dot-2">.</span>
// <span className="animate-listening-dot-3">.</span>
// </div>
// )
}
export default ChatCallStatus

View File

@ -1,118 +0,0 @@
'use client'
import { Button } from '@/components/ui/button'
import { useDoRtcOperation } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import { RtcOperation } from '@/services/im'
import React, { useState } from 'react'
import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat'
import { CustomMessageType } from '@/types/im'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import {
hasReceivedAiGreetingAtom,
hasStartAICallAtom,
isCallAtom,
selectedConversationIdAtom,
} from '@/atoms/im'
import { useQueryClient } from '@tanstack/react-query'
import { walletKeys } from '@/lib/query-keys'
const ChatEndButton = ({
roomId,
taskId,
onLeave,
callStartTime,
abortController,
}: {
roomId: string
taskId: string
onLeave: () => Promise<void>
callStartTime: number | null
abortController: AbortController | null
}) => {
const { aiId, handleUserMessage } = useChatConfig()
const { mutateAsync: doRtcOperation } = useDoRtcOperation()
const [loading, setLoading] = useState(false)
const { nim } = useNimChat()
const { sendMessageActive } = useNimMsgContext()
const selectedConversationId = useAtomValue(selectedConversationIdAtom)
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom)
const setIsCall = useSetAtom(isCallAtom)
const [hasStartAICall, setHasStartAICall] = useAtom(hasStartAICallAtom)
const queryClient = useQueryClient()
const handleEndCall = async () => {
try {
setLoading(true)
const duration = Date.now() - (callStartTime || 0)
// 如果已开始AI通话则停止AI通话
try {
if (hasStartAICall) {
await doRtcOperation({
data: {
roomId: roomId,
optType: RtcOperation.STOP,
aiId: aiId,
taskId: taskId,
duration,
},
})
} else {
await doRtcOperation({
data: {
roomId: roomId,
optType: RtcOperation.CANCEL,
aiId: aiId,
taskId: taskId,
duration,
},
})
}
setHasStartAICall(false)
} catch (error) {
setHasStartAICall(false)
}
await onLeave()
if (!hasReceivedAiGreeting) {
const text = 'Call Canceled'
const msg = nim.V2NIMMessageCreator.createCustomMessage(
text,
JSON.stringify({
type: CustomMessageType.CALL_CANCEL,
duration: Date.now() - (callStartTime || 0),
})
)
sendMessageActive({
msg,
conversationId: selectedConversationId || '',
isLoading: false,
})
// 通知用户发送了消息,重置自动聊天定时器
handleUserMessage()
}
setIsCall(false)
await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
} catch (error) {
console.log(error)
} finally {
setLoading(false)
}
}
return (
<Button
size="large"
className="min-w-[80px]"
variant="destructive"
loading={loading}
onClick={handleEndCall}
>
<i className="iconfont icon-hang-up !text-[24px]" />
</Button>
)
}
export default ChatEndButton

View File

@ -1,35 +0,0 @@
'use client'
import React from 'react'
import RtcClient from './rtc-client'
import { LocalAudioPropertiesInfo } from '@byteplus/rtc'
interface IProps {
onRef: (ref: any) => void
config: any
streamOptions: any
handleUserPublishStream: any
handleUserUnpublishStream: any
handleUserStartVideoCapture?: any
handleUserStopVideoCapture?: any
handleUserJoin: any
handleUserLeave: any
handleAutoPlayFail: any
handleEventError: any
handlePlayerEvent: any
handleRoomBinaryMessageReceived: any
handleLocalAudioPropertiesReport: (event: LocalAudioPropertiesInfo[]) => void
}
export default class RtcComponent extends React.Component<IProps, any> {
rtc: RtcClient
constructor(props: IProps) {
super(props)
this.rtc = new RtcClient(props)
}
componentDidMount() {
this.props.onRef(this.rtc)
}
render() {
return <></>
}
}

View File

@ -1,15 +0,0 @@
'use client'
import { isCallAtom } from '@/atoms/im'
import ChatCallContainer from './ChatCallContainer'
import { useAtomValue } from 'jotai'
const ChatCall = () => {
const isCall = useAtomValue(isCallAtom)
if (!isCall) return null
return <ChatCallContainer />
}
export default ChatCall

View File

@ -1,162 +0,0 @@
import VERTC, { MediaType, RoomMode, StreamIndex } from '@byteplus/rtc'
export default class RtcClient {
constructor(props) {
this.config = props.config
this.streamOptions = props.streamOptions
this.engine = VERTC.createEngine(props.config.appId)
this.handleUserPublishStream = props.handleUserPublishStream
this.handleUserUnpublishStream = props.handleUserUnpublishStream
// this.handleUserStartVideoCapture = props.handleUserStartVideoCapture;
// this.handleUserStopVideoCapture = props.handleUserStopVideoCapture;
this.handleEventError = props.handleEventError
this.setRemoteVideoPlayer = this.setRemoteVideoPlayer.bind(this)
this.handleUserJoin = props.handleUserJoin
this.handleUserLeave = props.handleUserLeave
this.handleAutoPlayFail = props.handleAutoPlayFail
this.handlePlayerEvent = props.handlePlayerEvent
this.handleRoomBinaryMessageReceived = props.handleRoomBinaryMessageReceived
this.handleLocalAudioPropertiesReport = props.handleLocalAudioPropertiesReport
this.bindEngineEvents()
}
SDKVERSION = VERTC.getSdkVersion()
bindEngineEvents() {
this.engine.on(VERTC.events.onUserPublishStream, this.handleUserPublishStream)
this.engine.on(VERTC.events.onUserUnpublishStream, this.handleUserUnpublishStream)
// this.engine.on(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture);
// this.engine.on(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture);
this.engine.on(VERTC.events.onUserJoined, this.handleUserJoin)
this.engine.on(VERTC.events.onUserLeave, this.handleUserLeave)
this.engine.on(VERTC.events.onAutoplayFailed, (events) => {
console.log('VERTC.events.onAutoplayFailed', events.userId)
this.handleAutoPlayFail(events)
})
this.engine.on(VERTC.events.onPlayerEvent, this.handlePlayerEvent)
this.engine.on(VERTC.events.onError, (e) => this.handleEventError(e, VERTC))
this.engine.on(VERTC.events.onRoomBinaryMessageReceived, this.handleRoomBinaryMessageReceived)
this.engine.on(VERTC.events.onLocalAudioPropertiesReport, this.handleLocalAudioPropertiesReport)
}
async setRemoteVideoPlayer(remoteUserId, domId) {
await this.engine.subscribeStream(remoteUserId, MediaType.AUDIO_AND_VIDEO)
await this.engine.setRemoteVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, {
userId: remoteUserId,
renderDom: domId,
})
}
/**
* remove the listeners when `createEngine`
*/
removeEventListener() {
this.engine.off(VERTC.events.onUserPublishStream, this.handleStreamAdd)
this.engine.off(VERTC.events.onUserUnpublishStream, this.handleStreamRemove)
// this.engine.off(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture);
// this.engine.off(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture);
this.engine.off(VERTC.events.onUserJoined, this.handleUserJoin)
this.engine.off(VERTC.events.onUserLeave, this.handleUserLeave)
this.engine.off(VERTC.events.onAutoplayFailed, this.handleAutoPlayFail)
this.engine.off(VERTC.events.onPlayerEvent, this.handlePlayerEvent)
this.engine.off(VERTC.events.onError, this.handleEventError)
}
join(token, roomId, uid) {
return this.engine.joinRoom(
token,
roomId,
{
userId: uid,
},
{
isAutoPublish: false,
isAutoSubscribeAudio: true,
isAutoSubscribeVideo: false,
roomMode: RoomMode.RTC,
}
)
}
/**
* get the devices
* @returns
*/
async getDevices() {
const devices = await VERTC.enumerateAudioCaptureDevices()
return {
audioInputs: devices,
}
}
/**
* create the local stream with the config and publish the local stream
* @param {*} callback
*/
async createLocalStream(userId, callback) {
const devices = await this.getDevices()
const devicesStatus = {
video: 1,
audio: 1,
}
if (!devices.audioInputs.length && !devices.videoInputs.length) {
callback({
code: -1,
msg: 'Failed to enumerate devices.',
devicesStatus: {
video: 0,
audio: 0,
},
})
return
}
if (this.streamOptions.audio && devices.audioInputs.length) {
await this.engine.startAudioCapture(devices.audioInputs[0].deviceId)
} else {
devicesStatus['video'] = 0
// this.engine.unpublishStream(MediaType.AUDIO);
}
if (this.streamOptions.video && devices.videoInputs.length) {
// await this.engine.startVideoCapture(devices.videoInputs[0].deviceId);
} else {
devicesStatus['audio'] = 0
// this.engine.unpublishStream(MediaType.VIDEO);
}
// this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, {
// renderDom: 'local-player',
// userId,
// });
// this.engine.publishStream(MediaType.AUDIO);
callback &&
callback({
code: 0,
msg: 'Failed to enumerate devices.',
devicesStatus,
})
}
async changeAudioState(isMicOn) {
if (isMicOn) {
await this.engine.publishStream(MediaType.AUDIO)
} else {
await this.engine.unpublishStream(MediaType.AUDIO)
}
}
// async changeVideoState(isVideoOn) {
// if (isVideoOn) {
// await this.engine.startVideoCapture();
// } else {
// await this.engine.stopVideoCapture();
// }
// }
async leave() {
await Promise.all([this.engine?.stopAudioCapture()])
await this.engine?.unpublishStream(MediaType.AUDIO).catch(console.warn)
this.engine.leaveRoom()
this.engine.destroy()
}
async enableAudioPropertiesReport(config) {
console.log('enableAudioPropertiesReport', config)
await this.engine.enableAudioPropertiesReport(config)
}
}

View File

@ -1,78 +0,0 @@
import { IRTCEngine } from '@byteplus/rtc'
export interface AudioStats {
CodecType: string
End2EndDelay: number
MuteState: boolean
PacketLossRate: number
RecvBitrate: number
RecvLevel: number
TotalFreezeTime: number
TotalPlayDuration: number
TransportDelay: number
}
export interface RTCClient {
engine: IRTCEngine
init: (...args: any[]) => void
join: (...args: any[]) => any
publishStream: (...args: any[]) => Promise<void>
unpublishStream: (...args: any[]) => Promise<void>
subscribe: (...args: any[]) => void
leave: (...args: any[]) => Promise<void>
on: (...args: any[]) => void
off: (...args: any[]) => void
setupLocalVideoPlayer: (...args: any[]) => void
createLocalStream: (...args: any[]) => void
setRemoteVideoPlayer: (...args: any[]) => void
removeEventListener: (...args: any[]) => void
changeAudioState: (...args: any[]) => void
changeVideoState: (...args: any[]) => void
bindEngineEvents: (...args: any[]) => void
enableAudioPropertiesReport: (...args: any[]) => void
}
export interface Stream {
userId: string
hasAudio: boolean
hasVideo: boolean
isScreen: boolean
videoStreamDescriptions: any[]
stream: {
screen: boolean
}
getId: () => string
enableAudio: () => void
disableAudio: () => void
enableVideo: () => void
disableVideo: () => void
close: () => void
init: (...args: any[]) => void
play: (id: string, options?: any) => void
setVideoEncoderConfiguration: (...args: any[]) => void
getStats(): any
getAudioLevel(): number
playerComp: any
}
export type SubscribeOption = {
video?: boolean
audio?: boolean
}
export type DeviceInstance = {
deviceId: string
groupId: string
kind: 'audioinput' | 'audiooutput' | 'videoinput'
label: string
}
export type StreamOption = {
audio: boolean
video: boolean
data?: boolean
screen?: boolean
mediaStream?: MediaStream
microphoneId?: string
cameraId?: string
}

View File

@ -1,335 +0,0 @@
'use client'
import { useAtom, useSetAtom } from 'jotai'
import {
isChatBackgroundDrawerOpenAtom,
isCrushLevelDrawerOpenAtom,
isCrushLevelRetrieveDrawerOpenAtom,
createDrawerOpenState,
} from '@/atoms/chat'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerHeader,
InlineDrawerDescription,
InlineDrawerFooter,
} from './InlineDrawer'
import { Button, IconButton } from '@/components/ui/button'
import { AiUserImBaseInfoOutput, BackgroundImgListOutput } from '@/services/im/types'
import { useDelChatBackground, useGetChatBackgroundList, useSetChatBackground } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import { cn } from '@/lib/utils'
import Image from 'next/image'
import { Checkbox } from '@/components/ui/checkbox'
import React, { useEffect, useState } from 'react'
import { Tag } from '@/components/ui/tag'
import { useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys } from '@/lib/query-keys'
import { ImageViewer } from '@/components/ui/image-viewer'
import { useImageViewer } from '@/hooks/useImageViewer'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
const BackgroundImageViewerAction = ({
aiId,
datas,
backgroundId,
onDeleted,
isSelected,
currentIndex,
onChange,
}: {
aiId: number
datas: BackgroundImgListOutput[]
backgroundId: number
onDeleted?: (nextIndex: number | null) => void
isSelected: boolean
currentIndex: number
onChange: (backgroundId: number) => void
}) => {
const [isOpen, setIsOpen] = useState(false)
const { mutateAsync: deleteBackground, isPending: isDeletingBackground } = useDelChatBackground({
aiId,
backgroundId,
})
const handleDelete = async () => {
await deleteBackground({ aiId, backgroundId })
const nextLength = datas.length - 1
if (nextLength <= 0) {
onDeleted?.(null)
return
}
const isLast = currentIndex >= nextLength
const nextIndex = isLast ? nextLength - 1 : currentIndex
onDeleted?.(nextIndex)
setIsOpen(false)
}
const handleSelect = () => {
// 如果只有一张背景且当前已选中,不允许取消选中
if (datas.length === 1 && isSelected) {
return
}
onChange(backgroundId)
}
return (
<>
<div className="bg-outline-normal h-6 w-px" />
<div
className="bg-surface-element-light-normal flex h-8 cursor-pointer items-center justify-center gap-2 rounded-full px-3 backdrop-blur-lg"
onClick={() => handleSelect()}
>
<Checkbox shape="round" checked={isSelected} />
<div className="txt-label-s">Select</div>
</div>
{!!backgroundId && <div className="bg-outline-normal h-6 w-px" />}
{!!backgroundId && (
<IconButton
iconfont="icon-trashcan"
variant="tertiary"
size="small"
loading={isDeletingBackground}
onClick={handleDelete}
/>
)}
</>
)
}
const BackgroundItem = ({
item,
selected,
inUse,
onClick,
onImagePreview,
totalCount,
}: {
item: BackgroundImgListOutput
selected: boolean
inUse: boolean
onClick: () => void
onImagePreview: () => void
totalCount: number
}) => {
const handleClick = () => {
// 如果只有一张背景且当前已选中,不允许取消选中
if (totalCount === 1 && selected) {
return
}
onClick()
}
return (
<div className="group relative cursor-pointer" onClick={handleClick}>
<div
className={cn(
'bg-surface-element-normal relative aspect-[3/4] overflow-hidden rounded-lg',
selected && 'border-primary-normal border-2 border-solid'
)}
>
<Image src={item.imgUrl || ''} alt={''} fill className="object-cover" />
</div>
{item.isDefault && (
<Tag className="absolute top-2 left-2" variant="dark" size="small">
Default
</Tag>
)}
{inUse && <Checkbox shape="round" checked className="absolute top-2 right-2" />}
<IconButton
className="absolute right-2 bottom-2 opacity-0 transition-opacity group-hover:opacity-100"
size="xs"
variant="contrast"
onClick={(e) => {
e.stopPropagation()
onImagePreview()
}}
>
<i className="iconfont icon-icon-fullImage" />
</IconButton>
</div>
)
}
const ChatBackgroundDrawer = () => {
const [selectId, setSelectId] = useState<number | undefined>()
const { aiId, aiInfo } = useChatConfig()
const [drawerState, setDrawerState] = useAtom(isChatBackgroundDrawerOpenAtom)
const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const router = useRouter()
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
const setIsCrushLevelDrawerOpen = (open: boolean) =>
setCrushLevelDrawerState(createDrawerOpenState(open))
// 图片查看器
const {
isOpen: isViewerOpen,
currentIndex: viewerIndex,
openViewer,
closeViewer,
handleIndexChange,
} = useImageViewer()
const { backgroundImg, aiUserHeartbeatRelation } = aiInfo || {}
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {}
const isUnlock = heartbeatLevelNum && heartbeatLevelNum >= 10
const { data: originBackgroundList } = useGetChatBackgroundList({ aiId })
const { mutateAsync: updateChatBackground, isPending: isUpdatingChatBackground } =
useSetChatBackground({ aiId })
const queryClient = useQueryClient()
const backgroundList = React.useMemo(() => {
return originBackgroundList?.map((item) => ({
...item,
backgroundId: item.backgroundId || 0,
}))
}, [originBackgroundList])
useEffect(() => {
if (!backgroundList?.length || !aiInfo) return
const defaultId = backgroundList.find((item) => item.imgUrl === backgroundImg)?.backgroundId
setSelectId(defaultId)
}, [backgroundList, aiInfo])
const handleUnlock = () => {
// // todo
// router.push(`/generate/image-2-background?id=${aiId}`);
// return;
if (!aiId) return
if (!aiUserHeartbeatRelation) return
if (isUnlock) {
router.push(`/generate/image-2-background?id=${aiId}`)
} else {
setIsCrushLevelDrawerOpen(true)
}
}
const handleConfirm = async () => {
const { imgUrl, isDefault } =
backgroundList?.find((item) => item.backgroundId === selectId) || {}
const result = {
aiId,
backgroundId: selectId || '',
}
if (selectId !== 0) {
result.backgroundId = selectId || ''
}
await updateChatBackground(result)
queryClient.setQueryData(imKeys.imUserInfo(aiId), (old: AiUserImBaseInfoOutput) => {
return {
...old,
backgroundImg: imgUrl,
isDefaultBackground: !!isDefault,
}
})
setOpen(false)
}
const handleImagePreview = (index: number) => {
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index)
}
return (
<InlineDrawer
id="chat-background-drawer"
open={open}
onOpenChange={setOpen}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent>
<InlineDrawerHeader>Chat Background</InlineDrawerHeader>
<InlineDrawerDescription className="overflow-y-auto">
<div>
<div className="bg-surface-element-normal flex items-center justify-between gap-4 rounded-lg p-4">
<div className="flex-1">
<div className="txt-title-s">Generate Image</div>
<div className="txt-body-s text-txt-secondary-normal mt-1">
{isUnlock ? 'Unlocked' : 'Unlocks at Lv.10'}
</div>
</div>
<Button size="small" onClick={handleUnlock}>
{isUnlock ? 'Generate' : 'Unlock'}
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
{backgroundList?.map((item, index) => (
<BackgroundItem
key={item.backgroundId}
selected={selectId === item.backgroundId}
inUse={item.imgUrl === backgroundImg}
item={item}
onClick={() => setSelectId(item.backgroundId)}
onImagePreview={() => handleImagePreview(index)}
totalCount={backgroundList?.length || 0}
/>
))}
</div>
</div>
</InlineDrawerDescription>
<InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
size="large"
variant="primary"
loading={isUpdatingChatBackground}
onClick={handleConfirm}
>
Confirm
</Button>
</InlineDrawerFooter>
</InlineDrawerContent>
<ImageViewer
images={backgroundList?.map((item) => item.imgUrl || '') || []}
currentIndex={viewerIndex}
open={isViewerOpen}
onClose={closeViewer}
onIndexChange={handleIndexChange}
showChooseButton={false}
ActionComponent={() => {
return (
<BackgroundImageViewerAction
aiId={aiId}
datas={backgroundList || []}
backgroundId={backgroundList?.[viewerIndex]?.backgroundId || 0}
currentIndex={viewerIndex}
isSelected={selectId === backgroundList?.[viewerIndex]?.backgroundId}
onChange={setSelectId}
onDeleted={(nextIndex) => {
if (nextIndex === null) {
// 删除后没有图片了
closeViewer()
return
}
// 调整到新的索引,避免越界
handleIndexChange(nextIndex)
}}
/>
)
}}
/>
</InlineDrawer>
)
}
export default ChatBackgroundDrawer

View File

@ -1,204 +0,0 @@
'use client'
import {
isChatButtleDrawerOpenAtom,
isCrushLevelDrawerOpenAtom,
createDrawerOpenState,
} from '@/atoms/chat'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerDescription,
InlineDrawerFooter,
InlineDrawerHeader,
} from './InlineDrawer'
import { useAtom, useSetAtom } from 'jotai'
import { Button } from '@/components/ui/button'
import { useGetChatBubbleDictList, useGetMyChatSetting, useSetChatBubble } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import { ChatBubbleListOutput, UnlockType } from '@/services/im/types'
import Image from 'next/image'
import { Tag } from '@/components/ui/tag'
import { useEffect, useState } from 'react'
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
import { cn } from '@/lib/utils'
import ChatBubble from '../ChatMessageItems/ChatBubble'
import { Checkbox } from '@/components/ui/checkbox'
import { useRouter } from 'next/navigation'
import { isVipDrawerOpenAtom } from '@/atoms/im'
import { useCurrentUser } from '@/hooks/auth'
import { VipType } from '@/services/wallet'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys } from '@/lib/query-keys'
const ChatButtleItem = ({
item,
selected,
inUse,
onClick,
}: {
item: ChatBubbleListOutput
selected: boolean
inUse: boolean
onClick: () => void
}) => {
return (
<div className="cursor-pointer" onClick={onClick}>
<div
className={cn(
'bg-surface-element-normal relative flex aspect-[41/30] items-center justify-center rounded-lg p-[2px]',
selected && 'border-primary-normal border-2 border-solid p-0'
)}
>
<ChatBubble isDefault={item.isDefault} img={item.webImgUrl}>
Hi
</ChatBubble>
{inUse && <Checkbox checked={true} shape="round" className="absolute top-2 right-2" />}
{item.isDefault && (
<Tag className="absolute top-2 left-2" variant="dark" size="small">
Default
</Tag>
)}
{!item.isUnlock && (
<Tag className="absolute top-2 right-2" variant="dark" size="small">
<i className="iconfont icon-private-border" />
</Tag>
)}
</div>
{item.unlockType === 'MEMBER' ? (
<div className="txt-label-m mt-2 bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-center text-transparent">
{item.name}
</div>
) : (
<div className="txt-label-m text-txt-primary-normal mt-2 text-center">{item.name}</div>
)}
</div>
)
}
const ChatButtleDrawer = () => {
const [selectedCode, setSelectedCode] = useState<string | undefined>()
const [drawerState, setDrawerState] = useAtom(isChatButtleDrawerOpenAtom)
const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { aiId, aiInfo } = useChatConfig()
const { chatBubble } = aiInfo || {}
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
const setIsCrushLevelDrawerOpen = (open: boolean) =>
setCrushLevelDrawerState(createDrawerOpenState(open))
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const queryClient = useQueryClient()
const { data: chatBubbleDictList } = useGetChatBubbleDictList({ aiId })
const { mutateAsync: setChatBubble, isPending: isSettingChatBubble } = useSetChatBubble({ aiId })
useEffect(() => {
if (!chatBubbleDictList?.length) return
const defaultCode = chatBubble?.code?.toString()
setSelectedCode(defaultCode || chatBubbleDictList[0]?.code?.toString())
}, [chatBubbleDictList, chatBubble])
useEffect(() => {
if (open) {
queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) })
const defaultCode = chatBubble?.code?.toString()
if (defaultCode) {
setSelectedCode(defaultCode)
}
}
}, [open])
const handleSetChatBubble = async (code: string) => {
await setChatBubble({ aiId, code })
setOpen(false)
}
const renderConfirmButton = () => {
if (!selectedCode) return
const { isUnlock, unlockType, unlockHeartbeatLevel } =
chatBubbleDictList?.find((item) => item.code === selectedCode) || {}
if (isUnlock || isUnlock === null) {
return (
<Button
size="large"
loading={isSettingChatBubble}
onClick={() => handleSetChatBubble(selectedCode)}
>
Confirm
</Button>
)
}
if (unlockType === UnlockType.Member) {
return (
<Button
size="large"
variant="vip"
onClick={() => setIsVipDrawerOpen({ open: true, vipType: VipType.CUSTOM_CHAT_BUBBLE })}
>
<div className="flex items-center gap-2">
<Image src="/icons/vip-black.svg" alt="vip" className="block" width={24} height={24} />
<span className="txt-label-l">Unlock</span>
</div>
</Button>
)
}
if (unlockType === UnlockType.HeartbeatLevel) {
return (
<Button size="large" onClick={() => setIsCrushLevelDrawerOpen(true)}>
<div className="flex items-center gap-2">
<Image
src="/icons/like-gradient.svg"
alt="vip"
className="block"
width={24}
height={24}
/>
<span className="txt-label-l">
{useHeartLevelTextFromLevel(unlockHeartbeatLevel)} Unlock
</span>
</div>
</Button>
)
}
return null
}
return (
<InlineDrawer
id="chat-buttle-drawer"
open={open}
onOpenChange={setOpen}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent>
<InlineDrawerHeader>Chat Buttles</InlineDrawerHeader>
<InlineDrawerDescription>
<div className="grid grid-cols-2 gap-4">
{chatBubbleDictList?.map((item) => (
<ChatButtleItem
key={item.code}
item={item}
inUse={item.code === chatBubble?.code}
selected={item.code === selectedCode}
onClick={() => {
setSelectedCode(item.code)
}}
/>
))}
</div>
</InlineDrawerDescription>
<InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
Cancel
</Button>
{renderConfirmButton()}
</InlineDrawerFooter>
</InlineDrawerContent>
</InlineDrawer>
)
}
export default ChatButtleDrawer

View File

@ -1,114 +0,0 @@
'use client'
import { isChatModelDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerDescription,
InlineDrawerFooter,
InlineDrawerHeader,
} from './InlineDrawer'
import { useAtom } from 'jotai'
import { Button, IconButton } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import Image from 'next/image'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useGetChatModelDictList } from '@/hooks/useIm'
import { useEffect } from 'react'
import { ChatPriceType, ChatPriceTypeMap } from '@/hooks/useWallet'
const ChatModelDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isChatModelDrawerOpenAtom)
const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { data: chatModelDictList } = useGetChatModelDictList()
console.log('chatModelDictList', chatModelDictList)
return (
<InlineDrawer
id="chat-model-drawer"
open={open}
onOpenChange={setOpen}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent>
<InlineDrawerHeader>Chat Model</InlineDrawerHeader>
<InlineDrawerDescription>
<div className="bg-surface-element-normal overflow-hidden rounded-lg p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="txt-title-s">Role-Playing Model</div>
<Tooltip>
<TooltipTrigger asChild>
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<div className="space-y-2">
<p className="break-words">
Text Message Price: Refers to the cost of chatting with the character via
text messages, including sending text, images, or gifts. Charged per
message.
</p>
<p className="break-words">
Voice Message Price: Refers to the cost of sending a voice message to the
character or playing the characters voice. Charged per use.
</p>
<p className="break-words">
Voice Call Price: Refers to the cost of having a voice call with the
character. Charged per minute.
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<Checkbox checked={true} shape="round" />
</div>
<div className="txt-body-m text-txt-secondary-normal mt-1">
Role-play a conversation with AI
</div>
<div className="bg-surface-district-normal mt-3 rounded-sm p-3">
<div className="flex items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal">
{ChatPriceTypeMap[ChatPriceType.TEXT]}/Text Message
</span>
</div>
<div className="mt-3 flex items-center justify-between gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal">
{ChatPriceTypeMap[ChatPriceType.VOICE]}/Send or play voice
</span>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal">
{ChatPriceTypeMap[ChatPriceType.VOICE_CALL]}/min Voice call
</span>
</div>
</div>
</div>
</div>
<div className="txt-body-m text-txt-secondary-normal mt-6">More models coming soon</div>
</InlineDrawerDescription>
<InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="large" onClick={() => setOpen(false)}>
Save
</Button>
</InlineDrawerFooter>
</InlineDrawerContent>
</InlineDrawer>
)
}
export default ChatModelDrawer

View File

@ -1,71 +0,0 @@
'use client'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu'
import { IconButton } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import DeleteMessageDialog from './DeleteMessageDialog'
import { useState } from 'react'
import useShare from '@/hooks/useShare'
import { useChatConfig } from '../../../context/chatConfig'
const ChatProfileAction = () => {
const [deleteMessageDialogOpen, setDeleteMessageDialogOpen] = useState(false)
const { shareFacebook, shareTwitter } = useShare()
const { aiId } = useChatConfig()
const handleDeleteMessage = () => {
setDeleteMessageDialogOpen(true)
}
const handleShareFacebook = () => {
shareFacebook({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
}
const handleShareTwitter = () => {
shareTwitter({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
}
return (
<div className="absolute top-6 right-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton iconfont="icon-More" variant="ghost" size="small" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleShareFacebook}>
<i className="iconfont icon-social-facebook text-txt-primary-normal !text-[16px]" />
<span>Share to Facebook</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleShareTwitter}>
<i className="iconfont icon-social-twitter text-txt-primary-normal !text-[16px]" />
<span>Share to X</span>
</DropdownMenuItem>
<div className="my-3 px-2">
<Separator className="bg-outline-normal" />
</div>
<DropdownMenuItem onClick={handleDeleteMessage}>
<i className="iconfont icon-trashcan text-txt-primary-normal !text-[16px]" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteMessageDialog
open={deleteMessageDialogOpen}
onOpenChange={setDeleteMessageDialogOpen}
/>
</div>
)
}
export default ChatProfileAction

View File

@ -1,14 +0,0 @@
'use client'
import ChatProfileShareIcon from './ChatProfileShareIcon'
import ChatProfileLikeIcon from './ChatProfileLikeIcon'
const ChatProfileLikeAction = () => {
return (
<div className="flex items-center justify-center gap-4">
<ChatProfileLikeIcon />
<ChatProfileShareIcon />
</div>
)
}
export default ChatProfileLikeAction

View File

@ -1,50 +0,0 @@
'use client'
import { IconButton } from '@/components/ui/button'
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
import { useDoAiUserLiked } from '@/hooks/useCommon'
import { aiUserKeys, imKeys } from '@/lib/query-keys'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useChatConfig } from '../../../context/chatConfig'
const ChatProfileLikeIcon = () => {
const { aiId, aiInfo } = useChatConfig()
const { mutateAsync: doAiUserLiked } = useDoAiUserLiked()
const { liked } = aiInfo || {}
const queryClient = useQueryClient()
const handleLike = () => {
doAiUserLiked({ aiId: Number(aiId), likedStatus: liked ? 'CANCELED' : 'LIKED' })
queryClient.setQueryData(aiUserKeys.baseInfo({ aiId: Number(aiId) }), (oldData: any) => {
return {
...oldData,
liked: !liked,
}
})
queryClient.setQueryData(imKeys.imUserInfo(Number(aiId)), (oldData: any) => {
return {
...oldData,
liked: !liked,
}
})
queryClient.setQueryData(aiUserKeys.stat({ aiId: Number(aiId) }), (oldData: any) => {
return {
...oldData,
likedNum: !liked ? oldData.likedNum + 1 : oldData.likedNum - 1,
}
})
}
return (
<IconButton variant="tertiary" size="large" onClick={handleLike}>
{!liked ? (
<i className="iconfont icon-Like" />
) : (
<i className="iconfont icon-Like-fill text-important-normal" />
)}
</IconButton>
)
}
export default ChatProfileLikeIcon

View File

@ -1,70 +0,0 @@
'use client'
import { isChatProfileEditDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import { useGetMyChatSetting } from '@/hooks/useIm'
import { useSetAtom } from 'jotai'
import { useChatConfig } from '../../../context/chatConfig'
import { Gender } from '@/types/user'
import { cn, getAge } from '@/lib/utils'
const ChatProfilePersona = () => {
const { aiId } = useChatConfig()
const setDrawerState = useSetAtom(isChatProfileEditDrawerOpenAtom)
const setIsChatProfileEditDrawerOpen = (open: boolean) =>
setDrawerState(createDrawerOpenState(open))
const { data: chatSettingData } = useGetMyChatSetting({ aiId })
const { nickname, sex, birthday, whoAmI } = chatSettingData || {}
const genderMap = {
[Gender.MALE]: 'Male',
[Gender.FEMALE]: 'Female',
[Gender.OTHER]: 'Unspecified',
}
return (
<div className="flex w-full flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="txt-title-s">My Chat Persona</div>
<div
className="txt-label-m text-primary-variant-normal cursor-pointer"
onClick={() => setIsChatProfileEditDrawerOpen(true)}
>
Edit
</div>
</div>
<div className="bg-surface-base-normal rounded-m py-1">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Nickname</div>
<div className="txt-body-l text-txt-primary-normal flex-1 truncate text-right">
{nickname}
</div>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Gender</div>
<div className="txt-body-l text-txt-primary-normal">
{genderMap[sex as keyof typeof genderMap]}
</div>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Age</div>
<div className="txt-body-l text-txt-primary-normal">{getAge(Number(birthday))}</div>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Who am I</div>
<div
className={cn(
'txt-body-l text-txt-primary-normal flex-1 truncate text-right',
whoAmI ? 'text-txt-primary-normal' : 'text-txt-secondary-normal'
)}
>
{whoAmI || 'Unfilled'}
</div>
</div>
</div>
</div>
)
}
export default ChatProfilePersona

View File

@ -1,52 +0,0 @@
'use client'
import { IconButton } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import useShare from '@/hooks/useShare'
import { useParams } from 'next/navigation'
const ChatProfileShareIcon = () => {
const { userId } = useParams()
const { shareFacebook, shareTwitter } = useShare()
const handleShareFacebook = () => {
shareFacebook({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
})
}
const handleShareTwitter = () => {
shareTwitter({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton variant="tertiary" size="large">
<i className="iconfont icon-Share-border" />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleShareFacebook}>
<i className="iconfont icon-social-facebook" />
<span>Share to Facebook</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleShareTwitter}>
<i className="iconfont icon-social-twitter" />
<span>Share to X</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default ChatProfileShareIcon

View File

@ -1,75 +0,0 @@
'use client'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { useNimConversation, useNimMsgContext } from '@/context/NimChat/useNimChat'
import { useAtomValue, useSetAtom } from 'jotai'
import { selectedConversationIdAtom } from '@/atoms/im'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys } from '@/lib/query-keys'
import { useChatConfig } from '../../../context/chatConfig'
import { useDeleteConversations } from '@/hooks/useIm'
const DeleteMessageDialog = ({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) => {
const { aiId } = useChatConfig()
const { removeConversationById } = useNimConversation()
const { clearHistoryMessage } = useNimMsgContext()
const selectedConversationId = useAtomValue(selectedConversationIdAtom)
const [loading, setLoading] = useState(false)
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom)
const router = useRouter()
const queryClient = useQueryClient()
const { mutateAsync: deleteConversations } = useDeleteConversations()
const handleDeleteMessage = async () => {
if (!selectedConversationId) return
setLoading(true)
await removeConversationById(selectedConversationId)
await clearHistoryMessage(selectedConversationId)
await deleteConversations({ aiIdList: [aiId] })
setSelectedConversationId(null)
router.push('/chat')
queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) })
setLoading(false)
onOpenChange(false)
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Deletion is permanent. Your accumulated Affection points and the character's memories will
not be affected. Please confirm deletion.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={loading} onClick={handleDeleteMessage}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export default DeleteMessageDialog

View File

@ -1,281 +0,0 @@
import { InlineDrawer, InlineDrawerContent } from '../InlineDrawer'
import { useAtom, useSetAtom } from 'jotai'
import {
isChatBackgroundDrawerOpenAtom,
isChatProfileDrawerOpenAtom,
createDrawerOpenState,
} from '@/atoms/chat'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Tag } from '@/components/ui/tag'
import { Switch } from '@/components/ui/switch'
import { useEffect, useState } from 'react'
import ChatProfileAction from './ChatProfileAction'
import { useChatConfig } from '../../../context/chatConfig'
import Image from 'next/image'
import { formatNumberToKMB, getAge } from '@/lib/utils'
import { useGetAIUserStat } from '@/hooks/aiUser'
import { IconButton } from '@/components/ui/button'
import { useGetMyChatSetting, useSetAutoPlayVoice } from '@/hooks/useIm'
import ChatProfilePersona from './ChatProfilePersona'
import { isChatButtleDrawerOpenAtom, isChatModelDrawerOpenAtom } from '@/atoms/chat'
import { Badge } from '@/components/ui/badge'
import { useRedDot, RED_DOT_KEYS } from '@/hooks/useRedDot'
import { imKeys } from '@/lib/query-keys'
import { AiUserImBaseInfoOutput } from '@/services/im'
import { useQueryClient } from '@tanstack/react-query'
import CrushLevelAvatar from '../../CrushLevelAvatar'
import ChatProfileLikeAction from './ChatProfileLikeAction'
import { isVipDrawerOpenAtom } from '@/atoms/im'
import { useCurrentUser } from '@/hooks/auth'
import { VipType } from '@/services/wallet'
import Decimal from 'decimal.js'
const genderMap = {
0: '/icons/male.svg',
1: '/icons/female.svg',
2: '/icons/gender-neutral.svg',
}
const ChatProfileDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isChatProfileDrawerOpenAtom)
const isChatProfileDrawerOpen = drawerState.open
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const setModelDrawerState = useSetAtom(isChatModelDrawerOpenAtom)
const setIsChatModelDrawerOpen = (open: boolean) =>
setModelDrawerState(createDrawerOpenState(open))
const setButtleDrawerState = useSetAtom(isChatButtleDrawerOpenAtom)
const setIsChatButtleDrawerOpen = (open: boolean) =>
setButtleDrawerState(createDrawerOpenState(open))
const setBackgroundDrawerState = useSetAtom(isChatBackgroundDrawerOpenAtom)
const setIsChatBackgroundDrawerOpen = (open: boolean) =>
setBackgroundDrawerState(createDrawerOpenState(open))
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const { aiId, aiInfo } = useChatConfig()
const { data: user } = useCurrentUser() || {}
const { isMember } = user || {}
const isOwner = user?.userId === aiInfo?.userId
// 使用红点管理hooks
const { hasRedDot, markAsViewed } = useRedDot()
const { data: statData } = useGetAIUserStat({ aiId })
const {
sex,
birthday,
characterName,
tagName,
chatBubble,
isDefaultBackground,
isAutoPlayVoice,
aiUserHeartbeatRelation,
} = aiInfo || {}
const { likedNum, chatNum, conversationNum, coinNum } = statData || {}
const { heartbeatLevel } = aiUserHeartbeatRelation || {}
const { mutate: setAutoPlayVoice } = useSetAutoPlayVoice()
const queryClient = useQueryClient()
useEffect(() => {
if (isChatProfileDrawerOpen) {
queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) })
}
}, [isChatProfileDrawerOpen])
const statList = [
{
label: 'Liked',
value: formatNumberToKMB(likedNum || 0),
},
{
label: 'Chat',
value: formatNumberToKMB(chatNum || 0),
},
{
label: 'People',
value: formatNumberToKMB(conversationNum || 0),
},
isOwner && {
label: 'Crush Coin',
value: formatNumberToKMB(new Decimal(coinNum || 0).div(100).toNumber()),
},
].filter(Boolean) as { label: string; value: string | number }[]
const handleClose = () => {
setIsChatProfileDrawerOpen(false)
}
return (
<InlineDrawer
id="chatProfile"
open={isChatProfileDrawerOpen}
onOpenChange={setIsChatProfileDrawerOpen}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent>
{/* <div className="absolute top-0 left-0 right-0 h-[228px]" style={{ background: "linear-gradient(0deg, rgba(194, 65, 230, 0) 0%, #F264A4 100%)" }} /> */}
<div className="relative h-full w-full">
{/* Header with back and more buttons */}
<ChatProfileAction />
<IconButton
iconfont="icon-arrow-right"
variant="ghost"
size="small"
className="absolute top-6 left-6"
onClick={handleClose}
/>
<div className="h-full w-full overflow-y-auto">
<div
className="flex flex-col items-center justify-start gap-6 px-6 pt-12 pb-10"
style={{
background: heartbeatLevel
? 'linear-gradient(0deg, rgba(194, 65, 230, 0) 0%, #F264A4 100%)'
: undefined,
backgroundSize: '100% 228px',
backgroundRepeat: 'no-repeat',
}}
>
<CrushLevelAvatar showText />
{/* Name and Tags */}
<div className="flex w-full flex-col items-start justify-start gap-4">
<div className="flex w-full flex-wrap items-start justify-center gap-2">
<Tag>
<Image
src={genderMap[sex as keyof typeof genderMap]}
alt="Gender"
width={16}
height={16}
/>
<div>{getAge(Number(birthday))}</div>
</Tag>
<Tag>{characterName}</Tag>
<Tag>{tagName}</Tag>
</div>
</div>
<ChatProfileLikeAction />
{/* Statistics */}
<div className="flex w-full flex-row items-start justify-start px-1 py-0">
{statList.map((item, index) => (
<div
key={index}
className="flex flex-1 flex-col items-center justify-start gap-1 px-1 py-0"
>
<div className="txt-numDisplay-s text-txt-primary-normal">{item.value}</div>
<div className="txt-label-s text-txt-secondary-normal">{item.label}</div>
</div>
))}
</div>
<ChatProfilePersona />
{/* Chat Setting */}
<div className="flex w-full flex-col items-start justify-start gap-3">
<div className="txt-title-s w-full text-left">Chat Setting</div>
<div className="flex w-full flex-col items-start justify-start gap-4">
<div
className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => {
setIsChatModelDrawerOpen(true)
}}
>
<div className="txt-label-l flex-shrink-0">Chat Model</div>
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
<div className="txt-body-l text-txt-primary-normal text-right">
Role-Playing
</div>
<IconButton iconfont="icon-arrow-right-border" size="small" variant="ghost" />
</div>
</div>
<div className="bg-surface-base-normal rounded-m w-full py-1">
<div
className="flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => {
markAsViewed(RED_DOT_KEYS.CHAT_BUBBLE)
setIsChatButtleDrawerOpen(true)
}}
>
<div className="txt-label-l flex-1">
<div className="flex flex-row items-center gap-2">
<div>Chat Bubble</div>
{hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE) && <Badge variant="dot" />}
</div>
</div>
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
{/* <div className="txt-body-l text-txt-primary-normal truncate">{chatBubble?.name || 'Default'}</div> */}
<IconButton
iconfont="icon-arrow-right-border"
size="small"
variant="ghost"
/>
</div>
</div>
<div
className="flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => {
markAsViewed(RED_DOT_KEYS.CHAT_BACKGROUND)
setIsChatBackgroundDrawerOpen(true)
}}
>
<div className="txt-label-l flex-1">
<div className="flex flex-row items-center gap-2">
<div>Chat Background</div>
{hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) && <Badge variant="dot" />}
</div>
</div>
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
{/* <div className="txt-body-l text-txt-primary-normal truncate">{isDefaultBackground ? 'Default' : ''}</div> */}
<IconButton
iconfont="icon-arrow-right-border"
size="small"
variant="ghost"
/>
</div>
</div>
</div>
<div
className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => {
const checked = !isAutoPlayVoice
if (!isMember) {
setIsVipDrawerOpen({ open: true, vipType: VipType.AUTO_PLAY_VOICE })
return
}
queryClient.setQueryData(
imKeys.imUserInfo(aiId),
(oldData: AiUserImBaseInfoOutput) => {
return {
...oldData,
isAutoPlayVoice: checked,
}
}
)
setAutoPlayVoice({ aiId, isAutoPlayVoice: checked })
}}
>
<div className="txt-label-l flex-1">Auto play voice</div>
<div className="flex h-8 items-center">
<Switch size="sm" checked={!!isAutoPlayVoice} />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</InlineDrawerContent>
</InlineDrawer>
)
}
export default ChatProfileDrawer

View File

@ -1,441 +0,0 @@
import { useEffect, useState, useCallback } from 'react'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerDescription,
InlineDrawerFooter,
InlineDrawerHeader,
} from './InlineDrawer'
import { z } from 'zod'
import dayjs from 'dayjs'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { Gender } from '@/types/user'
import { Input } from '@/components/ui/input'
import GenderInput from '@/components/features/genderInput'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { isChatProfileEditDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import { useAtom } from 'jotai'
import { calculateAge, getDaysInMonth } from '@/lib/utils'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { useGetMyChatSetting, useSetMyChatSetting } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useCheckNickname } from '@/hooks/auth'
const currentYear = dayjs().year()
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`)
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'))
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'))
const characterFormSchema = z
.object({
nickname: z
.string()
.trim()
.min(1, 'Please Enter nickname')
.min(2, 'Nickname must be between 2 and 20 characters')
.max(20, 'Nickname must be less than 20 characters'),
sex: z.enum(Gender, { message: 'Please select gender' }),
year: z.string().min(1, 'Please select year'),
month: z.string().min(1, 'Please select month'),
day: z.string().min(1, 'Please select day'),
profile: z.string().trim().optional(),
})
.refine(
(data) => {
const age = calculateAge(data.year, data.month, data.day)
return age >= 18
},
{
message: 'Character age must be at least 18 years old',
path: ['year'],
}
)
.refine(
(data) => {
if (data.profile) {
if (data.profile.trim().length > 300) {
return false
}
return data.profile.trim().length >= 10
}
return true
},
{
message: 'At least 10 characters',
path: ['profile'],
}
)
const ChatProfileEditDrawer = () => {
const [loading, setLoading] = useState(false)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const { aiId } = useChatConfig()
const [drawerState, setDrawerState] = useAtom(isChatProfileEditDrawerOpenAtom)
const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { data: chatSettingData } = useGetMyChatSetting({ aiId })
const { mutateAsync: setMyChatSetting } = useSetMyChatSetting({ aiId })
const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined
const form = useForm<z.infer<typeof characterFormSchema>>({
resolver: zodResolver(characterFormSchema),
defaultValues: {
nickname: chatSettingData?.nickname || '',
sex: chatSettingData?.sex || undefined,
year: birthday?.year().toString() || undefined,
month:
birthday?.month() !== undefined
? (birthday.month() + 1).toString().padStart(2, '0')
: undefined,
day: birthday?.date().toString().padStart(2, '0') || undefined,
profile: chatSettingData?.whoAmI || '',
},
})
const { mutateAsync: checkNickname } = useCheckNickname({
onError: (error) => {
form.setError('nickname', {
message: error.errorMsg,
})
},
})
// 处理关闭抽屉的逻辑
const handleCloseDrawer = useCallback(() => {
if (form.formState.isDirty) {
setShowConfirmDialog(true)
} else {
setOpen(false)
}
}, [form.formState.isDirty, setOpen])
// 确认放弃修改
const handleConfirmDiscard = useCallback(() => {
form.reset()
setShowConfirmDialog(false)
setOpen(false)
}, [form, setOpen])
// 取消放弃修改
const handleCancelDiscard = useCallback(() => {
setShowConfirmDialog(false)
}, [])
useEffect(() => {
if (chatSettingData) {
form.reset({
nickname: chatSettingData.nickname,
sex: chatSettingData.sex,
year: birthday?.year().toString(),
month:
birthday?.month() !== undefined
? (birthday.month() + 1).toString().padStart(2, '0')
: undefined,
day: birthday?.date().toString().padStart(2, '0'),
profile: chatSettingData.whoAmI || '',
})
}
}, [chatSettingData])
// ESC键关闭抽屉
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
handleCloseDrawer()
}
}
if (open) {
document.addEventListener('keydown', handleKeyDown)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [open, handleCloseDrawer])
async function onSubmit(data: z.infer<typeof characterFormSchema>) {
if (!form.formState.isDirty) {
setOpen(false)
return
}
setLoading(true)
try {
const isExist = await checkNickname({
nickname: data.nickname.trim(),
})
if (isExist) {
form.setError('nickname', {
message: 'This nickname is already taken',
})
return
}
await setMyChatSetting({
aiId,
nickname: data.nickname,
birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
whoAmI: data.profile || '',
})
setOpen(false)
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
const selectedYear = form.watch('year')
const selectedMonth = form.watch('month')
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
const genderTexts = [
{
value: Gender.MALE,
label: 'Male',
},
{
value: Gender.FEMALE,
label: 'Female',
},
{
value: Gender.OTHER,
label: 'Other',
},
]
const gender = form.watch('sex')
const genderText = genderTexts.find((text) => text.value === gender)?.label
return (
<>
<InlineDrawer
id="chat-profile-edit-drawer"
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
handleCloseDrawer()
} else {
setOpen(isOpen)
}
}}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent>
<InlineDrawerHeader>My Chat Persona</InlineDrawerHeader>
<InlineDrawerDescription>
<Form {...form}>
<form className="space-y-8">
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel>Nickname</FormLabel>
<FormControl>
<Input
placeholder="Enter nickname"
maxLength={20}
error={!!form.formState.errors.nickname}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="">
<div className="txt-label-m text-txt-secondary-normal">Gender</div>
<div className="mt-3">
<div className="bg-surface-element-normal rounded-m txt-body-l text-txt-secondary-disabled flex h-12 items-center px-4 py-3">
{genderText}
</div>
<div className="txt-body-s text-txt-secondary-disabled mt-1">
Please note: gender cannot be changed after setting
</div>
</div>
</div>
{/* <FormField
control={form.control}
name="sex"
render={({ field }) => (
<FormItem>
<FormLabel>Gender</FormLabel>
<FormControl>
<GenderInput
value={field.value as Gender}
onChange={field.onChange}
disabled={true}
/>
</FormControl>
</FormItem>
)}
/> */}
<div>
<Label className="txt-label-m mb-3 block">Birthday</Label>
<div className="flex gap-2">
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{months.map((m, index) => (
<SelectItem key={m} value={m}>
{monthTexts[index]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="day"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent>
{days.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<FormMessage>
{form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage>
</div>
<FormField
control={form.control}
name="profile"
render={({ field }) => (
<FormItem>
<FormLabel>
My Persona
<span className="txt-label-m text-txt-secondary-normal">(Optional)</span>
</FormLabel>
<FormControl>
<Textarea
{...field}
maxLength={300}
error={!!form.formState.errors.profile}
placeholder="Set your own persona in CrushLevel"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</InlineDrawerDescription>
<InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={handleCloseDrawer}>
Cancel
</Button>
<Button
type="submit"
size="large"
onClick={form.handleSubmit(onSubmit)}
loading={loading}
>
Save
</Button>
</InlineDrawerFooter>
</InlineDrawerContent>
</InlineDrawer>
{/* 确认放弃修改的对话框 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
<AlertDialogDescription>
The edited content will not be saved after exiting. Please confirm whether to continue
exiting?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDiscard}>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}>
Exit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default ChatProfileEditDrawer

View File

@ -1,31 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useChatConfig } from '../../../context/chatConfig'
import { useCurrentUser } from '@/hooks/auth'
import Link from 'next/link'
const CrushLevelAvatarGroup = () => {
const { aiInfo, aiId } = useChatConfig()
const { data: currentUser } = useCurrentUser()
const { headImage: currentUserHeadImg } = currentUser || {}
const { headImg, nickname } = aiInfo || {}
return (
<div className="flex h-[124px] items-center justify-between px-[42]">
<Link className="h-20 w-20" href={`/@${aiId}`}>
<Avatar className="h-20 w-20">
<AvatarImage src={headImg} />
<AvatarFallback>{nickname?.slice(0, 1)}</AvatarFallback>
</Avatar>
</Link>
<Link className="h-20 w-20" href={`/profile`}>
<Avatar className="h-20 w-20">
<AvatarImage src={currentUserHeadImg} />
<AvatarFallback>{currentUser?.nickname?.slice(0, 1) || ''}</AvatarFallback>
</Avatar>
</Link>
</div>
)
}
export default CrushLevelAvatarGroup

View File

@ -1,37 +0,0 @@
import { IconButton } from '@/components/ui/button'
import { Tag } from '@/components/ui/tag'
import { cn } from '@/lib/utils'
import { HeartbeatLevelDictOutput } from '@/services/im'
import Image from 'next/image'
const HeartList = ({ datas }: { datas: HeartbeatLevelDictOutput[] | undefined }) => {
return (
<div className="mt-6 grid grid-cols-2 gap-4">
{datas?.map((item) => (
<div key={item.code}>
<div className="bg-surface-element-normal relative aspect-[41/30] overflow-hidden rounded-lg">
<Image src={item.imgUrl || ''} alt={item.name || ''} fill className="object-cover" />
<div className="txt-numMonotype-xs text-txt-secondary-normal absolute bottom-2 left-3">
{`${item.startVal}`}
</div>
{!item.isUnlock && (
<Tag size="small" variant="dark" className="absolute top-2 right-2">
<i className="iconfont icon-private-border !text-[12px]" />
</Tag>
)}
</div>
<div
className={cn(
'txt-label-m mt-2 text-center',
!item.isUnlock && 'text-txt-secondary-normal'
)}
>
{item.name}
</div>
</div>
))}
</div>
)
}
export default HeartList

View File

@ -1,332 +0,0 @@
import React from 'react'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerDescription,
InlineDrawerHeader,
} from '../InlineDrawer'
import { IconButton, Button } from '@/components/ui/button'
import Image from 'next/image'
import CrushLevelAvatarGroup from './CrushLevelAvatarGroup'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai'
import {
isCrushLevelDrawerOpenAtom,
isCrushLevelRetrieveDrawerOpenAtom,
createDrawerOpenState,
} from '@/atoms/chat'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useGetHeartbeatLevel, useSetShowRelationship } from '@/hooks/useIm'
import { useChatConfig } from '../../../context/chatConfig'
import HeartList from './HeartList'
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
import numeral from 'numeral'
import { imKeys } from '@/lib/query-keys'
import { useQueryClient } from '@tanstack/react-query'
import { headerLevelDictMap } from '@/components/features/AIRelationTag'
const CrushLevelDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isCrushLevelDrawerOpenAtom)
const isCrushLevelDrawerOpen = drawerState.open
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const setRetrieveDrawerState = useSetAtom(isCrushLevelRetrieveDrawerOpenAtom)
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) =>
setRetrieveDrawerState(createDrawerOpenState(open))
const { aiId, aiInfo } = useChatConfig()
const queryClient = useQueryClient()
// 图片加载状态管理
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false)
const [showHeartImage, setShowHeartImage] = React.useState(false)
const { data, refetch } = useGetHeartbeatLevel({
aiId: Number(aiId),
enabled: false, // 禁用自动查询,手动控制何时获取数据
})
const { heartbeatLeveLDictList, aiUserHeartbeatRelation } = data || {}
const { heartbeatLevel, heartbeatVal, heartbeatScore, dayCount, subtractHeartbeatVal, isShow } =
aiUserHeartbeatRelation || {}
const heartLevelText = useHeartLevelTextFromLevel(heartbeatLevel)
const { mutate: setShowRelationship, isPending: isSetShowRelationshipPending } =
useSetShowRelationship({ aiId: Number(aiId) })
// 计算心的位置
const calculateHeartPosition = () => {
const defaultTop = 150
if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) {
return defaultTop // 默认位置
}
// 获取最低等级的起始值和最高等级的起始值
const sortedLevels = [...heartbeatLeveLDictList].sort(
(a, b) => (a.startVal || 0) - (b.startVal || 0)
)
const minStartVal = sortedLevels[0]?.startVal || 0
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0
// 如果只有一个等级或者最大最小值相等,使用默认位置
if (maxStartVal <= minStartVal) {
return defaultTop
}
// 计算当前心动值在整个等级系统中的总进度0-1
const totalProgress = Math.max(
0,
Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))
)
// 将总进度映射到位置范围top 75空心到 top 150全心
const minTop = defaultTop // 空心位置
const maxTop = 75 // 全心位置
const calculatedTop = minTop + totalProgress * (maxTop - minTop)
return calculatedTop
}
const heartTop = calculateHeartPosition()
// 根据位置决定心的显示状态
const getHeartImageSrc = () => {
// top 75为空心top 150为全心
// 根据位置变化动态调整心的显示状态
// 目前只有一个心的图片可以通过透明度或其他CSS属性来模拟空心/全心效果
// 或者后续可以添加不同的图片资源
return '/images/crushlevel/heart.png'
}
// 当抽屉打开时获取心动等级数据
React.useEffect(() => {
if (isCrushLevelDrawerOpen) {
refetch()
// 重置图片加载状态
setIsBgTopLoaded(false)
setShowHeartImage(false)
}
}, [isCrushLevelDrawerOpen, refetch])
// 处理 bg-top 图片加载完成后的延迟显示逻辑
React.useEffect(() => {
if (isBgTopLoaded) {
// bg-top 加载完成后延迟 300ms 显示心形图片
const timer = setTimeout(() => {
setShowHeartImage(true)
}, 300)
return () => clearTimeout(timer)
}
}, [isBgTopLoaded])
// bg-top 图片加载完成的处理函数
const handleBgTopLoad = () => {
setIsBgTopLoaded(true)
}
const renderLineText = () => {
if (!heartbeatVal) {
return (
<div className="flex w-full items-center gap-2">
<div className="bg-outline-normal h-px flex-1" />
<span className="txt-title-m">No Crush Connection Yet</span>
<div className="bg-outline-normal h-px flex-1" />
</div>
)
}
if (heartbeatLevel && isShow) {
return (
<div className="flex w-full items-center gap-2">
<div className="bg-outline-normal h-px flex-1" />
<span className="txt-display-s">· {headerLevelDictMap[heartbeatLevel]?.title} ·</span>
<div className="bg-outline-normal h-px flex-1" />
</div>
)
}
return null
}
return (
<InlineDrawer
id="crushLevel"
open={isCrushLevelDrawerOpen}
onOpenChange={setIsCrushLevelDrawerOpen}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent className="overflow-y-auto">
{/* 紫色渐变背景 */}
<div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden">
<Image
src="/images/crushlevel/bg-bottom.png"
alt="CrushLevel"
fill
className="object-cover"
/>
{/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */}
{showHeartImage && (
<Image
src={getHeartImageSrc()}
alt="Crush Level"
className="animate-in fade-in-0 slide-in-from-bottom-4 absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out"
width={124}
height={124}
style={{
top: `${heartTop}px`,
}}
/>
)}
<Image
src="/images/crushlevel/bg-top.png"
alt="Crush Level"
fill
className="object-cover"
onLoad={handleBgTopLoad}
/>
</div>
<div className="relative inset-0">
<InlineDrawerHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<div className="txt-title-m">CrushLevel</div>
<Tooltip>
<TooltipTrigger asChild>
<IconButton iconfont="icon-question" variant="tertiaryDark" size="mini" />
</TooltipTrigger>
<TooltipContent>
<div className="w-64 space-y-2">
<p>
* Increase your Crush Value by chatting or sending gifts. If you dont
interact for 24 hours, your Crush Value may gradually decrease.
</p>
<p>
* Your virtual characters emotional responses during conversations will
affect whether the Crush Value goes up or down.
</p>
<p>
* A higher Crush Value boosts your Crush Level, unlocking new titles,
features, and relationship stages with your character.
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton iconfont="icon-More" variant="ghost" size="small" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.preventDefault() // 阻止默认行为
e.stopPropagation() // 阻止事件冒泡
if (isSetShowRelationshipPending) return
queryClient.setQueryData(imKeys.heartbeatLevel(aiId), (old: any) => {
return {
...old,
aiUserHeartbeatRelation: {
...old.aiUserHeartbeatRelation,
isShow: !old.aiUserHeartbeatRelation.isShow,
},
}
})
queryClient.setQueryData(imKeys.imUserInfo(aiId), (old: any) => {
return {
...old,
aiUserHeartbeatRelation: {
...old.aiUserHeartbeatRelation,
isShow: !old.aiUserHeartbeatRelation.isShow,
},
}
})
setShowRelationship({ aiId: Number(aiId), isShow: !isShow })
}}
onSelect={(e) => {
e.preventDefault() // 阻止 onSelect 默认关闭行为
}}
>
<div className="flex w-full items-center justify-between">
<div className="txt-body-l flex-1">Hide Relationship</div>
<Switch
size="sm"
className="cursor-pointer"
checked={!isShow}
disabled={isSetShowRelationshipPending}
/>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</InlineDrawerHeader>
<CrushLevelAvatarGroup />
<div>
{/* 等级和温度信息 */}
<div className="flex flex-col items-center gap-4 px-6">
{heartbeatVal ? (
<div className="txt-numDisplay-s flex items-center gap-2">
<span>{heartLevelText || 'Lv.0'}</span>
<div className="bg-outline-normal h-[18px] w-px" />
<span>
{heartbeatVal || 0}
<span className="txt-numMonotype-s">&#8451;</span>
</span>
</div>
) : (
<div className="px-6 text-center">
<span className="txt-numDisplay-s">
{heartbeatVal || 0}
<span className="txt-numMonotype-s">&#8451;</span>
</span>
</div>
)}
{/* Meet 分割线 */}
{renderLineText()}
{/* 描述文本 */}
{!!heartbeatVal && (
<p className="txt-body-s">
{`Known for ${Math.max(dayCount || 0, 1)} days | Crush Value higher than ${numeral(heartbeatScore).format('0.00%')} of users`}
</p>
)}
</div>
</div>
{!!subtractHeartbeatVal && (
<div className="my-6 px-6">
<div className="bg-surface-element-normal rounded-m flex items-center justify-between gap-2 p-4">
<div className="txt-body-s flex-1">{`Crush Value lost: -${subtractHeartbeatVal}`}</div>
<Button
variant="ghost"
size="small"
onClick={() => {
setIsCrushLevelRetrieveDrawerOpen(true)
}}
>
Retrieve
</Button>
</div>
</div>
)}
{/* 权限卡片网格 */}
<InlineDrawerDescription className="pb-6">
<HeartList datas={heartbeatLeveLDictList} />
</InlineDrawerDescription>
</div>
</InlineDrawerContent>
</InlineDrawer>
)
}
export default CrushLevelDrawer

View File

@ -1,236 +0,0 @@
import Image from 'next/image'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerDescription,
InlineDrawerFooter,
InlineDrawerHeader,
} from './InlineDrawer'
import { Button } from '@/components/ui/button'
import { useAtom, useSetAtom } from 'jotai'
import { isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import { useBuyHeartbeat, useGetHeartbeatLevel } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import numeral from 'numeral'
import { formatFromCents } from '@/utils/number'
import React, { useEffect } from 'react'
import { useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
import { isChargeDrawerOpenAtom } from '@/atoms/im'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys, walletKeys } from '@/lib/query-keys'
const CrushLevelRetrieveDrawer = () => {
// 图片加载状态管理
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false)
const [showHeartImage, setShowHeartImage] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [drawerState, setDrawerState] = useAtom(isCrushLevelRetrieveDrawerOpenAtom)
const isCrushLevelRetrieveDrawerOpen = drawerState.open
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) =>
setDrawerState(createDrawerOpenState(open))
const { aiId, aiInfo } = useChatConfig()
const { data, refetch } = useGetHeartbeatLevel({ aiId: Number(aiId) })
const { heartbeatLeveLDictList } = data || {}
const { aiUserHeartbeatRelation } = aiInfo || {}
const { subtractHeartbeatVal, heartbeatVal, price } = aiUserHeartbeatRelation || {}
const { mutateAsync: retrieveHeartbeatVal, isPending: isRetrieveHeartbeatValPending } =
useBuyHeartbeat({ aiId: Number(aiId) })
const { data: walletData } = useGetWalletBalance()
const walletUpdate = useUpdateWalletBalance()
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom)
const queryClient = useQueryClient()
const handleRetrieveHeartbeatVal = async () => {
if (loading) return
try {
setLoading(true)
if (
!walletUpdate.checkSufficient(
Math.max((price || 0) * (subtractHeartbeatVal || 0), 100) / 100
)
) {
setIsChargeDrawerOpen(true)
return
}
await retrieveHeartbeatVal({ aiId: Number(aiId), heartbeatVal: subtractHeartbeatVal || 0 })
await queryClient.invalidateQueries({ queryKey: imKeys.heartbeatLevel(Number(aiId)) })
await queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(Number(aiId)) })
await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
setIsCrushLevelRetrieveDrawerOpen(false)
} catch (error) {
setLoading(false)
} finally {
setLoading(false)
}
}
// 计算心的位置
const calculateHeartPosition = () => {
const defaultTop = 150
if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) {
return defaultTop // 默认位置
}
// 获取最低等级的起始值和最高等级的起始值
const sortedLevels = [...heartbeatLeveLDictList].sort(
(a, b) => (a.startVal || 0) - (b.startVal || 0)
)
const minStartVal = sortedLevels[0]?.startVal || 0
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0
// 如果只有一个等级或者最大最小值相等,使用默认位置
if (maxStartVal <= minStartVal) {
return defaultTop
}
// 计算当前心动值在整个等级系统中的总进度0-1
const totalProgress = Math.max(
0,
Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))
)
// 将总进度映射到位置范围top 75空心到 top 150全心
const minTop = defaultTop // 空心位置
const maxTop = 75 // 全心位置
const calculatedTop = minTop + totalProgress * (maxTop - minTop)
return calculatedTop
}
const heartTop = calculateHeartPosition()
// 根据位置决定心的显示状态
const getHeartImageSrc = () => {
// top 75为空心top 150为全心
// 根据位置变化动态调整心的显示状态
// 目前只有一个心的图片可以通过透明度或其他CSS属性来模拟空心/全心效果
// 或者后续可以添加不同的图片资源
return '/images/crushlevel/heart.png'
}
// 当抽屉打开时获取心动等级数据
React.useEffect(() => {
if (isCrushLevelRetrieveDrawerOpen) {
refetch()
// 重置图片加载状态
setIsBgTopLoaded(false)
setShowHeartImage(false)
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
}
}, [isCrushLevelRetrieveDrawerOpen, refetch])
// 处理 bg-top 图片加载完成后的延迟显示逻辑
React.useEffect(() => {
if (isBgTopLoaded) {
// bg-top 加载完成后延迟 300ms 显示心形图片
const timer = setTimeout(() => {
setShowHeartImage(true)
}, 300)
return () => clearTimeout(timer)
}
}, [isBgTopLoaded])
// bg-top 图片加载完成的处理函数
const handleBgTopLoad = () => {
setIsBgTopLoaded(true)
}
return (
<InlineDrawer
id="crushLevelRetrieve"
open={isCrushLevelRetrieveDrawerOpen}
onOpenChange={setIsCrushLevelRetrieveDrawerOpen}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent>
<div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden">
<Image
src="/images/crushlevel/bg-bottom.png"
alt="CrushLevel"
fill
className="object-cover"
/>
{/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */}
{showHeartImage && (
<Image
src={getHeartImageSrc()}
alt="Crush Level"
className="animate-in fade-in-0 slide-in-from-bottom-4 absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out"
width={124}
height={124}
style={{
top: `${heartTop}px`,
}}
/>
)}
<Image
src="/images/crushlevel/bg-top.png"
alt="Crush Level"
fill
className="object-cover"
onLoad={handleBgTopLoad}
/>
</div>
<div className="absolute inset-0 flex flex-col">
<InlineDrawerHeader> </InlineDrawerHeader>
<InlineDrawerDescription>
<div className="pt-[124px]">
<div className="txt-title-m text-center">Recover Crush Value</div>
<div className="mt-6">
<div className="flex items-center justify-between py-3">
<div className="txt-label-l flex-1">Price per Unit</div>
<div className="txt-numMonotype-s flex items-center gap-2">
<Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} />
<div>{formatFromCents(price || 0)}/&#8451;</div>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="txt-label-l flex-1">Quantity</div>
<div className="txt-numMonotype-s flex items-center gap-2">
{/* <Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} /> */}
<div>{subtractHeartbeatVal}&#8451;</div>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="txt-label-l flex-1">Total</div>
<div className="txt-numMonotype-s flex items-center gap-2">
<Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} />
<div>
{formatFromCents(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100))}
</div>
</div>
</div>
</div>
</div>
</InlineDrawerDescription>
<InlineDrawerFooter>
<Button
variant="tertiary"
size="large"
onClick={() => {
setIsCrushLevelRetrieveDrawerOpen(false)
}}
>
Cancel
</Button>
<Button
variant="primary"
size="large"
onClick={handleRetrieveHeartbeatVal}
loading={loading}
>
Purchase
</Button>
</InlineDrawerFooter>
</div>
</InlineDrawerContent>
</InlineDrawer>
)
}
export default CrushLevelRetrieveDrawer

View File

@ -1,174 +0,0 @@
import { IconButton } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from 'react'
// 管理所有抽屉的层级顺序
export const DrawerLayerContext = createContext<{
openOrder: string[]
registerDrawer: (id: string) => void
unregisterDrawer: (id: string) => void
bringToFront: (id: string) => void
getZIndex: (id: string) => number
}>({
openOrder: [],
registerDrawer: () => {},
unregisterDrawer: () => {},
bringToFront: () => {},
getZIndex: () => 0,
})
export const DrawerLayerProvider = ({
children,
baseZIndex = 10,
}: {
children: React.ReactNode
baseZIndex?: number
}) => {
const [openOrder, setOpenOrder] = useState<string[]>([])
const registerDrawer = useCallback((id: string) => {
setOpenOrder((prev) => (prev.includes(id) ? prev : [...prev, id]))
}, [])
const unregisterDrawer = useCallback((id: string) => {
setOpenOrder((prev) => prev.filter((item) => item !== id))
}, [])
const bringToFront = useCallback((id: string) => {
setOpenOrder((prev) => {
// 如果该抽屉已经在最前面,则不需要更新
if (prev.length > 0 && prev[prev.length - 1] === id) {
return prev
}
const filtered = prev.filter((item) => item !== id)
return [...filtered, id]
})
}, [])
const getZIndex = useCallback(
(id: string) => {
const index = openOrder.indexOf(id)
if (index === -1) return baseZIndex
return baseZIndex + index
},
[openOrder, baseZIndex]
)
const value = useMemo(
() => ({ openOrder, registerDrawer, unregisterDrawer, bringToFront, getZIndex }),
[openOrder, registerDrawer, unregisterDrawer, bringToFront, getZIndex]
)
return <DrawerLayerContext.Provider value={value}>{children}</DrawerLayerContext.Provider>
}
const InlineDrawerContext = createContext<{
id: string
open: boolean
onOpenChange: (open: boolean) => void
}>({
id: '',
open: false,
onOpenChange: () => {},
})
export const InlineDrawer = ({
id,
open,
onOpenChange,
timestamp,
children,
}: {
id: string
open: boolean
onOpenChange: (open: boolean) => void
timestamp?: number
children: React.ReactNode
}) => {
const { registerDrawer, unregisterDrawer, bringToFront } = useContext(DrawerLayerContext)
// 当抽屉打开时注册并置顶;当关闭或卸载时移除
// 监听 timestamp 变化,确保每次重新打开时都会置顶
useEffect(() => {
if (open) {
registerDrawer(id)
bringToFront(id)
}
}, [open, timestamp, id, registerDrawer, bringToFront])
useEffect(() => {
return () => {
unregisterDrawer(id)
}
}, [id, unregisterDrawer, open])
// 当抽屉关闭时不渲染任何内容
if (!open) {
return null
}
return (
<InlineDrawerContext.Provider value={{ id, open, onOpenChange }}>
{children}
</InlineDrawerContext.Provider>
)
}
export const InlineDrawerContent = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
const { id } = useContext(InlineDrawerContext)
const { getZIndex, bringToFront } = useContext(DrawerLayerContext)
const zIndex = getZIndex(id)
return (
<div
className={cn(
'bg-background-default border-outline-normal absolute inset-0 flex w-[400px] flex-col border-l border-solid',
className
)}
style={{ zIndex }}
onPointerDownCapture={() => bringToFront(id)}
>
{children}
</div>
)
}
export const InlineDrawerHeader = ({ children }: { children: React.ReactNode }) => {
const { onOpenChange } = useContext(InlineDrawerContext)
return (
<div className="flex items-center gap-2 p-6">
<IconButton
iconfont="icon-arrow-right"
variant="ghost"
size="small"
onClick={() => onOpenChange(false)}
/>
<div className="txt-title-m min-w-0 flex-1">{children}</div>
</div>
)
}
export const InlineDrawerDescription = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
return <div className={cn('flex-1 overflow-y-auto px-6', className)}>{children}</div>
}
export const InlineDrawerFooter = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
return <div className={cn('flex items-center justify-end gap-4 p-6', className)}>{children}</div>
}

View File

@ -1,372 +0,0 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import {
isSendGiftsDrawerOpenAtom,
createDrawerOpenState,
isCrushLevelDrawerOpenAtom,
} from '@/atoms/chat'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerDescription,
InlineDrawerFooter,
InlineDrawerHeader,
} from './InlineDrawer'
import { Button } from '@/components/ui/button'
import Image from 'next/image'
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { GiftOutput } from '@/services/im'
import { useGetGiftList, useSendGift } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import { Tag } from '@/components/ui/tag'
import { isChargeDrawerOpenAtom, isVipDrawerOpenAtom, isWaitingForReplyAtom } from '@/atoms/im'
import { toast } from 'sonner'
import numeral from 'numeral'
import { useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
import { useCurrentUser } from '@/hooks/auth'
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
import { VipType } from '@/services/wallet'
import { useQueryClient } from '@tanstack/react-query'
import { walletKeys } from '@/lib/query-keys'
// 礼物价格转换工具函数(分转元)
const convertGiftPriceToYuan = (priceInCents: number): number => {
return priceInCents / 100
}
// 礼物卡片组件
const GiftCard = ({
gift,
isSelected,
onSelect,
heartbeatVal,
isMember,
}: {
gift: GiftOutput
isSelected: boolean
onSelect: (gift: GiftOutput) => void
heartbeatVal: number
isMember: boolean
}) => {
const handleClick = () => {
onSelect(gift)
}
const renderDisabledTag = () => {
if (gift.isMemberGift) {
if (isMember) {
return null
}
return (
<Tag className="absolute top-0 left-0" size="small">
<i className="iconfont icon-private-border !text-[12px]" />
</Tag>
)
}
if (gift.startVal && typeof heartbeatVal === 'number' && heartbeatVal < gift.startVal) {
return (
<Tag className="absolute top-0 left-0" size="small">
<i className="iconfont icon-private-border !text-[12px]" />
</Tag>
)
}
return null
}
return (
<div
className={cn(
'min-w-24 flex-1 cursor-pointer rounded-2xl p-2 transition-all duration-200',
'hover:bg-surface-element-normal flex flex-col items-center gap-1 border border-transparent bg-transparent',
isSelected && 'border-primary-variant-normal bg-surface-element-normal border shadow-lg'
)}
onClick={handleClick}
>
{/* 礼物图片 */}
<div className="relative aspect-square w-full">
<Image src={gift.icon} alt={gift.name} fill className="object-contain" />
{renderDisabledTag()}
</div>
{/* 礼物名称 */}
<div className="txt-label-m text-txt-primary-normal line-clamp-1 text-center">
{gift.name}
</div>
{/* 价格 */}
<div className="flex items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={12} height={12} />
<span className="txt-numMonotype-xs text-txt-primary-normal">
{numeral(convertGiftPriceToYuan(gift.price)).format('0,0')}
</span>
</div>
</div>
)
}
const SendGiftsDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isSendGiftsDrawerOpenAtom)
const isSendGiftsDrawerOpen = drawerState.open
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const [selectedGift, setSelectedGift] = useState<GiftOutput | null>(null)
const [quantity, setQuantity] = useState(1)
const { aiId, aiInfo, handleUserMessage } = useChatConfig()
// const isWaitingForReply = useAtomValue(isWaitingForReplyAtom);
const isWaitingForReply = false
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom)
const { data: currentUser } = useCurrentUser()
const { isMember } = currentUser || {}
const setIsCrushLevelDrawerOpen = useSetAtom(isCrushLevelDrawerOpenAtom)
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const queryClient = useQueryClient()
const { aiUserHeartbeatRelation } = aiInfo || {}
const { heartbeatVal } = aiUserHeartbeatRelation || {}
const { data } = useGetGiftList()
const giftList = data?.datas || []
const isOwner = currentUser?.userId === aiInfo?.userId
const { mutateAsync: sendGift, isPending: isSendGiftPending } = useSendGift()
// 当礼物列表加载完成且抽屉打开时,自动选中第一个礼物
useEffect(() => {
if (isSendGiftsDrawerOpen && giftList.length > 0 && !selectedGift) {
setSelectedGift(giftList[0])
setQuantity(1)
}
}, [isSendGiftsDrawerOpen, giftList, selectedGift])
// 当抽屉关闭时,重置选中状态
useEffect(() => {
if (!isSendGiftsDrawerOpen) {
setSelectedGift(null)
setQuantity(1)
}
if (isSendGiftsDrawerOpen) {
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
}
}, [isSendGiftsDrawerOpen])
// 获取钱包余额和更新方法
const { data: walletData } = useGetWalletBalance()
const walletUpdate = useUpdateWalletBalance()
// 用户余额(单位:元)
const balanceString = walletData?.balanceString || '0'
// 计算总价(礼物价格是分单位,需要转换为元)
const totalPrice = selectedGift ? convertGiftPriceToYuan(selectedGift.price) * quantity : 0
// 是否能够购买
const canPurchase = selectedGift && walletUpdate.checkSufficient(totalPrice)
const handleQuantityChange = (delta: number) => {
const newQuantity = Math.max(1, Math.min(100, quantity + delta))
setQuantity(newQuantity)
}
const handleGiftSelect = (gift: GiftOutput | null) => {
setSelectedGift(gift)
// 重置数量为1
setQuantity(1)
}
const handleSendGift = async () => {
if (!selectedGift) return
// if (isOwner) {
// toast.error('You cannot send gifts to yourself.');
// return;
// }
if (!canPurchase) {
setIsChargeDrawerOpen(true)
return
}
try {
await sendGift({
giftId: selectedGift.id,
aiId: Number(aiId),
num: quantity,
})
walletUpdate.deduct(totalPrice, { skipInvalidation: true })
// 成功后刷新余额数据
walletUpdate.refresh()
// 通知用户发送了消息,重置自动聊天定时器
handleUserMessage()
// 重置选择状态
setSelectedGift(null)
setQuantity(1)
} catch (error) {
// 失败时回滚余额
toast.error('Gift sending failed. Please try again.')
console.error('送礼物失败:', error)
}
}
const getButton = () => {
const { isMemberGift, startVal, heartbeatLevel } = selectedGift || {}
if (isMemberGift) {
if (!isMember) {
return (
<Button
size="large"
variant="vip"
onClick={() => setIsVipDrawerOpen({ open: true, vipType: VipType.SPECIAL_GIFT })}
>
<div className="flex items-center gap-2">
<Image
src="/icons/vip-black.svg"
alt="vip"
className="block"
width={24}
height={24}
/>
<span className="txt-label-l">Unlock</span>
</div>
</Button>
)
}
}
if (heartbeatLevel) {
if (
(startVal && typeof heartbeatVal === 'number' && heartbeatVal < startVal) ||
!heartbeatVal
) {
return (
<Button
size="large"
onClick={() => setIsCrushLevelDrawerOpen(createDrawerOpenState(true))}
>
<div className="flex items-center gap-2">
<Image
src="/icons/like-gradient.svg"
alt="vip"
className="block"
width={24}
height={24}
/>
<span className="txt-label-l">
{useHeartLevelTextFromLevel(heartbeatLevel)} Unlock
</span>
</div>
</Button>
)
}
}
return (
<Button onClick={handleSendGift} disabled={isWaitingForReply} loading={isSendGiftPending}>
Gift
</Button>
)
}
return (
<InlineDrawer
id="sendGifts"
open={isSendGiftsDrawerOpen}
onOpenChange={setIsSendGiftsDrawerOpen}
timestamp={drawerState.timestamp}
>
<InlineDrawerContent className="bg-background-default">
<InlineDrawerHeader>Send Gifts</InlineDrawerHeader>
<InlineDrawerDescription className="flex-1 overflow-y-auto">
{/* 礼物网格 */}
<div className="grid grid-cols-3 gap-2 pb-6">
{giftList.map((gift) => (
<GiftCard
key={gift.id}
gift={gift}
isSelected={selectedGift?.id === gift.id}
heartbeatVal={heartbeatVal || 0}
isMember={!!isMember}
onSelect={handleGiftSelect}
/>
))}
</div>
</InlineDrawerDescription>
<InlineDrawerFooter className="bg-background-default/65 flex-col gap-4 backdrop-blur-md">
{/* 数量选择器 */}
{selectedGift && (
<div className="flex w-full items-center justify-between">
<div className="txt-label-m text-txt-primary-normal">Quantity</div>
<div className="flex items-center gap-2">
<Button
variant="tertiary"
className="!w-12 min-w-0 rounded-sm px-0"
onClick={() => handleQuantityChange(-1)}
>
<i className="iconfont icon-reduce" />
</Button>
<Input
className="w-20 text-center"
value={quantity}
onChange={(e) => {
const value = e.target.value
// 只能输入正整数
if (!/^\d+$/.test(value)) {
return
}
const numValue = Number(value)
// 限制最大值为100
if (numValue > 100) {
setQuantity(100)
return
}
setQuantity(numValue)
}}
/>
<Button
variant="tertiary"
className="!w-12 min-w-0 rounded-sm px-0"
onClick={() => handleQuantityChange(1)}
>
<i className="iconfont icon-add" />
</Button>
</div>
</div>
)}
{/* 总价显示 */}
{selectedGift && (
<div className="flex w-full items-center justify-between">
<div className="txt-label-m text-txt-primary-normal">Total</div>
<div className="flex items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-numMonotype-s text-txt-primary-normal">
{numeral(totalPrice).format('0,0')}
</span>
</div>
</div>
)}
{/* 余额和购买按钮 */}
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-1">
<span className="txt-label-m text-txt-primary-normal">Balance:</span>
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-numMonotype-s text-txt-primary-normal">{balanceString}</span>
</div>
{getButton()}
</div>
</InlineDrawerFooter>
</InlineDrawerContent>
</InlineDrawer>
)
}
export default SendGiftsDrawer

View File

@ -1,30 +0,0 @@
import CrushLevelDrawer from './CrushLevelDrawer'
import SendGiftsDrawer from './SendGiftsDrawer'
import CrushLevelRetrieveDrawer from './CrushLevelRetrieveDrawer'
import ChatProfileDrawer from './ChatProfileDrawer'
import { DrawerLayerContext } from './InlineDrawer'
import { useContext } from 'react'
import { cn } from '@/lib/utils'
import ChatProfileEditDrawer from './ChatProfileEditDrawer'
import ChatModelDrawer from './ChatModelDrawer'
import ChatButtleDrawer from './ChatButtleDrawer'
import ChatBackgroundDrawer from './ChatBackgroundDrawer'
const ChatDrawers = () => {
const { openOrder } = useContext(DrawerLayerContext)
return (
<div className={cn('w-[400px]', openOrder.length === 0 && 'hidden')}>
<SendGiftsDrawer />
<CrushLevelDrawer />
<CrushLevelRetrieveDrawer />
<ChatProfileDrawer />
<ChatProfileEditDrawer />
<ChatModelDrawer />
<ChatButtleDrawer />
<ChatBackgroundDrawer />
</div>
)
}
export default ChatDrawers

View File

@ -1,54 +0,0 @@
'use client'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from '@/components/ui/alert-dialog'
import { useEffect, useState } from 'react'
import { useChatConfig } from '../../context/chatConfig'
import { useRouter } from 'next/navigation'
const ChatFirstGuideDialog = () => {
const [open, setOpen] = useState(false)
const { aiId } = useChatConfig()
const router = useRouter()
useEffect(() => {
if (!!localStorage.getItem('create-ai-show-guide')) {
setOpen(true)
localStorage.removeItem('create-ai-show-guide')
}
}, [])
const handleConfirm = () => {
setOpen(false)
router.push(`/@${aiId}`)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Create Album</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<p>
Go to the characters profile to create an album, attract more chatters, and increase
your earnings.
</p>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Not now</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Go</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export default ChatFirstGuideDialog

View File

@ -1,148 +0,0 @@
'use client'
import React, { useState } from 'react'
import { cn } from '@/lib/utils'
import { IconButton } from '@/components/ui/button'
import Image from 'next/image'
interface ReplySuggestion {
id: string
text: string
isSkeleton?: boolean
}
interface AiReplySuggestionsProps {
suggestions: ReplySuggestion[]
currentPage: number
totalPages: number
isLoading?: boolean
onSuggestionEdit: (suggestion: ReplySuggestion) => void
onSuggestionSend: (suggestion: ReplySuggestion) => void
onPageChange: (page: number) => void
onClose: () => void
className?: string
}
export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
suggestions,
currentPage,
totalPages,
isLoading = false,
onSuggestionEdit,
onSuggestionSend,
onPageChange,
onClose,
className,
}) => {
// 检查是否显示骨架屏:当前页的建议中有骨架屏标记
const showSkeleton = suggestions.some((s) => s.isSkeleton)
return (
<div className={cn('flex w-full flex-col items-start justify-start gap-4', className)}>
{/* 标题栏 */}
<div className="flex w-full items-center justify-between">
<p className="txt-label-m text-txt-secondary-normal">Choose one or edit</p>
<IconButton variant="tertiaryDark" size="xs" onClick={onClose}>
<i className="iconfont icon-close" />
</IconButton>
</div>
{/* 建议列表 */}
{showSkeleton
? // 骨架屏 - 固定显示3条建议的布局
suggestions.map((suggestion) => (
<div
key={suggestion.id}
className="bg-surface-element-light-normal flex w-full items-end justify-start gap-4 overflow-hidden rounded-xl py-2 pr-4 pl-4 backdrop-blur-[16px]"
>
<div className="flex-1 px-0 py-1">
<div className="bg-surface-element-normal h-6 w-full animate-pulse rounded"></div>
</div>
<div className="bg-surface-element-normal size-8 flex-shrink-0 animate-pulse rounded-full"></div>
</div>
))
: // 实际建议内容
suggestions.map((suggestion) => (
<div
key={suggestion.id}
className="bg-surface-element-light-normal hover:bg-surface-element-light-hover flex w-full cursor-pointer items-end justify-start gap-4 overflow-hidden rounded-xl py-2 pr-4 pl-4 backdrop-blur-[16px] transition-colors"
onClick={() => onSuggestionSend(suggestion)}
>
<div className="flex-1 px-0 py-1">
<div className="txt-body-l overflow-hidden">
<p>{suggestion.text}</p>
</div>
</div>
<IconButton
variant="ghost"
size="small"
onClick={(e) => {
e.stopPropagation() // 阻止事件冒泡,避免触发卡片点击
onSuggestionEdit(suggestion)
}}
className="flex-shrink-0"
>
<i className="iconfont icon-icon_order_remark" />
</IconButton>
</div>
))}
{/* VIP 解锁选项 */}
{/* <div
className="backdrop-blur backdrop-filter bg-[rgba(255,255,255,0.15)] relative rounded-[24px] w-full cursor-pointer"
onClick={onVipClick}
>
<div className="flex gap-2 items-center justify-start overflow-hidden pl-4 pr-2 py-2">
<div className="flex-1 flex gap-2 items-center justify-start px-0 py-1">
<Image src="/icons/vip.svg" alt="vip" width={16} height={16} />
<div
className="bg-clip-text bg-gradient-to-r from-[#ff9696] to-[#8df3e2] via-50% via-[#aa90f9] txt-label-m"
style={{ WebkitTextFillColor: "transparent" }}
>
Unlock more with VIP
</div>
</div>
<IconButton
variant="ghost"
size="small"
className="flex-shrink-0"
>
<i className="iconfont icon-arrow-right" />
</IconButton>
</div>
<div className="absolute border border-[#ff9696] border-solid inset-0 pointer-events-none rounded-[24px]" />
</div> */}
{/* 分页控制 */}
<div className="flex w-full items-center justify-center gap-4">
<div className="flex items-start justify-start gap-2">
<IconButton
variant="tertiaryDark"
size="xs"
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
<i className="iconfont icon-arrow-left-border" />
</IconButton>
<div className="flex h-6 min-w-6 items-center justify-center gap-3">
<span className="txt-numMonotype-xs">
{currentPage}/{totalPages}
</span>
</div>
<IconButton
variant="tertiaryDark"
size="xs"
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
<i className="iconfont icon-arrow-right-border" />
</IconButton>
</div>
</div>
</div>
)
}
export default AiReplySuggestions

View File

@ -1,141 +0,0 @@
'use client'
import { IconButton } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { useState, useCallback } from 'react'
import { useChatConfig } from '../../context/chatConfig'
import { useSetAtom } from 'jotai'
import { isCrushLevelDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import useShare from '@/hooks/useShare'
import { ChatPriceType, useUpdateWalletBalance } from '@/hooks/useWallet'
import { isCallAtom, isCoinInsufficientAtom } from '@/atoms/im'
import { toast } from 'sonner'
import { useNimMsgContext } from '@/context/NimChat/useNimChat'
const ChatActionPlus = ({ onUploadImage }: { onUploadImage: () => void }) => {
const [open, setOpen] = useState(false)
const { aiInfo, aiId } = useChatConfig()
const { audioPlayer } = useNimMsgContext()
const setDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { aiUserHeartbeatRelation } = aiInfo || {}
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {}
const { shareFacebook, shareTwitter } = useShare()
const { checkSufficientByType } = useUpdateWalletBalance()
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom)
const setIsCall = useSetAtom(isCallAtom)
const handleShareFacebook = () => {
shareFacebook({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
}
const handleShareTwitter = () => {
shareTwitter({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
}
// 请求麦克风权限
const requestMicrophonePermission = useCallback(async (): Promise<boolean> => {
try {
// 先尝试同时请求麦克风和摄像头权限
let stream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
console.log('成功获取麦克风和摄像头权限')
} catch (error) {
// 如果摄像头权限失败,只请求麦克风权限
console.log('摄像头权限获取失败,仅请求麦克风权限:', error)
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
console.log('成功获取麦克风权限')
}
// 立即停止流,我们只是为了获取权限
stream.getTracks().forEach((track) => track.stop())
return true
} catch (error) {
console.log('requestMicrophonePermission error', JSON.stringify(error))
// 可以在这里显示用户友好的错误提示
toast(
'Microphone permission is required to make a voice call. Please allow microphone access in your browser settings.'
)
return false
}
}, [])
const handleMakeCall = async () => {
audioPlayer?.stop()
if (!heartbeatLevelNum || heartbeatLevelNum < 4) {
setIsCrushLevelDrawerOpen(true)
return
}
if (!checkSufficientByType(ChatPriceType.VOICE_CALL)) {
setIsCoinInsufficient(true)
return
}
// 在开始通话前请求麦克风权限
const hasPermission = await requestMicrophonePermission()
if (!hasPermission) {
return // 如果没有权限,不继续进行通话
}
setIsCall(true)
}
const handleUploadImage = () => {
if (!heartbeatLevelNum || heartbeatLevelNum < 2) {
setIsCrushLevelDrawerOpen(true)
return
}
if (!checkSufficientByType(ChatPriceType.TEXT)) {
setIsCoinInsufficient(true)
return
}
onUploadImage()
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<IconButton variant="ghost" size="small">
<i className={cn('iconfont', open ? 'icon-close' : 'icon-add')} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleUploadImage}>
<i className="iconfont icon-uploadimg" />
<span>Send an Image</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleMakeCall}>
<i className="iconfont icon-Call" />
<span>Make a Call</span>
</DropdownMenuItem>
<div className="my-3 px-2">
<Separator className="bg-outline-normal" />
</div>
<div className="txt-label-m text-txt-secondary-normal px-2 py-3">Share to</div>
<DropdownMenuItem onClick={handleShareFacebook}>
<i className="iconfont icon-social-facebook" />
<span>Share to Facebook</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleShareTwitter}>
<i className="iconfont icon-social-twitter" />
<span>Share to X</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default ChatActionPlus

View File

@ -1,61 +0,0 @@
'use client'
import { IconButton } from '@/components/ui/button'
import { useMemo, useEffect } from 'react'
interface ChatImagePreviewProps {
selectedImage: File | null
imageUploading: boolean
removeImage: () => void
}
const ChatImagePreview = ({
selectedImage,
imageUploading,
removeImage,
}: ChatImagePreviewProps) => {
// 使用useMemo缓存ObjectURL避免每次渲染都创建新的URL
const imageUrl = useMemo(() => {
if (!selectedImage) return null
return URL.createObjectURL(selectedImage)
}, [selectedImage])
// 组件卸载或图片变更时清理ObjectURL避免内存泄漏
useEffect(() => {
return () => {
if (imageUrl) {
URL.revokeObjectURL(imageUrl)
}
}
}, [imageUrl])
if (!selectedImage || !imageUrl) {
return null
}
return (
<div className="ml-4 w-full">
<div className="relative inline-block">
<div
className="relative h-24 w-24 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${imageUrl})` }}
>
{/* 上传进度遮罩 */}
{imageUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
</div>
)}
{/* 删除按钮 */}
<div className="absolute top-2 right-2 leading-none">
<IconButton variant="tertiaryDark" size="mini" onClick={removeImage}>
<i className="iconfont icon-close" />
</IconButton>
</div>
</div>
</div>
</div>
)
}
export default ChatImagePreview

View File

@ -1,722 +0,0 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { IconButton } from '@/components/ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { isSendGiftsDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat'
import {
selectedConversationIdAtom,
isWaitingForReplyAtom,
isCoinInsufficientAtom,
} from '@/atoms/im'
import { Textarea } from '@/components/ui/textarea'
import { useS3Upload } from '@/hooks/useS3Upload'
import { BizTypeEnum } from '@/services/common/types'
import ChatImagePreview from './ChatImagePreview'
import { useGetTextFromAsrVoice } from '@/hooks/useIm'
import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation'
import { CustomMessageType } from '@/types/im'
import ChatActionPlus from './ChatActionPlus'
import AiReplySuggestions from './AiReplySuggestions'
import { useAiReplySuggestions } from '@/hooks/useAiReplySuggestions'
import { useChatConfig } from '../../context/chatConfig/useChatConfig'
import { toast } from 'sonner'
import CoinInsufficientDialog from '@/components/features/coin-insufficient-dialog'
import { ChatPriceType, useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
import { PriceType } from '@/services/wallet'
import { useSearchParams } from 'next/navigation'
interface ChatInputProps {
onSendMessage?: (message: string, images?: File[]) => void
onSendVoice?: (audioBlob: Blob) => void
userAvatar?: string
className?: string
placeholder?: string
}
interface ImageError {
type: 'format' | 'size'
message: string
}
interface ImageInfo {
url: string
width: number
height: number
file?: File
}
const ChatInput: React.FC<ChatInputProps> = ({
onSendMessage,
onSendVoice,
userAvatar,
className,
placeholder = 'Chat',
}) => {
const searchParams = useSearchParams()
const [message, setMessage] = useState('')
const [selectedImage, setSelectedImage] = useState<File | null>(null)
const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null)
const [imageError, setImageError] = useState<ImageError | null>(null)
const [isRecording, setIsRecording] = useState(false)
const [isListening, setIsListening] = useState(false)
const [isProcessingAudio, setIsProcessingAudio] = useState(false) // 音频处理状态(上传+ASR
const [isComposing, setIsComposing] = useState(false) // 中文输入法组合输入状态
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const isRecordingRef = useRef<boolean>(false)
const isCancelledRef = useRef<boolean>(false)
const recordingStartTimeRef = useRef<number>(0) // 录音开始时间
const recordingTimerRef = useRef<NodeJS.Timeout | null>(null) // 60秒定时器
const setDrawerState = useSetAtom(isSendGiftsDrawerOpenAtom)
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { sendMessageActive, audioPlayer } = useNimMsgContext()
const { nim } = useNimChat()
const selectedConversationId = useAtomValue(selectedConversationIdAtom)
// const isWaitingForReply = useAtomValue(isWaitingForReplyAtom);
const isWaitingForReply = false
const { mutateAsync: getTextFromAsrVoice, isPending: isTextFromAsrVoicePending } =
useGetTextFromAsrVoice()
const { aiId, handleUserMessage } = useChatConfig()
const { checkSufficientByType } = useUpdateWalletBalance()
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom)
// AI建议回复相关状态
const {
suggestions,
currentPage,
totalPages,
isLoading: suggestionsLoading,
isVisible: suggestionsVisible,
showSuggestions,
hideSuggestions,
handlePageChange,
} = useAiReplySuggestions({
aiId: Number(aiId),
})
// S3上传钩子
const {
uploading: imageUploading,
uploadFile,
cancelUpload,
error: uploadError,
progress: uploadProgress,
} = useS3Upload({
bizType: BizTypeEnum.IM,
onSuccess: (url) => {
console.log('Image uploaded successfully:', url)
},
onError: (error) => {
console.error('Image upload failed:', error)
setImageError({ type: 'size', message: 'Image upload failed, please try again' })
},
})
// 自动调整 Textarea 高度的函数
const adjustTextareaHeight = () => {
const textarea = textareaRef.current
if (textarea) {
// 重置高度以获得准确的 scrollHeight
textarea.style.height = 'auto'
// 计算内容高度但限制最大高度为视口的40%以防止过度扩展
const maxHeight = Math.min(textarea.scrollHeight, window.innerHeight * 0.4)
textarea.style.height = maxHeight + 'px'
}
}
// 重置 Textarea 高度到初始状态
const resetTextareaHeight = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = '32px' // 设置为最小高度
}
}
// 监听 message 变化,自动调整高度
useEffect(() => {
adjustTextareaHeight()
}, [message])
// 从 URL 参数中获取 text 并填充到输入框
useEffect(() => {
const textFromUrl = searchParams.get('text')
if (textFromUrl) {
setMessage(textFromUrl)
// 聚焦到输入框
if (textareaRef.current) {
textareaRef.current.focus()
}
} else {
if (textareaRef.current) {
textareaRef.current.focus()
}
}
}, [searchParams])
// 音频上传的S3配置
const { uploadFile: uploadAudioFile } = useS3Upload({
bizType: BizTypeEnum.SOUND_PATH,
})
// 获取图片尺寸信息
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
URL.revokeObjectURL(url)
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
})
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Failed to load image'))
}
img.src = url
})
}
// 验证图片格式和大小
const validateImage = (file: File): ImageError | null => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png']
const maxSize = 10 * 1024 * 1024 // 10MB
if (!allowedTypes.includes(file.type)) {
return {
type: 'format',
message: 'Supported formats: JPG, JPEG, PNG',
}
}
if (file.size > maxSize) {
return {
type: 'size',
message: 'Image size must be less than 10MB',
}
}
return null
}
// 处理发送消息
const handleSend = () => {
if (isRecording) {
// 手动停止录音时,也需要检查时长
mediaRecorderRef.current?.stop()
setIsRecording(false)
isRecordingRef.current = false
return
}
// 如果正在等待回复或正在上传图片,禁止发送新消息
if (isWaitingForReply || imageUploading || isProcessingAudio) {
return
}
// 如果有图片,必须同时有文字内容
if (imageInfo && !message.trim()) {
return
}
if (!checkSufficientByType(ChatPriceType.TEXT)) {
setIsCoinInsufficient(true)
return
}
// 必须有文字内容或者同时有图片和文字
if (message.trim() || (imageInfo && message.trim())) {
let msg
if (imageInfo) {
// 创建图文消息(自定义消息)
const customContent = {
type: CustomMessageType.IMAGE,
url: `${imageInfo.url}`,
width: imageInfo.width,
height: imageInfo.height,
}
msg = nim.V2NIMMessageCreator.createCustomMessage(
message.trim(), // text字段
JSON.stringify(customContent) // custom字段
)
} else {
// 纯文本消息
msg = nim.V2NIMMessageCreator.createTextMessage(message.trim())
}
sendMessageActive({
msg,
conversationId: selectedConversationId || '',
})
// 通知用户发送了消息,重置自动聊天定时器
handleUserMessage()
// 清空输入
setMessage('')
setSelectedImage(null)
setImageInfo(null)
setImageError(null)
// 重置 Textarea 高度
resetTextareaHeight()
}
}
// 处理键盘事件
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
e.preventDefault()
handleSend()
}
}
// 处理中文输入法组合事件
const handleCompositionStart = () => {
setIsComposing(true)
}
const handleCompositionEnd = () => {
setIsComposing(false)
}
// 处理图片选择
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const error = validateImage(file)
if (error) {
setImageError(error)
setSelectedImage(null)
setImageInfo(null)
} else {
setSelectedImage(file)
setImageError(null)
try {
// 获取图片尺寸
const dimensions = await getImageDimensions(file)
// 开始上传
const uploadedUrl = await uploadFile(file)
console.log('uploadedUrl', uploadedUrl)
if (uploadedUrl) {
setImageInfo({
url: `${uploadedUrl}`,
width: dimensions.width,
height: dimensions.height,
file,
})
}
} catch (error) {
console.error('处理图片失败:', error)
setImageError({ type: 'size', message: 'Image processing failed, please try again' })
setSelectedImage(null)
}
}
}
// 清空input的值允许重新选择同一个文件
e.target.value = ''
}
// 取消录音
const handleCancelRecording = () => {
if (mediaRecorderRef.current && isRecording) {
// 标记为取消状态防止在onstop中处理音频
isCancelledRef.current = true
// 清除60秒定时器
if (recordingTimerRef.current) {
clearTimeout(recordingTimerRef.current)
recordingTimerRef.current = null
}
mediaRecorderRef.current.stop()
// 获取所有音频轨道并停止
if (mediaRecorderRef.current.stream) {
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop())
}
setIsRecording(false)
isRecordingRef.current = false
setIsListening(false)
setIsProcessingAudio(false) // 重置音频处理状态
audioChunksRef.current = []
recordingStartTimeRef.current = 0
}
}
// 处理语音录制
const handleVoiceRecord = async () => {
if (!checkSufficientByType(ChatPriceType.VOICE)) {
setIsCoinInsufficient(true)
return
}
audioPlayer?.stop()
if (!isRecording) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
// 添加音频上下文来检测音量
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
const microphone = audioContext.createMediaStreamSource(stream)
microphone.connect(analyser)
analyser.smoothingTimeConstant = 0.8
analyser.fftSize = 1024
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
mediaRecorder.ondataavailable = (event) => {
audioChunksRef.current.push(event.data)
}
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
stream.getTracks().forEach((track) => track.stop())
audioContext.close()
setIsListening(false)
isRecordingRef.current = false
// 清除60秒定时器
if (recordingTimerRef.current) {
clearTimeout(recordingTimerRef.current)
recordingTimerRef.current = null
}
// 如果是取消录音,直接返回,不处理音频
if (isCancelledRef.current) {
isCancelledRef.current = false // 重置取消状态
recordingStartTimeRef.current = 0
return
}
// 检查录音时长
const recordingDuration = Date.now() - recordingStartTimeRef.current
if (recordingDuration < 1000) {
// 录音时间少于1秒显示提示
toast.error('Voice too short')
recordingStartTimeRef.current = 0
return
}
// 开始音频处理(上传+ASR
setIsProcessingAudio(true)
try {
// 将 Blob 转换为 base64
const base64Data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
console.log('result', result)
// 移除 data:audio/mp3;base64, 前缀
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(audioBlob)
})
// 调用ASR接口获取文字
const resp = await getTextFromAsrVoice({
data: base64Data,
aiId: Number(aiId),
})
const text = resp?.content
const trimmedText = text?.trim()
if (trimmedText) {
// 纯文本消息
const msg = nim.V2NIMMessageCreator.createTextMessage(trimmedText)
sendMessageActive({
msg,
conversationId: selectedConversationId || '',
})
// 通知用户发送了消息,重置自动聊天定时器
handleUserMessage()
// 重置 Textarea 高度(虽然语音消息不会改变输入框内容,但为了保持一致性)
resetTextareaHeight()
}
} catch (error) {
console.error('音频上传或转换失败:', error)
// 可以在这里添加错误提示给用户
} finally {
// 完成音频处理
setIsProcessingAudio(false)
recordingStartTimeRef.current = 0
}
}
mediaRecorder.start()
setIsRecording(true)
isRecordingRef.current = true
isCancelledRef.current = false // 重置取消状态
// 记录录音开始时间
recordingStartTimeRef.current = Date.now()
// 设置60秒定时器自动停止录音
recordingTimerRef.current = setTimeout(() => {
if (mediaRecorderRef.current && isRecordingRef.current) {
mediaRecorderRef.current.stop()
setIsRecording(false)
isRecordingRef.current = false
}
}, 60000) // 60秒
// 检测音量的函数
const checkVolume = () => {
if (isRecordingRef.current) {
analyser.getByteFrequencyData(dataArray)
const volume = dataArray.reduce((sum, value) => sum + value, 0) / bufferLength
setIsListening(volume > 10) // 设置音量阈值
requestAnimationFrame(checkVolume)
}
}
// 确保在设置录音状态后再开始检测音量
checkVolume()
} catch (error) {
console.error('Error accessing microphone:', error)
}
} else {
// 发送动作改在发送消息的按钮上
return
// 手动停止录音时,也需要检查时长
mediaRecorderRef.current?.stop()
setIsRecording(false)
isRecordingRef.current = false
}
}
// 移除选中的图片
const removeImage = () => {
// 如果正在上传,取消上传
if (imageUploading) {
cancelUpload()
}
setSelectedImage(null)
setImageInfo(null)
setImageError(null)
}
// 处理AI建议编辑放到输入框
const handleSuggestionEdit = (suggestion: any) => {
setMessage(suggestion.text)
hideSuggestions()
}
// 处理AI建议发送直接发送消息
const handleSuggestionSend = (suggestion: any) => {
// 直接发送建议内容作为消息
if (suggestion.text.trim()) {
const msg = nim.V2NIMMessageCreator.createTextMessage(suggestion.text.trim())
sendMessageActive({
msg,
conversationId: selectedConversationId || '',
})
// 通知用户发送了消息,重置自动聊天定时器
handleUserMessage()
hideSuggestions()
// 重置 Textarea 高度
resetTextareaHeight()
}
}
return (
<div className={cn('relative flex w-full flex-col', className)}>
{/* AI建议回复面板 */}
{suggestionsVisible && (
<div className="mb-4 rounded-xl px-16 pt-6">
<AiReplySuggestions
suggestions={suggestions}
currentPage={currentPage}
totalPages={totalPages}
isLoading={suggestionsLoading}
onSuggestionEdit={handleSuggestionEdit}
onSuggestionSend={handleSuggestionSend}
onPageChange={handlePageChange}
onClose={hideSuggestions}
/>
</div>
)}
{/* 错误提示 */}
{imageError && (
<div className="mx-3 mb-2 rounded-lg border border-red-500/30 bg-red-500/20 px-3 py-2">
<p className="text-sm text-red-400">{imageError.message}</p>
</div>
)}
{/* 输入区域 */}
<div className="flex items-end gap-3">
<IconButton onClick={() => setIsSendGiftsDrawerOpen(true)}>
<i className="iconfont icon-gift-border" />
</IconButton>
{/* 输入框容器 */}
<div className="relative flex-1">
{isRecording ? (
/* 录音状态界面 - 按照Figma设计稿样式 */
<div className="box-border flex min-h-[48px] items-center justify-start gap-4 overflow-clip rounded-3xl bg-[rgba(255,255,255,0.15)] p-2 backdrop-blur backdrop-filter">
{/* 左侧语音按钮 */}
<IconButton
variant="ghost"
size="small"
onClick={handleVoiceRecord}
className="flex-shrink-0 bg-transparent"
>
<i className="iconfont icon-voice_msg text-white" />
</IconButton>
{/* 中间声波纹 */}
<div className="flex flex-1 items-center justify-center px-4">
<VoiceWaveAnimation
animated={isListening}
barCount={40}
className="w-full max-w-[316px]"
/>
</div>
{/* 右侧删除按钮 */}
<IconButton variant="tertiary" size="small" onClick={handleCancelRecording}>
<i className="iconfont icon-close" />
</IconButton>
</div>
) : (
/* 正常输入状态界面 */
<div
className={cn(
'bg-surface-element-light-press relative flex min-h-[48px] items-end rounded-xl p-2 backdrop-blur-sm',
isWaitingForReply && 'opacity-60'
)}
>
{/* 语音录制按钮 */}
<IconButton
variant="ghost"
size="small"
onClick={handleVoiceRecord}
className="flex-shrink-0"
>
<i className="iconfont icon-voice_msg" />
</IconButton>
{/* 文本输入 */}
<div className={cn('w-full')}>
<ChatImagePreview
selectedImage={selectedImage}
imageUploading={imageUploading}
removeImage={removeImage}
/>
{/* 输入框 */}
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyPress}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={placeholder}
// placeholder={isWaitingForReply ? "等待AI回复中..." : placeholder}
// disabled={isWaitingForReply}
className="text-txt-primary-normal placeholder:text-txt-tertiary-normal txt-body-l max-h-none min-h-[24px] flex-1 resize-none overflow-hidden border-none bg-transparent py-1 break-all outline-none"
rows={1}
maxLength={500}
style={{
height: 'auto',
minHeight: '32px',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
onInput={adjustTextareaHeight}
/>
</div>
{/* 右侧按钮组 */}
<div className="ml-2 flex items-center gap-2">
{/* 提示词提示按钮 */}
<IconButton
variant="ghost"
size="small"
onClick={() => {
if (!checkSufficientByType(ChatPriceType.TEXT)) {
setIsCoinInsufficient(true)
return
}
if (suggestionsVisible) {
hideSuggestions()
} else {
showSuggestions()
}
}}
className={cn(suggestionsVisible && 'bg-surface-element-hover')}
>
<i className="iconfont icon-prompt" />
</IconButton>
<ChatActionPlus onUploadImage={() => fileInputRef.current?.click()} />
</div>
</div>
)}
</div>
{
/* 发送按钮 */
<IconButton
variant="default"
size="large"
loading={isProcessingAudio}
onClick={() => handleSend()}
disabled={
!isRecording &&
((!message.trim() && !imageInfo) ||
(imageInfo && !message.trim()) ||
isWaitingForReply ||
imageUploading)
}
className="flex-shrink-0"
>
<i className="iconfont icon-icon-send" />
</IconButton>
}
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png"
onChange={handleImageSelect}
className="hidden"
/>
</div>
</div>
)
}
export default ChatInput

View File

@ -1,35 +0,0 @@
import ChatInput from './ChatInput'
const ChatMessageAction = () => {
const handleSendMessage = (message: string, images?: File[]) => {
console.log('发送消息:', message)
if (images && images.length > 0) {
console.log('发送图片:', images)
}
// TODO: 实现发送消息的逻辑
}
const handleSendVoice = (audioBlob: Blob) => {
console.log('发送语音:', audioBlob)
// TODO: 实现发送语音的逻辑
}
return (
<div className="pb-6">
<div className="relative mx-auto flex max-w-[752px] items-center gap-4">
<div
className="absolute right-0 bottom-0 left-0 h-[120px]"
style={{ background: 'linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 40.83%)' }}
/>
<div className="relative w-full">
<ChatInput
onSendMessage={handleSendMessage}
onSendVoice={handleSendVoice}
placeholder="Mesaage"
/>
</div>
</div>
</div>
)
}
export default ChatMessageAction

View File

@ -1,90 +0,0 @@
'use client'
import { ExtendedMessage } from '@/atoms/im'
import { useNimMsgContext } from '@/context/NimChat/useNimChat'
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
import * as React from 'react'
import { useChatConfig } from '../../context/chatConfig'
import { LoadingIcon } from '@/components/ui/button'
import { WaveAnimation } from '@/components/ui/wave-animation'
import {
extractTextForVoice,
calculateAudioDuration,
formatAudioDuration,
} from '@/utils/textParser'
const ChatAudioTag = ({ message }: { message: ExtendedMessage }) => {
const { audioPlayer } = useNimMsgContext()
const { aiInfo, aiId } = useChatConfig()
const { dialoguePitch, dialogueSpeechRate, voiceType } = aiInfo || {}
const { isGenerating, generateAudioUrl } = useVoiceTTS({
cacheEnabled: true,
needCheckSufficient: true,
})
const audioText = extractTextForVoice(message.text || '')
// 计算预估的音频时长
const estimatedDuration = React.useMemo(() => {
return calculateAudioDuration(
message.text || '',
typeof dialogueSpeechRate === 'number' ? dialogueSpeechRate : 0
)
}, [message.text, dialogueSpeechRate])
// 格式化时长显示
const formattedDuration = formatAudioDuration(estimatedDuration)
const isPlaying = audioPlayer?.isPlaying(message.messageClientId)
const handlePlay = async () => {
const audioUrl = await generateAudioUrl({
text: audioText,
voiceType: voiceType,
speechRate: dialogueSpeechRate,
pitchRate: dialoguePitch,
aiId: aiId,
})
if (!audioUrl) {
return
}
audioPlayer?.play(message.messageClientId, audioUrl)
}
// const duration = message.duration;
const renderIcon = () => {
if (isGenerating) {
return <LoadingIcon className="!text-[16px] leading-none" />
}
if (isPlaying) {
return <WaveAnimation className="text-txt-primary-normal animate-pulse" />
}
return <i className="iconfont icon-Play leading-none" />
}
// const renderDuration = () => {
// if (message.duration) {
// return <span className="txt-label-s">{message.duration}</span>
// }
// }
if (!audioText) {
return null
}
return (
<div
className="bg-surface-float-normal hover:bg-surface-float-hover absolute -top-3 left-0 flex cursor-pointer items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1"
onClick={handlePlay}
>
<div className="flex h-4 w-4 items-center">{renderIcon()}</div>
<span className="txt-label-s">{formattedDuration}</span>
</div>
)
}
export default ChatAudioTag

View File

@ -1,38 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { ReactNode } from 'react'
interface ChatBubbleProps {
children: ReactNode
className?: string
isDefault?: boolean
img?: string
}
const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) => {
return (
<div className="relative max-w-[496px] p-4">
<div
className={cn(
isDefault && ['bg-primary-normal absolute inset-0 rounded-lg backdrop-blur-[32px]'],
!isDefault && [
'absolute -inset-2',
'border-[30px] border-transparent',
'[border-image-slice:70_fill]',
'[border-image-width:30px]',
'[border-image-repeat:stretch]',
],
className
)}
style={{
borderImageSource: isDefault ? 'none' : `url(${img || ''})`,
// borderImageSource: isDefault ? 'none' : `url(https://hhb.crushlevel.ai/static/chatBubble/chat_bubble_temp_1.png)`,
}}
></div>
<div className="relative min-w-[20px] text-left">{children}</div>
</div>
)
}
export default ChatBubble

View File

@ -1,24 +0,0 @@
'use client'
import { ExtendedMessage } from '@/atoms/im'
const CallCancelItem = ({
message,
customData,
}: {
message: ExtendedMessage
customData: {
duration: number
}
}) => {
return (
<div className="my-6 flex justify-center">
<div className="bg-surface-element-normal txt-body-s flex items-center gap-2 rounded-xs px-2 py-1 backdrop-blur-2xl">
<div>Call Canceled</div>
<i className="iconfont icon-hang-up" />
</div>
</div>
)
}
export default CallCancelItem

View File

@ -1,42 +0,0 @@
'use client'
import { ExtendedMessage } from '@/atoms/im'
import React from 'react'
const CallEndItem = ({
message,
customData,
}: {
message: ExtendedMessage
customData: {
duration: number
}
}) => {
const { duration } = customData || {}
// 毫秒转mm:ss, 如果是小时则为hh:mm
const durationText = React.useCallback((duration: number) => {
const hours = Math.floor(duration / 3600000)
const minutes = Math.floor((duration % 3600000) / 60000)
const seconds = Math.floor((duration % 60000) / 1000)
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
} else if (minutes > 0) {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
} else {
return `00:${seconds.toString().padStart(2, '0')}`
}
}, [])
return (
<div className="my-6 flex justify-center">
<div className="bg-surface-element-normal txt-body-s flex items-center gap-2 rounded-xs px-2 py-1 backdrop-blur-2xl">
<div>{`Call duration ${durationText(duration)}`}</div>
<i className="iconfont icon-hang-up" />
</div>
</div>
)
}
export default CallEndItem

View File

@ -1,103 +0,0 @@
'use client'
import { useMemo } from 'react'
import { calculateImageDisplaySize } from '@/utils/imageDisplayLogic'
import { useImageViewer } from '@/hooks/useImageViewer'
import { ImageViewer } from '@/components/ui/image-viewer'
import Image from 'next/image'
import { IconButton } from '@/components/ui/button'
const ChatImageContainer = ({
url,
width,
height,
containerWidth,
}: {
url: string
width: number
height: number
containerWidth: number
}) => {
const imageViewer = useImageViewer()
// 参数验证:确保 width 和 height 是有效的数字
const validWidth = width && width > 0 ? width : 300 // 默认宽度
const validHeight = height && height > 0 ? height : 200 // 默认高度
// 计算图片展示尺寸(使用固定常量,避免响应式导致的死循环)
const imageDisplaySize = useMemo(() => {
const result = calculateImageDisplaySize(validWidth, validHeight, {
W_MAX: containerWidth * 0.5,
H_MAX: containerWidth * 0.5,
W_MIN: 48,
H_MIN: 48,
RATIO_MIN: 9 / 21,
RATIO_MAX: 21 / 9,
})
// 防护:如果计算结果异常,使用备用尺寸
if (result.width <= 1 || result.height <= 1) {
return {
width: 200,
height: 150,
type: 'small' as const,
ratio: validWidth / validHeight,
}
}
return result
}, [validWidth, validHeight, containerWidth])
const handleImageClick = () => {
imageViewer.openViewer([url], 0)
}
// 判断图片是否截断,如果截断,则显示截断提示
const isImageTruncated = useMemo(() => {
return imageDisplaySize.width < validWidth || imageDisplaySize.height < validHeight
}, [imageDisplaySize, validWidth, validHeight])
return (
<>
<div
className="bg-surface-base-normal relative cursor-pointer overflow-hidden rounded-lg transition-opacity hover:opacity-90"
style={{
width: imageDisplaySize.width,
height: imageDisplaySize.height,
}}
onClick={handleImageClick}
>
<img
src={url}
alt=""
className="h-full w-full object-cover"
style={{
objectFit: 'cover',
}}
/>
{isImageTruncated && (
<IconButton className="absolute right-2 bottom-2" size="xs" variant="contrast">
<i className="iconfont icon-icon-fullImage" />
</IconButton>
)}
{/* 调试信息 - 可以在开发时显示 */}
{process.env.NODE_ENV === 'development' && (
<div className="bg-opacity-50 absolute top-1 left-1 rounded bg-black p-1 text-xs text-white">
{imageDisplaySize.type} {imageDisplaySize.width}×{imageDisplaySize.height}
</div>
)}
</div>
<ImageViewer
images={imageViewer.images}
currentIndex={imageViewer.currentIndex}
open={imageViewer.isOpen}
onClose={imageViewer.closeViewer}
onIndexChange={imageViewer.handleIndexChange}
showChooseButton={false}
showBottomBar={false}
/>
</>
)
}
export default ChatImageContainer

View File

@ -1,55 +0,0 @@
'use client'
import { ExtendedMessage } from '@/atoms/im'
import ChatImageContainer from './ChatImageContainer'
import { useContainerWidth } from '@/hooks/useContainerWidth'
import ChatUserTextContainer from '../../ChatUserTextContainer'
const ChatCustomImageItem = ({
message,
customData,
isUser,
}: {
message: ExtendedMessage
customData: {
type: string
url: string
width: number
height: number
}
isUser: boolean
}) => {
const { width: containerWidth, containerRef } = useContainerWidth()
if (isUser) {
return (
<>
<div ref={containerRef} className="flex items-center justify-end pb-8">
<ChatImageContainer
url={customData.url}
width={Number(customData.width)}
height={Number(customData.height)}
containerWidth={containerWidth}
/>
</div>
<ChatUserTextContainer message={message} />
</>
)
}
return (
<>
<div ref={containerRef} className="flex justify-start">
<ChatImageContainer
url={customData.url}
width={Number(customData.width)}
height={Number(customData.height)}
containerWidth={containerWidth}
/>
</div>
<div className="h-8" />
</>
)
}
export default ChatCustomImageItem

View File

@ -1,224 +0,0 @@
'use client'
import { ExtendedMessage, isChargeDrawerOpenAtom } from '@/atoms/im'
import { Button } from '@/components/ui/button'
import { ImageViewer } from '@/components/ui/image-viewer'
import { useUnlockAlbumImage, useUnlockImage, useViewUnlockAlbumImage } from '@/hooks/aiUser'
import { useImageViewer } from '@/hooks/useImageViewer'
import Image from 'next/image'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { formatFromCents } from '@/utils/number'
import { useUpdateWalletBalance } from '@/hooks/useWallet'
import { useSetAtom } from 'jotai'
const AlbumImageViewerAction = ({
aiId,
albumId,
messageServerId,
onUnlockSuccess,
unlockPrice,
}: {
aiId: number
albumId: number
messageServerId: string
unlockPrice: number
onUnlockSuccess?: ({ img1, img2, img3 }: { img1: string; img2: string; img3: string }) => void
}) => {
const { mutateAsync: unlockAlbumImage, isPending: isUnlocking } = useUnlockImage()
const walletUpdate = useUpdateWalletBalance()
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom)
const handleUnlock = async () => {
try {
if (!walletUpdate.checkSufficient((unlockPrice || 0) / 100)) {
setIsChargeDrawerOpen(true)
return
}
const resp = await unlockAlbumImage({ aiId, albumId: Number(albumId), messageServerId })
toast.success('Unlock success')
// 解锁成功后调用回调函数
onUnlockSuccess?.(resp)
} catch (error) {
toast.error('Unlock failed')
console.error('解锁失败:', error)
}
}
return (
<>
<Button variant="primary" size="small" onClick={handleUnlock} loading={isUnlocking}>
Unlock
</Button>
</>
)
}
const ChatUnlockImageItem = ({
message,
customData,
}: {
message: ExtendedMessage
customData: {
albumId: string
url: string
unlockPrice: number
}
}) => {
const { albumId, url, unlockPrice } = customData || {}
// 状态管理
const [isActuallyLocked, setIsActuallyLocked] = useState<boolean | null>(null) // null表示未检查true表示上锁false表示已解锁
const [checkedImages, setCheckedImages] = useState<{
img1?: string
img2?: string
img3?: string
}>({})
const [isChecking, setIsChecking] = useState(false)
// hooks
const { mutateAsync: viewUnlockAlbumImage } = useViewUnlockAlbumImage()
// 图片查看器
const {
isOpen: isViewerOpen,
currentIndex: viewerIndex,
openViewer,
closeViewer,
handleIndexChange,
} = useImageViewer()
// 检查图片解锁状态
const checkUnlockStatus = async () => {
if (isChecking) return // 防止重复请求
setIsChecking(true)
try {
const result = await viewUnlockAlbumImage({
aiId: Number(message.senderId.split('@')[0]),
albumId: Number(albumId),
messageServerId: message.messageServerId,
})
// 如果接口返回了解锁后的图片,说明图片已经解锁
if (result && (result.img1 || result.img2 || result.img3)) {
setIsActuallyLocked(false)
setCheckedImages(result)
} else {
setIsActuallyLocked(true)
}
} catch (error) {
// 如果接口出错,保持锁定状态
console.error('检查图片解锁状态失败:', error)
setIsActuallyLocked(true)
} finally {
setIsChecking(false)
}
}
// 处理解锁成功后的状态刷新
const handleUnlockSuccess = ({
img1,
img2,
img3,
}: {
img1: string
img2: string
img3: string
}) => {
setCheckedImages({ img1, img2, img3 })
setIsActuallyLocked(false)
}
const handleClick = async () => {
// 如果还没有检查过解锁状态,先检查
if (isActuallyLocked === null) {
await checkUnlockStatus()
}
// 根据检查结果决定显示的图片
const imagesToShow =
isActuallyLocked === false && checkedImages.img1
? [checkedImages.img3].filter((img): img is string => Boolean(img))
: [url]
openViewer(imagesToShow, 0)
}
return (
<>
<div
className="relative h-[160px] w-[160px] cursor-pointer overflow-hidden rounded-lg"
onClick={handleClick}
>
<img src={url} className="h-full w-full object-cover" alt="" />
{/* 只有在确认图片已解锁时才不显示遮罩,未检查或上锁时都显示遮罩 */}
{
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/20">
<i className="iconfont icon-private-border !text-[24px] leading-none" />
<div className="txt-label-m flex items-center gap-1">
<div className="relative h-4 w-4">
<Image src="/icons/diamond.svg" alt="diamond" fill className="object-contain" />
</div>
<span>{`${formatFromCents(unlockPrice || 0)} unlock`}</span>
</div>
</div>
}
{/* Loading状态覆盖层 */}
{isChecking && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
)}
</div>
<div className="h-8" />
<ImageViewer
images={
isActuallyLocked === false && checkedImages.img1
? [checkedImages.img1, checkedImages.img2, checkedImages.img3].filter(
(img): img is string => Boolean(img)
)
: [url]
}
currentIndex={0}
open={isViewerOpen}
onClose={closeViewer}
onIndexChange={handleIndexChange}
showChooseButton={false}
showPagination={false}
ActionComponent={() => {
// 只有在确认图片上锁时才显示解锁按钮
if (isActuallyLocked !== true) {
return null
}
return (
<AlbumImageViewerAction
aiId={Number(message.senderId.split('@')[0])}
albumId={Number(albumId)}
unlockPrice={unlockPrice}
messageServerId={message.messageServerId}
onUnlockSuccess={handleUnlockSuccess}
/>
)
}}
OverlayComponent={() => {
// 只有在确认图片上锁且有解锁价格时才显示覆盖层
if (isActuallyLocked !== true || !unlockPrice) {
return null
}
return (
<div className="absolute inset-0 z-5 flex flex-col items-center justify-center gap-6 bg-black/15 backdrop-blur-3xl">
<i className="iconfont icon-private !text-[48px] leading-none" />
<div className="flex items-center gap-2">
<Image src="/icons/diamond.svg" alt="diamond" width={32} height={32} />
<div className="txt-title-m">{`${formatFromCents(unlockPrice || 0)} to unlock`}</div>
</div>
</div>
)
}}
/>
</>
)
}
export default ChatUnlockImageItem

View File

@ -1,47 +0,0 @@
'use client'
import { ExtendedMessage } from '@/atoms/im'
import Image from 'next/image'
import ChatUserTextContainer from '../ChatUserTextContainer'
const SendGiftItem = ({
message,
customData,
}: {
message: ExtendedMessage
customData: {
giftId: number
giftName: string
giftNum: number
type: string
title: string
giftIcon: string
}
}) => {
const { giftName, giftNum, giftIcon } = customData || {}
return (
<>
<ChatUserTextContainer message={message} />
<div className="flex justify-end pb-8">
<div className="bg-surface-element-normal flex w-[366px] items-center gap-2 rounded-lg px-4 py-3 backdrop-blur-2xl">
<div className="bg-surface-nest-normal rounded-sm p-2">
<Image
src={giftIcon || ''}
alt={giftName}
width={40}
height={40}
className="object-cover"
/>
</div>
<div className="flex items-center gap-1">
<div className="txt-label-l">{`${giftName}`}</div>
<div className="txt-numMonotype-m">{`x${giftNum}`}</div>
</div>
</div>
</div>
</>
)
}
export default SendGiftItem

View File

@ -1,37 +0,0 @@
'use client'
import { ExtendedMessage } from '@/atoms/im'
import ChatCustomImageItem from './ChatCustomImageItem'
import SendGiftItem from './SendGiftItem'
import { CustomMessageType } from '@/types/im'
import CallEndItem from './CallEnditem'
import ChatUnlockImageItem from './ChatUnlockImageItem'
import CallCancelItem from './CallCancelItem'
const ChatCustomItem = ({ message, isUser }: { message: ExtendedMessage; isUser: boolean }) => {
const { attachment } = message || {}
const { raw } = attachment || {}
const customData = JSON.parse(raw || '{}')
const { type, albumId, unlockPrice } = customData || {}
if (type === CustomMessageType.IMAGE) {
if (albumId && !!unlockPrice) {
return <ChatUnlockImageItem message={message} customData={customData} />
}
return <ChatCustomImageItem message={message} customData={customData} isUser={isUser} />
}
if (type === CustomMessageType.IM_SEND_GIFT) {
return <SendGiftItem message={message} customData={customData} />
}
if (type === CustomMessageType.CALL) {
return <CallEndItem message={message} customData={customData} />
}
if (type === CustomMessageType.CALL_CANCEL) {
return <CallCancelItem message={message} customData={customData} />
}
}
export default ChatCustomItem

View File

@ -1,78 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton'
const ChatLoadMoreSkeleton = () => {
return (
<div className="mb-4 space-y-6">
{/* 简化的历史消息骨架屏 - 占位高度约450px */}
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-4 w-[320px]" />
<Skeleton className="h-4 w-[280px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-4 w-[200px]" />
</div>
</div>
</div>
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-4 w-[380px]" />
<Skeleton className="h-4 w-[340px]" />
<Skeleton className="h-4 w-[260px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-4 w-[160px]" />
</div>
</div>
</div>
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-4 w-[350px]" />
<Skeleton className="h-4 w-[300px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
</div>
)
}
export default ChatLoadMoreSkeleton

View File

@ -1,19 +0,0 @@
const ChatLoadingContainer = () => {
return (
<>
<div className="flex justify-start pb-10">
<div className="bg-surface-element-dark-hover relative flex max-w-[496px] items-center justify-center rounded-lg px-6 pt-4 pb-4 backdrop-blur-[32px]">
{/* 三个点loading动画 */}
<div className="flex items-center gap-1">
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full [animation-delay:-0.3s]"></div>
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full [animation-delay:-0.15s]"></div>
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full"></div>
</div>
</div>
</div>
<div className="h-8" />
</>
)
}
export default ChatLoadingContainer

View File

@ -1,175 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton'
const ChatMessageSkeleton = () => {
return (
<div className="mt-8 space-y-8">
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[350px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[150px]" />
</div>
</div>
</div>
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[400px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[300px]" />
</div>
</div>
</div>
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[350px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[150px]" />
</div>
</div>
</div>
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[400px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[300px]" />
</div>
</div>
</div>
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[350px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[150px]" />
</div>
</div>
</div>
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[400px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[300px]" />
</div>
</div>
</div>
{/* AI消息骨架屏 */}
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative max-w-[496px] rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[400px]" />
</div>
{/* 播放按钮骨架屏 */}
<div className="bg-surface-float-normal absolute -top-3 left-0 flex items-center gap-2 rounded-l-sm rounded-tr-sm px-3 py-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
{/* 用户消息骨架屏 */}
<div className="flex items-center justify-end">
<div className="bg-primary-normal/20 max-w-[496px] rounded-lg p-4 backdrop-blur-[32px]">
<div className="space-y-2">
<Skeleton className="h-5 w-[300px]" />
</div>
</div>
</div>
</div>
)
}
export default ChatMessageSkeleton

View File

@ -1,254 +0,0 @@
import { ExtendedMessage, MessageLikeStatus, getUserLikeStatus } from '@/atoms/im'
import { useTypingEffect } from '@/hooks/useTypingEffect'
import { useEffect, useCallback, useRef } from 'react'
import { useSetAtom } from 'jotai'
import { msgListAtom } from '@/atoms/im'
import { parseTextWithBrackets } from '@/utils/textParser'
import { IconButton } from '@/components/ui/button'
import { useNimMsgContext, useNimChat } from '@/context/NimChat/useNimChat'
import { useState, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { useMessageLike } from '@/hooks/useMessageLike'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { toast } from 'sonner'
import { useDoFeedback } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import { WaveAnimation } from '@/components/ui/wave-animation'
import ChatAudioTag from './ChatAudioTag'
const ChatOtherTextContainer = ({ message }: { message: ExtendedMessage }) => {
const setMsgList = useSetAtom(msgListAtom)
const { nim } = useNimChat()
const { aiId } = useChatConfig()
const { mutateAsync: doFeedback, isPending: isFeedbackPending } = useDoFeedback()
const { addMsg, audioPlayer } = useNimMsgContext()
const { createTime } = message
const is7DaysMessage = createTime && Date.now() - createTime < 7 * 24 * 60 * 60 * 1000
// 防抖相关状态
const [optimisticOptType, setOptimisticOptType] = useState<number | null>(null)
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 获取当前登录用户ID
const currentUserId = nim.V2NIMLoginService.getLoginUser()
// 从serverExtension获取当前用户的点赞状态
const serverExtension = JSON.parse(message.serverExtension || '{}')
const { optType } = serverExtension
// 使用乐观更新的状态,优先显示本地状态
const currentOptType = optimisticOptType !== null ? optimisticOptType : optType
const isLike = currentOptType === 1
const isDislike = currentOptType === 2
// 使用打字机效果,只对新消息启用
const { displayedText, isTyping } = useTypingEffect({
text: message.text || '',
speed: 10,
enabled: message.isNewMessage || false,
})
// 解析文本中的括号内容
const textParts = parseTextWithBrackets(displayedText)
// 防抖请求函数
const debouncedFeedback = useCallback(
async (newOptType: number) => {
try {
// 更新本地消息状态
addMsg(
message.conversationId,
[
{
...message,
serverExtension: JSON.stringify({
...serverExtension,
optType: newOptType,
}),
},
],
false
)
// 发送反馈请求
await doFeedback({
aiId,
messageId: message.messageServerId,
content: message.text || '',
optType: newOptType as 0 | 1 | 2,
})
if (message.messageServerId.includes('prologue')) {
return
}
// 请求成功,清除乐观状态
setOptimisticOptType(null)
} catch (error) {
// 请求失败,恢复原状态
setOptimisticOptType(null)
toast.error('Operation failed, please try again')
}
},
[aiId, message, serverExtension, addMsg, doFeedback]
)
// 处理点赞
const handleLike = useCallback(() => {
const newOptType = isLike ? 0 : 1
// 立即更新乐观状态
setOptimisticOptType(newOptType)
// 清除之前的防抖定时器
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
// 设置新的防抖定时器
debounceTimeoutRef.current = setTimeout(() => {
debouncedFeedback(newOptType)
}, 2000) // 300ms防抖延迟
}, [isLike, debouncedFeedback])
// 处理踩
const handleDislike = useCallback(() => {
const newOptType = isDislike ? 0 : 2
// 立即更新乐观状态
setOptimisticOptType(newOptType)
// 清除之前的防抖定时器
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
// 设置新的防抖定时器
debounceTimeoutRef.current = setTimeout(() => {
debouncedFeedback(newOptType)
}, 2000) // 300ms防抖延迟
}, [isDislike, debouncedFeedback])
// 清理定时器
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
}
}, [])
// 当打字效果完成后,清除 isNewMessage 标识
useEffect(() => {
if (message.isNewMessage && !isTyping && displayedText === message.text) {
setMsgList((prev) => {
const newMsgList = prev.clone()
const messages = newMsgList.get(message.conversationId) || []
const updatedMessages = messages.map((msg) =>
msg.messageClientId === message.messageClientId ? { ...msg, isNewMessage: false } : msg
)
newMsgList.set(message.conversationId, updatedMessages, false)
return newMsgList
})
}
}, [
isTyping,
displayedText,
message.text,
message.isNewMessage,
message.messageClientId,
message.conversationId,
setMsgList,
])
const handleCopy = () => {
navigator.clipboard.writeText(message.text || '')
toast.success('Copied to clipboard')
}
return (
<div className="group">
<div className="flex justify-start">
<div className="bg-surface-element-dark-hover relative flex max-w-[496px] items-start justify-start rounded-lg px-4 pt-6 pb-4 backdrop-blur-[32px]">
{/* 消息内容 */}
<div className={cn('txt-body-l')}>
{textParts.map((part, index) => (
<span
key={index}
className={part.isInBrackets ? 'text-txt-secondary-normal' : ''}
style={{
wordBreak: 'break-word',
}}
>
{part.text}
</span>
))}
{isTyping && <span className="animate-pulse">|</span>}
</div>
<ChatAudioTag message={message} />
</div>
</div>
<div className="flex h-8 items-center gap-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<IconButton variant="tertiary" size="xs" onClick={handleCopy} tabIndex={-1}>
<i className="iconfont icon-copy" />
</IconButton>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Copy</p>
</TooltipContent>
</Tooltip>
{is7DaysMessage && (
<Tooltip>
<TooltipTrigger asChild>
<IconButton
variant="tertiary"
size="xs"
onClick={handleLike}
className={cn('transition-colors')}
tabIndex={-1}
>
<i
className={cn(
'iconfont',
isLike ? 'icon-post_recommend_fill' : 'icon-icon_post_recommend'
)}
/>
</IconButton>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Good</p>
</TooltipContent>
</Tooltip>
)}
{is7DaysMessage && (
<Tooltip>
<TooltipTrigger asChild>
<IconButton
variant="tertiary"
size="xs"
onClick={handleDislike}
className={cn('transition-colors')}
tabIndex={-1}
>
<i
className={cn(
'iconfont',
isDislike ? 'icon-post_Notrecommend_fill' : 'icon-icon_post_Notrecommend'
)}
/>
</IconButton>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Bad</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)
}
export default ChatOtherTextContainer

View File

@ -1,15 +0,0 @@
import { ExtendedMessage } from '@/atoms/im'
import ChatUserTextContainer from './ChatUserTextContainer'
import ChatOtherTextContainer from './ChatOtherTextContainer'
const ChatTextItem = ({ isUser, message }: { isUser: boolean; message: ExtendedMessage }) => {
if (isUser) {
// 用户消息 - 右侧粉色背景
return <ChatUserTextContainer message={message} />
}
// AI消息 - 左侧深色半透明背景
return <ChatOtherTextContainer message={message} />
}
export default ChatTextItem

Some files were not shown because too many files have changed in this diff Show More