feat: 初始化项目

This commit is contained in:
liuyonghe0111 2025-10-28 15:59:26 +08:00
commit 199475af53
93 changed files with 10506 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

29
.prettierignore Normal file
View File

@ -0,0 +1,29 @@
# 依赖
node_modules
.pnp
.pnp.js
# 构建产物
.next
out
dist
build
# 日志
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 本地环境文件
.env*.local
# 锁文件
package-lock.json
pnpm-lock.yaml
yarn.lock
# 其他
.DS_Store
*.pem

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"plugins": ["prettier-plugin-tailwindcss"],
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"arrowParens": "always",
"endOfLine": "lf"
}

30
README.md Normal file
View File

@ -0,0 +1,30 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

25
eslint.config.mjs Normal file
View File

@ -0,0 +1,25 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
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'),
{
ignores: [
'node_modules/**',
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
],
},
];
export default eslintConfig;

46
next.config.ts Normal file
View File

@ -0,0 +1,46 @@
import type { NextConfig } from 'next';
const endpoints = {
frog: process.env.NEXT_PUBLIC_FROG_API_URL,
bear: process.env.NEXT_PUBLIC_BEAR_API_URL,
lion: process.env.NEXT_PUBLIC_LION_API_URL,
shark: process.env.NEXT_PUBLIC_SHARK_API_URL,
cow: process.env.NEXT_PUBLIC_COW_API_URL,
pigeon: process.env.NEXT_PUBLIC_PIGEON_API_URL,
};
const nextConfig: NextConfig = {
reactStrictMode: false,
eslint: {
ignoreDuringBuilds: true,
},
// async rewrites() {
// return [
// {
// source: '/api/frog/:path*',
// destination: `${endpoints.frog}/api/frog/:path*`,
// },
// {
// source: '/api/bear/:path*',
// destination: `${endpoints.bear}/api/bear/:path*`,
// },
// {
// source: '/api/lion/:path*',
// destination: `${endpoints.lion}/api/lion/:path*`,
// },
// {
// source: '/api/shark/:path*',
// destination: `${endpoints.shark}/api/shark/:path*`,
// },
// {
// source: '/api/cow/:path*',
// destination: `${endpoints.cow}/api/cow/:path*`,
// },
// {
// source: '/api/pigeon/:path*',
// destination: `${endpoints.pigeon}/api/pigeon/:path*`,
// },
// ];
// },
};
export default nextConfig;

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "next-demo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.2",
"ahooks": "^3.9.5",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"next": "15.5.4",
"next-intl": "^4.3.11",
"qs": "^6.14.0",
"radix-ui": "^1.4.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.65.0",
"react-virtuoso": "^4.14.1",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.20",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4",
"typescript": "^5"
}
}

5935
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ['@tailwindcss/postcss'],
};
export default config;

BIN
public/bubble/default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,7 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-outside-1_248_3157" maskUnits="userSpaceOnUse" x="2" y="2.5" width="17" height="17" fill="black">
<rect fill="white" x="2" y="2.5" width="17" height="17"/>
<path d="M4 6C4 5.17157 4.67157 4.5 5.5 4.5H15.5C16.3284 4.5 17 5.17157 17 6V13C17 13.8284 16.3284 14.5 15.5 14.5H6.5L4 17V6Z"/>
</mask>
<path d="M4 17H2C2 17.8089 2.48728 18.5382 3.23463 18.8478C3.98198 19.1573 4.84222 18.9862 5.41421 18.4142L4 17ZM6.5 14.5V12.5H5.67157L5.08579 13.0858L6.5 14.5ZM5.5 4.5V6.5H15.5V4.5V2.5H5.5V4.5ZM17 6H15V13H17H19V6H17ZM4 17H6V6H4H2V17H4ZM15.5 14.5V12.5H6.5V14.5V16.5H15.5V14.5ZM6.5 14.5L5.08579 13.0858L2.58579 15.5858L4 17L5.41421 18.4142L7.91421 15.9142L6.5 14.5ZM17 13H15C15 12.7239 15.2239 12.5 15.5 12.5V14.5V16.5C17.433 16.5 19 14.933 19 13H17ZM15.5 4.5V6.5C15.2239 6.5 15 6.27614 15 6H17H19C19 4.067 17.433 2.5 15.5 2.5V4.5ZM5.5 4.5V2.5C3.567 2.5 2 4.067 2 6H4H6C6 6.27614 5.77614 6.5 5.5 6.5V4.5Z" fill="white" mask="url(#path-1-outside-1_248_3157)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,9 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 3.5H12C13.1046 3.5 14 4.39543 14 5.5V11.5C14 12.6046 13.1046 13.5 12 13.5H9.70703C9.35903 13.5 9.02371 13.6212 8.75684 13.8398L8.64648 13.9395L6.5 16.0859V15C6.5 14.1716 5.82843 13.5 5 13.5H4C2.89543 13.5 2 12.6046 2 11.5V5.5C2 4.39543 2.89543 3.5 4 3.5Z" stroke="white" stroke-width="2"/>
<mask id="mask0_229_5437" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="8" y="6" width="13" height="14">
<path d="M14.5 13.5H8V20H20.5V6.5H14.5V13.5Z" fill="white"/>
</mask>
<g mask="url(#mask0_229_5437)">
<path d="M17 8H11.5C10.3954 8 9.5 8.89543 9.5 10V14C9.5 15.1046 10.3954 16 11.5 16H13.293C13.641 16 13.9763 16.1212 14.2432 16.3398L14.3535 16.4395L15.5 17.5859V17.5C15.5 16.6716 16.1716 16 17 16C18.1046 16 19 15.1046 19 14V10C19 8.89543 18.1046 8 17 8Z" stroke="white" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.80038 0C11.3465 0.000209617 12.6001 1.25363 12.6002 2.7998V9.7998C12.6002 10.0575 12.3911 10.2666 12.1334 10.2666H9.80038C9.28492 10.2666 8.86679 10.6847 8.86679 11.2002V13.5332C8.86679 13.7909 8.65773 14 8.39999 14H4.1998C2.65356 13.9999 1.4001 12.7464 1.39999 11.2002V2.7998C1.4001 1.25356 2.65356 0.000105732 4.1998 0H9.80038ZM4.1998 6.7666C3.81328 6.76669 3.4996 7.08025 3.4996 7.4668C3.49967 7.85328 3.81332 8.1669 4.1998 8.16699H8.86679C9.2532 8.16682 9.56594 7.85323 9.56601 7.4668C9.56601 7.08031 9.25324 6.76678 8.86679 6.7666H4.1998ZM4.1998 3.9668C3.81328 3.96689 3.4996 4.28045 3.4996 4.66699C3.49978 5.05339 3.81339 5.3671 4.1998 5.36719H6.9996C7.38609 5.36719 7.69962 5.05344 7.6998 4.66699C7.6998 4.28039 7.3862 3.9668 6.9996 3.9668H4.1998Z" fill="#D3D7FF"/>
<path d="M10.7333 11.2H12.6L9.80001 14V12.1333C9.80001 11.6179 10.2179 11.2 10.7333 11.2Z" fill="#D3D7FF"/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@ -0,0 +1,10 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_229_4195)">
<path d="M7.00852 8.68583L5.27699 10.4381C4.83025 10.8902 4.12494 10.9602 3.59806 10.6048C3.06105 10.2425 2.86587 9.54429 3.13714 8.95603L6.4239 1.8285C6.75067 1.11987 7.45975 0.666016 8.24009 0.666016H14.5165C15.4716 0.666016 16.2934 1.3413 16.4786 2.27826L17.6399 8.15471C17.843 9.18191 17.2193 10.1918 16.21 10.4704L11.2533 11.8384L10.748 17.4636C10.6868 18.1444 10.1162 18.666 9.43268 18.666C8.73311 18.666 8.15491 18.1205 8.11432 17.4221L7.61992 8.91644C7.60195 8.60727 7.2262 8.46554 7.00852 8.68583Z" stroke="#FFE1A7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_229_4195">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 838 B

View File

@ -0,0 +1,9 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.2803 4C29.8236 4.00012 37.5596 10.1895 37.5596 17.8242C37.5594 24.5034 31.6387 30.0759 23.7666 31.3662L21.1123 35.9658C20.7428 36.6056 19.8188 36.6055 19.4492 35.9658L16.7939 31.3662C8.92162 30.0761 3.00013 24.5035 3 17.8242C3 10.1894 10.7368 4 20.2803 4ZM12.1797 15.1582C10.6886 15.1582 9.4796 16.3673 9.47949 17.8584C9.47949 19.3496 10.6885 20.5586 12.1797 20.5586C13.6707 20.5584 14.8789 19.3494 14.8789 17.8584C14.8788 16.3674 13.6706 15.1584 12.1797 15.1582ZM20.2793 15.1582C18.7882 15.1582 17.5792 16.3673 17.5791 17.8584C17.5791 19.3496 18.7881 20.5586 20.2793 20.5586C21.7705 20.5586 22.9795 19.3496 22.9795 17.8584C22.9794 16.3673 21.7704 15.1582 20.2793 15.1582ZM28.3789 15.1582C26.888 15.1584 25.6798 16.3674 25.6797 17.8584C25.6797 19.3494 26.8879 20.5584 28.3789 20.5586C29.8701 20.5586 31.0791 19.3496 31.0791 17.8584C31.079 16.3673 29.87 15.1582 28.3789 15.1582Z" fill="url(#paint0_linear_290_2970)"/>
<defs>
<linearGradient id="paint0_linear_290_2970" x1="20.2798" y1="4" x2="20.2798" y2="36.4456" gradientUnits="userSpaceOnUse">
<stop stop-color="#AFCFFF"/>
<stop offset="1" stop-color="#277EFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,9 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.2803 4C29.8236 4.00012 37.5596 10.1895 37.5596 17.8242C37.5594 24.5034 31.6387 30.0759 23.7666 31.3662L21.1123 35.9658C20.7428 36.6056 19.8188 36.6055 19.4492 35.9658L16.7939 31.3662C8.92162 30.0761 3.00013 24.5035 3 17.8242C3 10.1894 10.7368 4 20.2803 4ZM12.1797 15.1582C10.6886 15.1582 9.4796 16.3673 9.47949 17.8584C9.47949 19.3496 10.6885 20.5586 12.1797 20.5586C13.6707 20.5584 14.8789 19.3494 14.8789 17.8584C14.8788 16.3674 13.6706 15.1584 12.1797 15.1582ZM20.2793 15.1582C18.7882 15.1582 17.5792 16.3673 17.5791 17.8584C17.5791 19.3496 18.7881 20.5586 20.2793 20.5586C21.7705 20.5586 22.9795 19.3496 22.9795 17.8584C22.9794 16.3673 21.7704 15.1582 20.2793 15.1582ZM28.3789 15.1582C26.888 15.1584 25.6798 16.3674 25.6797 17.8584C25.6797 19.3494 26.8879 20.5584 28.3789 20.5586C29.8701 20.5586 31.0791 19.3496 31.0791 17.8584C31.079 16.3673 29.87 15.1582 28.3789 15.1582Z" fill="url(#paint0_linear_251_3104)"/>
<defs>
<linearGradient id="paint0_linear_251_3104" x1="20.2798" y1="4" x2="20.2798" y2="36.4456" gradientUnits="userSpaceOnUse">
<stop stop-color="#69A5FF"/>
<stop offset="1" stop-color="#0066FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3H16C17.1046 3 18 3.89543 18 5V14C18 15.1046 17.1046 16 16 16H12.707C12.359 16 12.0237 16.1212 11.7568 16.3398L11.6465 16.4395L10.5 17.5859L9.35352 16.4395L9.24316 16.3398C8.97629 16.1212 8.64097 16 8.29297 16H5C3.89543 16 3 15.1046 3 14V5C3 3.89543 3.89543 3 5 3Z" stroke="white" stroke-width="2"/>
<path d="M8 7H13" stroke="white" stroke-width="2"/>
<path d="M6.5 11H14.5" stroke="white" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@ -0,0 +1,4 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.83325 1.92871C10.2457 1.69056 10.7537 1.69061 11.1663 1.92871L17.5901 5.63672C18.0024 5.87486 18.256 6.31488 18.2561 6.79102V14.208C18.2561 14.6844 18.0026 15.1251 17.5901 15.3633L11.1663 19.0713C10.7538 19.3093 10.2457 19.3094 9.83325 19.0713L3.4104 15.3633C2.99787 15.1251 2.74341 14.6844 2.74341 14.208V6.79102C2.74354 6.3148 2.99797 5.87484 3.4104 5.63672L9.83325 1.92871Z" stroke="white" stroke-width="2"/>
<path d="M17.8889 6.61133L10.5 10.8891M10.5 10.8891L3.11108 6.61133M10.5 10.8891V18.3613" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 674 B

View File

@ -0,0 +1,12 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_229_5400)">
<path d="M5.5 4.5H11.5C12.6046 4.5 13.5 5.39543 13.5 6.5V12.5C13.5 13.6046 12.6046 14.5 11.5 14.5H10.9141C10.4499 14.5 10.0024 14.6614 9.64648 14.9531L9.5 15.0859L8.6084 15.9775L8.09961 15.2998C7.72189 14.7964 7.12939 14.5 6.5 14.5H5.5C4.39543 14.5 3.5 13.6046 3.5 12.5V6.5C3.5 5.39543 4.39543 4.5 5.5 4.5Z" stroke="white" stroke-width="2"/>
<path d="M15.9142 2.91391C16.6952 3.69496 16.6952 4.96129 15.9142 5.74234" stroke="white" stroke-width="2"/>
<path d="M18.2399 1.4997C19.802 3.0618 19.802 5.59445 18.2399 7.15655" stroke="white" stroke-width="2"/>
</g>
<defs>
<clipPath id="clip0_229_5400">
<rect width="21" height="21" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 803 B

View File

@ -0,0 +1,13 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_229_5389)">
<circle cx="10.5" cy="8" r="3.5" stroke="white" stroke-width="2"/>
<path d="M16.5 17.5C16.5 14.1863 13.8137 11.5 10.5 11.5C7.18629 11.5 4.5 14.1863 4.5 17.5" stroke="white" stroke-width="2"/>
<path d="M16.0858 6.70688C16.8668 7.48793 16.8668 8.75426 16.0858 9.53531" stroke="white" stroke-width="2"/>
<path d="M18.4116 5.29267C19.9737 6.85476 19.9737 9.38742 18.4116 10.9495" stroke="white" stroke-width="2"/>
</g>
<defs>
<clipPath id="clip0_229_5389">
<rect width="21" height="21" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 657 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 8L16.4853 16.4853" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M16 8L7.51472 16.4853" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@ -0,0 +1,6 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 5.5H13" stroke="white" stroke-width="2"/>
<path d="M13 9.00195H19" stroke="white" stroke-width="2"/>
<path d="M8 4.50195L8 16.502" stroke="white" stroke-width="2"/>
<path d="M16 9.50195L16 16.502" stroke="white" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@ -0,0 +1,3 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5192 11.6362C22.1807 12.0221 22.1807 12.9779 21.5192 13.3638L5.85549 22.501C5.50113 22.7077 5.06236 22.4232 5.10645 22.0153L5.91944 14.4952C5.96718 14.0535 6.30055 13.6963 6.73786 13.6182L13 12.5L6.73786 11.3818C6.30055 11.3037 5.96718 10.9465 5.91944 10.5048L5.10645 2.98467C5.06236 2.5768 5.50113 2.29233 5.85549 2.49903L21.5192 11.6362Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@ -0,0 +1,9 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.835 2.00977C10.9144 1.89385 11.0856 1.89385 11.165 2.00977L14.166 6.38867C14.452 6.80591 14.8732 7.1118 15.3584 7.25488L20.4502 8.75586C20.5848 8.79555 20.638 8.95797 20.5527 9.06934L17.3154 13.2773C17.007 13.6783 16.8455 14.1731 16.8594 14.6787L17.0059 19.9854C17.0097 20.1258 16.8707 20.2268 16.7383 20.1797L11.7373 18.4004C11.2605 18.2308 10.7395 18.2308 10.2627 18.4004L5.26172 20.1797C5.12932 20.2268 4.99028 20.1258 4.99414 19.9854L5.14062 14.6787C5.15447 14.1731 4.993 13.6783 4.68457 13.2773L1.44727 9.06934C1.362 8.95797 1.41517 8.79555 1.5498 8.75586L6.6416 7.25488C7.12681 7.1118 7.54796 6.80591 7.83398 6.38867L10.835 2.00977Z" stroke="url(#paint0_linear_233_2112)" stroke-width="2"/>
<defs>
<linearGradient id="paint0_linear_233_2112" x1="11" y1="0" x2="11" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFDA06"/>
<stop offset="1" stop-color="#FF5900"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

View File

@ -0,0 +1,9 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0102 1.4443C10.4868 0.748817 11.5132 0.748817 11.9898 1.4443L14.9908 5.82309C15.1469 6.05076 15.3766 6.21769 15.6414 6.29573L20.7332 7.79671C21.5419 8.03511 21.8591 9.01127 21.3449 9.67951L18.1078 13.8867C17.9395 14.1055 17.8518 14.3756 17.8593 14.6515L18.0053 19.9579C18.0285 20.8007 17.1981 21.404 16.4037 21.1216L11.4021 19.343C11.142 19.2505 10.858 19.2505 10.5979 19.343L5.59632 21.1216C4.80191 21.404 3.97154 20.8007 3.99472 19.9579L4.14066 14.6515C4.14825 14.3756 4.06049 14.1055 3.89218 13.8867L0.655057 9.67951C0.140903 9.01127 0.458076 8.03511 1.26682 7.79671L6.35864 6.29573C6.62339 6.21769 6.85314 6.05076 7.00918 5.82309L10.0102 1.4443Z" fill="url(#paint0_linear_233_2111)"/>
<defs>
<linearGradient id="paint0_linear_233_2111" x1="11" y1="0" x2="11" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFDA06"/>
<stop offset="1" stop-color="#FF5900"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1014 B

View File

@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_229_6433" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="15">
<path d="M7.3401 0.96287C7.65787 0.499212 8.34213 0.499211 8.6599 0.962869L10.6605 3.88206C10.7646 4.03384 10.9177 4.14513 11.0942 4.19716L14.4888 5.19781C15.0279 5.35674 15.2394 6.00752 14.8966 6.45301L12.7385 9.25782C12.6263 9.40365 12.5678 9.58372 12.5729 9.76766L12.6702 13.3053C12.6856 13.8672 12.1321 14.2694 11.6025 14.081L8.26804 12.8953C8.09466 12.8337 7.90534 12.8337 7.73196 12.8953L4.39755 14.081C3.86794 14.2694 3.31436 13.8672 3.32981 13.3053L3.42711 9.76766C3.43217 9.58372 3.37366 9.40365 3.26145 9.25782L1.10337 6.45301C0.760602 6.00752 0.97205 5.35674 1.51121 5.19781L4.90576 4.19716C5.08226 4.14513 5.23543 4.03384 5.33945 3.88206L7.3401 0.96287Z" fill="#1F1F1F"/>
</mask>
<g mask="url(#mask0_229_6433)">
<path d="M7.3401 0.96287C7.65787 0.499212 8.34213 0.499211 8.6599 0.962869L10.6605 3.88206C10.7646 4.03384 10.9177 4.14513 11.0942 4.19716L14.4888 5.19781C15.0279 5.35674 15.2394 6.00752 14.8966 6.45301L12.7385 9.25782C12.6263 9.40365 12.5678 9.58372 12.5729 9.76766L12.6702 13.3053C12.6856 13.8672 12.1321 14.2694 11.6025 14.081L8.26804 12.8953C8.09466 12.8337 7.90534 12.8337 7.73196 12.8953L4.39755 14.081C3.86794 14.2694 3.31436 13.8672 3.32981 13.3053L3.42711 9.76766C3.43217 9.58372 3.37366 9.40365 3.26145 9.25782L1.10337 6.45301C0.760602 6.00752 0.97205 5.35674 1.51121 5.19781L4.90576 4.19716C5.08226 4.14513 5.23543 4.03384 5.33945 3.88206L7.3401 0.96287Z" fill="black" fill-opacity="0.4"/>
<path d="M8.00018 0V12.8L4.39772 14.081C3.86811 14.2694 3.31453 13.8672 3.32999 13.3053L3.42728 9.76765C3.43234 9.58371 3.37384 9.40365 3.26163 9.25782L1.10355 6.45301C0.760778 6.00752 0.972226 5.35674 1.51139 5.19781L4.90593 4.19716C5.08244 4.14513 5.2356 4.03384 5.33963 3.88206L8.00018 0Z" fill="url(#paint0_linear_229_6433)"/>
</g>
<defs>
<linearGradient id="paint0_linear_229_6433" x1="4.19595" y1="0" x2="4.19595" y2="14.4721" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFDA06"/>
<stop offset="1" stop-color="#FF5900"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,121 @@
'use client';
import { TagSelect, Rate } from '@/components';
import Image from 'next/image';
import { RightArrowIcon } from '@/assets/chatacter';
import { ReportIcon } from '@/assets/common';
import { useParams, useRouter } from 'next/navigation';
export default function CharacterBasicInfo() {
const router = useRouter();
const { id } = useParams();
return (
<div className="flex w-full">
<Image
src="/test.png"
alt="character-basic-info"
width={338}
height={600}
/>
<div className="w-full pt-5">
<div className="flex items-center justify-between">
<span className="text-text-color text-4xl">Maeve o'connell</span>
<div>
<ReportIcon />
</div>
</div>
<div className="text-text-color/60 mt-4 flex items-center gap-2">
<div
style={{ backgroundColor: 'rgba(255, 102, 0, 1)' }}
className="text-text-color inline-flex h-4 w-4 justify-center rounded-md text-xs"
>
ID
</div>
<div>user123456</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
></div>
<div>18</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
></div>
<div>The Male Lead</div>
</div>
{/* 角色评分 */}
<Rate className="mt-8" value={7} readonly />
{/* 角色TAG */}
<TagSelect
className="mt-10"
readonly
options={[
{ label: 'tag1', value: 'tag1' },
{
label: 'tag2',
value: 'tag2',
},
]}
/>
{/* divider */}
<div className="bg-text-color/10 mt-10 h-0.25 w-full"></div>
{/* description */}
<div className="mt-10.5">
<div className="flex">
<Image
src={'/character/desc.svg'}
alt="description"
className="mr-1"
width={18}
height={20}
/>
Description:
</div>
<div className="text-text-color/60 mt-5 text-sm">
{
'description text description textdescription textdescription textdescription textdescription textdescription textdescription text'
}
</div>
</div>
{/* from and chat */}
<div className="flex items-center justify-between gap-10 pt-5">
{/* FROM */}
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
<div className="flex items-center">
<Image
src="/images/character/from.png"
alt="from"
className="rounded-lg"
width={45}
height={60}
/>
<div className="text-text-color/80 max-w-110 pr-2 text-sm">
<span style={{ color: 'rgb(0, 102, 255' }}>Form:</span>
{" The CEO's Contract Wife"}
</div>
</div>
<RightArrowIcon />
</div>
{/* Chat Button */}
<div
onClick={() => router.push(`/character/${id}/chat`)}
className="inline-flex h-12.5 w-75 items-center justify-center gap-3 rounded-full hover:cursor-pointer"
style={{
background: `linear-gradient(92.76deg,rgba(166, 83, 255, 1) 0%,rgba(0, 101, 255, 1) 80%,rgba(0, 157, 255, 1) 100%)`,
}}
>
<Image
src={'/component/send.svg'}
alt="chat"
width={20}
height={20}
/>
Chat Now
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
'use client';
import CharacterBasicInfo from './components/BasicInfo';
import Image from 'next/image';
import { Rate } from '@/components';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { cn } from '@/lib';
import { CommentIcon } from '@/assets/chatacter';
export default function CharacterDetail({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { id } = useParams();
const tabs = [
{ path: '/review', Icon: CommentIcon, label: 'Review' },
{
path: '/list',
label: 'Character List',
},
];
return (
<div
style={{
background:
'linear-gradient(90deg, rgba(44, 20, 65, 1) 1.08%, rgba(44, 20, 65, 0) 100%), linear-gradient(89.21deg, rgba(255, 255, 63, 0) 0.68%, rgba(255, 255, 63, 0.3) 111.9%)',
}}
className="flex min-h-full w-full flex-col items-center"
>
<div className="max-w-300 pt-7.5">
<CharacterBasicInfo />
</div>
{/* 评分 */}
<div className="bg-text-color/5 flex h-20 w-full max-w-300 items-center justify-between rounded-full px-7.5">
<div className="flex items-center gap-5">
<Image
src={'/character/figure.svg'}
alt="figure"
width={20}
height={20}
/>
<div className="font-bold">How Was this book ?</div>
</div>
<div className="flex flex-col items-end gap-1">
<div
className="flex gap-2"
style={{ color: 'rgba(255, 225, 167, 1)' }}
>
Tap to Rate
<Image
src={'/character/figure.svg'}
alt="figure"
width={20}
height={20}
/>
</div>
<Rate />
</div>
</div>
{/* Tabs */}
<div className="mt-11 flex w-full max-w-300 items-center justify-start gap-5">
{tabs.map((tab, index) => {
const isActive = pathname.includes(tab.path);
const dom = (
<div
onClick={() => router.push(`/character/${id}${tab.path}`)}
className={cn(
'flex gap-2.5 hover:cursor-pointer',
!isActive && 'text-text-color/60'
)}
key={tab.path}
>
{tab.Icon ? <tab.Icon /> : null}
{tab.label}
</div>
);
return [
!!index && (
<div
key={`divider_${index}`}
className="bg-text-color/10 h-2.5 w-0.5"
></div>
),
dom,
];
})}
</div>
<div className="mt-10 w-full max-w-300">{children}</div>
</div>
);
}

View File

@ -0,0 +1,4 @@
'use client';
export default function CharacterList() {
return <div>CharacterList</div>;
}

View File

@ -0,0 +1,62 @@
'use client';
import { HeartIcon, ReplyIcon } from '@/assets/chatacter';
import { ReportIcon } from '@/assets/common';
import { Rate } from '@/components';
import Image from 'next/image';
const Comment = () => {
return (
<div>
<div className="flex w-full">
<Image
src={'/images/character/avatar.png'}
alt="comment"
width={100}
height={100}
/>
<div className="w-full">
<div className="mt-1.25 flex items-center justify-between">
<div className="flex items-center justify-between gap-5">
<span className="text-sm">{'User'}</span>
<Rate readonly value={7.5} />
</div>
<div className="text-text-color/80 text-sm text-[15px]">
{'2025/07/31 12:59'}
</div>
</div>
<div className="text-text-color/80 mt-4.5 text-[15px]">
{
'A hauntingly beautiful meditation on memory and loss that lingers long after the final page is turned.'
}
</div>
{/* 操作按钮 */}
<div className="mt-5 flex items-center justify-between">
<div className="flex items-center gap-10">
<div
style={{ color: 'rgba(0, 204, 136, 1)' }}
className="flex items-center gap-2.5 hover:cursor-pointer"
>
<ReplyIcon />
<span>REPLY</span>
</div>
<div className="flex items-center gap-2.5 hover:cursor-pointer">
<HeartIcon liked />
<span>999</span>
</div>
</div>
<ReportIcon />
</div>
</div>
</div>
</div>
);
};
export default function CharacterReview() {
return (
<div>
<Comment />
</div>
);
}

View File

@ -0,0 +1,4 @@
'use client';
export default function ArchiveHistory() {
return <div>ArchiveHistory</div>;
}

View File

@ -0,0 +1,107 @@
'use client';
import { useAtom, useSetAtom } from 'jotai';
import { historyListOpenAtom, leftTabActiveKeyAtom } from '../atoms';
import { cn } from '@/lib';
import Image from 'next/image';
import { BackIcon, CharaterHistoryIcon } from '@/assets/chatacter';
import { useRouter } from 'next/navigation';
import { Icon, Drawer } from '@/components';
export default function Side() {
const [activeKey, setActiveKey] = useAtom(leftTabActiveKeyAtom);
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
const router = useRouter();
const tabs = [
{
element: (
<div className="border-0.25 h-10 w-10 hover:border-amber-500">
<Image
src="/component/star_full.svg"
alt="info"
width={38}
height={38}
/>
</div>
),
key: 'info',
},
{
element: (
<div className="">
<CharaterHistoryIcon />
</div>
),
key: 'history',
},
];
const bottomActions = [
{
element: (
<div onClick={() => setHistoryListOpen(true)}>
<Icon
size={40}
src="/character/history_list.svg"
hoverSrc="/character/history_list_hover.svg"
/>
</div>
),
},
{
type: 'devider',
},
{
element: (
<div
onClick={() => router.back()}
className="text-text-color/80 hover:text-text-color hover:cursor-pointer"
>
<BackIcon />
</div>
),
},
];
return (
<div className="flex h-full w-25 flex-col justify-between bg-[#15121A]">
<div className="flex flex-col gap-9 pt-7.5">
{tabs.map((item) => {
const isActive = activeKey === item.key;
return (
<div
className={cn(
'flex h-10 justify-end border-r-[2px] pr-4.5 hover:cursor-pointer',
isActive ? 'border-[rgba(0,102,255,1)]' : 'border-transparent'
)}
onClick={() => setActiveKey(item.key as any)}
key={item.key}
>
{item.element}
</div>
);
})}
</div>
<div className="mb-10 flex flex-col gap-7.5">
{bottomActions.map((item, index) => {
if (item.element) {
return (
<div className="flex h-10 justify-end pr-5" key={`item_${index}`}>
{item.element}
</div>
);
}
// divider
return (
<div
className="ml-12 h-0.5 w-5 bg-[rgba(44,42,49,1)]"
key={`divider_${index}`}
></div>
);
})}
</div>
<Drawer />
</div>
);
}

View File

@ -0,0 +1,23 @@
'use client';
import Side from './Side';
import { useAtomValue } from 'jotai';
import { leftTabActiveKeyAtom } from '../atoms';
import Info from './info';
import ArchiveHistory from './ArchiveHistory';
import { memo } from 'react';
const Left = memo(() => {
const activeKey = useAtomValue(leftTabActiveKeyAtom);
const Component = activeKey === 'info' ? Info : ArchiveHistory;
return (
<div className="flex h-full w-112">
<Side />
<div className="flex-1">
<Component />
</div>
</div>
);
});
export default Left;

View File

@ -0,0 +1,4 @@
'use client';
export default function Info() {
return <div>Info</div>;
}

View File

@ -0,0 +1,16 @@
'use client';
import { useAtomValue } from 'jotai';
import { isPortraitModeAtom } from '../atoms';
import ChatMessageList from './components/ChatMessageList';
import PortraitChat from './components/PortraitChat';
export default function ChatList() {
const isPortraitMode = useAtomValue(isPortraitModeAtom);
return (
<div className="mb-10 flex-1">
{isPortraitMode ? <PortraitChat /> : <ChatMessageList />}
</div>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import {
GenerateInputIcon,
PhoneCallIcon,
PortraitModeIcon,
} from '@/assets/chatacter';
import { useAtom } from 'jotai';
import { isPortraitModeAtom } from '../atoms';
export default function Actions() {
const [isPortraitMode, setIsPortraitMode] = useAtom(isPortraitModeAtom);
const className = 'text-[#4269D6] hover:cursor-pointer hover:text-[#0066FF]';
return (
<div className="mb-4 flex justify-between">
<div className="flex items-center gap-5">
<div className={className}>
<GenerateInputIcon />
</div>
<div className={className}>
<PhoneCallIcon />
</div>
</div>
<div
onClick={() => setIsPortraitMode(!isPortraitMode)}
className="hover:cursor-pointer"
>
<PortraitModeIcon />
</div>
</div>
);
}

View File

@ -0,0 +1,168 @@
'use client';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import Message from './Message';
import { useRef, useState } from 'react';
import { ScrollTobottom } from '@/assets/chatacter';
const messages = [
{
id: 1,
content: 'Hello, how are you?',
sender: 'user',
},
{
id: 2,
content: 'I am fine, thank you!',
sender: 'assistant',
},
{
id: 3,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 4,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 5,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 6,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 7,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 8,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 9,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 10,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 11,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 12,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 13,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 14,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 15,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 16,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 17,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 18,
content: 'My name is John Doe.',
sender: 'assistant',
},
{
id: 19,
content:
'What is your name? What is your name?What is your name?What is your name?What is your name?What is your name?What is your name?',
sender: 'user',
},
{
id: 20,
content: 'My name is John Doe.',
sender: 'assistant',
},
];
// 普通模式聊天内容展示框
export default function ChatMessageList() {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
// 滚动到最新消息
const scrollToBottom = () => {
virtuosoRef.current?.scrollToIndex({
index: messages.length - 1,
behavior: 'smooth',
});
};
return (
<div
style={{ position: 'relative', height: '100%', marginBottom: '2.5rem' }}
className="hide-scrollbar"
>
<Virtuoso
ref={virtuosoRef}
style={{ height: '100%' }}
data={messages}
alignToBottom
followOutput="smooth"
initialTopMostItemIndex={messages.length - 1}
atBottomStateChange={(atBottom) => {
// 当不在底部时显示按钮,在底部时隐藏
setShowScrollButton(!atBottom);
}}
itemContent={(index, item) => (
<Message
item={item}
index={index}
isLast={index === messages.length - 1}
/>
)}
/>
{/* 回到底部按钮 */}
{showScrollButton && (
<div
onClick={scrollToBottom}
className="absolute -right-1 bottom-0 z-10 flex h-10 w-10 items-center justify-center rounded-full hover:scale-110 hover:cursor-pointer"
>
<ScrollTobottom />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,27 @@
.chat-message-bubble {
color: rgba(51, 51, 51, 1);
border: 15px solid transparent; /* 必写,否则不渲染 */
border-image-slice: 50 fill; /* ✨按你图的四边数值调整 */
border-image-repeat: round; /* 或 round 视效果调整 */
border-image-width: 15px;
}
/* 隐藏滚动条但保留滚动功能 */
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
/* 对内部所有可滚动元素也应用隐藏滚动条 */
.hide-scrollbar * {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.hide-scrollbar *::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}

View File

@ -0,0 +1,52 @@
'use client';
import './Message.css';
import { cn } from '@/lib';
interface UserMessageProps {
item: any;
isLast: boolean;
}
const UserMessage = ({ item, isLast }: UserMessageProps) => {
return (
<div className={cn('flex w-full justify-end pt-7', isLast && 'pb-7')}>
<div className="max-w-[80%] rounded-xl bg-[rgba(15,15,25,0.8)] px-5 py-3.5">
{item.content}
</div>
</div>
);
};
interface AssistantMessageProps {
item: any;
isLast: boolean;
}
const AssistantMessage = ({ item, isLast }: AssistantMessageProps) => {
return (
<div className={cn('w-full pt-7', isLast && 'pb-7')}>
<div
style={{
borderImageSource: 'url(/bubble/default.png)',
}}
className="chat-message-bubble max-w-[80%]"
>
{item.content}
</div>
</div>
);
};
interface MessageProps {
item: any;
index: number;
isLast: boolean;
}
export default function Message({ item, index, isLast }: MessageProps) {
if (item.sender === 'user') {
return <UserMessage item={item} isLast={isLast} />;
} else {
return <AssistantMessage item={item} isLast={isLast} />;
}
}

View File

@ -0,0 +1,6 @@
'use client';
// 立绘模式聊天内容框
export default function PortraitChat() {
return <div>PortraitChat</div>;
}

View File

@ -0,0 +1,20 @@
'use client';
import Input from './input';
import Actions from './actions';
import ChatList from './ChatList';
export default function Main() {
return (
<div className="flex h-full w-[calc(100vw-900px)] flex-col px-10">
{/* chat list */}
<ChatList />
{/* actions */}
<Actions />
{/* inputs */}
<Input />
</div>
);
}

View File

@ -0,0 +1,47 @@
'use client';
import { VoiceIcon } from '@/assets/chatacter';
import { cn } from '@/lib';
import Image from 'next/image';
import { useRef, useEffect, useState } from 'react';
export default function Input() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState('');
const className =
'h-11 inline-flex items-center justify-center w-15 rounded-full hover:cursor-pointer shrink-0';
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 100); // 最大高度 100px
textarea.style.height = `${newHeight}px`;
// 隐藏滚动条
textarea.style.scrollbarWidth = 'none'; // Firefox
// @ts-ignore
textarea.style.msOverflowStyle = 'none'; // IE/Edge
}
}, [value]);
return (
<div className="bg-text-color/15 mb-10 flex min-h-13 w-full items-end justify-between gap-2 rounded-[25px] px-1 py-1">
<div className={cn(className, 'hover:bg-text-color/20')}>
<VoiceIcon />
</div>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="输入消息..."
className="hide-scrollbar h-10 max-h-25 w-full resize-none bg-transparent px-1 py-2.5 leading-normal outline-none"
rows={1}
/>
<div className={cn(className, 'bg-text-color/20')}>
<Image src="/component/send.svg" width={20} height={20} alt="send" />
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
'use client';
import { useControllableValue } from 'ahooks';
import { cn } from '@/lib';
import { CheckIcon, DeleteIcon, UploadImgIcon } from '@/assets/common';
type BackgroundItem = {
item?: any;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
};
const ItemRender = (props: BackgroundItem) => {
const { item, selected, onSelect, onDelete } = props;
const handleDelete = (e: React.MouseEvent<HTMLSpanElement>) => {
e.stopPropagation();
e.preventDefault();
onDelete?.();
};
return (
<div
className={cn(
'relative flex h-38 items-end justify-center rounded-[10px] pb-1 hover:cursor-pointer hover:border-2 hover:border-[rgba(0,102,255,1)]',
selected && 'border-2 border-[rgba(0,102,255,1)]'
)}
onClick={onSelect}
style={{
backgroundImage: `url(${item.url})`,
backgroundSize: 'cover', // 等比例缩放图片直到铺满容器
backgroundPosition: 'center', // 图片居中显示
backgroundRepeat: 'no-repeat', // 不重复
}}
>
{selected && (
<span className="absolute right-0 bottom-0 flex h-4 w-5 items-center justify-center rounded-tl-[5px] bg-[rgba(0,102,255,1)]">
<CheckIcon />
</span>
)}
<span onClick={handleDelete} className="hover:text-[#E2503D]">
<DeleteIcon size={20} />
</span>
</div>
);
};
type BackgroundProps = {
value?: string;
onChange?: (value: string) => void;
defaultBackgrounds?: BackgroundItem[];
} & React.HTMLAttributes<HTMLDivElement>;
export default function Background(props: BackgroundProps) {
const { defaultBackgrounds = [], ...rest } = props;
const [value, onChange] = useControllableValue(props);
const handleUpload = () => {};
const handleDelete = () => {};
const items = [
{
value: '1',
url: '/test.png',
},
{
value: '2',
url: '/test.png',
},
{
value: '3',
url: '/test.png',
},
{
value: '4',
url: '/test.png',
},
];
return (
<div {...rest}>
<div className="grid grid-cols-3 gap-2.5">
{items.map((i) => (
<ItemRender
selected={value === i.value}
onSelect={() => onChange(i.value)}
onDelete={handleDelete}
key={i.value}
item={i}
/>
))}
<div
onClick={handleUpload}
title="Upload Background Image"
className={cn(
'h-38 w-full overflow-hidden rounded-lg hover:cursor-pointer',
'bg-text-color/10 flex items-center justify-center text-[#C3C4D5] hover:text-white'
)}
>
<UploadImgIcon />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,164 @@
'use client';
import Form, { FormItem } from '@/components/ui/form';
import React from 'react';
import { cn } from '@/lib';
import { Select, Switch, Number, FontSize } from '@/components/ui/inputs';
import Background from './Background';
import { AddIcon, DeleteIcon } from '@/assets/common';
import { ModelSelectDialog } from '@/components';
const Title: React.FC<
{
text?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
> = (props) => {
const { text, children, ...rest } = props;
return (
<div className="mt-10 flex flex-col gap-2.5">
<div {...rest} className={cn('font-bold', rest.className)}>
{text}
</div>
{children}
</div>
);
};
const SettingForm = React.memo(() => {
console.log('SettingForm');
const options = [
{
label: 'Model 1',
value: 'model1',
},
{
label: 'Model 2',
value: 'model2',
},
{
label: 'Model 3',
value: 'model3',
},
{
label: 'Model 4',
value: 'model4',
},
];
return (
<div className="flex h-full flex-col">
<div
className="flex items-center text-lg font-black"
style={{ height: 83 }}
>
Setting
</div>
<div className="flex-1 overflow-y-auto pr-10">
<Form>
<Title text="Switch Model">
<FormItem
name="name"
render={({ value, onChange }) => (
<ModelSelectDialog value={value} onChange={onChange} />
)}
/>
<FormItem
name="name1"
render={({ value, onChange }) => (
<Switch
icon={'/character/model_long_text.svg'}
text="Short Text Mode"
value={value}
onChange={onChange}
/>
)}
/>
</Title>
<Title text="Sound">
<FormItem
name="name12"
render={({ value, onChange }) => (
<Select.View
icon={'/character/voice_actor.svg'}
text="Voice Actor"
/>
)}
/>
<FormItem
name="name12"
render={({ value, onChange }) => (
<Switch
icon={'/character/play_dialogue_only.svg'}
text="Play dialogue only"
value={value}
onChange={onChange}
/>
)}
/>
</Title>
<Title text="Maximum number of response tokens">
<FormItem
name="name13"
render={({ value, onChange }) => (
<Number value={value} onChange={onChange} />
)}
/>
</Title>
<Title text="Appearance">
<FormItem
name="fontSize"
render={() => {
return <FontSize />;
}}
/>
<FormItem
name="name12"
render={({ value, onChange }) => (
<Select
placeholder="Chat Mode"
icon={'/character/chat_mode.svg'}
options={[
{ label: 'Chat Mode', value: 'chat_mode' },
{ label: 'Chat Bubble', value: 'chat_bubble' },
]}
/>
)}
/>
<FormItem
name="name12"
render={({ value, onChange }) => (
<Select.View
icon={'/character/chat_bubble.svg'}
text="Chat Bubble"
/>
)}
/>
</Title>
<Title text="Background">
<FormItem
name="background"
render={({ value, onChange }) => (
<Background value={value} onChange={onChange} />
)}
/>
</Title>
</Form>
<div className="mt-12.5 mb-10 flex flex-col gap-5">
<div className="tk-button border-1 border-[#E2503D] text-[rgba(255,59,48,1)] hover:bg-[rgba(255,59,48,0.1)]">
<DeleteIcon /> Delete
</div>
<div className="tk-button border-text-color/80 border-1 hover:bg-white hover:text-[rgba(0,102,255,1)]">
<AddIcon /> START NEW CHAT
</div>
</div>
</div>
</div>
);
});
export default SettingForm;

View File

@ -0,0 +1,13 @@
import { atom } from 'jotai';
// 是否打开两侧的设置
export const settingOpenAtom = atom(false);
// 是否是立绘模式
export const isPortraitModeAtom = atom(false);
// 左侧 tab active key
export const leftTabActiveKeyAtom = atom<'info' | 'history'>('info');
// 左侧 角色历史列表
export const historyListOpenAtom = atom<boolean>(false);

View File

@ -0,0 +1,11 @@
.tk-button {
height: 50px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-weight: 900;
font-size: 17px;
cursor: pointer;
}

View File

@ -0,0 +1,56 @@
'use client';
import { cn } from '@/lib';
import SettingForm from './Right';
import { useAtom } from 'jotai';
import { settingOpenAtom } from './atoms';
import Main from './Main';
import Left from './Left';
import { Drawer } from '@/components';
import './index.css';
import { useRef } from 'react';
import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common';
export default function CharacterChat() {
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom);
const container = useRef<HTMLDivElement>(null);
return (
<div
ref={container}
className="relative flex h-full w-full justify-center overflow-hidden"
>
<Main />
{/* 设置按钮 */}
<div
className={cn(
'absolute top-8 right-10 z-10 h-10 w-10 select-none hover:cursor-pointer',
'text-text-color/10 hover:text-text-color/20'
)}
onClick={() => setSettingOpen(!settingOpen)}
>
{settingOpen ? <FullScreenIcon /> : <ExitFullScreenIcon />}
</div>
{/* 左侧 */}
<Drawer
open={settingOpen}
position="left"
width={448}
destroyOnClose
container={container.current}
>
<Left />
</Drawer>
{/* 右侧设置 */}
<Drawer
open={settingOpen}
position="right"
width={448}
destroyOnClose
container={container.current}
>
<SettingForm />
</Drawer>
</div>
);
}

View File

@ -0,0 +1,118 @@
'use client';
import { TagSelect, VirtualGrid, Rate } from '@/components';
import { useInfiniteQuery } from '@tanstack/react-query';
import React, { useMemo, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
const request = async (params: any) => {
const pageSize = 20;
await new Promise((resolve) => setTimeout(resolve, 500));
return {
rows: new Array(pageSize).fill(0).map((_, i) => ({
id: `item_${params.index + i}`,
name: `一个提示词${params.index + i}`,
})),
nextPage: params.index + pageSize,
hasMore: params.index + pageSize < 80,
};
};
const RoleCard: React.FC<any> = ({ item }) => {
const router = useRouter();
return (
<div
onClick={() => router.push(`/character/${item.id}/review`)}
className="relative flex h-full w-full flex-col justify-between rounded-[20px] hover:cursor-pointer"
style={{
backgroundImage: `url(${item.from || '/test.png'})`,
}}
>
{/* from */}
<div>
<Image
src={item.from || '/test.png'}
alt="from"
width={55}
height={78}
/>
</div>
{/* info */}
<div className="px-2.5 pb-3">
<div className="font-bold">{item.name}</div>
<div className="text-text-color/60 mt-4 text-sm">
{item.description}
</div>
<div className="flex justify-between">
<Rate value={item.rate || 7} readonly />
<div></div>
</div>
</div>
</div>
);
};
export default function Novel() {
const [params, setParams] = useState({});
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } =
useInfiniteQuery<{ rows: any[]; nextPage: number; hasMore: boolean }>({
initialPageParam: 1,
queryKey: ['novels', params],
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextPage : undefined,
queryFn: ({ pageParam }) => request({ index: pageParam, params }),
});
const options = [
{
label: 'tag1',
value: 'tag1',
},
{
label: 'tag2',
value: 'tag2',
},
{
label: 'tag3',
value: 'tag3',
},
];
const dataSource = useMemo(() => {
return data?.pages?.flatMap((page) => page.rows) || [];
}, [data]);
return (
<div className="h-full">
<VirtualGrid
padding={{ left: 50, right: 50 }}
dataSource={dataSource}
isLoading={isLoading}
isLoadingMore={isFetchingNextPage}
noMoreData={!hasNextPage}
noMoreDataRender={null}
loadMore={() => {
fetchNextPage();
}}
header={
<TagSelect
options={options}
mode="multiple"
render={(item) => `# ${item.label}`}
onChange={(v) => {
setParams({ ...params, tags: v });
}}
className="mx-12.5 my-7.5"
/>
}
gap={20}
className="h-full"
rowHeight={448}
keySetter={(item) => item.id}
columnCalc={(width) => Math.floor(width / 250)}
itemRender={(item) => <RoleCard item={item} />}
/>
</div>
);
}

View File

@ -0,0 +1,2 @@
import MainLayout from '@/layouts/MainLayout';
export default MainLayout;

View File

@ -0,0 +1,80 @@
'use client';
import { TagSelect, VirtualGrid } from '@/components';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
const request = async (params: any) => {
const pageSize = 20;
await new Promise((resolve) => setTimeout(resolve, 2000));
return {
rows: new Array(pageSize).fill(0).map((_, i) => ({
id: `item_${params.index + i}`,
name: `一个提示词${params.index + i}`,
})),
nextPage: params.index + pageSize,
hasMore: params.index + pageSize < 80,
};
};
export default function Novel() {
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } =
useInfiniteQuery<{ rows: any[]; nextPage: number; hasMore: boolean }>({
initialPageParam: 1,
queryKey: ['novels'],
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextPage : undefined,
queryFn: ({ pageParam }) => request({ index: pageParam }),
});
const options = [
{
label: 'tag1',
value: 'tag1',
},
{
label: 'tag2',
value: 'tag2',
},
{
label: 'tag3',
value: 'tag3',
},
];
const dataSource = useMemo(() => {
return data?.pages?.flatMap((page) => page.rows) || [];
}, [data]);
return (
<div className="h-full">
<VirtualGrid
padding={{ left: 50, right: 50 }}
dataSource={dataSource}
isLoading={isLoading}
isLoadingMore={isFetchingNextPage}
noMoreData={!hasNextPage}
noMoreDataRender={null}
loadMore={() => {
fetchNextPage();
}}
header={
<TagSelect
options={options}
mode="multiple"
render={(item) => `# ${item.label}`}
className="mx-12.5 my-7.5"
/>
}
className="h-full"
rowHeight={300}
keySetter={(item) => item.id}
columnCalc={(width) => Math.floor(width / 200)}
itemRender={(item) => (
<div className="relative h-full w-full rounded-md border border-gray-200 p-1">
<span>{item.name}</span>
</div>
)}
/>
</div>
);
}

View File

@ -0,0 +1,5 @@
'use client';
export default function Record() {
return <div>Record</div>;
}

View File

@ -0,0 +1,4 @@
'use client';
export default function Video() {
return <div>Video</div>;
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

68
src/app/globals.css Normal file
View File

@ -0,0 +1,68 @@
@import 'tailwindcss';
:root {
--header-bg: #141325;
--background: #14132d;
--text-color: #fff;
--text-color-1: rgba(174, 196, 223, 1);
}
@theme inline {
--color-header-bg: var(--header-bg);
--color-bg: var(--background);
--color-text-color: var(--text-color);
--color-text-color-1: var(--text-color-1);
}
body {
color: var(--text-color);
box-sizing: border-box;
background-color: var(--background);
font-family: Arial, Helvetica, sans-serif;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: rgba(20, 19, 37, 0.5);
border-radius: 5px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(
180deg,
rgba(103, 103, 103, 0.8) 0%,
rgba(103, 103, 103, 0.6) 100%
);
border-radius: 5px;
transition: background 0.3s ease;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(
180deg,
rgba(174, 196, 223, 0.8) 0%,
rgba(174, 196, 223, 0.6) 100%
);
}
::-webkit-scrollbar-thumb:active {
background: linear-gradient(
180deg,
rgba(174, 196, 223, 1) 0%,
rgba(174, 196, 223, 0.8) 100%
);
}
/* Firefox 滚动条样式 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(103, 103, 103, 0.6) rgba(20, 19, 37, 0.5);
}

35
src/app/layout.tsx Normal file
View File

@ -0,0 +1,35 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import GlobalContainer from '@/layouts/GlobalContainer';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Visual Novel',
description: 'A demo of next-intl with client-side language switching',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<GlobalContainer>{children}</GlobalContainer>
</body>
</html>
);
}

3
src/app/loading.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function Loading() {
return <p>Loading...</p>;
}

5
src/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/novel');
}

View File

@ -0,0 +1,283 @@
import type { IconProps } from '@/types/common';
export const CommentIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 24}
height={props.size || props.height || 24}
viewBox="0 0 12 12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_236_2255)">
<mask id="path-1-inside-1_236_2255" fill="white">
<path d="M5.99976 0.480469C9.18092 0.480469 11.7595 2.54394 11.7595 5.08887C11.7593 7.31489 9.78634 9.17124 7.16284 9.60156L6.2771 11.1367C6.15384 11.3497 5.84557 11.3498 5.72241 11.1367L4.83569 9.60156C2.2129 9.17081 0.240226 7.31449 0.23999 5.08887C0.23999 2.544 2.8187 0.48057 5.99976 0.480469Z" />
</mask>
<path
d="M5.99976 0.480469V-0.519531H5.99972L5.99976 0.480469ZM11.7595 5.08887L12.7595 5.08897V5.08887H11.7595ZM7.16284 9.60156L7.00098 8.61475L6.53345 8.69144L6.29667 9.10181L7.16284 9.60156ZM6.2771 11.1367L7.14265 11.6375L7.14327 11.6365L6.2771 11.1367ZM5.72241 11.1367L4.85648 11.6369L4.85657 11.637L5.72241 11.1367ZM4.83569 9.60156L5.70162 9.10139L5.46486 8.6915L4.99776 8.61478L4.83569 9.60156ZM0.23999 5.08887H-0.76001V5.08897L0.23999 5.08887ZM5.99976 0.480469V1.48047C8.84822 1.48047 10.7595 3.29278 10.7595 5.08887H11.7595H12.7595C12.7595 1.7951 9.51361 -0.519531 5.99976 -0.519531V0.480469ZM11.7595 5.08887L10.7595 5.08876C10.7594 6.66569 9.3174 8.2348 7.00098 8.61475L7.16284 9.60156L7.3247 10.5884C10.2553 10.1077 12.7592 7.96409 12.7595 5.08897L11.7595 5.08887ZM7.16284 9.60156L6.29667 9.10181L5.41093 10.637L6.2771 11.1367L7.14327 11.6365L8.02901 10.1013L7.16284 9.60156ZM6.2771 11.1367L5.41155 10.6359C5.67297 10.1841 6.32599 10.1825 6.58825 10.6364L5.72241 11.1367L4.85657 11.637C5.36515 12.5172 6.63472 12.5154 7.14265 11.6375L6.2771 11.1367ZM5.72241 11.1367L6.58834 10.6366L5.70162 9.10139L4.83569 9.60156L3.96976 10.1017L4.85648 11.6369L5.72241 11.1367ZM4.83569 9.60156L4.99776 8.61478C2.68195 8.23445 1.24016 6.66537 1.23999 5.08876L0.23999 5.08887L-0.76001 5.08897C-0.759706 7.96362 1.74384 10.1072 4.67363 10.5883L4.83569 9.60156ZM0.23999 5.08887H1.23999C1.23999 3.29287 3.15137 1.48056 5.99979 1.48047L5.99976 0.480469L5.99972 -0.519531C2.48603 -0.519419 -0.76001 1.79514 -0.76001 5.08887H0.23999Z"
mask="url(#path-1-inside-1_236_2255)"
/>
<path d="M4.75 6H7.25" stroke="currentColor" strokeLinecap="round" />
<path d="M4 4H8" stroke="currentColor" strokeLinecap="round" />
</g>
<defs>
<clipPath id="clip0_236_2255">
<rect width="12" height="12" />
</clipPath>
</defs>
</svg>
);
};
export const RightArrowIcon = () => {
return (
<svg
width="17"
height="13"
viewBox="0 0 17 13"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.44238 6.5H15.4424M15.4424 6.5L10.9424 2M15.4424 6.5L10.9424 11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
};
export const HeartIcon = (props: IconProps & { liked?: boolean }) => {
const { liked } = props;
const width = props.size || props.width || 20;
const height = props.size || props.height || 20;
if (liked) {
return (
<svg
width={width}
height={height}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.99998 3.54831C11.981 1.48389 15.1928 1.48392 17.1738 3.54831C19.1548 5.61274 19.1548 8.95973 17.1738 11.0242L11.0219 17.4351C10.458 18.0228 9.54202 18.0228 8.97808 17.4351L2.82619 11.0242C0.845219 8.95973 0.845199 5.61272 2.82619 3.54831C4.77622 1.51617 7.91885 1.48441 9.90619 3.45298L9.99998 3.54831Z"
fill="#FF0644"
/>
</svg>
);
}
return (
<svg
width={width}
height={height}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.7213 4.24023C12.3086 2.58628 14.8644 2.58651 16.4518 4.24023C18.0615 5.91774 18.0615 8.65453 16.4518 10.332L10.3004 16.7432C10.1302 16.9204 9.87012 16.9203 9.69983 16.7432L3.54749 10.332C1.93779 8.65452 1.93779 5.91772 3.54749 4.24023C5.10729 2.61488 7.60168 2.58732 9.19299 4.15332V4.1543L9.28674 4.25L10.0094 4.9834L10.7213 4.24023Z"
stroke="white"
strokeOpacity="0.8"
strokeWidth="2"
/>
</svg>
);
};
export const ReplyIcon = (props: IconProps) => {
const width = props.size || props.width || 20;
const height = props.size || props.height || 20;
return (
<svg
width={width}
height={height}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask id="path-1-inside-1_229_4188" fill="white">
<path d="M10 2C14.4183 2 18 5.13401 18 9C18 12.2858 15.4121 15.0404 11.9229 15.7939L10.8574 17.5713C10.469 18.2182 9.53104 18.2181 9.14258 17.5713L8.07617 15.7939C4.58738 15.0401 2 12.2855 2 9C2 5.13401 5.58172 2 10 2Z" />
</mask>
<path
d="M11.9229 15.7939L11.5007 13.839L10.6532 14.022L10.2074 14.7656L11.9229 15.7939ZM10.8574 17.5713L12.572 18.601L12.5728 18.5996L10.8574 17.5713ZM9.14258 17.5713L7.42759 18.6003L7.428 18.601L9.14258 17.5713ZM8.07617 15.7939L9.79116 14.765L9.3454 14.022L8.49855 13.8391L8.07617 15.7939ZM10 2V4C13.5729 4 16 6.48108 16 9H18H20C20 3.78693 15.2636 0 10 0V2ZM18 9H16C16 11.1528 14.27 13.241 11.5007 13.839L11.9229 15.7939L12.345 17.7489C16.5543 16.8399 20 13.4188 20 9H18ZM11.9229 15.7939L10.2074 14.7656L9.14202 16.543L10.8574 17.5713L12.5728 18.5996L13.6383 16.8222L11.9229 15.7939ZM10.8574 17.5713L9.14284 16.5416C9.53134 15.8947 10.4687 15.8947 10.8572 16.5416L9.14258 17.5713L7.428 18.601C8.59342 20.5416 11.4066 20.5416 12.572 18.601L10.8574 17.5713ZM9.14258 17.5713L10.8576 16.5423L9.79116 14.765L8.07617 15.7939L6.36119 16.8229L7.42759 18.6003L9.14258 17.5713ZM8.07617 15.7939L8.49855 13.8391C5.72976 13.2408 4 11.1527 4 9H2H0C0 13.4183 3.445 16.8395 7.65379 17.7488L8.07617 15.7939ZM2 9H4C4 6.48108 6.42708 4 10 4V2V0C4.73636 0 0 3.78693 0 9H2Z"
fill="#00CC88"
mask="url(#path-1-inside-1_229_4188)"
/>
</svg>
);
};
export const VoiceIcon = (props: IconProps) => {
return (
<svg
width="26"
height="26"
viewBox="0 0 26 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="8.6665"
y="2.59961"
width="8.66667"
height="12.1333"
rx="4.33333"
fill="currentColor"
/>
<path
d="M19.9333 11.2656C19.9333 15.0948 16.8292 18.199 13 18.199C9.17081 18.199 6.06665 15.0948 6.06665 11.2656"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M13 18.1992V22.5326"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
};
export const GenerateInputIcon = () => {
return (
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="15" cy="15" r="15" fill="currentColor" />
<path
d="M15.1924 7C18.8884 7.00004 21.8848 9.99635 21.8848 13.6924C21.8847 15.899 20.8176 17.8565 19.167 19.0771C18.5324 19.5465 18.0381 20.3099 18.0381 21.2207V22.1924C18.038 22.4685 17.8142 22.6924 17.5381 22.6924H12.8457C12.5698 22.6921 12.3457 22.4683 12.3457 22.1924V21.2207C12.3457 20.3099 11.8523 19.5465 11.2178 19.0771C9.56718 17.8565 8.50002 15.899 8.5 13.6924C8.5 9.99632 11.4963 7 15.1924 7Z"
stroke="white"
strokeWidth="2"
/>
<path
d="M18.2693 13.1155C18.2693 12.1597 17.4821 11.3848 16.5111 11.3848C15.9858 11.3848 15.5146 11.6118 15.1924 11.9713C14.8702 11.6118 14.399 11.3848 13.8737 11.3848C12.9027 11.3848 12.1155 12.1597 12.1155 13.1155"
stroke="white"
strokeWidth="2"
/>
</svg>
);
};
export const PhoneCallIcon = () => {
return (
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="15" cy="15" r="15" fill="currentColor" />
<path
d="M19.5686 23.5C18.7412 23.5 17.8132 23.3408 16.8404 23.0338C14.5036 22.2947 12.2003 20.7482 10.3554 18.6787C7.59373 15.5744 6.27437 12.4814 6.53153 9.72964C6.6098 9.04738 6.82224 8.18318 7.82853 7.48954C8.60002 6.85276 9.48332 6.48889 10.1765 6.50026C10.9033 6.51163 11.1493 6.77316 11.8984 7.87616C13.0501 9.49085 13.1283 10.2868 13.0836 10.7417C13.0053 11.4922 12.6028 11.8219 12.2339 12.1176C12.1444 12.1858 12.055 12.2654 11.9767 12.3336C11.5294 12.9135 10.9816 13.7209 13.3296 16.1884C15.331 18.1442 16.4156 18.383 16.874 18.3489C17.2318 18.3262 17.3659 18.1329 17.4218 18.0646L17.433 18.0533C18.1822 16.9844 18.4281 16.7228 19.1437 16.6091C19.8369 16.4954 20.1947 16.7228 21.6706 17.6666C23.0012 18.5195 23.6161 19.088 23.482 19.9863C23.4372 20.9643 22.6993 22.2947 21.8719 22.8973C21.3017 23.2953 20.5078 23.5 19.5686 23.5Z"
stroke="white"
strokeWidth="2"
/>
</svg>
);
};
export const PortraitModeIcon = () => {
return (
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="15" cy="15" r="15" fill="white" fillOpacity="0.8" />
<path
d="M18.7732 16.9893C18.5432 17.3628 18.409 17.8017 18.4089 18.2725V18.2734C18.4091 18.7205 18.532 19.138 18.741 19.499C18.7181 19.5386 18.6944 19.578 18.6736 19.6191L18.5964 19.7881C18.289 20.5304 18.3676 21.3636 18.781 22.0264C17.1522 23.311 15.5544 24 15.0544 24C14.1854 23.9993 10.022 21.9537 8.10425 18.2568C7.03541 17.8202 6.13781 16.554 5.83276 15.4375C5.49299 14.1934 5.99316 13.5208 7.03394 13.0938C7.49743 9.10073 10.9116 6.00023 15.0544 6C19.1748 6.00002 22.574 9.06689 23.0671 13.0283C23.9096 13.3367 24.4401 13.7794 24.5291 14.5039C23.5925 13.9714 22.3816 14.1027 21.5828 14.9014C21.5384 14.9457 21.496 14.9912 21.4558 15.0381C21.2051 15.0508 21.0064 14.9527 20.9246 14.8867C21.0002 14.8994 21.103 14.8292 21.1453 14.792C20.9053 14.8681 20.3721 14.6321 20.1355 14.5049C20.1887 14.494 20.3125 14.378 20.3943 14.2939C20.301 14.3745 20.1372 14.4354 20.0564 14.458C19.8923 14.3307 19.3988 14.2669 19.1726 14.251C19.4701 14.0733 19.5487 13.8985 19.5535 13.8076C19.5372 13.9305 19.2813 14.1745 18.321 14.3936C17.2606 14.6354 16.5744 15.5661 16.364 16.001C16.6708 15.6806 16.9883 15.4477 17.3044 15.2822C17.226 15.4439 17.1824 15.6256 17.1824 15.8174C17.1825 16.495 17.7313 17.0438 18.4089 17.0439C18.5357 17.0439 18.6582 17.0249 18.7732 16.9893ZM10.7195 13.8076C10.7242 13.8985 10.8029 14.0733 11.1003 14.251C10.8741 14.2669 10.3807 14.3307 10.2166 14.458C10.1357 14.4354 9.97195 14.3745 9.87866 14.2939C9.96041 14.378 10.0843 14.494 10.1375 14.5049C9.90081 14.6321 9.36767 14.8681 9.12769 14.792C9.16993 14.8292 9.2728 14.8994 9.34839 14.8867C9.24318 14.9716 8.94407 15.1126 8.59058 14.998C8.91146 15.4117 9.66133 16.3185 10.0906 16.6367C9.86438 16.3556 9.49679 15.7108 9.83765 15.3799C9.7798 15.3374 9.65809 15.2374 9.63257 15.1738C9.71672 15.2162 9.90035 15.2908 9.96362 15.2529C9.99732 15.2326 10.4026 15.0334 10.9919 14.9531C10.7721 15.1749 10.6365 15.4805 10.6365 15.8174C10.6366 16.4951 11.1863 17.0439 11.864 17.0439C12.5416 17.0438 13.0904 16.495 13.0906 15.8174C13.0906 15.6255 13.0461 15.444 12.9675 15.2822C13.2839 15.4477 13.6019 15.6803 13.9089 16.001C13.6985 15.5661 13.0124 14.6354 11.9519 14.3936C10.9916 14.1745 10.7357 13.9305 10.7195 13.8076ZM19.28 14.9531C19.8698 15.0334 20.2756 15.2325 20.3093 15.2529C20.3726 15.2908 20.5562 15.2162 20.6404 15.1738C20.6149 15.2374 20.4931 15.3374 20.4353 15.3799C20.5604 15.5013 20.5898 15.6654 20.5662 15.8379C20.2075 15.8813 19.8737 16.0026 19.5798 16.1836C19.616 16.0679 19.6364 15.945 19.6365 15.8174C19.6365 15.4802 19.5001 15.1749 19.28 14.9531ZM21.0066 15.8184H21.0037C21.0058 15.8159 21.0074 15.813 21.0095 15.8105C21.0086 15.8132 21.0075 15.8157 21.0066 15.8184Z"
fill="#333333"
/>
<path
d="M24.9546 19.9092C25.4063 19.9093 25.7729 20.2758 25.7729 20.7275C25.7727 21.1791 25.4062 21.5448 24.9546 21.5449H22.8384L23.0786 21.7852C23.3979 22.1047 23.398 22.6229 23.0786 22.9424C22.7592 23.2618 22.2409 23.2616 21.9214 22.9424L20.2847 21.3057C20.0507 21.0717 19.9813 20.7198 20.1079 20.4141C20.2346 20.1084 20.5329 19.9092 20.8638 19.9092H24.9546ZM22.7397 16.0576C23.0592 15.7386 23.5766 15.7386 23.896 16.0576L25.5327 17.6943C25.7667 17.9283 25.837 18.2803 25.7104 18.5859C25.5838 18.8915 25.2853 19.0907 24.9546 19.0908H20.8638C20.4119 19.0908 20.0454 18.7243 20.0454 18.2725C20.0456 17.8208 20.412 17.4551 20.8638 17.4551H22.979L22.7397 17.2148C22.4202 16.8953 22.4202 16.3771 22.7397 16.0576Z"
fill="#333333"
/>
</svg>
);
};
export const ScrollTobottom = () => {
return (
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="15" cy="15" r="15" fill="white" fillOpacity="0.1" />
<path
d="M12.5 8C12.5 7.44772 12.9477 7 13.5 7H16.5C17.0523 7 17.5 7.44772 17.5 8V12H19.7962C20.6554 12 21.1146 13.0119 20.5488 13.6585L15.7526 19.1399C15.3542 19.5952 14.6458 19.5952 14.2474 19.1399L9.45119 13.6585C8.88543 13.0119 9.34461 12 10.2038 12H12.5V8Z"
fill="white"
fillOpacity="0.8"
/>
<rect
x="7.5"
y="21"
width="15"
height="2"
rx="1"
fill="white"
fillOpacity="0.8"
/>
</svg>
);
};
export const CharaterHistoryIcon = () => {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="20" cy="10" r="6" fill="white" fillOpacity="0.6" />
<path
d="M20 18C22.2563 18 24.3869 18.4472 26.2744 19.2373C21.5445 20.2582 18 24.4647 18 29.5C18 31.7314 18.6979 33.799 19.8848 35.5C12.206 35.5058 6.00021 36.0779 6 29.667C6 23.2237 12.268 18 20 18Z"
// fill="white"
fillOpacity="0.6"
/>
<path
d="M29 22C32.866 22 36 25.134 36 29C36 32.866 32.866 36 29 36C25.134 36 22 32.866 22 29C22 25.134 25.134 22 29 22ZM29 23.665C28.2627 23.665 27.665 24.2627 27.665 25V29.4453L29.4316 31.8008C29.8739 32.3905 30.711 32.5105 31.3008 32.0684C31.8905 31.6261 32.0105 30.789 31.5684 30.1992L30.335 28.5547V25C30.335 24.2627 29.7373 23.665 29 23.665Z"
// fill="white"
fillOpacity="0.6"
/>
</svg>
);
};
export const BackIcon = () => {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="20" cy="20" r="20" fill="black" fillOpacity="0.2" />
<circle cx="20" cy="20" r="19.5" stroke="currentColor" fillOpacity="0" />
<path
d="M18.5 14L12.5 20M12.5 20L18.5 26M12.5 20H27.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

321
src/assets/common/index.tsx Normal file
View File

@ -0,0 +1,321 @@
import type { IconProps } from '@/types/common';
export const VideoIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M21.1091 4.89062C21.48 4.16255 22.5204 4.16253 22.8913 4.89062L26.4968 11.9678C28.1698 13.3475 29.2768 15.3957 29.3991 17.7197L29.5573 20.7197C29.7981 25.2967 26.1514 29.1405 21.5681 29.1406H12.4323C7.84893 29.1406 4.20228 25.2968 4.44308 20.7197L4.60129 17.7197C4.82504 13.4708 8.33575 10.1406 12.5905 10.1406H18.4343L21.1091 4.89062ZM15.9997 15.3105C14.9997 14.7332 13.7497 15.4547 13.7497 16.6094V22.6709C13.7497 23.8255 14.9998 24.5477 15.9997 23.9707L21.2497 20.9395C22.2497 20.3621 22.2497 18.9182 21.2497 18.3408L15.9997 15.3105ZM11.1091 4.88965C11.4801 4.16181 12.5204 4.16175 12.8913 4.88965L14.5476 8.14062H13.9392C12.0072 8.14062 10.2147 8.68645 8.69601 9.62402L11.1091 4.88965Z" />
</svg>
);
};
export const CharacterIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.1122 8.36133C9.14386 9.68365 8.44462 11.2144 8.09514 12.8745C7.60011 13.2071 7.13601 13.6482 6.79924 14.2451C6.20071 15.3061 6.21365 16.4747 6.51067 17.562C6.77158 18.517 7.26469 19.4797 7.89738 20.2954C8.38464 20.9236 9.01742 21.5429 9.78166 21.9937C10.6638 23.4464 11.7579 24.6899 12.8749 25.7158C11.1233 25.339 8.2773 24.2245 6.23283 22.064C5.10434 21.9148 3.92418 20.9212 3.34563 19.9214C2.70107 18.8073 3.00253 18.0311 3.88127 17.354C3.2892 13.3827 5.73131 9.5216 9.66789 8.4668C9.81589 8.42714 9.96406 8.39208 10.1122 8.36133Z"
fill="currentColor"
/>
<path
d="M19.7107 5.5C24.7467 5.50002 28.901 9.2481 29.5037 14.0898C30.9167 14.6071 31.619 15.4335 31.1814 17.0352C30.785 18.486 29.57 20.1445 28.1551 20.5703C25.6842 24.9798 20.7645 27.4999 19.7107 27.5C18.6495 27.4997 13.6694 24.9455 11.2156 20.4805C9.90932 19.9468 8.8121 18.3998 8.43924 17.0352C8.02378 15.5143 8.63552 14.6918 9.90799 14.1699C10.4746 9.28949 14.6471 5.50009 19.7107 5.5ZM15.3103 18C15.0343 18.0001 14.8077 18.2244 14.8347 18.499C15.0853 21.026 17.2175 22.9999 19.8103 23C22.4032 23 24.5354 21.026 24.7859 18.499C24.813 18.2244 24.5864 18 24.3103 18H15.3103Z"
fill="currentColor"
/>
</svg>
);
};
export const NovelIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.5007 6C27.0903 6.00024 30.0007 8.01487 30.0007 10.5L30.9285 27.1934C30.9538 27.6513 30.3125 28.0005 29.8855 27.833C28.4746 27.2776 26.176 27 23.5007 27C20.3219 27 18.4596 27.3926 17.3806 28.1768C17.1575 28.3388 16.8441 28.3385 16.6208 28.1768C15.542 27.3925 13.6795 27.0001 10.5007 27C7.82545 27 5.52699 27.2776 4.11595 27.833C3.68891 28.0011 3.04756 27.6516 3.07298 27.1934L4.00072 10.5C4.00072 8.01472 6.91087 6 10.5007 6C13.1402 6.00018 14.8716 7.082 16.0007 8.64844V17C16.0007 17.5523 16.4484 18 17.0007 18C17.5527 17.9996 18.0007 17.5521 18.0007 17V8.64844C19.13 7.08207 20.8611 6 23.5007 6ZM10.3249 10.2031C9.92776 9.624 9.07281 9.62415 8.67552 10.2031L7.93724 11.2793C7.80721 11.469 7.61587 11.6088 7.39525 11.6738L6.14427 12.042C5.47032 12.2407 5.20605 13.0545 5.63451 13.6113L6.4304 14.6455C6.57041 14.8277 6.64368 15.0526 6.63744 15.2822L6.6013 16.5869C6.58226 17.2891 7.27438 17.792 7.93626 17.5566L9.16478 17.1191C9.3814 17.0421 9.61807 17.0422 9.8347 17.1191L11.0642 17.5566C11.726 17.7918 12.4182 17.289 12.3992 16.5869L12.363 15.2822C12.3568 15.0525 12.4299 14.8277 12.5701 14.6455L13.366 13.6113C13.7941 13.0546 13.5298 12.2408 12.8562 12.042L11.6042 11.6738C11.3836 11.6088 11.1923 11.469 11.0622 11.2793L10.3249 10.2031Z"
fill="currentColor"
/>
</svg>
);
};
export const RecordIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 7C22.5228 7 27 11.4772 27 17C27 22.5228 22.5228 27 17 27C11.4772 27 7 22.5228 7 17C7 11.4772 11.4772 7 17 7ZM17 10C16.4477 10 16 10.4477 16 11V17C16 17.2652 16.1054 17.5195 16.293 17.707L19.293 20.707C19.6835 21.0976 20.3165 21.0976 20.707 20.707C21.0976 20.3165 21.0976 19.6835 20.707 19.293L18 16.5859V11C18 10.4477 17.5523 10 17 10ZM10.5 5C11.3407 5 12.1124 5.29606 12.7158 5.79004C10.5353 6.62388 8.66109 8.07439 7.30566 9.92969C7.10994 9.49305 7 9.00948 7 8.5C7 6.567 8.567 5 10.5 5ZM23.5 5C25.433 5 27 6.567 27 8.5C27 9.00962 26.8892 9.49295 26.6934 9.92969C25.3378 8.07437 23.4639 6.62375 21.2832 5.79004C21.8867 5.29577 22.6591 5 23.5 5Z"
fill="currentColor"
/>
</svg>
);
};
export const ReportIcon = (props: IconProps) => {
const width = props.size || props.width || 20;
const height = props.size || props.height || 20;
return (
<svg
width={width}
height={height}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.29 16H2.70996L10 3.04004L17.29 16Z"
stroke="white"
strokeOpacity="0.8"
strokeWidth="2"
/>
<path
d="M10 9.5V11.5"
stroke="white"
strokeOpacity="0.8"
strokeLinecap="round"
/>
<circle cx="10" cy="13" r="0.5" fill="white" fillOpacity="0.8" />
</svg>
);
};
export const DeleteIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 18}
height={props.size || props.height || 18}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.6001 5.59961L6.32884 20.1745C6.36876 20.9728 7.02766 21.5996 7.82697 21.5996H16.1732C16.9725 21.5996 17.6314 20.9728 17.6714 20.1745L18.4001 5.59961"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M3.19995 6.40039H20.8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M8.80005 3.19922H15.2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M9.6001 11.1992L9.6001 15.9992"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M14.3999 11.1992L14.3999 15.9992"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
};
export const AddIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 15}
height={props.size || props.height || 15}
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_254_6694)">
<rect y="6.5" width="15" height="2" rx="1" fill="currentColor" />
<rect
x="8.5"
width="15"
height="2"
rx="1"
transform="rotate(90 8.5 0)"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_254_6694">
<rect width="15" height="15" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
};
export const UploadImgIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.5 12V6C22.5 4.34315 21.1569 3 19.5 3H4.5C2.84315 3 1.5 4.34315 1.5 6V18C1.5 19.6569 2.84315 21 4.5 21H13.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M6 16L10 12.5L12 14.5L18 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="10" cy="8" r="1" fill="currentColor" />
<path
d="M16.5 18H22.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M19.5 15L19.5 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
};
export const CheckIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 11}
height={props.size || props.height || 8}
viewBox="0 0 11 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 4L4 7L10 1"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export const FullScreenIcon = () => {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="40" height="40" rx="20" fill="currentColor" />
<rect
x="11"
y="11"
width="18"
height="18"
rx="2"
stroke="white"
strokeWidth="2"
/>
<path
d="M25 15L22 18"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M25 18V15H22"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M18 22L15 25"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M15 22V25H18"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
};
export const ExitFullScreenIcon = () => {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="40" height="40" rx="20" fill="currentColor" />
<rect
x="11"
y="11"
width="18"
height="18"
rx="2"
stroke="white"
strokeWidth="2"
/>
<path
d="M22 18L25 15"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M22 15V18H25"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M15 25L18 22"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M18 25V22H15"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
};

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 2L10 7L5 12" stroke="white" stroke-opacity="0.8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@ -0,0 +1,52 @@
import type { IconProps } from '@/types/common';
export const A_Icon = (props: IconProps) => {
return (
<svg
width={props.size || 20}
height={props.size || 20}
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M15.7109 15.5H13.1953L12.1953 12.8984H7.61719L6.67188 15.5H4.21875L8.67969 4.04688H11.125L15.7109 15.5ZM11.4531 10.9688L9.875 6.71875L8.32812 10.9688H11.4531Z" />
<path d="M14 4H18" stroke="currentColor" />
</svg>
);
};
export const APlusIcon = (props: IconProps) => {
return (
<svg
width={props.size || 20}
height={props.size || 20}
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.7109 15.5H13.1953L12.1953 12.8984H7.61719L6.67188 15.5H4.21875L8.67969 4.04688H11.125L15.7109 15.5ZM11.4531 10.9688L9.875 6.71875L8.32812 10.9688H11.4531Z"
fill="currentColor"
/>
<path d="M14 4H18" stroke="currentColor" />
<path d="M16 2L16 6" stroke="currentColor" />
</svg>
);
};
export const FrameIcon = () => {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.5333 9.39103C3.46663 8.77519 3.46663 7.23559 4.5333 6.61975L10.6053 3.11408C11.8646 2.38703 13.3624 3.58087 12.9344 4.97058L12.145 7.53454C12.0505 7.84133 12.0505 8.16945 12.145 8.47625L12.9344 11.0402C13.3624 12.4299 11.8646 13.6237 10.6053 12.8967L4.5333 9.39103Z"
fill="currentColor"
/>
</svg>
);
};

View File

@ -0,0 +1,32 @@
'use client';
import { Modal, Select } from '@/components';
import { useControllableValue } from 'ahooks';
type ModelSelectDialogProps = {
value?: string;
onChange?: (value: string) => void;
};
export default function ModelSelectDialog(props: ModelSelectDialogProps) {
const [value, onChange] = useControllableValue(props, {
valuePropName: 'value',
defaultValuePropName: 'defaultValue',
trigger: 'onChange',
});
const options = [
{ label: 'Model 1', value: 'model1' },
{ label: 'Model 2', value: 'model2' },
{ label: 'Model 3', value: 'model3' },
];
return (
<Modal
title="REPLY"
trigger={
<Select.View icon={'/character/model_switch.svg'} text="Model 1" />
}
>
Content
</Modal>
);
}

9
src/components/index.tsx Normal file
View File

@ -0,0 +1,9 @@
export { default as Modal } from './ui/modal';
export { default as Select } from './ui/inputs/select';
export { default as Rate } from './ui/rate';
export { default as Form } from './ui/form';
export { default as Icon } from './ui/icon';
export { default as Drawer } from './ui/drawer';
export { default as ModelSelectDialog } from './feature/ModelSelectDialog';
export { default as VirtualGrid } from './ui/VirtualGrid';
export { default as TagSelect } from './ui/tag';

View File

@ -0,0 +1,327 @@
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useDebounceFn, useUpdate } from 'ahooks';
import cloneDeep from 'lodash/cloneDeep';
import { is } from 'zod/locales';
type VirtualGridProps<T extends any = any> = {
rowHeight?: number;
itemRender?: (item: T) => React.ReactNode;
dataSource?: T[];
noMoreData?: boolean;
noMoreDataRender?: (() => React.ReactNode) | null;
isLoadingMore?: boolean;
isLoading?: boolean;
loadMore?: () => void;
gap?: number;
padding?: {
right?: number;
left?: number;
};
columnCalc?: (width: number) => number;
keySetter?: (item: T) => string;
preRenderHeight?: number;
header?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
function VirtualGrid<T extends any = any>(
props: VirtualGridProps<T>
): React.ReactNode {
const {
rowHeight = 0,
itemRender,
columnCalc,
gap = 10,
dataSource = [],
noMoreData = false,
loadMore,
header,
keySetter,
preRenderHeight = 100,
padding,
isLoadingMore,
isLoading,
noMoreDataRender = () => (
<div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
No more data
</div>
),
...others
} = props;
const { left = 0, right = 0 } = padding || {};
const containerRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const listHeight = useRef<number>(0);
const previousDataSourceLength = useRef<number>(0);
// 画布数据
const scrollState = useRef<{
// 画布测量宽高
viewWidth: number;
viewHeight: number;
// 视图的开始位置距离顶部的位置
start: number;
// 列数
column: number;
// 每个cell的实际宽度
width: number;
// header 的测量高度
headerHeight: number;
// 实际可用宽度
usableWidth: number;
}>({
viewWidth: 0,
viewHeight: 0,
start: 0,
column: 0,
width: 0,
headerHeight: 0,
usableWidth: 0,
});
// 计算过布局几何属性的数据
const queueState = useRef<
{
// 源数据
item: T;
// 到顶部的距离
y: number;
// 最终样式
style: React.CSSProperties;
}[]
>([]);
// 需要实际渲染的数据
const renderList = useRef<
{
item: T;
y: number;
style: React.CSSProperties;
}[]
>([]);
// 手动更新,避免多次刷新组件
const update = useUpdate();
// 初始化画布参数在container size改变时需要调用
const initScrollState = () => {
if (!containerRef.current) return;
scrollState.current.viewWidth = containerRef.current.clientWidth;
scrollState.current.viewHeight = containerRef.current.clientHeight;
// 实际可用宽度为视口宽度减去左右padding
scrollState.current.usableWidth =
containerRef.current.clientWidth - left - right;
// 根据实际可用宽度计算列数
const column = columnCalc?.(scrollState.current.usableWidth) || 1;
scrollState.current.column = column;
scrollState.current.start = containerRef.current.scrollTop;
// 每个cell的实际宽度(为可用宽度除以列数)
scrollState.current.width =
(scrollState.current.usableWidth - (column - 1) * gap!) / column;
// header 的测量高度
scrollState.current.headerHeight = headerRef.current?.clientHeight || 0;
};
const genereateRenderList = () => {
const finalStart = scrollState.current.start - preRenderHeight!;
const finalEnd =
scrollState.current.start +
scrollState.current.viewHeight +
preRenderHeight!;
const newRenderList = [];
// 注意这里需要遍历dataSource的长度而不是queueState的长度
for (let i = 0; i < dataSource.length; i++) {
if (
queueState.current[i].y + rowHeight! >= finalStart &&
queueState.current[i].y <= finalEnd
) {
newRenderList.push(cloneDeep(queueState.current[i]));
}
}
if (
newRenderList.length !== renderList.current.length ||
newRenderList[0]?.y !== renderList.current[0]?.y ||
newRenderList[0]?.style.width !== renderList.current[0]?.style.width
) {
update();
}
renderList.current = newRenderList;
};
// 重新计算高度和滚动位置
const resetHeightAndScroll = () => {
// 最小有一行
const maxRow = Math.max(
Math.ceil(dataSource.length / scrollState.current.column),
1
);
// 高度 = 最大行数 * 行高 + (最大行数 - 1) * 间距 + header 高度
listHeight.current =
maxRow * rowHeight! +
(maxRow - 1) * gap! +
scrollState.current.headerHeight;
// 如果数据长度小于等于之前的,则滚动到顶部
if (dataSource.length <= previousDataSourceLength.current) {
containerRef.current?.scrollTo({
top: 0,
behavior: 'instant',
});
}
previousDataSourceLength.current = dataSource.length;
genereateRenderList();
};
const calculateCellRect = (i: number) => {
// 第几行, 从0开始
const row = Math.floor(i / scrollState.current.column);
// 第几列, 从0开始
const col = i % scrollState.current.column;
// 到顶部的距离
const y = scrollState.current.headerHeight + row * rowHeight! + gap! * row;
// 到左边的距离
const x = left + col * scrollState.current.width + col * gap!;
return {
y,
x,
};
};
// 页面尺寸变化,重新计算布局
const resizeQueueData = () => {
for (let i = 0; i < dataSource.length; i++) {
const { y, x } = calculateCellRect(i);
queueState.current[i].style = {
height: rowHeight!,
width: scrollState.current.width,
transform: `translate(${x}px, ${y}px)`,
};
queueState.current[i].y = y;
}
resetHeightAndScroll();
};
const updateQueueData = () => {
for (let i = 0; i < dataSource.length; i++) {
const item = dataSource[i];
// 如果是已经计算过,则只更新数据
if (queueState.current[i]) {
queueState.current[i].item = dataSource[i];
continue;
}
const { y, x } = calculateCellRect(i);
queueState.current.push({
item: item,
y: y,
style: {
height: rowHeight!,
width: scrollState.current.width,
transform: `translate(${x}px, ${y}px)`,
},
});
}
resetHeightAndScroll();
};
const handleScroll = () => {
if (!containerRef.current) return;
scrollState.current.start = containerRef.current.scrollTop;
genereateRenderList();
if (noMoreData || isLoadingMore || isLoading) return;
const { scrollTop, clientHeight } = containerRef.current;
if (scrollTop + clientHeight >= listHeight.current) {
loadMore?.();
}
};
const { run: handleResize } = useDebounceFn(
() => {
initScrollState();
resizeQueueData();
},
{
wait: 200,
maxWait: 300,
}
);
// 监听容器和header的尺寸变化
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
resizeObserver.observe(headerRef.current!);
}
return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
resizeObserver.unobserve(headerRef.current!);
}
resizeObserver.disconnect();
};
}, []);
// 当 dataSource 变化时,重新计算布局
useEffect(() => {
// 初始化布局
if (!scrollState.current.viewWidth) {
initScrollState();
}
updateQueueData();
}, [dataSource]);
const loadingHeight = 20;
return (
<div
ref={containerRef}
{...others}
className={`overflow-auto ${others.className}`}
onScroll={handleScroll}
>
<div
className="relative w-full"
style={{ height: listHeight.current + loadingHeight }}
>
<div ref={headerRef} className="flex">
{header}
</div>
{renderList.current.map(({ item, style }) => (
<div
className="absolute top-0 left-0 overflow-hidden"
key={keySetter?.(item)}
style={style}
>
{itemRender?.(item)}
</div>
))}
{!!renderList.current.length && !noMoreData && (
<div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
Loading more...
</div>
)}
{noMoreData && noMoreDataRender?.()}
{isLoadingMore && (
<div className="absolute flex w-full items-center justify-center">
Loading...
</div>
)}
{!renderList.current.length && !isLoading && (
<div className="absolute flex w-full items-center justify-center">
No data
</div>
)}
</div>
</div>
);
}
export default VirtualGrid;

View File

@ -0,0 +1,176 @@
'use client';
import { useEffect, useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type DrawerProps = {
open?: boolean;
onCloseChange?: (open: boolean) => void;
children?: React.ReactNode;
container?: HTMLElement | null;
position?: 'left' | 'right' | 'top' | 'bottom';
width?: number;
destroyOnClose?: boolean;
};
export default function Drawer({
open = false,
onCloseChange,
children,
container,
position = 'right',
width = 400,
destroyOnClose = false,
}: DrawerProps) {
// shouldRender 控制是否渲染 DOM用于 destroyOnClose
const [shouldRender, setShouldRender] = useState(false);
const mountedRef = useRef(false);
const drawerRef = useRef<HTMLDivElement>(null);
const prevOpenRef = useRef(open);
// 确保组件在客户端挂载后再渲染
useEffect(() => {
mountedRef.current = true;
}, []);
// 当 open 变为 true 时,立即设置 shouldRender 为 true
useEffect(() => {
if (open) {
setShouldRender(true);
}
}, [open]);
// 控制渲染时机,用于动画
useEffect(() => {
// 获取隐藏位置的 transform 值
const getHiddenTransform = () => {
switch (position) {
case 'left':
return 'translateX(-100%)';
case 'right':
return 'translateX(100%)';
case 'top':
return 'translateY(-100%)';
case 'bottom':
return 'translateY(100%)';
default:
return 'translateX(100%)';
}
};
if (open) {
// 打开:等待下一帧,确保 DOM 已经渲染,然后直接操作 style 触发动画
requestAnimationFrame(() => {
if (drawerRef.current) {
drawerRef.current.style.transform = 'translateX(0) translateY(0)';
}
});
} else if (prevOpenRef.current) {
// 关闭:直接操作 style 开始关闭动画
if (drawerRef.current) {
drawerRef.current.style.transform = getHiddenTransform();
}
}
prevOpenRef.current = open;
}, [open, position]);
// 处理动画结束后的销毁
const handleTransitionEnd = useCallback(() => {
// 只有在关闭状态且设置了 destroyOnClose 时,才销毁 DOM
if (!open && destroyOnClose) {
setShouldRender(false);
}
}, [open, destroyOnClose, setShouldRender]);
// 如果还没挂载,或者设置了销毁且不应该渲染,则不显示
if (!mountedRef.current || (!shouldRender && destroyOnClose)) {
return null;
}
// 根据位置计算样式
const getPositionStyles = (): React.CSSProperties => {
// 如果有 container使用 absolute 定位,否则使用 fixed 定位
const positionType = container ? 'absolute' : 'fixed';
// 获取隐藏位置的 transform 值
const getHiddenTransform = () => {
switch (position) {
case 'left':
return 'translateX(-100%)';
case 'right':
return 'translateX(100%)';
case 'top':
return 'translateY(-100%)';
case 'bottom':
return 'translateY(100%)';
default:
return 'translateX(100%)';
}
};
const baseStyles: React.CSSProperties = {
position: positionType,
zIndex: 5,
boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
transition: 'transform 0.3s ease-in-out',
// 初始状态:隐藏在屏幕外
transform: getHiddenTransform(),
};
switch (position) {
case 'left':
return {
...baseStyles,
top: 0,
left: 0,
height: '100%',
width: `${width}px`,
};
case 'right':
return {
...baseStyles,
top: 0,
right: 0,
height: '100%',
width: `${width}px`,
};
case 'top':
return {
...baseStyles,
top: 0,
left: 0,
width: '100%',
height: `${width}px`,
};
case 'bottom':
return {
...baseStyles,
bottom: 0,
left: 0,
width: '100%',
height: `${width}px`,
};
default:
return baseStyles;
}
};
// 使用 portal 渲染到指定容器,默认是 body
const targetContainer =
container || (mountedRef.current ? document.body : null);
if (!targetContainer) return null;
return createPortal(
<div
ref={drawerRef}
style={getPositionStyles()}
onTransitionEnd={handleTransitionEnd}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
targetContainer
);
}

View File

@ -0,0 +1,279 @@
import React from 'react';
import {
FormProvider,
useForm,
useFormContext,
Controller,
useWatch,
} from 'react-hook-form';
import type {
FieldValues,
SubmitErrorHandler,
Path,
PathValue,
} from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// 增强的类型定义
type FormProps<TValues extends FieldValues = FieldValues> =
React.PropsWithChildren<{
initialValues?: Partial<TValues>;
onSubmit?: (data: TValues) => void | Promise<void>;
onChange?: (data: Partial<TValues>) => void;
/** 使用 zod 校验规则时传入 */
schema?: z.ZodType<TValues>;
/** 表单提交时的加载状态 */
loading?: boolean;
/** 是否禁用整个表单 */
disabled?: boolean;
}>;
// 简化的 FormAction 类型 - 基于 react-hook-form 的 methods + 自定义 validateFields
type FormAction<TValues extends FieldValues = FieldValues> = ReturnType<
typeof useForm<TValues>
> & {
/** 增强的校验方法,返回校验通过的数据或 null */
validateFields: () => Promise<TValues | null>;
};
const Form = <TValues extends FieldValues = FieldValues>(
props: FormProps<TValues>,
ref: React.Ref<FormAction<TValues>>
) => {
const {
initialValues,
onSubmit = () => {},
onChange,
schema,
loading = false,
disabled = false,
children,
} = props;
const methods = useForm({
defaultValues: initialValues as any,
resolver: schema ? zodResolver(schema as any) : undefined,
mode: 'onSubmit',
reValidateMode: 'onSubmit',
disabled,
});
// 提交状态管理
const [isSubmitting, setIsSubmitting] = React.useState(false);
React.useImperativeHandle(
ref,
(): FormAction<TValues> => ({
...methods,
validateFields: async () => {
const isValid = await methods.trigger();
if (isValid) return methods.getValues() as TValues;
return null;
},
}),
[methods]
);
// 值变更回调
React.useEffect(() => {
if (!onChange) return;
const subscription = methods.watch((data) => onChange(data as TValues));
return () => subscription.unsubscribe();
}, [methods, onChange]);
// 增强的提交处理
const handleSubmit = React.useCallback(
async (data: TValues) => {
if (loading || isSubmitting) return;
try {
setIsSubmitting(true);
await onSubmit(data);
} catch (error) {
console.error('Form submission error:', error);
// 可以在这里添加全局错误处理
} finally {
setIsSubmitting(false);
}
},
[onSubmit, loading, isSubmitting]
);
const handleInvalid: SubmitErrorHandler<TValues> = React.useCallback(
(errors) => {
console.warn('Form validation failed:', errors);
// 可以在这里添加全局错误处理,比如滚动到第一个错误字段
},
[]
);
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleSubmit as any, handleInvalid)}>
<fieldset
disabled={disabled || loading || isSubmitting}
style={{ border: 'none', padding: 0, margin: 0 }}
>
{children}
</fieldset>
</form>
</FormProvider>
);
};
// 使用 forwardRef 包装以支持泛型
const FormWithRef = React.forwardRef(Form) as <
TValues extends FieldValues = FieldValues,
>(
props: FormProps<TValues> & { ref?: React.Ref<FormAction<TValues>> }
) => React.ReactElement;
// 增强的 FormItemProps
type FormItemProps<TValues extends FieldValues = FieldValues> = {
/** 校验触发时机 */
trigger?: 'onChange' | 'onBlur';
/** 字段名,支持嵌套路径如 'user.name' */
name: Path<TValues>;
/** 渲染函数 */
render: (props: {
value: PathValue<TValues, Path<TValues>>;
onChange: (value: PathValue<TValues, Path<TValues>>) => void;
onBlur: () => void;
error: string | undefined;
}) => React.ReactNode;
/** 是否禁用该字段 */
disabled?: boolean;
};
function getByPath(obj: any, path: string): any {
const segments = path.split('.');
let current = obj;
for (const key of segments) {
if (current == null) return undefined;
current = current[key];
}
return current;
}
// 增强的 FormItem 组件
export const FormItem = <TValues extends FieldValues = FieldValues>(
props: FormItemProps<TValues>
) => {
const { name, render, trigger = 'onChange', disabled = false } = props;
const {
control,
formState,
trigger: triggerValidation,
} = useFormContext<TValues>();
return (
<Controller
name={name}
control={control}
disabled={disabled}
render={({ field, fieldState }) => {
const errorFromField = fieldState.error?.message as string | undefined;
const errorFromForm = getByPath(formState.errors, name)?.message as
| string
| undefined;
const error = errorFromField || errorFromForm;
// 变更处理函数
const handleChange = (value: PathValue<TValues, Path<TValues>>) => {
field.onChange(value);
if (trigger === 'onChange') {
triggerValidation(name);
}
};
const handleBlur = () => {
field.onBlur();
if (trigger === 'onBlur') {
triggerValidation(name);
}
};
return (
<>
{render({
value: field.value,
onChange: handleChange,
onBlur: handleBlur,
error,
})}
</>
);
}}
/>
);
};
// 增强的 FormErrorProps
type FormErrorProps<TValues extends FieldValues = FieldValues> = {
/** 要监听的字段名数组 */
name: Path<TValues>[];
/** 渲染函数 */
render: (props: { errors: Record<string, string> }) => React.ReactNode;
};
// 增强的 FormError 组件
export const FormError = <TValues extends FieldValues = FieldValues>(
props: FormErrorProps<TValues>
) => {
const { name, render } = props;
const { formState } = useFormContext<TValues>();
// 收集指定字段的错误信息
const errors: Record<string, string> = {};
let hasError = false;
name.forEach((fieldName) => {
const error = getByPath(formState.errors, fieldName);
if (error?.message) {
errors[fieldName as string] = error.message;
hasError = true;
}
});
// 只有存在错误时才渲染
if (!hasError) {
return null;
}
return <>{render({ errors })}</>;
};
// 简化的 FormDependencyProps
type FormDependencyProps<TValues extends FieldValues = FieldValues> = {
/** 依赖的字段名数组 */
dependencies: Path<TValues>[];
/** 渲染函数 */
render: (props: {
values: Record<string, PathValue<TValues, Path<TValues>>>;
}) => React.ReactNode;
};
// 增强的 FormDependency 组件
export const FormDependency = <TValues extends FieldValues = FieldValues>(
props: FormDependencyProps<TValues>
) => {
const { dependencies, render } = props;
const { control } = useFormContext<TValues>();
// 使用 useWatch 监听多个字段
const watchedValues = useWatch({
control,
name: dependencies as readonly Path<TValues>[],
});
// 将监听到的值组合成对象
const values = React.useMemo(() => {
const result: Record<string, any> = {};
dependencies.forEach((dep, index) => {
result[dep as string] = Array.isArray(watchedValues)
? watchedValues[index]
: watchedValues;
});
return result;
}, [dependencies, watchedValues]);
return <>{render({ values })}</>;
};
export default FormWithRef;

View File

@ -0,0 +1,46 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { cn } from '@/lib';
type IconProps = {
src?: string;
hoverSrc?: string;
isActive?: boolean;
activeSrc?: string;
size?: number;
} & React.HTMLAttributes<HTMLSpanElement>;
export default function Icon({
src,
hoverSrc,
isActive = false,
activeSrc,
size = 40,
className,
...rest
}: IconProps) {
const [isHovered, setIsHovered] = useState(false);
// 决定显示哪个图片
const getImageSrc = () => {
if (isActive && activeSrc) {
return activeSrc;
}
if (isHovered && hoverSrc) {
return hoverSrc;
}
return src || '';
};
return (
<span
className={cn('hover:cursor-pointer', className)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
{...rest}
>
<Image src={getImageSrc()} alt="icon" width={size} height={size} />
</span>
);
}

View File

@ -0,0 +1,56 @@
'use client';
import { cn } from '@/lib';
import { useControllableValue } from 'ahooks';
import { APlusIcon, A_Icon } from '@/assets/components';
import { InputLeft } from '.';
import isNumber from 'lodash/isNumber';
type FontSizeProps = {
value?: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
// 最小精度
} & React.HTMLAttributes<HTMLDivElement>;
export default function FontSize(props: FontSizeProps) {
const { min = 1, max = 100 } = props;
const [value, onChange] = useControllableValue(props);
const className =
'hover:cursor-pointer hover:bg-text-color/10 rounded-md p-1 text-text-color/60';
return (
<div className={cn('input-view justify-between')}>
<InputLeft icon={'/component/font_size.svg'} text="Font Size" />
<div className="flex">
<span
onClick={() =>
isNumber(value) && onChange(Math.max(value - 1, min ?? 0))
}
className={className}
>
<A_Icon />
</span>
<input
min={min}
max={max}
value={value ?? ''}
onChange={(e) => {
const val = e.target.value;
onChange(val ? parseFloat(val) : undefined);
}}
className="w-30 [appearance:textfield] border-none bg-transparent text-center outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
/>
<span
onClick={() =>
isNumber(value) && onChange(Math.min(value + 1, max ?? 0))
}
className={className}
>
<APlusIcon />
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
.input-view {
display: flex;
padding: 0 20px;
align-items: center;
height: 60px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(0, 0, 0, 0.2);
}

View File

@ -0,0 +1,18 @@
'use client';
import './index.css';
import Image from 'next/image';
export const InputLeft = (props: { icon?: any; text?: string }) => {
const { icon, text } = props;
return (
<div className="flex items-center gap-2.5">
<Image src={icon} width={20} height={20} alt="icon" />
<span className="text-text-color/80 font-bold">{text}</span>
</div>
);
};
export { default as Select } from './select';
export { default as Number } from './number';
export { default as Switch } from './switch';
export { default as FontSize } from './FontSize';

View File

@ -0,0 +1,55 @@
'use client';
import { cn } from '@/lib';
import { useControllableValue } from 'ahooks';
import { FrameIcon } from '@/assets/components';
import isNumber from 'lodash/isNumber';
type NumberProps = {
value?: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
// 最小精度
step?: number;
defaultValue?: number;
} & React.HTMLAttributes<HTMLDivElement>;
export default function Number(props: NumberProps) {
const { min = -9999999, max = 9999999, step = 1 } = props;
const [value, setValue] = useControllableValue<number | undefined>(props);
const className =
'hover:cursor-pointer hover:bg-text-color/10 rounded-md p-1 text-text-color/60';
return (
<div className={cn('input-view justify-between')}>
<span
onClick={() =>
isNumber(value) && setValue(Math.max(value - 1, min ?? 0))
}
className={className}
>
<FrameIcon />
</span>
<input
className="[appearance:textfield] border-none bg-transparent text-center outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
value={value ?? ''}
onChange={(e) => {
const val = e.target.value;
setValue(val ? parseFloat(val) : undefined);
}}
min={min}
max={max}
step={step}
/>
<span
onClick={() =>
isNumber(value) && setValue(Math.min(value + 1, max ?? 0))
}
className={cn(className, 'rotate-180')}
>
<FrameIcon />
</span>
</div>
);
}

View File

@ -0,0 +1,130 @@
'use client';
import { cn } from '@/lib';
import React, { useState } from 'react';
import rightIcon from '@/assets/components/go_right.svg';
import Image from 'next/image';
import { Select as SelectPrimitive } from 'radix-ui';
import { useControllableValue } from 'ahooks';
import { InputLeft } from '.';
const { Root, Trigger, Portal, Content, Viewport, Item, ItemText } =
SelectPrimitive;
const View: React.FC<
{
icon?: any;
text?: string;
expand?: boolean;
} & React.HTMLAttributes<HTMLDivElement>
> = (props) => {
const { icon, text, expand, ...rest } = props;
return (
<div
{...rest}
className={cn('input-view justify-between', props.className)}
>
<InputLeft icon={icon} text={text} />
<Image
className={cn('transition-transform', expand ? 'rotate-90' : '')}
src={rightIcon}
width={14}
height={14}
alt="right"
/>
</div>
);
};
type SelectProps = {
options?: {
label: string;
value: string;
}[];
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
render?: (option: { label: string; value: string }) => React.ReactNode;
placeholder?: string;
icon?: any;
contentClassName?: string; // 自定义下拉框样式
defaultOpen?: boolean; // 默认是否打开
onOpenChange?: (open: boolean) => void; // 打开/关闭回调
} & React.HTMLAttributes<HTMLDivElement>;
function Select(props: SelectProps) {
const {
options = [],
render,
placeholder = '请选择',
icon,
className,
contentClassName,
} = props;
// 使用 useControllableValue 管理状态,支持受控和非受控模式
const [value, setValue] = useControllableValue<string | undefined>(props);
const [open, setOpen] = useState(false);
// 找到当前选中项的 label
const selectedLabel = options.find((opt) => opt.value === value)?.label;
return (
<Root
value={value}
onValueChange={setValue}
open={open}
onOpenChange={() => setOpen(!open)}
>
{/* Trigger - 使用 View 组件 */}
<Trigger asChild>
<View
icon={icon}
expand={open}
text={selectedLabel || placeholder}
className={className}
/>
</Trigger>
{/* 下拉内容 */}
<Portal>
<Content
className={cn(
'rounded-[20px] border border-white/10',
'overflow-hidden',
contentClassName
)}
position="popper"
side="bottom"
align="start"
style={{
width: 'var(--radix-select-trigger-width)', // 默认跟随 Trigger 宽度
backgroundColor: 'rgba(26, 23, 34, 1)',
zIndex: 9999,
}}
>
<Viewport className="p-2">
{options.map((option) => (
<Item
key={option.value}
value={option.value}
className={cn(
'cursor-pointer outline-none',
'rounded-lg px-5 py-3', // 添加内边距和圆角
'hover:bg-white/10', // 悬停效果
'transition-colors', // 平滑过渡
'data-[highlighted]:bg-white/10' // Radix UI 高亮状态
)}
>
<ItemText>{render ? render(option) : option.label}</ItemText>
</Item>
))}
</Viewport>
</Content>
</Portal>
</Root>
);
}
Select.View = View;
export default Select;

View File

@ -0,0 +1,50 @@
'use client';
import { cn } from '@/lib';
import { useControllableValue } from 'ahooks';
import { InputLeft } from '.';
import { Switch as SwitchPrimitive } from 'radix-ui';
const { Root, Thumb } = SwitchPrimitive;
type SwitchProps = {
value?: boolean;
onChange?: (value: boolean) => void;
icon?: any;
text?: string;
} & React.HTMLAttributes<HTMLDivElement>;
export default function Switch(props: SwitchProps) {
const { icon, text, ...rest } = props;
const [value, onChange] = useControllableValue(props);
return (
<div {...rest} className={cn('input-view justify-between', rest.className)}>
<InputLeft icon={icon} text={text} />
<Root
checked={value}
onCheckedChange={onChange}
className={cn(
'relative inline-flex h-[28px] w-[52px] shrink-0 items-center',
'cursor-pointer rounded-full',
'transition-colors duration-200 ease-in-out',
'focus-visible:ring-2 focus-visible:ring-white/20 focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
// 未选中状态 - 灰色背景
'bg-white/20',
// 选中状态 - 绿色背景
'data-[state=checked]:bg-[rgb(0,110,74)]' // 注意RGB 值之间不能有空格
)}
>
<Thumb
className={cn(
'pointer-events-none block h-[24px] w-[24px] rounded-full',
'bg-white shadow-lg',
'transition-transform duration-200 ease-in-out',
// 未选中时在左侧2px 偏移)
'translate-x-[2px]',
// 选中时移到右侧52px - 24px - 2px = 26px
'data-[state=checked]:translate-x-[26px]'
)}
/>
</Root>
</div>
);
}

View File

@ -0,0 +1,30 @@
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.7);
}
.dialog-content {
position: fixed;
top: 50%;
left: 50%;
max-height: 100vh;
max-width: 100vw;
z-index: 50;
transform: translate(-50%, -50%);
border-radius: 0.75rem;
/* 用 inset box-shadow 模拟边框 */
box-shadow:
0 0 0 1px rgba(118, 106, 150, 1) inset,
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
background: linear-gradient(
180deg,
rgba(35, 25, 60, 1) 0%,
rgba(26, 23, 34, 1) 100%
);
padding: 30px;
}

View File

@ -0,0 +1,60 @@
'use client';
import { useControllableValue } from 'ahooks';
import { Dialog as DialogPrimitive } from 'radix-ui';
import './index.css';
import Image from 'next/image';
import { cn } from '@/lib';
type ModalProps = {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
trigger?: React.ReactNode;
title?: string;
classNames?: Record<'content' | 'overlay', string>;
};
export default function Modal(props: ModalProps) {
const { children, trigger, title, classNames } = props;
const [open, setOpen] = useControllableValue(props, {
defaultValue: false,
defaultValuePropName: 'defaultOpen',
valuePropName: 'open',
trigger: 'onOpenChange',
});
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen} modal>
{trigger && (
<DialogPrimitive.Trigger
className="cursor-pointer"
onClick={() => setOpen(true)}
asChild
>
{trigger}
</DialogPrimitive.Trigger>
)}
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="dialog-overlay" />
<DialogPrimitive.Content
className={cn('dialog-content w-125', classNames?.content)}
>
<div className="mb-7 flex justify-between">
<DialogPrimitive.Title>{title}</DialogPrimitive.Title>
<DialogPrimitive.Close>
<Image
onClick={() => setOpen(false)}
src="/component/close.svg"
alt="close"
width={30}
className="translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80"
height={30}
/>
</DialogPrimitive.Close>
</div>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View File

@ -0,0 +1,96 @@
'use client';
import { useControllableValue } from 'ahooks';
import React, { useState } from 'react';
import { cn } from '@/lib';
import Image from 'next/image';
type RateProps = {
readonly?: boolean;
// 0 - 10
value?: number;
onChange?: (value: number) => void;
} & React.HTMLAttributes<HTMLDivElement>;
export default function Rate(props: RateProps) {
const { readonly = false, ...others } = props;
const [value = 0, onChange] = useControllableValue(props);
const [hoveredValue, setHoveredValue] = useState(0);
const readonlyRender = () => {
// value 范围 0-10每颗星代表 2 分
const fullCounts = Math.floor(value / 2); // 满星数量
const halfCount = value % 2; // 半星数量0 或 1
const emptyCount = 5 - fullCounts - halfCount; // 空星数量
const starDoms = [
// 满星
...Array.from({ length: fullCounts }, (_, i) => (
<Image
key={`full-${i}`}
src={'/component/star_full.svg'}
width={20}
height={20}
alt="full star"
/>
)),
// 半星
...Array.from({ length: halfCount }, (_, i) => (
<Image
key={`half-${i}`}
src={'/component/star_half.svg'}
width={20}
height={20}
alt="half star"
/>
)),
// 空星
];
return (
<>
{starDoms}
<span className="font-normal" style={{ color: 'rgba(255, 225, 0, 1)' }}>
{value}
</span>
</>
);
};
const editRender = () => {
const finalValue = hoveredValue || value;
// 将 0-10 的值转换为 0-5 的星级
const starValue = finalValue / 2;
return Array.from({ length: 5 }, (_, index) => {
let src = '/component/star_empty.svg';
// 判断当前星星应该显示什么状态
if (index < Math.floor(starValue)) {
// 满星
src = '/component/star_full.svg';
}
return (
<Image
key={index}
src={src}
width={20}
height={20}
alt="star"
className="cursor-pointer"
onClick={() => onChange?.((index + 1) * 2)}
onMouseEnter={() => setHoveredValue((index + 1) * 2)}
/>
);
});
};
const render = readonly ? readonlyRender : editRender;
return (
<div
{...others}
onMouseLeave={() => setHoveredValue(0)}
className={cn('flex items-center gap-1', props.className)}
>
{render()}
</div>
);
}

76
src/components/ui/tag.tsx Normal file
View File

@ -0,0 +1,76 @@
'use client';
import { useControllableValue } from 'ahooks';
import { cn } from '@/lib';
type TagItem = {
label: string;
value: string;
};
type TagSelectProps = {
options?: {
label: string;
value: string;
}[];
readonly?: boolean;
mode?: 'single' | 'multiple';
value?: string | string[];
onChange?: (value: string | string[], option: TagItem | TagItem[]) => void;
render?: (option: TagItem, index: number) => React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
const TagSelect: React.FC<TagSelectProps> = (props) => {
const {
options = [],
readonly = false,
mode = 'single',
render = (o) => o.label,
...rest
} = props;
const [value, onChange] = useControllableValue(props, {
valuePropName: 'value',
defaultValuePropName: 'defaultValue',
trigger: 'onChange',
});
const selected =
mode === 'single' ? [value].filter(Boolean) : (value as string[]) || [];
const handleSelect = (option: TagItem) => {
if (readonly) return;
if (mode === 'single') {
onChange(option.value, option);
} else {
const newValues = selected.includes(option.value)
? selected.filter((v) => v !== option.value)
: [...selected, option.value];
const newOptions = selected.includes(option.value)
? selected.filter((v) => v !== option.value)
: [...selected, option.value];
onChange(newValues, newOptions);
}
};
return (
<div {...rest} className={cn('flex flex-wrap gap-2.5', rest.className)}>
{options.map((option, index) => {
return (
<div
className={cn(
'bg-text-color/20 text-text-color-1 flex h-7.5 items-center rounded-full px-2.5 py-1 text-sm',
selected?.includes(option.value) && 'text-text-color',
!readonly && 'hover:cursor-pointer'
)}
key={option.value}
onClick={() => handleSelect(option)}
>
{render(option, index)}
</div>
);
})}
</div>
);
};
export default TagSelect;

34
src/hooks/index.ts Normal file
View File

@ -0,0 +1,34 @@
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
type TranslatorFunction = ReturnType<typeof useTranslations>;
type TranslationValues = Parameters<TranslatorFunction>[1];
export const useMsg = (prefix: string) => {
const msg = useTranslations();
const pageMsg = useCallback(
(
key: string,
values?: TranslationValues
): ReturnType<TranslatorFunction> => {
return msg(`${prefix}_${key}`, values);
},
[msg, prefix]
);
return {
pageMsg,
msg,
};
};
export const useMounted = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted;
};

View File

@ -0,0 +1,48 @@
'use client';
import { NextIntlClientProvider } from 'next-intl';
import { createContext, useContext, useState, ReactNode } from 'react';
import zhMessages from '@/locales/zh';
import enMessages from '@/locales/en';
type Locale = 'zh' | 'en';
const messages: Record<Locale, any> = {
zh: zhMessages,
en: enMessages,
};
interface LocaleContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
}
const LocaleContext = createContext<LocaleContextType | undefined>(undefined);
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within IntlProvider');
}
return context;
}
interface IntlProviderProps {
children: ReactNode;
}
export function IntlProvider({ children }: IntlProviderProps) {
const [locale, setLocale] = useState<Locale>('zh');
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider
locale={locale}
messages={messages[locale]}
timeZone="Asia/Shanghai"
>
{children}
</NextIntlClientProvider>
</LocaleContext.Provider>
);
}

View File

@ -0,0 +1,30 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
// 在 Client Component 内部创建 QueryClient 实例
// 使用 useState 确保每个请求只创建一次实例
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 默认配置:数据在 5 分钟后过期
staleTime: 60 * 1000 * 5,
// 失败后不自动重试
retry: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -0,0 +1,15 @@
'use client';
import { IntlProvider } from './IntlProvider';
import { QueryProvider } from './QueryProvider';
export default function GlobalContainer({
children,
}: {
children: React.ReactNode;
}) {
return (
<IntlProvider>
<QueryProvider>{children}</QueryProvider>
</IntlProvider>
);
}

View File

@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { useMsg } from '@/hooks';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib';
import {
NovelIcon,
VideoIcon,
CharacterIcon,
RecordIcon,
} from '@/assets/common';
const NavRoutes = React.memo(() => {
const { pageMsg } = useMsg('menu');
const pathname = usePathname();
const routes = [
{
path: '/novel',
icon: NovelIcon,
label: pageMsg('novel'),
},
{
path: '/video',
icon: VideoIcon,
label: pageMsg('video'),
},
{
path: '/character',
icon: CharacterIcon,
label: pageMsg('character'),
},
{
path: '/record',
icon: RecordIcon,
label: pageMsg('record'),
},
];
return (
<div className="flex items-center gap-7">
{routes.map((route) => {
const isActive = pathname.startsWith(route.path);
return (
<Link
className={cn(
'flex gap-2',
isActive
? 'text-text-color'
: 'text-[#2C223F] hover:text-[#4F3F6D]'
)}
key={route.path}
href={route.path}
>
<route.icon />
{route.label}
</Link>
);
})}
</div>
);
});
const RightActions = () => {
return <div>Avator</div>;
};
export default function Header() {
return (
<div className="bg-header-bg flex h-15 w-full items-center justify-between px-10">
<NavRoutes />
<RightActions />
</div>
);
}

View File

@ -0,0 +1,21 @@
'use client';
import Header from './header';
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="h-screen w-screen overflow-auto">
<Header />
<div
style={{ height: 'calc(100vh - 60px)' }}
className="w-full overflow-auto"
>
{children}
</div>
</div>
);
}

138
src/lib/auth.ts Normal file
View File

@ -0,0 +1,138 @@
import Cookies from 'js-cookie';
const TOKEN_COOKIE_NAME = 'st';
const DEVICE_ID_COOKIE_NAME = 'sd';
// 生成设备ID的函数
function generateDeviceId(): string {
const timestamp = Date.now().toString(36);
const randomStr = Math.random().toString(36).substring(2, 15);
const browserInfo =
typeof window !== 'undefined'
? `${window.navigator.userAgent}${window.screen.width}${window.screen.height}`
.replace(/\s/g, '')
.substring(0, 10)
: 'server';
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase();
}
export const authManager = {
// 获取token - 支持客户端和服务端
getToken: (cookieString?: string): string | null => {
// 服务端环境从传入的cookie字符串中解析
if (typeof window === 'undefined' && cookieString) {
const cookies = parseCookieString(cookieString);
return cookies[TOKEN_COOKIE_NAME] || null;
}
// 客户端环境从document.cookie或localStorage获取
if (typeof window !== 'undefined') {
// 优先从cookie获取
const cookieToken = Cookies.get(TOKEN_COOKIE_NAME);
if (cookieToken) {
return cookieToken;
}
}
return null;
},
// 获取设备ID - 支持客户端和服务端
getDeviceId: (cookieString?: string): string => {
// 服务端环境从传入的cookie字符串中解析
if (typeof window === 'undefined' && cookieString) {
const cookies = parseCookieString(cookieString);
let deviceId = cookies[DEVICE_ID_COOKIE_NAME];
// 如果服务端没有设备ID生成一个临时的
if (!deviceId) {
deviceId = generateDeviceId();
}
return deviceId;
}
// 客户端环境
if (typeof window !== 'undefined') {
let deviceId = Cookies.get(DEVICE_ID_COOKIE_NAME);
// 如果没有设备ID生成一个新的
if (!deviceId) {
deviceId = generateDeviceId();
authManager.setDeviceId(deviceId);
}
return deviceId;
}
// 兜底情况生成临时设备ID
return generateDeviceId();
},
// 设置token
setToken: (token: string): void => {
if (typeof window !== 'undefined') {
// 设置cookie30天过期
Cookies.set(TOKEN_COOKIE_NAME, token, {
expires: 30,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
}
},
// 设置设备ID
setDeviceId: (deviceId: string): void => {
if (typeof window !== 'undefined') {
// 设置cookie365天过期设备ID应该长期保存
Cookies.set(DEVICE_ID_COOKIE_NAME, deviceId, {
expires: 365,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
}
},
// 清除token但保留设备ID
removeToken: (): void => {
if (typeof window !== 'undefined') {
Cookies.remove(TOKEN_COOKIE_NAME);
// 注意这里不清除设备ID
}
},
// 清除所有数据包括设备ID
clearAll: (): void => {
if (typeof window !== 'undefined') {
Cookies.remove(TOKEN_COOKIE_NAME);
Cookies.remove(DEVICE_ID_COOKIE_NAME);
}
},
// 检查是否已登录
isAuthenticated: (cookieString?: string): boolean => {
return !!authManager.getToken(cookieString);
},
// 初始化设备ID确保用户第一次访问时就有设备ID
initializeDeviceId: (): void => {
if (typeof window !== 'undefined') {
authManager.getDeviceId(); // 这会自动生成并保存设备ID如果不存在的话
}
},
};
// 解析cookie字符串的辅助函数
function parseCookieString(cookieString: string): Record<string, string> {
const cookies: Record<string, string> = {};
cookieString.split(';').forEach((cookie) => {
const [name, ...rest] = cookie.trim().split('=');
if (name) {
cookies[name] = rest.join('=');
}
});
return cookies;
}

7
src/lib/index.ts Normal file
View File

@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
// 合并类名
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

157
src/lib/request.ts Normal file
View File

@ -0,0 +1,157 @@
import axios, {
type AxiosInstance,
type CreateAxiosDefaults,
type AxiosRequestConfig,
} from 'axios';
import { authManager } from '@/lib/auth';
import type { ApiResponse } from '@/types/api';
import { API_STATUS, ApiError } from '@/types/api';
// 扩展 AxiosRequestConfig 以支持 ignoreError 选项
export interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
ignoreError?: boolean;
}
// // 扩展 AxiosInstance 类型以支持带有 ignoreError 的请求
// export interface ExtendedAxiosInstance extends AxiosInstance {
// post<T = any, R = T, D = any>(
// url: string,
// data?: D,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// get<T = any, R = T>(
// url: string,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// put<T = any, R = T, D = any>(
// url: string,
// data?: D,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// delete<T = any, R = T>(
// url: string,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// patch<T = any, R = T, D = any>(
// url: string,
// data?: D,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// }
export interface CreateHttpClientConfig extends CreateAxiosDefaults {
serviceName: string;
cookieString?: string; // 用于服务端渲染时传递cookie
showErrorToast?: boolean; // 是否自动显示错误提示默认为true
}
export function createHttpClient(config: CreateHttpClientConfig) {
const {
serviceName,
cookieString,
showErrorToast = true,
...axiosConfig
} = config;
const instance = axios.create({
baseURL: '/',
timeout: 120000,
headers: {
'Content-Type': 'application/json',
Platform: 'web',
},
...axiosConfig,
});
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// 获取token - 支持服务端和客户端
const token = authManager.getToken(cookieString);
if (token) {
// Java后端使用AUTH_TK字段接收token
config.headers['AUTH_TK'] = token;
}
// 获取设备ID - 支持服务端和客户端
const deviceId = authManager.getDeviceId(cookieString);
if (deviceId) {
// Java后端使用AUTH_DID字段接收设备ID
config.headers['AUTH_DID'] = deviceId;
}
// 添加服务标识
config.headers['X-Service'] = serviceName;
// 服务端渲染时传递cookie
if (typeof window === 'undefined' && cookieString) {
config.headers.Cookie = cookieString;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
instance.interceptors.response.use(
(response) => {
const apiResponse = response.data as ApiResponse;
// 检查业务状态
if (apiResponse.status === API_STATUS.OK) {
// 成功返回content内容
return apiResponse.content;
} else {
// 检查是否忽略错误
const ignoreError = (response.config as ExtendedAxiosRequestConfig)
?.ignoreError;
// 业务错误创建ApiError并抛出
const apiError = new ApiError(
apiResponse.errorCode,
apiResponse.errorMsg,
apiResponse.traceId,
!!ignoreError
);
// 错误提示由providers.tsx中的全局错误处理统一管理
// 这里不再直接显示toast避免重复弹窗
return Promise.reject(apiError);
}
},
(error) => {
// 检查是否忽略错误
const ignoreError = (error.config as ExtendedAxiosRequestConfig)
?.ignoreError;
// 网络错误或其他HTTP错误
let errorMessage = 'Network exception, please try again later';
let errorCode = 'NETWORK_ERROR';
// 创建标准化的错误对象
const traceId = error.response?.headers?.['x-trace-id'] || 'unknown';
const apiError = new ApiError(
errorCode,
errorMessage,
traceId,
!!ignoreError
);
return Promise.reject(apiError);
}
);
return instance;
}
// 创建不显示错误提示的HTTP客户端用于静默请求
export function createSilentHttpClient(serviceName: string) {
return createHttpClient({
serviceName,
showErrorToast: false,
});
}

6
src/locales/en.ts Normal file
View File

@ -0,0 +1,6 @@
export default {
menu_novel: 'Novels',
menu_video: 'Video Comics',
menu_character: 'Characters',
menu_record: 'Record',
};

6
src/locales/zh.ts Normal file
View File

@ -0,0 +1,6 @@
export default {
menu_novel: '小说',
menu_video: '视频',
menu_character: '角色',
menu_record: '记录',
};

65
src/types/api.ts Normal file
View File

@ -0,0 +1,65 @@
// 后端API响应的通用格式
export interface ApiResponse<T = any> {
content: T;
status: string;
errorCode: string;
errorMsg: string;
traceId: string;
}
// API错误类型
export class ApiError extends Error {
constructor(
public errorCode: string,
public errorMsg: string,
public traceId: string,
public ignoreError?: boolean
) {
super(errorMsg);
this.name = 'ApiError';
// 确保在多环境/继承链中 instanceof 正常工作
Object.setPrototypeOf(this, ApiError.prototype);
}
}
// 成功状态常量
export const API_STATUS = {
OK: 'OK',
} as const;
// NIM 消息发送错误类型,匹配 V2NIMError 结构
export interface NIMMessageSendErrorDetail {
/** 可能的详细错误描述 */
reason?: string;
/** 原始错误 */
rawError?: Error;
/** 请求返回的原始数据 */
rawData?: string;
/** 错误发生的时间 */
timetag?: number;
/** 命令类型 */
cmd?: string;
/** 其他详细信息 */
[key: string]: any;
}
export class NIMMessageSendError extends Error {
constructor(
public code: number,
public desc: string,
public detail?: NIMMessageSendErrorDetail
) {
super(desc);
this.name = 'NIMMessageSendError';
Object.setPrototypeOf(this, NIMMessageSendError.prototype);
}
// 从 V2NIMError 创建 NIMMessageSendError 实例的静态方法
static fromV2NIMError(error: any): NIMMessageSendError {
return new NIMMessageSendError(
error.code || 0,
error.desc || error.message || 'Unknown error',
error.detail
);
}
}

5
src/types/common.ts Normal file
View File

@ -0,0 +1,5 @@
export type IconProps = {
size?: number;
width?: number;
height?: number;
};

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}