feat(project): 调整项目到A18
4
.env
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
# pnpm 配置
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
|
||||
|
|
@ -3,6 +3,5 @@
|
|||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ import type { NextConfig } from 'next'
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
|
|
|||
27
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 429 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"></span>
|
||||
<div class="name">挂断电话</div>
|
||||
<div class="code-name">&#xe620;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">电话</div>
|
||||
<div class="code-name">&#xe61f;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">艾特</div>
|
||||
<div class="code-name">&#xe61d;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">性别</div>
|
||||
<div class="code-name">&#xe61e;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">Frame 247</div>
|
||||
<div class="code-name">&#xe61c;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">编辑</div>
|
||||
<div class="code-name">&#xe61b;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">展开</div>
|
||||
<div class="code-name">&#xe615;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">Frame 195</div>
|
||||
<div class="code-name">&#xe616;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">展开-1</div>
|
||||
<div class="code-name">&#xe617;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">生成</div>
|
||||
<div class="code-name">&#xe618;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">复制</div>
|
||||
<div class="code-name">&#xe619;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">Frame 194</div>
|
||||
<div class="code-name">&#xe61a;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">gender-female-line</div>
|
||||
<div class="code-name">&#xe614;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">gender-male-line</div>
|
||||
<div class="code-name">&#xe613;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">刷新</div>
|
||||
<div class="code-name">&#xe612;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">箭头</div>
|
||||
<div class="code-name">&#xe610;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">关闭</div>
|
||||
<div class="code-name">&#xe611;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">搜索</div>
|
||||
<div class="code-name">&#xe60d;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">折叠</div>
|
||||
<div class="code-name">&#xe60e;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">展开</div>
|
||||
<div class="code-name">&#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"
|
||||
><span class="iconfont">&#x33;</span>
|
||||
</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"><link rel="stylesheet" href="./iconfont.css">
|
||||
</code></pre>
|
||||
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><span class="iconfont icon-xxx"></span>
|
||||
</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"><script src="./iconfont.js"></script>
|
||||
</code></pre>
|
||||
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
|
||||
<pre><code class="language-html"><style>
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</code></pre>
|
||||
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-xxx"></use>
|
||||
</svg>
|
||||
</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>
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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 |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 994 B |
|
|
@ -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 |
|
|
@ -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&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 |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 484 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 511 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 character’s 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
}))
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
'use client'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CharacterPage() {
|
||||
redirect('/home')
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 <></>
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 character’s 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 don’t
|
||||
interact for 24 hours, your Crush Value may gradually decrease.
|
||||
</p>
|
||||
<p>
|
||||
* Your virtual character’s 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">℃</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 text-center">
|
||||
<span className="txt-numDisplay-s">
|
||||
{heartbeatVal || 0}
|
||||
<span className="txt-numMonotype-s">℃</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
|
||||
|
|
@ -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)}/℃</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}℃</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
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 character’s 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||