├── .env.exmaple
├── .eslintrc.js
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .prettierignore
├── .prettierrc.js
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── commitlint.config.js
├── docs
├── banner.png
└── qrcode.png
├── index.html
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
└── favicon.svg
├── src
├── api
│ ├── app.ts
│ ├── billing.ts
│ ├── chat.ts
│ ├── salesman.ts
│ ├── task.ts
│ └── user.ts
├── assets
│ └── poster.png
├── components
│ ├── Icon.tsx
│ ├── Markdown.tsx
│ ├── SvgIcon.tsx
│ └── ui
│ │ ├── alert-dialog.tsx
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── radio-group.tsx
│ │ ├── scroll-area.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
├── constants.ts
├── hooks
│ ├── use-app-config.ts
│ ├── use-auth.ts
│ ├── use-mobile-code.ts
│ ├── use-mobile-screen.ts
│ ├── use-share-openid.ts
│ ├── use-task.tsx
│ └── use-wechat.ts
├── layout
│ ├── Header.tsx
│ ├── TabBar.tsx
│ ├── TitleHeader.tsx
│ └── index.tsx
├── locales
│ ├── en.json
│ ├── index.ts
│ └── zh.json
├── main.tsx
├── pages
│ ├── billing
│ │ ├── BillingRecords.tsx
│ │ ├── OfflinePayDialog.tsx
│ │ ├── PayDialog.tsx
│ │ └── index.tsx
│ ├── chat
│ │ ├── Chat.tsx
│ │ ├── ChatItem.tsx
│ │ ├── Conversation.tsx
│ │ ├── MessageExporter.tsx
│ │ ├── RoleListDialog.tsx
│ │ └── index.tsx
│ ├── error-page.tsx
│ ├── login
│ │ ├── LoginForm.tsx
│ │ ├── Protocol.tsx
│ │ ├── QrCodeDialog.tsx
│ │ └── index.tsx
│ ├── salesman
│ │ ├── ListScroll.tsx
│ │ ├── WithdrawalDialog.tsx
│ │ ├── WithdrawalListDialog.tsx
│ │ └── index.tsx
│ └── user
│ │ ├── ShareDialog.tsx
│ │ └── index.tsx
├── router
│ └── index.tsx
├── store
│ ├── app.ts
│ ├── billing.ts
│ ├── chat.ts
│ ├── index.ts
│ ├── salesman.ts
│ └── user.ts
├── styles
│ └── tailwind.less
└── utils
│ ├── index.ts
│ ├── request.ts
│ └── stream-api.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts
/.env.exmaple:
--------------------------------------------------------------------------------
1 | VITE_API_DOMAIN='http://xxxxx'
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: { browser: true, es2020: true, node: true },
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:prettier/recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | 'plugin:tailwindcss/recommended',
9 | ],
10 | parser: '@typescript-eslint/parser',
11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
12 | plugins: ['react-refresh', 'tailwindcss'],
13 | rules: {
14 | 'react-refresh/only-export-components': 'warn',
15 | 'react-hooks/exhaustive-deps': 'off',
16 | '@typescript-eslint/no-var-requires': 'off',
17 | 'tailwindcss/no-custom-classname': 'off',
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | !.vscode/extensions.json
17 | .idea
18 | .DS_Store
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 | .env
25 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install commitlint -e $GIT_PARAMS
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | cache
2 | .cache
3 | package.json
4 | package-lock.json
5 | public
6 | CHANGELOG.md
7 | .yarn
8 | dist
9 | node_modules
10 | .next
11 | build
12 | .contentlayer
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 120,
3 | tabWidth: 2,
4 | useTabs: false,
5 | semi: true,
6 | singleQuote: true,
7 | quoteProps: 'as-needed',
8 | jsxSingleQuote: false,
9 | trailingComma: 'all',
10 | bracketSpacing: true,
11 | jsxBracketSameLine: false,
12 | arrowParens: 'always',
13 | rangeStart: 0,
14 | rangeEnd: Infinity,
15 | requirePragma: false,
16 | insertPragma: false,
17 | proseWrap: 'preserve',
18 | htmlWhitespaceSensitivity: 'css',
19 | vueIndentScriptAndStyle: false,
20 | endOfLine: 'lf',
21 | };
22 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.format.enable": true,
3 | "eslint.validate": [
4 | "javascript",
5 | "javascriptreact",
6 | "typescript",
7 | "typescriptreact",
8 | ],
9 | "[vue]": {
10 | "editor.formatOnSave": true,
11 | "editor.defaultFormatter": "esbenp.prettier-vscode"
12 | },
13 | "[typescriptreact]": {
14 | "editor.formatOnSave": true,
15 | "editor.defaultFormatter": "esbenp.prettier-vscode"
16 | },
17 | "[javascriptreact]": {
18 | "editor.formatOnSave": true,
19 | "editor.defaultFormatter": "esbenp.prettier-vscode"
20 | },
21 | "[typescript]": {
22 | "editor.formatOnSave": true,
23 | "editor.defaultFormatter": "esbenp.prettier-vscode"
24 | },
25 | "[javascript]": {
26 | "editor.formatOnSave": true,
27 | "editor.defaultFormatter": "esbenp.prettier-vscode"
28 | }
29 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 GPTLink
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
GPTLink Web
3 |
只需简单几步,即可基于 GPTLink 快速搭建你的 ChatGPT 站点。
4 |
5 | [体验地址](https://gptlink-web.vercel.app) · [反馈](https://github.com/gptlink/gptlink-web/issues) · [微信加群](./docs/qrcode.png)
6 |
7 |

8 |
9 |
10 | ## 🎉 特性
11 |
12 | `GPTLink Web` 为 [gptlink](https://github.com/gptlink/gptlink) 项目用户端源码。可将此源码编译后后,替换 `gptlink/gptweb` 目录重新构建镜像,重新构建属于你的专属应用。
13 |
14 | - 接入 [GPTLink](https://gpt-link.com/) 接口, 内置丰富功能
15 | - 采用 `vite` + `react`, 开箱即用的极速开发体验
16 | - 基于 [Tailwind CSS](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) 生态,轻松定制 `UI`
17 |
18 | ## 📦 环境准备
19 |
20 | 1. `node` 建议使用 `^16 || ^18 || ^19` 版本
21 |
22 | 2. 安装 pnpm 包管理工具
23 |
24 | ```shell
25 | npm install pnpm -g
26 | ```
27 |
28 | ## 🔨使用
29 |
30 | - 获取项目代码
31 |
32 | ```shell
33 | git clone https://github.com/gptlink/gptlink-web
34 |
35 | cd gptlink-web
36 |
37 | * 安装依赖
38 | pnpm install
39 |
40 | # 本地调试,构建命令 pnpm run build
41 | pnpm run dev
42 | ```
43 |
44 | ## ✅ 版本计划
45 |
46 | - [x] 导出聊天分享图片
47 | - [x] 移动端适配
48 |
49 | ## 项目
50 |
51 | ### 项目配置
52 |
53 | 项目配置文件位于根目录 `.env` ,如若不存在此文件,将 `.env.example` 复制一份更名为 `.env` 作为配置项进行使用,详细配置如下:
54 |
55 | | 变量名称 | 示例 | 说明 |
56 | | --------------------- | --------------------------- | ---------------- |
57 | | VITE_APP_API_BASE_URL | `http://127.0.0.1` | 接口基础请求地址 |
58 |
59 | ## 参与贡献
60 |
61 | 贡献之前请先阅读 [贡献指南](https://github.com/gptlink/gptlink/blob/master/CONTRIBUTING.md)
62 |
63 | 我们深知这不是一个完美的产品,但是它只是一个开始,欢迎加入我们一起完善!
64 |
65 | ## License
66 |
67 | [MIT](./LICENSE)
68 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = { extends: ['@commitlint/config-conventional'] };
2 |
--------------------------------------------------------------------------------
/docs/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gptlink/gptlink-web/34fac1b8850e9cd2bc6979ec35800f7e6cd2b4bc/docs/banner.png
--------------------------------------------------------------------------------
/docs/qrcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gptlink/gptlink-web/34fac1b8850e9cd2bc6979ec35800f7e6cd2b4bc/docs/qrcode.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gptlink-web",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
9 | "preview": "vite preview",
10 | "prettier:write": "prettier --write \"**/*.{ts,tsx,html}\" --cache",
11 | "prepare": "husky install"
12 | },
13 | "dependencies": {
14 | "@iconify/react": "^4.1.0",
15 | "@radix-ui/react-alert-dialog": "^1.0.4",
16 | "@radix-ui/react-avatar": "^1.0.3",
17 | "@radix-ui/react-checkbox": "^1.0.4",
18 | "@radix-ui/react-dialog": "^1.0.4",
19 | "@radix-ui/react-dropdown-menu": "^2.0.5",
20 | "@radix-ui/react-label": "^2.0.1",
21 | "@radix-ui/react-radio-group": "^1.1.3",
22 | "@radix-ui/react-scroll-area": "^1.0.4",
23 | "@radix-ui/react-separator": "^1.0.3",
24 | "@radix-ui/react-slot": "^1.0.2",
25 | "@radix-ui/react-tabs": "^1.0.4",
26 | "@radix-ui/react-tooltip": "^1.0.6",
27 | "@types/js-cookie": "^3.0.3",
28 | "dayjs": "^1.11.7",
29 | "file-saver": "^2.0.5",
30 | "html-to-image": "^1.11.11",
31 | "i18next": "^22.5.0",
32 | "lodash-es": "^4.17.21",
33 | "lucide-react": "^0.233.0",
34 | "qrcode.react": "^3.1.0",
35 | "react": "^18.2.0",
36 | "react-dom": "^18.2.0",
37 | "react-hook-form": "^7.44.3",
38 | "react-hot-toast": "^2.4.1",
39 | "react-i18next": "^12.3.1",
40 | "react-markdown": "^8.0.7",
41 | "react-router-dom": "^6.11.2",
42 | "rehype-highlight": "^6.0.0",
43 | "rehype-katex": "^6.0.3",
44 | "rehype-raw": "^6.1.1",
45 | "remark-breaks": "^3.0.3",
46 | "remark-gfm": "^3.0.1",
47 | "remark-math": "^5.1.1",
48 | "tailwindcss": "^3.3.2",
49 | "tailwindcss-animate": "^1.0.5",
50 | "zustand": "^4.3.8"
51 | },
52 | "devDependencies": {
53 | "@commitlint/cli": "^17.6.5",
54 | "@commitlint/config-conventional": "^17.6.5",
55 | "@hookform/resolvers": "^3.1.0",
56 | "@tailwindcss/typography": "^0.5.9",
57 | "@types/file-saver": "^2.0.5",
58 | "@types/lodash-es": "^4.17.7",
59 | "@types/react": "^18.0.37",
60 | "@types/react-dom": "^18.0.11",
61 | "@types/uuid": "^9.0.2",
62 | "@typescript-eslint/eslint-plugin": "^5.59.0",
63 | "@typescript-eslint/parser": "^5.59.0",
64 | "@vitejs/plugin-react-swc": "^3.0.0",
65 | "autoprefixer": "^10.4.14",
66 | "class-variance-authority": "^0.6.0",
67 | "classnames": "^2.3.2",
68 | "clsx": "^1.2.1",
69 | "commitizen": "^4.3.0",
70 | "cz-conventional-changelog": "^3.3.0",
71 | "eslint": "^8.38.0",
72 | "eslint-config-prettier": "^8.8.0",
73 | "eslint-plugin-prettier": "^4.2.1",
74 | "eslint-plugin-react": "^7.32.2",
75 | "eslint-plugin-react-hooks": "^4.6.0",
76 | "eslint-plugin-react-refresh": "^0.3.4",
77 | "eslint-plugin-tailwindcss": "^3.12.1",
78 | "husky": "^8.0.3",
79 | "less": "^4.1.3",
80 | "lint-staged": "^13.2.2",
81 | "postcss": "^8.4.24",
82 | "react-infinite-scroll-component": "^6.1.0",
83 | "tailwind-merge": "^1.13.0",
84 | "typescript": "^5.0.2",
85 | "uuid": "^9.0.0",
86 | "vite": "^4.3.9",
87 | "weixin-js-sdk-ts": "^1.6.1",
88 | "zod": "^3.21.4"
89 | },
90 | "config": {
91 | "commitizen": {
92 | "path": "./node_modules/cz-conventional-changelog"
93 | }
94 | },
95 | "lint-staged": {
96 | "*.{js,jsx,ts,tsx}": [
97 | "npm run prettier:write",
98 | "npm run lint --fix",
99 | "git add ."
100 | ]
101 | }
102 | }
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/api/app.ts:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request';
2 | import wx from 'weixin-js-sdk-ts';
3 |
4 | export interface ConfigAgreementType {
5 | title: string;
6 | agreement: string;
7 | enable: boolean;
8 | }
9 |
10 | export enum LoginTypeEnum {
11 | PASSWORD = '1',
12 | WECHAT = '2',
13 | PHONE = '3',
14 | WECHAT_AND_PHONE = '4',
15 | }
16 |
17 | export interface AppConfigInterface {
18 | name: string;
19 | icp: string;
20 | web_logo: string;
21 | admin_logo: string;
22 | user_logo: string;
23 | }
24 |
25 | export interface LoginTypeInterface {
26 | login_type: LoginTypeEnum;
27 | mobile_verify: boolean;
28 | }
29 |
30 | export enum PaymentChannelEnum {
31 | OFFLINE = 'offline',
32 | WECHAT = 'wechat',
33 | }
34 |
35 | export interface PaymentConfigType {
36 | channel: PaymentChannelEnum;
37 | offline: string;
38 | }
39 |
40 | export interface AppConfigType extends LoginTypeInterface, AppConfigInterface, PaymentConfigType {}
41 |
42 | export interface JsSDKType {
43 | debug: boolean;
44 | beta: boolean;
45 | jsApiList: wx.jsApiList;
46 | openTagList: wx.openTagList;
47 | appId: string;
48 | nonceStr: string;
49 | timestamp: number;
50 | url: string;
51 | signature: string;
52 | }
53 |
54 | export interface ShareConfigType {
55 | desc: string;
56 | title: string;
57 | img_url: string;
58 | share_img: string;
59 | }
60 |
61 | export default {
62 | getConfigAgreement(): Promise {
63 | return request('config/agreement');
64 | },
65 | getAppConfig(): Promise {
66 | return request('config/basic-info');
67 | },
68 | getLoginType(): Promise {
69 | return request('config/login-type');
70 | },
71 | getJsSDK(url: string): Promise<{
72 | data: JsSDKType;
73 | }> {
74 | return request(`wechat/jssdk?url=${encodeURIComponent(url)}`);
75 | },
76 | getShareConfig(): Promise {
77 | return request('config/share');
78 | },
79 | getPaymentConfig(): Promise {
80 | return request('config/payment');
81 | },
82 | };
83 |
--------------------------------------------------------------------------------
/src/api/billing.ts:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request';
2 |
3 | export enum Channel {
4 | WECHAT = 'wechat',
5 | ALI_PAY = 'alipay',
6 | }
7 |
8 | export enum PayStatus {
9 | PENDING = 1,
10 | SUCCESS = 2,
11 | }
12 |
13 | export enum PayType {
14 | JSAPI = 'JSAPI',
15 | NATIVE = 'NATIVE',
16 | }
17 |
18 | export interface ExchangeRedemptionCodeType {
19 | id: number;
20 | name: string;
21 | type: number;
22 | expired_day: number;
23 | num: number;
24 | price: number;
25 | }
26 |
27 | export interface PackageType {
28 | id: number;
29 | name: string;
30 | price: number;
31 | identity: number;
32 | }
33 |
34 | export interface PayInfoType {
35 | id: number;
36 | price: string;
37 | channel: Channel;
38 | pay_type: PayType;
39 | package_name: string;
40 | data: {
41 | code_url: string;
42 | appId: string;
43 | timeStamp: string;
44 | nonceStr: string;
45 | signType: string;
46 | paySign: string;
47 | package: string;
48 | };
49 | }
50 |
51 | export interface OrderBillingType {
52 | id: number;
53 | package_id: number;
54 | channel: Channel;
55 | pay_type: PayType;
56 | user_id: string;
57 | status: PayStatus;
58 | platform: string;
59 | business_id: string;
60 | }
61 |
62 | export interface BillingDetailType {
63 | id: number;
64 | trade_no: string;
65 | user_id: number;
66 | status: number;
67 | }
68 |
69 | export default {
70 | getBillingPackage(): Promise {
71 | return request('package');
72 | },
73 | exchangeRedemptionCode(cdk: string): Promise {
74 | return request('cdk', {
75 | method: 'post',
76 | body: JSON.stringify({ cdk }),
77 | });
78 | },
79 | orderBilling(data: {
80 | channel: Channel;
81 | package_id: number;
82 | pay_type: PayType;
83 | platform: number;
84 | }): Promise {
85 | return request('order', {
86 | method: 'post',
87 | body: JSON.stringify(data),
88 | });
89 | },
90 | billingPayDetail(id: number): Promise {
91 | return request(`order/${id}/pay`);
92 | },
93 | billingDetail(id: number): Promise {
94 | return request(`order/${id}`);
95 | },
96 | };
97 |
--------------------------------------------------------------------------------
/src/api/chat.ts:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request';
2 |
3 | export interface RoleType {
4 | id: string;
5 | icon: string;
6 | name: string;
7 | prompt: string;
8 | desc: string;
9 | }
10 |
11 | export default {
12 | getRoleList(): Promise {
13 | return request(`chat-gpt-model?platform=1&is_all=true`);
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/src/api/salesman.ts:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request';
2 |
3 | export interface SalesmanChildType {
4 | nickname?: string;
5 | order_price: string;
6 | order_num: number;
7 | created_at: string;
8 | }
9 |
10 | export interface SalesmanOrderType {
11 | price: string;
12 | created_at: string;
13 | custom: {
14 | nickname: string;
15 | };
16 | }
17 |
18 | export interface SalesmanStatisticsType {
19 | order_num: number;
20 | order_price: string;
21 | custom_num: number;
22 | balance: string;
23 | ratio: number;
24 | }
25 |
26 | export interface SalesmanWithdrawalLastType {
27 | config: {
28 | name: string;
29 | account: string;
30 | };
31 | }
32 |
33 | export interface SalesmanWithdrawalType {
34 | id: number;
35 | serial_no: string;
36 | price: string;
37 | status: number;
38 | paid_no: string;
39 | user_id: number;
40 | created_at: string;
41 | }
42 |
43 | export default {
44 | getSalesmanChildList(per_page?: number, page?: number): Promise {
45 | return request(`/salesman/child?per_page=${per_page}&page=${page}`);
46 | },
47 | getSalesmanOrderList(per_page?: number, page?: number): Promise {
48 | return request(`/salesman/order?per_page=${per_page}&page=${page}`);
49 | },
50 | getSalesmanStatistics(): Promise {
51 | return request(`/salesman/statistics`);
52 | },
53 | withdrawal(data: { price: string; channel: string; config: { account: string; name: string } }): Promise {
54 | return request(`/salesman/withdrawal/apply`, {
55 | method: 'post',
56 | body: JSON.stringify(data),
57 | });
58 | },
59 | getSalesmanWithdrawalLast(): Promise {
60 | return request(`/salesman/withdrawal/last`);
61 | },
62 | getSalesmanWithdrawalList(per_page?: number, page?: number): Promise {
63 | return request(`/salesman/withdrawal?per_page=${per_page}&page=${page}`);
64 | },
65 | };
66 |
--------------------------------------------------------------------------------
/src/api/task.ts:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request';
2 |
3 | export enum TaskTypeEnums {
4 | REGISTER = 'register',
5 | INVITE = 'invite',
6 | SHARE = 'share',
7 | SALESMAN = 'salesman',
8 | }
9 |
10 | export interface TaskType {
11 | id: number;
12 | type: TaskTypeEnums;
13 | title: string;
14 | desc: string;
15 | is_completed: boolean;
16 | is_subscribe: boolean;
17 | model_count: 0;
18 | }
19 |
20 | export interface SalesmanConfigType {
21 | enable: boolean;
22 | open: boolean;
23 | rules: string;
24 | }
25 |
26 | export default {
27 | getTaskList(platform: number): Promise {
28 | return request(`task?platform=${platform}`);
29 | },
30 | checkTask(type: TaskTypeEnums): Promise<{ result: boolean }> {
31 | return request('task/check', { method: 'post', body: JSON.stringify({ type }) });
32 | },
33 | completionTask(type: TaskTypeEnums): Promise<{ result: boolean }> {
34 | return request('task/completion', { method: 'post', body: JSON.stringify({ type }) });
35 | },
36 | getUnreadTaskList(type: TaskTypeEnums): Promise<{
37 | type: TaskTypeEnums;
38 | expired_day: number;
39 | package_name: string;
40 | num: number;
41 | record_count: number;
42 | }> {
43 | return request(`/task/record/unread?type=${type}`);
44 | },
45 | readTask(type: TaskTypeEnums): Promise<{
46 | type: TaskTypeEnums;
47 | expired_day: string;
48 | package_name: string;
49 | num: number;
50 | record_count: number;
51 | }> {
52 | return request(`task/record/${type}/read`, { method: 'put' });
53 | },
54 | getSalesmanConfig(): Promise {
55 | return request(`config/salesman`);
56 | },
57 | };
58 |
--------------------------------------------------------------------------------
/src/api/user.ts:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request';
2 |
3 | export interface BillType {
4 | name: string;
5 | num: number;
6 | used: number;
7 | expired_at?: string;
8 | }
9 |
10 | export interface UserPackageType {
11 | num: number;
12 | package_name: string;
13 | created_at: string;
14 | type: number;
15 | expired_day: number;
16 | }
17 |
18 | export interface UserInfoType {
19 | nickname: string;
20 | avatar: string;
21 | identity: number;
22 | openid: string;
23 | }
24 |
25 | export interface UserInfoByCodeType {
26 | oauth_id: string;
27 | user: UserInfoType;
28 | access_token: string;
29 | }
30 |
31 | export interface WxQrCodeType {
32 | qr_code_url: string;
33 | }
34 |
35 | export default {
36 | getWxQrCode(type: string, redirectUrl: string): Promise {
37 | return request(`wechat/${type}/qrcode?type=${type}&redirect_url=${redirectUrl}`);
38 | },
39 | getUserInfoByCode(type: string, code: string, shareOpenid: string): Promise {
40 | return request(`wechat/${type}/login`, {
41 | method: 'post',
42 | body: JSON.stringify({ code, type, share_openid: shareOpenid }),
43 | });
44 | },
45 | getUserProfile(): Promise {
46 | return request('user/profile');
47 | },
48 | getUserBill(): Promise {
49 | return request('user/bill-package');
50 | },
51 | getUserPackages(): Promise {
52 | return request('user/package/record');
53 | },
54 | register(data: { nickname: string; mobile: string; password: string; share_openid: string; code: string }): Promise<{
55 | user: UserInfoType;
56 | access_token: string;
57 | }> {
58 | return request('auth/register', {
59 | method: 'post',
60 | body: JSON.stringify(data),
61 | });
62 | },
63 | login(data: { nickname: string; password: string }): Promise<{ user: UserInfoType; access_token: string }> {
64 | return request('auth/login', {
65 | method: 'post',
66 | body: JSON.stringify(data),
67 | });
68 | },
69 | getPhoneCode(mobile: string): Promise<{ data: string }> {
70 | return request('sms/send-code', {
71 | method: 'post',
72 | body: JSON.stringify({ mobile }),
73 | });
74 | },
75 | phoneLogin(data: {
76 | mobile: string;
77 | code: string;
78 | oauth_id: string;
79 | share_openid: string;
80 | }): Promise<{ user: UserInfoType; access_token: string }> {
81 | return request('sms/login', {
82 | method: 'post',
83 | body: JSON.stringify(data),
84 | });
85 | },
86 | resetPassword(data: {
87 | nickname: string;
88 | password: string;
89 | mobile: string;
90 | reenteredPassword: string;
91 | verify: string;
92 | verify_type: number;
93 | code: string;
94 | }): Promise {
95 | return request('auth/reset', {
96 | method: 'post',
97 | body: JSON.stringify(data),
98 | });
99 | },
100 | becomeSalesman(): Promise {
101 | return request('/user/salesman', {
102 | method: 'post',
103 | });
104 | },
105 | };
106 |
--------------------------------------------------------------------------------
/src/assets/poster.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gptlink/gptlink-web/34fac1b8850e9cd2bc6979ec35800f7e6cd2b4bc/src/assets/poster.png
--------------------------------------------------------------------------------
/src/components/Icon.tsx:
--------------------------------------------------------------------------------
1 | const Icon = ({ className = '' }) => {
2 | return (
3 |
11 | );
12 | };
13 |
14 | export default Icon;
15 |
--------------------------------------------------------------------------------
/src/components/Markdown.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, ReactNode } from 'react';
2 | import { Copy } from 'lucide-react';
3 | import toast from 'react-hot-toast';
4 | import ReactMarkdown from 'react-markdown';
5 | import RehypeHighlight from 'rehype-highlight';
6 | import RehypeKatex from 'rehype-katex';
7 | import RemarkBreaks from 'remark-breaks';
8 | import RemarkGfm from 'remark-gfm';
9 | import RemarkMath from 'remark-math';
10 | import rehypeRaw from 'rehype-raw';
11 |
12 | import { Button } from '@/components/ui/button';
13 | import { copyToClipboard } from '@/utils';
14 |
15 | export function PreCode(props: { children: ReactNode }) {
16 | const ref = useRef(null);
17 |
18 | return (
19 |
20 |
34 | {props.children}
35 |
36 | );
37 | }
38 |
39 | export function Markdown(props: { content: string }) {
40 | return (
41 |
46 | {props.content}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/SvgIcon.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react';
2 |
3 | const SvgIcon = ({ icon = '', className = '' }) => {
4 | return ;
5 | };
6 |
7 | export default SvgIcon;
8 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
5 |
6 | import { cn } from '@/utils';
7 | import { buttonVariants } from '@/components/ui/button';
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root;
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
12 |
13 | const AlertDialogPortal = ({ className, children, ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => (
14 |
15 | {children}
16 |
17 | );
18 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
19 |
20 | const AlertDialogOverlay = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, ...props }, ref) => (
24 |
32 | ));
33 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
34 |
35 | const AlertDialogContent = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
40 |
41 |
49 |
50 | ));
51 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
52 |
53 | const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
54 |
55 | );
56 | AlertDialogHeader.displayName = 'AlertDialogHeader';
57 |
58 | const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
59 |
60 | );
61 | AlertDialogFooter.displayName = 'AlertDialogFooter';
62 |
63 | const AlertDialogTitle = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, ...props }, ref) => (
67 |
68 | ));
69 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
70 |
71 | const AlertDialogDescription = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
76 | ));
77 | AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
78 |
79 | const AlertDialogAction = React.forwardRef<
80 | React.ElementRef,
81 | React.ComponentPropsWithoutRef
82 | >(({ className, ...props }, ref) => (
83 |
84 | ));
85 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
86 |
87 | const AlertDialogCancel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => (
91 |
96 | ));
97 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
98 |
99 | export {
100 | AlertDialog,
101 | AlertDialogTrigger,
102 | AlertDialogContent,
103 | AlertDialogHeader,
104 | AlertDialogFooter,
105 | AlertDialogTitle,
106 | AlertDialogDescription,
107 | AlertDialogAction,
108 | AlertDialogCancel,
109 | };
110 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
5 |
6 | import { cn } from '@/utils';
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
17 | ));
18 | Avatar.displayName = AvatarPrimitive.Root.displayName;
19 |
20 | const AvatarImage = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, ...props }, ref) => (
24 |
25 | ));
26 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
27 |
28 | const AvatarFallback = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
37 | ));
38 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
39 |
40 | export { Avatar, AvatarImage, AvatarFallback };
41 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-refresh/only-export-components */
2 | import * as React from 'react';
3 | import { Slot } from '@radix-ui/react-slot';
4 | import { cva, type VariantProps } from 'class-variance-authority';
5 |
6 | import { cn } from '@/utils';
7 |
8 | const buttonVariants = cva(
9 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
10 | {
11 | variants: {
12 | variant: {
13 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
14 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
15 | outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
16 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
17 | ghost: 'hover:bg-accent hover:text-accent-foreground',
18 | link: 'text-primary underline-offset-4 hover:underline',
19 | },
20 | size: {
21 | default: 'h-10 px-4 py-2',
22 | sm: 'h-9 rounded-md px-3',
23 | lg: 'h-11 rounded-md px-8',
24 | },
25 | },
26 | defaultVariants: {
27 | variant: 'default',
28 | size: 'default',
29 | },
30 | },
31 | );
32 |
33 | export interface ButtonProps
34 | extends React.ButtonHTMLAttributes,
35 | VariantProps {
36 | asChild?: boolean;
37 | }
38 |
39 | const Button = React.forwardRef(
40 | ({ className, variant, size, asChild = false, ...props }, ref) => {
41 | const Comp = asChild ? Slot : 'button';
42 | return ;
43 | },
44 | );
45 | Button.displayName = 'Button';
46 |
47 | export { Button, buttonVariants };
48 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/utils';
4 |
5 | const Card = React.forwardRef>(({ className, ...props }, ref) => (
6 |
7 | ));
8 | Card.displayName = 'Card';
9 |
10 | const CardHeader = React.forwardRef>(
11 | ({ className, ...props }, ref) => (
12 |
13 | ),
14 | );
15 | CardHeader.displayName = 'CardHeader';
16 |
17 | const CardTitle = React.forwardRef>(
18 | ({ className, ...props }, ref) => (
19 |
20 | ),
21 | );
22 | CardTitle.displayName = 'CardTitle';
23 |
24 | const CardDescription = React.forwardRef>(
25 | ({ className, ...props }, ref) => (
26 |
27 | ),
28 | );
29 | CardDescription.displayName = 'CardDescription';
30 |
31 | const CardContent = React.forwardRef>(
32 | ({ className, ...props }, ref) => ,
33 | );
34 | CardContent.displayName = 'CardContent';
35 |
36 | const CardFooter = React.forwardRef>(
37 | ({ className, ...props }, ref) => (
38 |
39 | ),
40 | );
41 | CardFooter.displayName = 'CardFooter';
42 |
43 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
44 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
5 | import { Check } from 'lucide-react';
6 |
7 | import { cn } from '@/utils';
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
22 |
23 |
24 |
25 | ));
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
27 |
28 | export { Checkbox };
29 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as DialogPrimitive from '@radix-ui/react-dialog';
5 | import { X } from 'lucide-react';
6 |
7 | import { cn } from '@/utils';
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = ({ className, children, ...props }: DialogPrimitive.DialogPortalProps) => (
14 |
15 | {children}
16 |
17 | );
18 | DialogPortal.displayName = DialogPrimitive.Portal.displayName;
19 |
20 | const DialogOverlay = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, ...props }, ref) => (
24 |
32 | ));
33 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
34 |
35 | const DialogContent = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, children, ...props }, ref) => (
39 |
40 |
41 |
49 | {children}
50 |
51 |
52 | Close
53 |
54 |
55 |
56 | ));
57 | DialogContent.displayName = DialogPrimitive.Content.displayName;
58 |
59 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
60 |
61 | );
62 | DialogHeader.displayName = 'DialogHeader';
63 |
64 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
65 |
66 | );
67 | DialogFooter.displayName = 'DialogFooter';
68 |
69 | const DialogTitle = React.forwardRef<
70 | React.ElementRef,
71 | React.ComponentPropsWithoutRef
72 | >(({ className, ...props }, ref) => (
73 |
78 | ));
79 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
80 |
81 | const DialogDescription = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
86 | ));
87 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
88 |
89 | export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
90 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
5 | import { Check, ChevronRight, Circle } from 'lucide-react';
6 |
7 | import { cn } from '@/utils';
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
41 |
42 | const DropdownMenuSubContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, ...props }, ref) => (
46 |
54 | ));
55 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
71 |
72 | ));
73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
74 |
75 | const DropdownMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean;
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ));
91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
92 |
93 | const DropdownMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |
107 |
108 |
109 |
110 |
111 | {children}
112 |
113 | ));
114 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
115 |
116 | const DropdownMenuRadioItem = React.forwardRef<
117 | React.ElementRef,
118 | React.ComponentPropsWithoutRef
119 | >(({ className, children, ...props }, ref) => (
120 |
128 |
129 |
130 |
131 |
132 |
133 | {children}
134 |
135 | ));
136 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
137 |
138 | const DropdownMenuLabel = React.forwardRef<
139 | React.ElementRef,
140 | React.ComponentPropsWithoutRef & {
141 | inset?: boolean;
142 | }
143 | >(({ className, inset, ...props }, ref) => (
144 |
149 | ));
150 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
151 |
152 | const DropdownMenuSeparator = React.forwardRef<
153 | React.ElementRef,
154 | React.ComponentPropsWithoutRef
155 | >(({ className, ...props }, ref) => (
156 |
157 | ));
158 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
159 |
160 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
161 | return ;
162 | };
163 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
164 |
165 | export {
166 | DropdownMenu,
167 | DropdownMenuTrigger,
168 | DropdownMenuContent,
169 | DropdownMenuItem,
170 | DropdownMenuCheckboxItem,
171 | DropdownMenuRadioItem,
172 | DropdownMenuLabel,
173 | DropdownMenuSeparator,
174 | DropdownMenuShortcut,
175 | DropdownMenuGroup,
176 | DropdownMenuPortal,
177 | DropdownMenuSub,
178 | DropdownMenuSubContent,
179 | DropdownMenuSubTrigger,
180 | DropdownMenuRadioGroup,
181 | };
182 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as LabelPrimitive from '@radix-ui/react-label';
3 | import { Slot } from '@radix-ui/react-slot';
4 | import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form';
5 |
6 | import { cn } from '@/utils';
7 | import { Label } from '@/components/ui/label';
8 |
9 | const Form = FormProvider;
10 |
11 | type FormFieldContextValue<
12 | TFieldValues extends FieldValues = FieldValues,
13 | TName extends FieldPath = FieldPath,
14 | > = {
15 | name: TName;
16 | };
17 |
18 | const FormFieldContext = React.createContext({} as FormFieldContextValue);
19 |
20 | const FormField = <
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath,
23 | >({
24 | ...props
25 | }: ControllerProps) => {
26 | return (
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | const useFormField = () => {
34 | const fieldContext = React.useContext(FormFieldContext);
35 | const itemContext = React.useContext(FormItemContext);
36 | const { getFieldState, formState } = useFormContext();
37 |
38 | const fieldState = getFieldState(fieldContext.name, formState);
39 |
40 | if (!fieldContext) {
41 | throw new Error('useFormField should be used within ');
42 | }
43 |
44 | const { id } = itemContext;
45 |
46 | return {
47 | id,
48 | name: fieldContext.name,
49 | formItemId: `${id}-form-item`,
50 | formDescriptionId: `${id}-form-item-description`,
51 | formMessageId: `${id}-form-item-message`,
52 | ...fieldState,
53 | };
54 | };
55 |
56 | type FormItemContextValue = {
57 | id: string;
58 | };
59 |
60 | const FormItemContext = React.createContext({} as FormItemContextValue);
61 |
62 | const FormItem = React.forwardRef>(
63 | ({ className, ...props }, ref) => {
64 | const id = React.useId();
65 |
66 | return (
67 |
68 |
69 |
70 | );
71 | },
72 | );
73 | FormItem.displayName = 'FormItem';
74 |
75 | const FormLabel = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef
78 | >(({ className, ...props }, ref) => {
79 | const { error, formItemId } = useFormField();
80 |
81 | return ;
82 | });
83 | FormLabel.displayName = 'FormLabel';
84 |
85 | const FormControl = React.forwardRef, React.ComponentPropsWithoutRef>(
86 | ({ ...props }, ref) => {
87 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
88 |
89 | return (
90 |
97 | );
98 | },
99 | );
100 | FormControl.displayName = 'FormControl';
101 |
102 | const FormDescription = React.forwardRef>(
103 | ({ className, ...props }, ref) => {
104 | const { formDescriptionId } = useFormField();
105 |
106 | return ;
107 | },
108 | );
109 | FormDescription.displayName = 'FormDescription';
110 |
111 | const FormMessage = React.forwardRef>(
112 | ({ className, children, ...props }, ref) => {
113 | const { error, formMessageId } = useFormField();
114 | const body = error ? String(error?.message) : children;
115 |
116 | if (!body) {
117 | return null;
118 | }
119 |
120 | return (
121 |
122 | {body}
123 |
124 | );
125 | },
126 | );
127 | FormMessage.displayName = 'FormMessage';
128 |
129 | // eslint-disable-next-line react-refresh/only-export-components
130 | export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
131 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/utils';
4 |
5 | export type InputProps = React.InputHTMLAttributes;
6 |
7 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
8 | return (
9 |
18 | );
19 | });
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as LabelPrimitive from '@radix-ui/react-label';
5 | import { cva, type VariantProps } from 'class-variance-authority';
6 |
7 | import { cn } from '@/utils';
8 |
9 | const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70');
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef & VariantProps
14 | >(({ className, ...props }, ref) => (
15 |
16 | ));
17 | Label.displayName = LabelPrimitive.Root.displayName;
18 |
19 | export { Label };
20 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
5 | import { Circle } from 'lucide-react';
6 |
7 | import { cn } from '@/utils';
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return ;
14 | });
15 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
16 |
17 | const RadioGroupItem = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => {
21 | return (
22 |
30 |
31 |
32 |
33 |
34 | );
35 | });
36 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
37 |
38 | export { RadioGroup, RadioGroupItem };
39 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
5 |
6 | import { cn } from '@/utils';
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
13 | {children}
14 |
15 |
16 |
17 | ));
18 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
19 |
20 | const ScrollBar = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, orientation = 'vertical', ...props }, ref) => (
24 |
35 |
36 |
37 | ));
38 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
39 |
40 | export { ScrollArea, ScrollBar };
41 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as SeparatorPrimitive from '@radix-ui/react-separator';
5 |
6 | import { cn } from '@/utils';
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
12 |
19 | ));
20 | Separator.displayName = SeparatorPrimitive.Root.displayName;
21 |
22 | export { Separator };
23 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as SheetPrimitive from '@radix-ui/react-dialog';
5 | import { VariantProps, cva } from 'class-variance-authority';
6 | import { X } from 'lucide-react';
7 |
8 | import { cn } from '@/utils';
9 |
10 | const Sheet = SheetPrimitive.Root;
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger;
13 |
14 | const portalVariants = cva('fixed inset-0 z-50 flex', {
15 | variants: {
16 | position: {
17 | top: 'items-start',
18 | bottom: 'items-end',
19 | left: 'justify-start',
20 | right: 'justify-end',
21 | },
22 | },
23 | defaultVariants: { position: 'right' },
24 | });
25 |
26 | interface SheetPortalProps extends SheetPrimitive.DialogPortalProps, VariantProps {}
27 |
28 | const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => (
29 |
30 | {children}
31 |
32 | );
33 | SheetPortal.displayName = SheetPrimitive.Portal.displayName;
34 |
35 | const SheetOverlay = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ));
48 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
49 |
50 | const sheetVariants = cva('fixed z-50 scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg', {
51 | variants: {
52 | position: {
53 | top: 'w-full animate-in slide-in-from-top duration-300',
54 | bottom: 'w-full animate-in slide-in-from-bottom duration-300',
55 | left: 'h-full animate-in slide-in-from-left duration-300',
56 | right: 'h-full animate-in slide-in-from-right duration-300',
57 | },
58 | size: {
59 | content: '',
60 | default: '',
61 | sm: '',
62 | lg: '',
63 | xl: '',
64 | full: '',
65 | },
66 | },
67 | compoundVariants: [
68 | {
69 | position: ['top', 'bottom'],
70 | size: 'content',
71 | class: 'max-h-screen',
72 | },
73 | {
74 | position: ['top', 'bottom'],
75 | size: 'default',
76 | class: 'h-1/3',
77 | },
78 | {
79 | position: ['top', 'bottom'],
80 | size: 'sm',
81 | class: 'h-1/4',
82 | },
83 | {
84 | position: ['top', 'bottom'],
85 | size: 'lg',
86 | class: 'h-1/2',
87 | },
88 | {
89 | position: ['top', 'bottom'],
90 | size: 'xl',
91 | class: 'h-5/6',
92 | },
93 | {
94 | position: ['top', 'bottom'],
95 | size: 'full',
96 | class: 'h-screen',
97 | },
98 | {
99 | position: ['right', 'left'],
100 | size: 'content',
101 | class: 'max-w-screen',
102 | },
103 | {
104 | position: ['right', 'left'],
105 | size: 'default',
106 | class: 'w-1/3',
107 | },
108 | {
109 | position: ['right', 'left'],
110 | size: 'sm',
111 | class: 'w-1/4',
112 | },
113 | {
114 | position: ['right', 'left'],
115 | size: 'lg',
116 | class: 'w-1/2',
117 | },
118 | {
119 | position: ['right', 'left'],
120 | size: 'xl',
121 | class: 'w-5/6',
122 | },
123 | {
124 | position: ['right', 'left'],
125 | size: 'full',
126 | class: 'w-screen',
127 | },
128 | ],
129 | defaultVariants: {
130 | position: 'right',
131 | size: 'default',
132 | },
133 | });
134 |
135 | export interface DialogContentProps
136 | extends React.ComponentPropsWithoutRef,
137 | VariantProps {
138 | showClose?: boolean;
139 | }
140 |
141 | const SheetContent = React.forwardRef, DialogContentProps>(
142 | ({ position, size, showClose = true, className, children, ...props }, ref) => (
143 |
144 |
145 |
146 | {children}
147 | {showClose && (
148 |
149 |
150 | Close
151 |
152 | )}
153 |
154 |
155 | ),
156 | );
157 | SheetContent.displayName = SheetPrimitive.Content.displayName;
158 |
159 | const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => (
160 |
161 | );
162 | SheetHeader.displayName = 'SheetHeader';
163 |
164 | const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => (
165 |
166 | );
167 | SheetFooter.displayName = 'SheetFooter';
168 |
169 | const SheetTitle = React.forwardRef<
170 | React.ElementRef,
171 | React.ComponentPropsWithoutRef
172 | >(({ className, ...props }, ref) => (
173 |
174 | ));
175 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
176 |
177 | const SheetDescription = React.forwardRef<
178 | React.ElementRef,
179 | React.ComponentPropsWithoutRef
180 | >(({ className, ...props }, ref) => (
181 |
182 | ));
183 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
184 |
185 | export { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
186 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/utils';
4 |
5 | const Table = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
10 | ),
11 | );
12 | Table.displayName = 'Table';
13 |
14 | const TableHeader = React.forwardRef>(
15 | ({ className, ...props }, ref) => ,
16 | );
17 | TableHeader.displayName = 'TableHeader';
18 |
19 | const TableBody = React.forwardRef>(
20 | ({ className, ...props }, ref) => (
21 |
22 | ),
23 | );
24 | TableBody.displayName = 'TableBody';
25 |
26 | const TableFooter = React.forwardRef>(
27 | ({ className, ...props }, ref) => (
28 |
29 | ),
30 | );
31 | TableFooter.displayName = 'TableFooter';
32 |
33 | const TableRow = React.forwardRef>(
34 | ({ className, ...props }, ref) => (
35 |
40 | ),
41 | );
42 | TableRow.displayName = 'TableRow';
43 |
44 | const TableHead = React.forwardRef>(
45 | ({ className, ...props }, ref) => (
46 | |
54 | ),
55 | );
56 | TableHead.displayName = 'TableHead';
57 |
58 | const TableCell = React.forwardRef>(
59 | ({ className, ...props }, ref) => (
60 | |
61 | ),
62 | );
63 | TableCell.displayName = 'TableCell';
64 |
65 | const TableCaption = React.forwardRef>(
66 | ({ className, ...props }, ref) => (
67 |
68 | ),
69 | );
70 | TableCaption.displayName = 'TableCaption';
71 |
72 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
73 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as TabsPrimitive from '@radix-ui/react-tabs';
5 |
6 | import { cn } from '@/utils';
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent };
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/utils';
4 |
5 | export type TextareaProps = React.TextareaHTMLAttributes;
6 |
7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
8 | return (
9 |
17 | );
18 | });
19 | Textarea.displayName = 'Textarea';
20 |
21 | export { Textarea };
22 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
5 |
6 | import { cn } from '@/utils';
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider;
9 |
10 | const Tooltip = TooltipPrimitive.Root;
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger;
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ));
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
31 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export enum StoreKey {
2 | Config = 'app-config',
3 | User = 'app-user',
4 | Chat = 'app-chat',
5 | AccessToken = 'app-access-token',
6 | ShareOpenId = 'app-share-open-id',
7 | }
8 |
9 | export enum LoginType {
10 | WEIXIN_WEB = 'weixinweb',
11 | WEIXIN = 'weixin',
12 | }
13 |
--------------------------------------------------------------------------------
/src/hooks/use-app-config.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import appService from '@/api/app';
4 | import { useAppStore } from '@/store';
5 |
6 | const useAppConfig = () => {
7 | const [setAppConfig, appConfig] = useAppStore((state) => [state.setAppConfig, state.appConfig]);
8 |
9 | useEffect(() => {
10 | const getAppConfig = async () => {
11 | const [appConfig, loginType, paymentConfig] = await Promise.all([
12 | appService.getAppConfig(),
13 | appService.getLoginType(),
14 | appService.getPaymentConfig(),
15 | ]);
16 | setAppConfig({ ...appConfig, ...loginType, ...paymentConfig });
17 | document.title = appConfig.name;
18 | };
19 |
20 | getAppConfig();
21 | }, []);
22 |
23 | return appConfig;
24 | };
25 |
26 | export default useAppConfig;
27 |
--------------------------------------------------------------------------------
/src/hooks/use-auth.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useNavigate, useLocation } from 'react-router-dom';
3 | import userService from '@/api/user';
4 | import { useUserStore } from '@/store';
5 |
6 | const useAuth = () => {
7 | const [setUserInfo, signOut, access_token] = useUserStore((state) => [
8 | state.setUserInfo,
9 | state.signOut,
10 | state.access_token,
11 | ]);
12 | const navigate = useNavigate();
13 | const location = useLocation();
14 |
15 | useEffect(() => {
16 | const getUserProfile = async () => {
17 | if (!access_token) return;
18 | try {
19 | const res = await userService.getUserProfile();
20 | setUserInfo(res);
21 | if (location.pathname === '/login') {
22 | navigate('/chat');
23 | }
24 | } catch (e) {
25 | navigate('/login');
26 | signOut();
27 | }
28 | };
29 |
30 | getUserProfile();
31 | }, []);
32 | };
33 |
34 | export default useAuth;
35 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile-code.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import userService from '@/api/user';
3 | import toast from 'react-hot-toast';
4 |
5 | const CODE_SECONDS = 60;
6 |
7 | const useMobileCode = () => {
8 | let timer = 0;
9 | const [time, setTime] = useState(0);
10 |
11 | useEffect(() => {
12 | if (time === CODE_SECONDS) timer = setInterval(() => setTime((time) => --time), 1000);
13 | else if (time <= 0) timer && clearInterval(timer);
14 | }, [time]);
15 |
16 | const handleGetCode = async (phoneNumber: string) => {
17 | if (!/^(?:(?:\+|00)86)?1\d{10}$/.test(phoneNumber)) {
18 | toast.error('错误的手机号码');
19 | return;
20 | }
21 |
22 | try {
23 | await userService.getPhoneCode(phoneNumber);
24 | setTime(CODE_SECONDS);
25 | } catch (e) {
26 | toast.error(e as string);
27 | }
28 | };
29 | return {
30 | time,
31 | handleGetCode,
32 | };
33 | };
34 |
35 | export default useMobileCode;
36 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile-screen.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | export function useMobileScreen() {
4 | const [isMobileScreen, setIsMobileScreen] = useState(true);
5 | useEffect(() => {
6 | setIsMobileScreen(window.document.body.clientWidth <= 600);
7 | window.addEventListener('resize', () => {
8 | setIsMobileScreen(window.document.body.clientWidth <= 600);
9 | });
10 | }, []);
11 |
12 | return isMobileScreen;
13 | }
14 |
--------------------------------------------------------------------------------
/src/hooks/use-share-openid.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useSearchParams } from 'react-router-dom';
3 | import { StoreKey } from '@/constants';
4 |
5 | const useShareOpenid = () => {
6 | const [searchParams, setSearchParams] = useSearchParams();
7 |
8 | useEffect(() => {
9 | const shareOpenId = searchParams.get('shareOpenId');
10 | if (shareOpenId) {
11 | localStorage.setItem(StoreKey.ShareOpenId, shareOpenId);
12 | setSearchParams('');
13 | }
14 | }, []);
15 | };
16 | export default useShareOpenid;
17 |
--------------------------------------------------------------------------------
/src/hooks/use-task.tsx:
--------------------------------------------------------------------------------
1 | import toast from 'react-hot-toast';
2 | import { isEmpty } from 'lodash-es';
3 |
4 | import taskService, { TaskTypeEnums } from '@/api/task';
5 | import { useUserStore } from '@/store';
6 | import { useBillingStore } from '@/store';
7 |
8 | const useTask = () => {
9 | const [isLogin] = useUserStore((state) => [state.isLogin()]);
10 | const [getCurrentBilling] = useBillingStore((state) => [state.getCurrentBilling]);
11 |
12 | // 分享成功
13 | async function shareCallback() {
14 | if (!isLogin) return;
15 | const type = TaskTypeEnums.SHARE;
16 | const { result } = await taskService.completionTask(type);
17 | if (!result) return;
18 | const unreadTask = await taskService.getUnreadTaskList(type);
19 | if (!isEmpty(unreadTask)) {
20 | // 修改记录为已读
21 | await taskService.readTask(type);
22 | getCurrentBilling();
23 | toast(() => (
24 |
25 |
👏 今日分享已完成!
26 |
27 | {`${
28 | unreadTask.num === -1
29 | ? `您的对话使用时长将延长${unreadTask.expired_day}天`
30 | : `您的对话次数将增加${unreadTask.num}次`
31 | }
32 | ,请前往使用吧`}
33 |
34 |
35 | ));
36 | }
37 | }
38 |
39 | async function checkTask(type: TaskTypeEnums) {
40 | if (!isLogin) return;
41 | const { result } = await taskService.checkTask(type);
42 | if (!result) return;
43 | //查询未完成的任务
44 | const unreadTask = await taskService.getUnreadTaskList(type);
45 | if (!isEmpty(unreadTask)) {
46 | await taskService.readTask(type);
47 | getCurrentBilling();
48 | toast(() => (
49 |
50 |
51 | {type === TaskTypeEnums.REGISTER
52 | ? '👏 欢迎加入,尽情使用吧'
53 | : `👏 ${unreadTask.record_count}个好友加入,真给力!`}
54 |
55 | {type === TaskTypeEnums.REGISTER ? (
56 |
57 | {`您将有${
58 | unreadTask.num === -1
59 | ? `${unreadTask.expired_day * unreadTask.record_count}天无限次`
60 | : `${unreadTask.num * unreadTask.record_count}次`
61 | }机会与您的助理对话,请前往使用吧`}
62 |
63 | ) : (
64 |
65 | {`${
66 | unreadTask.num === -1
67 | ? `+${unreadTask.expired_day * unreadTask.record_count}天对话时长`
68 | : `+${unreadTask.num * unreadTask.record_count}次对话次数`
69 | }`}
70 |
71 | )}
72 |
73 | ));
74 | }
75 | }
76 |
77 | return {
78 | shareCallback,
79 | checkTask,
80 | };
81 | };
82 |
83 | export default useTask;
84 |
--------------------------------------------------------------------------------
/src/hooks/use-wechat.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import wx from 'weixin-js-sdk-ts';
3 | import toast from 'react-hot-toast';
4 |
5 | import appService, { JsSDKType } from '@/api/app';
6 | import { useUserStore } from '@/store';
7 | import { PayInfoType } from '@/api/billing';
8 | import useTask from '@/hooks/use-task';
9 |
10 | export const initWxConfig = (config: JsSDKType) => {
11 | wx.config({
12 | debug: false,
13 | appId: config.appId,
14 | timestamp: config.timestamp,
15 | nonceStr: config.nonceStr,
16 | signature: config.signature,
17 | jsApiList: ['onMenuShareTimeline', 'onMenuShareAppMessage', 'updateTimelineShareData', 'updateAppMessageShareData'],
18 | openTagList: config.openTagList,
19 | });
20 | };
21 |
22 | const useWechat = () => {
23 | const [{ openid }] = useUserStore((state) => [state.userInfo]);
24 | const { shareCallback } = useTask();
25 |
26 | const redirectUrl = window.location.origin + window.location.pathname;
27 | const { VITE_API_DOMAIN } = import.meta.env;
28 |
29 | const isWeixinBrowser = useMemo(() => {
30 | const ua = navigator.userAgent.toLowerCase();
31 | return !!/micromessenger/.test(ua);
32 | }, [navigator]);
33 |
34 | const weChatLogin = () => {
35 | const wxUrl = `${VITE_API_DOMAIN}/wechat/weixin/redirect?redirect_url=${redirectUrl}`;
36 | window.location.href = wxUrl;
37 | };
38 |
39 | const handleWeChatPay = (payInfo: PayInfoType, callback: () => void): unknown => {
40 | const data = payInfo.data;
41 | if (!window.WeixinJSBridge) return;
42 | if (data) {
43 | const { appId, timeStamp, nonceStr, signType, paySign } = data;
44 | const params = {
45 | appId,
46 | timeStamp,
47 | nonceStr,
48 | signType,
49 | paySign,
50 | package: data.package,
51 | };
52 |
53 | window.WeixinJSBridge.invoke(
54 | // 调起支付
55 | 'getBrandWCPayRequest',
56 | params,
57 | (res: Record) => {
58 | if (res.err_msg === 'get_brand_wcpay_request:ok') {
59 | callback();
60 | }
61 | },
62 | );
63 | }
64 | };
65 |
66 | const weChatPay = (payInfo: PayInfoType, callback: () => void) => {
67 | if (typeof window.WeixinJSBridge === 'undefined') {
68 | document.addEventListener('WeixinJSBridgeReady', handleWeChatPay(payInfo, callback) as EventListener);
69 | } else {
70 | handleWeChatPay(payInfo, callback);
71 | }
72 | };
73 |
74 | const setWeixinShare = async () => {
75 | const [config, shareConfig] = await Promise.all([
76 | appService.getJsSDK(window.location.href),
77 | appService.getShareConfig(),
78 | ]);
79 |
80 | const params = {
81 | title: shareConfig.title,
82 | link: `${window.location.origin}${openid ? `/?shareOpenId=${openid}` : ''}`,
83 | imgUrl: shareConfig.img_url,
84 | desc: '',
85 | success: () => {
86 | console.log('设置成功');
87 | },
88 | cancel: () => {
89 | toast.error('设置失败');
90 | },
91 | };
92 |
93 | const opt = {
94 | ...params,
95 | success: async () => {
96 | shareCallback();
97 | },
98 | cancel: () => {
99 | toast.error('取消分享');
100 | },
101 | };
102 |
103 | initWxConfig(config.data);
104 |
105 | wx.updateAppMessageShareData(params);
106 | wx.updateTimelineShareData(params);
107 | wx.onMenuShareTimeline(opt);
108 | wx.onMenuShareAppMessage(opt);
109 |
110 | wx.error((err) => {
111 | console.error('分享内容错误:', err);
112 | });
113 | };
114 |
115 | return {
116 | isWeixinBrowser,
117 | weChatLogin,
118 | weChatPay,
119 | setWeixinShare,
120 | };
121 | };
122 |
123 | export default useWechat;
124 |
--------------------------------------------------------------------------------
/src/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useNavigate, useLocation, Link } from 'react-router-dom';
4 | import { MoonIcon, Sun, Laptop, Languages, Github } from 'lucide-react';
5 |
6 | import { useAppStore, ThemeModeType, LanguagesType, useUserStore } from '@/store';
7 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
8 | import { Button } from '@/components/ui/button';
9 | import { Separator } from '@/components/ui/separator';
10 | import {
11 | DropdownMenu,
12 | DropdownMenuItem,
13 | DropdownMenuContent,
14 | DropdownMenuRadioGroup,
15 | DropdownMenuRadioItem,
16 | DropdownMenuTrigger,
17 | } from '@/components/ui/dropdown-menu';
18 |
19 | export const ThemeMode = () => {
20 | const { t } = useTranslation();
21 | const [theme, setTheme] = useAppStore((state) => [state.theme, state.setTheme]);
22 |
23 | const switchTheme = (mode: ThemeModeType) => {
24 | setTheme(mode);
25 | };
26 |
27 | const getIcon = (item: ThemeModeType, size = 14) => {
28 | const iconMap = {
29 | [ThemeModeType.LIGHT]: ,
30 | [ThemeModeType.DARK]: ,
31 | [ThemeModeType.SYSTEM]: ,
32 | };
33 | return iconMap[item];
34 | };
35 |
36 | return (
37 |
38 |
39 |
42 |
43 |
44 | {[ThemeModeType.LIGHT, ThemeModeType.DARK, ThemeModeType.SYSTEM].map((item) => (
45 | switchTheme(item)}>
46 | {getIcon(item)} {t(item)}
47 |
48 | ))}
49 |
50 |
51 | );
52 | };
53 |
54 | export const SystemLanguages = () => {
55 | const { t, i18n } = useTranslation();
56 | const [language, setLanguage] = useAppStore((state) => [state.language, state.setLanguage]);
57 |
58 | const switchLanguage = (mode: LanguagesType) => {
59 | localStorage.setItem('language-mode', mode);
60 | i18n.changeLanguage(mode);
61 | setLanguage(mode);
62 | };
63 |
64 | useEffect(() => {
65 | switchLanguage(language);
66 | }, []);
67 |
68 | return (
69 |
70 |
71 |
74 |
75 |
76 | switchLanguage(val as LanguagesType)}>
77 | {[LanguagesType.ZH, LanguagesType.EN].map((item) => (
78 |
79 | {t(item)}
80 |
81 | ))}
82 |
83 |
84 |
85 | );
86 | };
87 |
88 | const UserDropDown = () => {
89 | const navigate = useNavigate();
90 | const [appConfig] = useAppStore((state) => [state.appConfig]);
91 | const [{ nickname, avatar }, signOut, isLogin] = useUserStore((state) => [
92 | state.userInfo,
93 | state.signOut,
94 | state.isLogin(),
95 | ]);
96 | const handleSignOut = () => {
97 | signOut();
98 | navigate('/login');
99 | };
100 |
101 | return (
102 | <>
103 | {isLogin ? (
104 |
105 |
106 |
113 |
114 |
115 | {
118 | navigate('/user');
119 | }}
120 | >
121 | 个人中心
122 |
123 | handleSignOut()}>
124 | 退出登录
125 |
126 |
127 |
128 | ) : (
129 |
130 | )}
131 | >
132 | );
133 | };
134 |
135 | export default function Header({ isPlain = false }) {
136 | const navigate = useNavigate();
137 | const { t } = useTranslation();
138 | const [appConfig, theme, setTheme] = useAppStore((state) => [state.appConfig, state.theme, state.setTheme]);
139 |
140 | const handleNavToChat = () => {
141 | navigate('/chat');
142 | };
143 |
144 | const location = useLocation();
145 |
146 | const navList = [
147 | {
148 | path: 'user',
149 | name: t('user center'),
150 | },
151 | {
152 | path: 'billing',
153 | name: t('billing center'),
154 | },
155 | ];
156 |
157 | useEffect(() => {
158 | setTheme(theme);
159 | }, []);
160 |
161 | return (
162 |
163 |
164 |
168 | {!isPlain && (
169 | <>
170 |
171 | {navList.map((item, index) => (
172 |
173 |
176 |
177 | ))}
178 | >
179 | )}
180 |
181 |
182 |
183 |
184 |
187 |
188 |
189 | {!isPlain && }
190 |
191 |
192 | );
193 | }
194 |
--------------------------------------------------------------------------------
/src/layout/TabBar.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { MessageSquare, User, CreditCard, MoreHorizontal, Github } from 'lucide-react';
3 | import { useNavigate, Link } from 'react-router-dom';
4 |
5 | import classNames from 'classnames';
6 |
7 | import { useAppStore } from '@/store';
8 | import {
9 | DropdownMenu,
10 | DropdownMenuItem,
11 | DropdownMenuContent,
12 | DropdownMenuTrigger,
13 | } from '@/components/ui/dropdown-menu';
14 | import { Button } from '@/components/ui/button';
15 |
16 | import { ThemeMode } from './Header';
17 |
18 | const TabBar = () => {
19 | const navList = [
20 | {
21 | path: '/chat',
22 | icon: ,
23 | },
24 | {
25 | path: 'user',
26 | icon: ,
27 | },
28 | {
29 | path: 'billing',
30 | icon: ,
31 | },
32 | ];
33 | const navigate = useNavigate();
34 |
35 | const [theme, setTheme] = useAppStore((state) => [state.theme, state.setTheme]);
36 |
37 | useEffect(() => {
38 | setTheme(theme);
39 | }, []);
40 |
41 | return (
42 |
43 | {navList.map((item, index) => (
44 |
navigate(item.path)}
49 | key={index}
50 | >
51 | {item.icon}
52 |
53 | ))}
54 |
55 |
56 |
59 |
60 |
61 |
62 |
63 |
64 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | export default TabBar;
76 |
--------------------------------------------------------------------------------
/src/layout/TitleHeader.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from 'react';
2 | import { AlignJustify } from 'lucide-react';
3 | import { useTranslation } from 'react-i18next';
4 | import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
5 | import { useChatStore } from '@/store';
6 |
7 | import Conversation from '../pages/chat/Conversation';
8 |
9 | export const TitleHeader = () => {
10 | const { t } = useTranslation();
11 | const [open, setOpen] = useState(false);
12 | const [currentConversation] = useChatStore((state) => [state.currentConversation]);
13 | const title = useMemo(() => {
14 | if (location.pathname.includes('user')) {
15 | return t('user center');
16 | }
17 | if (location.pathname.includes('billing')) {
18 | return t('billing center');
19 | }
20 | if (location.pathname.includes('salesman')) {
21 | return t('salesman center');
22 | }
23 | return currentConversation.title;
24 | }, [currentConversation, location.pathname]);
25 |
26 | return (
27 |
28 |
setOpen(val)}>
29 |
30 |
31 |
32 |
33 | setOpen(false)} />
34 |
35 |
36 | {title}
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { Outlet, useNavigate } from 'react-router-dom';
3 |
4 | import useAuth from '@/hooks/use-auth';
5 | import useAppConfig from '@/hooks/use-app-config';
6 | import { useMobileScreen } from '@/hooks/use-mobile-screen';
7 | import useWechat from '@/hooks/use-wechat';
8 | import useTask from '@/hooks/use-task';
9 | import useShareOpenid from '@/hooks/use-share-openid';
10 |
11 | import { TaskTypeEnums } from '@/api/task';
12 |
13 | import Header from './Header';
14 | import { TitleHeader } from './TitleHeader';
15 | import TabBar from './TabBar';
16 |
17 | const App = () => {
18 | const isMobileScreen = useMobileScreen();
19 | const navigate = useNavigate();
20 | const { setWeixinShare } = useWechat();
21 | const { checkTask } = useTask();
22 |
23 | useEffect(() => {
24 | if (location.pathname === '/') {
25 | navigate('/chat');
26 | }
27 | setWeixinShare();
28 | checkTask(TaskTypeEnums.REGISTER);
29 | checkTask(TaskTypeEnums.INVITE);
30 | }, []);
31 |
32 | useAuth();
33 | useAppConfig();
34 | useShareOpenid();
35 |
36 | return (
37 |
38 | {!isMobileScreen ? : }
39 |
40 | {isMobileScreen && !location.pathname.includes('salesman') && }
41 |
42 | );
43 | };
44 |
45 | export default App;
46 |
--------------------------------------------------------------------------------
/src/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "en": "English",
3 | "zh": "Chinese",
4 | "dark": "Dark",
5 | "light": "Light",
6 | "system": "System",
7 | "user center": "User Center",
8 | "billing center": "Billing Center",
9 | "salesman center": "Salesman Center",
10 | "new conversation": "new conversation",
11 | "role": "role",
12 | "valid times": "valid times"
13 | }
--------------------------------------------------------------------------------
/src/locales/index.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { initReactI18next } from 'react-i18next';
3 | import en from './en.json';
4 | import zh from './zh.json';
5 |
6 | const resources = {
7 | en: {
8 | translation: en,
9 | },
10 | zh: {
11 | translation: zh,
12 | },
13 | };
14 |
15 | i18n.use(initReactI18next).init({
16 | resources,
17 | lng: 'zh',
18 | interpolation: {
19 | escapeValue: false,
20 | },
21 | });
22 |
23 | export default i18n;
24 |
--------------------------------------------------------------------------------
/src/locales/zh.json:
--------------------------------------------------------------------------------
1 | {
2 | "en": "英文",
3 | "zh": "中文",
4 | "dark": "暗黑模式",
5 | "light": "亮色模式",
6 | "system": "跟随系统",
7 | "user center": "个人中心",
8 | "billing center": "充值中心",
9 | "salesman center": "分销员中心",
10 | "new conversation": "新话题",
11 | "role": "角色",
12 | "valid times": "有效次数"
13 | }
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client';
2 | import { RouterProvider } from 'react-router-dom';
3 | import { Toaster } from 'react-hot-toast';
4 |
5 | import './locales';
6 | import './styles/tailwind.less';
7 | import router from './router';
8 |
9 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
10 | <>
11 |
12 |
13 | >,
14 | );
15 |
--------------------------------------------------------------------------------
/src/pages/billing/BillingRecords.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Loader2 } from 'lucide-react';
3 |
4 | import userService, { UserPackageType } from '@/api/user';
5 | import {
6 | AlertDialog,
7 | AlertDialogContent,
8 | AlertDialogTitle,
9 | AlertDialogTrigger,
10 | AlertDialogHeader,
11 | AlertDialogAction,
12 | AlertDialogFooter,
13 | } from '@/components/ui/alert-dialog';
14 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
15 |
16 | export function BillingRecordsDialog({ children }: { children: React.ReactNode }) {
17 | const [userPackages, setUserPackages] = useState([]);
18 | const [open, setOpen] = useState(false);
19 | const [isLoading, setIsLoading] = useState(true);
20 |
21 | useEffect(() => {
22 | const getUserPackages = async () => {
23 | setIsLoading(true);
24 | const res = await userService.getUserPackages();
25 | setUserPackages(res);
26 | setIsLoading(false);
27 | };
28 | if (open) {
29 | getUserPackages();
30 | }
31 | }, [open]);
32 |
33 | return (
34 | setOpen(val)}>
35 | {children}
36 |
37 |
38 | 充值记录
39 |
40 | {isLoading ? (
41 |
42 | ) : (
43 |
44 |
45 |
46 | 名称
47 | 问答机会
48 | 日期
49 |
50 |
51 |
52 |
53 | {userPackages.map((item, index) => (
54 |
55 | {item.package_name}
56 | {item.num === -1 ? `+${item.expired_day}天` : `${item.num}次`}
57 | {item.created_at}
58 |
59 | ))}
60 |
61 |
62 | )}
63 |
64 | 确认
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/pages/billing/OfflinePayDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
2 |
3 | type PayDialogProps = {
4 | open: boolean;
5 | payInfo: string;
6 | handleOpenChange: (val: boolean) => void;
7 | };
8 |
9 | export function OfflinePayDialog({ open, payInfo, handleOpenChange }: PayDialogProps) {
10 | return (
11 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/billing/PayDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2 } from 'lucide-react';
2 | import { QRCodeCanvas } from 'qrcode.react';
3 |
4 | import { PayInfoType } from '@/api/billing';
5 | import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
6 |
7 | type PayDialogProps = {
8 | open: boolean;
9 | payInfo: PayInfoType | null;
10 | handleOpenChange: (val: boolean) => void;
11 | };
12 |
13 | export function PayDialog({ open, payInfo, handleOpenChange }: PayDialogProps) {
14 | return (
15 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/billing/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Loader2, CheckCircle2Icon } from 'lucide-react';
3 | import toast from 'react-hot-toast';
4 |
5 | import { useBillingStore, useAppStore } from '@/store';
6 | import billingService, { PackageType, PayInfoType, Channel, PayType } from '@/api/billing';
7 | import { PaymentChannelEnum } from '@/api/app';
8 | import SvgIcon from '@/components/SvgIcon';
9 | import { Input } from '@/components/ui/input';
10 | import { Button } from '@/components/ui/button';
11 | import { ScrollArea } from '@/components/ui/scroll-area';
12 | import useWechat from '@/hooks/use-wechat';
13 | import { useMobileScreen } from '@/hooks/use-mobile-screen';
14 |
15 | import { BillingRecordsDialog } from './BillingRecords';
16 | import { PayDialog } from './PayDialog';
17 | import { OfflinePayDialog } from './OfflinePayDialog';
18 |
19 | let payStatusInterval = 0;
20 |
21 | export default function Billing() {
22 | const [billingPackage, setBillingPackage] = useState([]);
23 | const [payDialogShow, setPayDialogShow] = useState(false);
24 | const [offlinePayDialogShow, setOfflinePayDialogShow] = useState(false);
25 | const [isLoading, setIsLoading] = useState(true);
26 | const [redemptionCode, setRedemptionCode] = useState('');
27 | const [payInfo, setPayInfo] = useState(null);
28 | const [getCurrentBilling] = useBillingStore((state) => [state.getCurrentBilling]);
29 | const [appConfig] = useAppStore((state) => [state.appConfig]);
30 |
31 | const { isWeixinBrowser, weChatPay } = useWechat();
32 | const isMobileScreen = useMobileScreen();
33 |
34 | useEffect(() => {
35 | const getBillingPackage = async () => {
36 | setIsLoading(true);
37 | const res = await billingService.getBillingPackage();
38 | setBillingPackage(res);
39 | setIsLoading(false);
40 | };
41 | getBillingPackage();
42 | }, []);
43 |
44 | const handleExchangeRedemptionCode = async () => {
45 | try {
46 | await billingService.exchangeRedemptionCode(redemptionCode);
47 | toast.success('兑换成功');
48 | getCurrentBilling();
49 | } catch (e) {
50 | toast.error(e as string);
51 | } finally {
52 | setRedemptionCode('');
53 | }
54 | };
55 |
56 | const handlePay = async (item: PackageType) => {
57 | if (appConfig.channel === PaymentChannelEnum.OFFLINE) {
58 | setOfflinePayDialogShow(true);
59 | return;
60 | }
61 | const res = await billingService.orderBilling({
62 | package_id: item.id,
63 | channel: Channel.WECHAT,
64 | pay_type: isWeixinBrowser ? PayType.JSAPI : PayType.NATIVE,
65 | platform: 1,
66 | });
67 |
68 | const payInfoRes = await billingService.billingPayDetail(res.id);
69 | setPayInfo(payInfoRes);
70 |
71 | if (isWeixinBrowser && isMobileScreen) {
72 | weChatPay(payInfoRes, () => {
73 | toast.success('支付成功');
74 | setPayInfo(null);
75 | getCurrentBilling();
76 | });
77 | } else {
78 | setPayDialogShow(true);
79 | payStatusInterval = setInterval(async () => {
80 | const { status } = await billingService.billingDetail(payInfoRes.id);
81 | if (status == 2) {
82 | toast.success('支付成功');
83 | setPayDialogShow(false);
84 | setPayInfo(null);
85 | clearInterval(payStatusInterval);
86 | getCurrentBilling();
87 | }
88 | }, 1500);
89 | }
90 | };
91 |
92 | const handlePayDialogShow = (val: boolean) => {
93 | setPayDialogShow(val);
94 | clearInterval(payStatusInterval);
95 | };
96 |
97 | return (
98 |
99 |
100 | {isLoading ? (
101 |
102 | ) : (
103 |
104 | {billingPackage.map((item, index) => (
105 |
106 |
107 | {item.price}
108 | 元
109 |
110 |
111 |
112 | {item.name}
113 |
114 |
115 | {item.identity === 2 && (
116 | <>
117 |
118 | 开发者专享
119 | >
120 | )}
121 |
122 |
125 |
126 | ))}
127 |
128 | )}
129 |
130 |
131 | setRedemptionCode(e.target.value)}
135 | />
136 |
139 |
140 |
141 |
142 |
143 | 充值说明
144 |
145 |
146 |
147 |
148 |
149 |
150 | - 1. 账户充值仅限微信在线支付方式,充值金额实时到账
151 | - 2. 账户有效次数自充值日起至用完为止
152 |
153 |
154 |
155 |
156 | {
160 | setOfflinePayDialogShow(val);
161 | }}
162 | />
163 |
164 | );
165 | }
166 |
--------------------------------------------------------------------------------
/src/pages/chat/Chat.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect, useMemo } from 'react';
2 | import { Loader2, PauseOctagon, SendIcon, Trash2Icon, DownloadIcon, MoreHorizontal } from 'lucide-react';
3 | import { useNavigate } from 'react-router-dom';
4 | import toast from 'react-hot-toast';
5 |
6 | import useAppConfig from '@/hooks/use-app-config';
7 | import { useChatStore, useUserStore } from '@/store';
8 | import { Textarea } from '@/components/ui/textarea';
9 | import { Button } from '@/components/ui/button';
10 | import IconSvg from '@/components/Icon';
11 | import { Checkbox } from '@/components/ui/checkbox';
12 | import { useMobileScreen } from '@/hooks/use-mobile-screen';
13 | import {
14 | DropdownMenu,
15 | DropdownMenuItem,
16 | DropdownMenuContent,
17 | DropdownMenuTrigger,
18 | } from '@/components/ui/dropdown-menu';
19 |
20 | import MessageExporter from './MessageExporter';
21 | import { ChatItem } from './ChatItem';
22 | import classNames from 'classnames';
23 |
24 | let scrollIntoViewTimeId = -1;
25 |
26 | const Footer = ({
27 | isDownload = false,
28 | selectedMessagesIDs,
29 | onIsDownloadChange,
30 | onSelectMessagesIds,
31 | }: {
32 | isDownload: boolean;
33 | selectedMessagesIDs: string[];
34 | onIsDownloadChange: (val: boolean) => void;
35 | onSelectMessagesIds: (ids: string[]) => void;
36 | }) => {
37 | const [userInput, setUserInput] = useState('');
38 | const [sendUserMessage, isStream, clearCurrentConversation, stopStream, currentChatData, setStream] = useChatStore(
39 | (state) => [
40 | state.sendUserMessage,
41 | state.isStream,
42 | state.clearCurrentConversation,
43 | state.stopStream,
44 | state.currentChatData(),
45 | state.setStream,
46 | ],
47 | );
48 | const [{ openid }, isLogin] = useUserStore((state) => [state.userInfo, state.isLogin()]);
49 | const [currentConversation, editConversation] = useChatStore((state) => [
50 | state.currentConversation,
51 | state.editConversation,
52 | ]);
53 |
54 | const isMobileScreen = useMobileScreen();
55 | const handleKeyPress = (e: React.KeyboardEvent) => {
56 | if (e.code === 'Enter' && !e.shiftKey && userInput.replace(/\n/g, '')) {
57 | handleSendUserMessage();
58 | }
59 | };
60 | const navigator = useNavigate();
61 | const appConfig = useAppConfig();
62 |
63 | const handleSendUserMessage = async () => {
64 | if (!isLogin) {
65 | toast.error('请登录');
66 | navigator('/login');
67 | return;
68 | }
69 | if (!currentChatData.length) {
70 | editConversation(currentConversation.uuid, { title: userInput });
71 | }
72 | sendUserMessage(userInput);
73 | setUserInput('');
74 | setTimeout(() => {
75 | textAreaRef.current?.focus();
76 | }, 1000);
77 | };
78 |
79 | const textAreaRef = useRef(null);
80 |
81 | const exportMessages = useMemo(() => {
82 | return currentChatData.filter((item) => {
83 | return selectedMessagesIDs.includes(item.id);
84 | });
85 | }, [onSelectMessagesIds]);
86 |
87 | const allMessagesIds = useMemo(() => {
88 | return currentChatData.map((item) => item.id);
89 | }, [currentChatData]);
90 |
91 | useEffect(() => {
92 | if (textAreaRef.current) {
93 | textAreaRef.current.style.height = '0px';
94 | const scrollHeight = textAreaRef.current.scrollHeight;
95 | textAreaRef.current.style.height = scrollHeight + 4 + 'px';
96 | }
97 | }, [textAreaRef, userInput]);
98 |
99 | useEffect(() => {
100 | setStream(false);
101 | }, []);
102 |
103 | return (
104 |
208 | );
209 | };
210 |
211 | const ChatBody = ({
212 | isDownload,
213 | selectedMessagesIDs,
214 | onSelectMessagesIds,
215 | }: {
216 | isDownload: boolean;
217 | selectedMessagesIDs: string[];
218 | onSelectMessagesIds: (ids: string[]) => void;
219 | }) => {
220 | const [isStream, currentChatData] = useChatStore((state) => [state.isStream, state.currentChatData()]);
221 |
222 | const bottom = useRef(null);
223 |
224 | const scrollLastMessageIntoView = (behavior: ScrollBehavior = 'smooth') => {
225 | if (!bottom.current) return;
226 | bottom.current.scrollIntoView({ behavior, block: 'end' });
227 | };
228 |
229 | useEffect(() => {
230 | scrollLastMessageIntoView('auto');
231 | }, []);
232 |
233 | useEffect(() => {
234 | if (isStream) {
235 | scrollLastMessageIntoView();
236 | scrollIntoViewTimeId = setInterval(() => {
237 | scrollLastMessageIntoView();
238 | }, 1000);
239 | } else {
240 | clearInterval(scrollIntoViewTimeId);
241 | }
242 | }, [isStream]);
243 |
244 | return (
245 |
246 |
247 | {currentChatData.length === 0 && (
248 |
249 |
250 | 开始提问吧~
251 |
252 | )}
253 | {currentChatData.map((item, index) => (
254 | {
260 | if (val) {
261 | onSelectMessagesIds(selectedMessagesIDs.concat(item.id));
262 | } else {
263 | onSelectMessagesIds(selectedMessagesIDs.filter((id) => id !== item.id));
264 | }
265 | }}
266 | />
267 | ))}
268 |
269 |
270 | );
271 | };
272 |
273 | const Chat = () => {
274 | const [isDownload, setIsDownload] = useState(false);
275 | const [selectedMessagesIDs, setSelectedMessagesIDs] = useState([]);
276 |
277 | useEffect(() => {
278 | if (!isDownload) {
279 | setSelectedMessagesIDs([]);
280 | }
281 | }, [isDownload]);
282 | return (
283 |
284 | setSelectedMessagesIDs(ids)}
288 | />
289 |
296 | );
297 | };
298 |
299 | export default Chat;
300 |
--------------------------------------------------------------------------------
/src/pages/chat/ChatItem.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { RefreshCcwIcon, CopyIcon, Loader2 } from 'lucide-react';
3 | import dayjs from 'dayjs';
4 | import toast from 'react-hot-toast';
5 |
6 | import { useUserStore, useChatStore, RoleTypeEnum, ChatItemType, useAppStore } from '@/store';
7 | import { copyToClipboard } from '@/utils';
8 | import { StatusEnum } from '@/utils/stream-api';
9 | import { Markdown } from '@/components/Markdown';
10 | import { Checkbox } from '@/components/ui/checkbox';
11 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
12 |
13 | export const ChatItem = ({
14 | data,
15 | isCheckedMode = false,
16 | isChecked = false,
17 | isDownload = false,
18 | onCheckedChange,
19 | }: {
20 | data: ChatItemType;
21 | isCheckedMode?: boolean;
22 | isChecked?: boolean;
23 | isDownload?: boolean;
24 | onCheckedChange?: (val: boolean) => void;
25 | }) => {
26 | const [{ nickname, avatar }] = useUserStore((state) => [state.userInfo]);
27 | const [regenerateChat] = useChatStore((state) => [state.regenerateChat]);
28 | const [appConfig] = useAppStore((state) => [state.appConfig]);
29 |
30 | const handleCopy = () => {
31 | copyToClipboard(data.text);
32 | toast.success('已复制到剪贴板');
33 | };
34 |
35 | const renderContent = () => {
36 | if (data.status === StatusEnum.START) {
37 | return ;
38 | } else if (data.status === StatusEnum.ERROR) {
39 | return data.error;
40 | } else {
41 | return ;
42 | }
43 | };
44 |
45 | return (
46 |
47 | {isCheckedMode && (
48 |
49 | )}
50 |
55 | {data.role === RoleTypeEnum.USER ? (
56 |
57 |
58 | {nickname.slice(0, 1)?.toUpperCase()}
59 |
60 | ) : (
61 |
62 |
63 |
64 | )}
65 |
70 |
{dayjs(data.dateTime).format('YYYY-MM-DD HH:mm:ss')}
71 |
76 |
84 | {renderContent()}
85 |
86 |
87 | {!isDownload && (
88 | <>
89 | {[RoleTypeEnum.ASSISTANT].includes(data.role) && (
90 | regenerateChat(data.requestId)}
94 | />
95 | )}
96 |
97 | >
98 | )}
99 |
100 |
101 |
102 |
103 |
104 | );
105 | };
106 |
--------------------------------------------------------------------------------
/src/pages/chat/Conversation.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { MessageSquare, PlusCircle, Edit2Icon, Trash2, X, Check } from 'lucide-react';
4 |
5 | import chatService, { RoleType } from '@/api/chat';
6 | import { useChatStore } from '@/store';
7 | import SvgIcon from '@/components/SvgIcon';
8 | import { Button } from '@/components/ui/button';
9 | import { ScrollArea } from '@/components/ui/scroll-area';
10 |
11 | import { RoleListDialog } from './RoleListDialog';
12 |
13 | const ConversationList = ({ onChange }: { onChange?: () => void }) => {
14 | const { t } = useTranslation();
15 | const [
16 | conversationList,
17 | currentConversation,
18 | addConversation,
19 | switchConversation,
20 | delConversation,
21 | editConversation,
22 | ] = useChatStore((state) => [
23 | state.conversationList,
24 | state.currentConversation,
25 | state.addConversation,
26 | state.switchConversation,
27 | state.delConversation,
28 | state.editConversation,
29 | ]);
30 |
31 | const [editTitle, setEditTitle] = useState('');
32 | const [inEditId, setInEditId] = useState('');
33 |
34 | const handleEditConversation = (e?: React.MouseEvent) => {
35 | setInEditId('');
36 | setEditTitle('');
37 | editConversation(inEditId, { title: editTitle });
38 | e?.stopPropagation();
39 | onChange?.();
40 | };
41 |
42 | const handleKeyDown = (event: React.KeyboardEvent) => {
43 | if (event.code == 'Enter') {
44 | handleEditConversation();
45 | }
46 | };
47 |
48 | return (
49 |
50 |
60 |
61 |
62 |
63 | {conversationList.map((item, index) => (
64 |
121 | ))}
122 |
123 |
124 |
125 | );
126 | };
127 |
128 | const RoleList = ({ data, onChange }: { data: RoleType[]; onChange?: () => void }) => {
129 | const { t } = useTranslation();
130 | const [addConversation] = useChatStore((state) => [state.addConversation]);
131 |
132 | return (
133 |
134 |
—— {t('role')} ——
135 |
136 | {data.slice(0, 8).map((item, index) => (
137 |
151 | ))}
152 |
153 |
addConversation(item.name, item.icon, item.prompt, item.id)}>
154 |
157 |
158 |
159 | );
160 | };
161 |
162 | const Conversation = ({ onChange }: { onChange?: () => void }) => {
163 | const [roleList, setRoleList] = useState([]);
164 |
165 | useEffect(() => {
166 | const getRoleList = async () => {
167 | const res = await chatService.getRoleList();
168 | setRoleList(res);
169 | };
170 | getRoleList();
171 | }, []);
172 |
173 | return (
174 |
178 | );
179 | };
180 |
181 | export default Conversation;
182 |
--------------------------------------------------------------------------------
/src/pages/chat/MessageExporter.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from 'react';
2 | import { createPortal } from 'react-dom';
3 | import { toPng, toSvg } from 'html-to-image';
4 | import { DownloadIcon } from 'lucide-react';
5 | import { QRCodeCanvas } from 'qrcode.react';
6 | import { saveAs } from 'file-saver';
7 |
8 | import { ChatItemType, useChatStore } from '@/store';
9 | import { Button } from '@/components/ui/button';
10 | import { ScrollArea } from '@/components/ui/scroll-area';
11 | import { useMobileScreen } from '@/hooks/use-mobile-screen';
12 | import useWechat from '@/hooks/use-wechat';
13 |
14 | import { ChatItem } from './ChatItem';
15 | import {
16 | AlertDialog,
17 | AlertDialogContent,
18 | AlertDialogHeader,
19 | AlertDialogDescription,
20 | AlertDialogTitle,
21 | AlertDialogAction,
22 | AlertDialogFooter,
23 | AlertDialogCancel,
24 | } from '@/components/ui/alert-dialog';
25 |
26 | const MessageExporter = ({ messages, shareUrl }: { messages: ChatItemType[]; shareUrl: string }) => {
27 | const messagesRef = useRef(null);
28 | const [open, setOpen] = useState(false);
29 | const [dataUrl, setDataUrl] = useState('');
30 | const isMobileScreen = useMobileScreen();
31 | const [currentConversation] = useChatStore((state) => [state.currentConversation]);
32 | const { isWeixinBrowser } = useWechat();
33 |
34 | const drawImage = async () => {
35 | setTimeout(async () => {
36 | if (!messagesRef.current) return;
37 | // 微信浏览器中 toPng 方法,偶发生成失败,所以使用 toSvg 方法
38 | const drawImageFn = isWeixinBrowser ? toSvg : toPng;
39 | const res = await drawImageFn(messagesRef.current, { style: { opacity: '1' } });
40 | setDataUrl(res);
41 | }, 300);
42 |
43 | setOpen(true);
44 | };
45 |
46 | return (
47 | <>
48 | {open &&
49 | createPortal(
50 |
51 |
52 | {messages.map((item, index) => (
53 |
54 | ))}
55 |
56 |
57 |
67 |
,
68 | document.body,
69 | )}
70 |
71 | setOpen(val)}>
72 |
73 |
74 | 对话海报
75 |
76 |
77 |
78 |
79 |
80 |
81 | {isMobileScreen && 长按图片保存}
82 |
83 | 取消
84 | {!isMobileScreen && (
85 | {
87 | saveAs(dataUrl, `${currentConversation.title}.jpg`);
88 | }}
89 | >
90 | 下载
91 |
92 | )}
93 |
94 |
95 |
96 |
97 |
107 | >
108 | );
109 | };
110 | export default MessageExporter;
111 |
--------------------------------------------------------------------------------
/src/pages/chat/RoleListDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { X } from 'lucide-react';
3 |
4 | import {
5 | AlertDialog,
6 | AlertDialogContent,
7 | AlertDialogHeader,
8 | AlertDialogTitle,
9 | AlertDialogTrigger,
10 | } from '@/components/ui/alert-dialog';
11 | import { Button } from '@/components/ui/button';
12 | import SvgIcon from '@/components/SvgIcon';
13 | import { RoleType } from '@/api/chat';
14 | import { ScrollArea } from '@/components/ui/scroll-area';
15 |
16 | export function RoleListDialog({
17 | data,
18 | children,
19 | roleSelect,
20 | }: {
21 | data: RoleType[];
22 | children: React.ReactNode;
23 | roleSelect: (item: RoleType) => void;
24 | }) {
25 | const [open, setOpen] = useState(false);
26 | return (
27 | setOpen(val)}>
28 | {children}
29 |
30 |
31 |
32 | 全部角色
33 |
36 |
37 |
38 |
39 |
40 | {data.map((item, index) => (
41 |
55 | ))}
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/pages/chat/index.tsx:
--------------------------------------------------------------------------------
1 | import Chat from './Chat';
2 | import Conversation from './Conversation';
3 | import { useMobileScreen } from '@/hooks/use-mobile-screen';
4 |
5 | export default function Home() {
6 | const isMobileScreen = useMobileScreen();
7 |
8 | return (
9 |
10 | {!isMobileScreen && }
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/error-page.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import { Button } from '@/components/ui/button';
3 |
4 | export default function ErrorPage() {
5 | return (
6 |
7 |
8 |
9 |
10 |
404
11 |
12 | Whoops! 页面未找到
13 |
14 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/pages/login/Protocol.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import AppServices, { ConfigAgreementType } from '@/api/app';
3 | import { Checkbox } from '@/components/ui/checkbox';
4 |
5 | import {
6 | AlertDialog,
7 | AlertDialogContent,
8 | AlertDialogHeader,
9 | AlertDialogDescription,
10 | AlertDialogTitle,
11 | AlertDialogTrigger,
12 | AlertDialogAction,
13 | AlertDialogFooter,
14 | } from '@/components/ui/alert-dialog';
15 |
16 | export function PrivacyProtocol({
17 | checked,
18 | onCheckedChange,
19 | }: {
20 | checked: boolean;
21 | onCheckedChange: (val: boolean) => void;
22 | }) {
23 | const [protocol, setProtocol] = useState({ title: '', agreement: '', enable: false });
24 |
25 | useEffect(() => {
26 | AppServices.getConfigAgreement().then((res) => {
27 | setProtocol(res);
28 | if (!res.enable) onCheckedChange(true);
29 | });
30 | }, []);
31 |
32 | if (!protocol.enable) return <>>;
33 |
34 | return (
35 | <>
36 |
37 | 我已阅读并同意
38 |
39 |
40 | 《{protocol.title}》
41 |
42 |
43 |
44 | {protocol.title}
45 |
46 |
50 |
51 | 确认
52 |
53 |
54 |
55 | >
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/pages/login/QrCodeDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, DialogContent } from '@/components/ui/dialog';
2 |
3 | type QrCodeDialogProps = {
4 | open: boolean;
5 | qrCode: string;
6 | handleOpenChange: (val: boolean) => void;
7 | };
8 |
9 | export function QrCodeDialog({ open, qrCode, handleOpenChange }: QrCodeDialogProps) {
10 | return (
11 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useSearchParams, useNavigate } from 'react-router-dom';
3 | import { Loader2 } from 'lucide-react';
4 |
5 | import { LoginType, StoreKey } from '@/constants';
6 | import { useUserStore, useAppStore } from '@/store';
7 | import userServices from '@/api/user';
8 | import { LoginTypeEnum } from '@/api/app';
9 | import Header from '@/layout/Header';
10 | import { Button } from '@/components/ui/button';
11 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
12 | import useAuth from '@/hooks/use-auth';
13 | import useAppConfig from '@/hooks/use-app-config';
14 | import useWechat from '@/hooks/use-wechat';
15 | import useShareOpenid from '@/hooks/use-share-openid';
16 |
17 | import { PrivacyProtocol } from './Protocol';
18 | import { QrCodeDialog } from './QrCodeDialog';
19 | import { LoginForm, RegisterDialog, RetrievePasswordDialog, PhoneLoginForm } from './LoginForm';
20 |
21 | export default function Login() {
22 | const [protocolChecked, setProtocolChecked] = useState(false);
23 | const [qrCodeDialogOpen, setQrCodeDialogOpen] = useState(false);
24 | const [qrCode, setQrCode] = useState('');
25 | const [isLoading, setIsLoading] = useState(false);
26 | const [oauthId, setOauthId] = useState('');
27 | const [loginType, setLoginType] = useAppStore((state) => [state.loginType, state.setLoginType]);
28 |
29 | const [searchParams, setSearchParams] = useSearchParams();
30 | const navigate = useNavigate();
31 | const [setUserInfo, setAccessToken] = useUserStore((state) => [state.setUserInfo, state.setAccessToken]);
32 | const { isWeixinBrowser, weChatLogin } = useWechat();
33 |
34 | const handleLogin = async () => {
35 | if (isWeixinBrowser) {
36 | weChatLogin();
37 | return;
38 | }
39 | const currentUrl = location.origin + location.pathname;
40 | const res = await userServices.getWxQrCode(LoginType.WEIXIN_WEB, currentUrl);
41 | setQrCodeDialogOpen(true);
42 | setQrCode(res.qr_code_url);
43 | };
44 |
45 | const getUserInfo = async () => {
46 | const code = searchParams.get('code');
47 | if (code) {
48 | setProtocolChecked(true);
49 | try {
50 | setIsLoading(true);
51 | const res = await userServices.getUserInfoByCode(
52 | isWeixinBrowser ? LoginType.WEIXIN : LoginType.WEIXIN_WEB,
53 | code,
54 | localStorage.getItem(StoreKey.ShareOpenId) || '',
55 | );
56 | if (res.oauth_id) {
57 | setOauthId(res.oauth_id);
58 | return;
59 | }
60 | setUserInfo(res.user);
61 | setAccessToken(res.access_token);
62 | setIsLoading(false);
63 | navigate('/chat');
64 | } catch {
65 | setSearchParams('');
66 | }
67 | }
68 | };
69 |
70 | useEffect(() => {
71 | getUserInfo();
72 | }, []);
73 |
74 | useAuth();
75 | useShareOpenid();
76 | const appConfig = useAppConfig();
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
setLoginType(val as LoginTypeEnum)}
87 | >
88 | {Array.isArray(appConfig.login_type) && (
89 |
90 | 微信扫码登陆
91 | 账号密码登陆
92 |
93 | )}
94 |
95 | {appConfig.name}
96 |
97 |
100 |
101 |
102 |
103 |
104 |
105 | {oauthId ? (
106 |
107 | ) : (
108 |
112 | )}
113 |
114 |
115 |
setProtocolChecked(val as boolean)}
118 | />
119 |
120 |
121 | {loginType === LoginTypeEnum.PASSWORD && (
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | )}
131 |
132 |
{
136 | setQrCodeDialogOpen(val);
137 | }}
138 | />
139 |
140 |
141 | );
142 | }
143 |
--------------------------------------------------------------------------------
/src/pages/salesman/ListScroll.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, CSSProperties } from 'react';
2 | import InfiniteScroll from 'react-infinite-scroll-component';
3 |
4 | import salesmanService, { SalesmanChildType, SalesmanOrderType } from '@/api/salesman';
5 |
6 | export enum TypeEnums {
7 | ORDER = 'order',
8 | CHILD = 'child',
9 | }
10 |
11 | type ListScrollProps = {
12 | type: TypeEnums;
13 | style?: CSSProperties;
14 | };
15 |
16 | export function ListScroll({ type, style }: ListScrollProps) {
17 | const [list, setList] = useState([]);
18 | const [scrollHeight, setScrollHeight] = useState(0);
19 | const [perPage] = useState(20);
20 | const [page, setPage] = useState(1);
21 | const [hasMore, setHasMore] = useState(true);
22 |
23 | const fetchMoreData = async () => {
24 | let res: SalesmanChildType[] = [];
25 | if (type === TypeEnums.CHILD) {
26 | res = await salesmanService.getSalesmanChildList(perPage, page);
27 | } else if (type === TypeEnums.ORDER) {
28 | const data = await salesmanService.getSalesmanOrderList(perPage, page);
29 | res = data.map((val: SalesmanOrderType) => {
30 | return {
31 | nickname: val.custom.nickname,
32 | order_price: val.price,
33 | created_at: val.created_at,
34 | order_num: 0,
35 | };
36 | });
37 | }
38 | if (res.length < perPage) {
39 | setHasMore(false);
40 | } else {
41 | setPage(page + 1);
42 | }
43 | setList(list.concat(res));
44 | };
45 |
46 | useEffect(() => {
47 | const listScrollRef = document.querySelector('.list-scroll');
48 | setScrollHeight(listScrollRef?.clientHeight || 0);
49 |
50 | fetchMoreData();
51 | }, []);
52 |
53 | return (
54 |
55 | {scrollHeight ? (
56 |
加载中...}
59 | dataLength={list.length}
60 | height={scrollHeight}
61 | endMessage={没有数据了~
}
62 | next={fetchMoreData}
63 | >
64 |
65 | {list.map((item, index) => (
66 | -
67 |
68 |
69 |
{item.nickname}
70 |
71 |
72 | {type === TypeEnums.CHILD ? '贡献佣金' : '获得佣金'}:
73 | {item.order_price}
74 |
75 |
76 |
77 |
78 |
79 |
80 | 关联时间:{item.created_at}
81 |
82 | {type === TypeEnums.CHILD && (
83 |
84 |
85 | 贡献订单数:{item.order_num}
86 |
87 |
88 | )}
89 |
90 |
91 |
92 |
93 | ))}
94 |
95 |
96 | ) : (
97 | <>>
98 | )}
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/pages/salesman/WithdrawalDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { X } from 'lucide-react';
3 | import { useForm } from 'react-hook-form';
4 | import { zodResolver } from '@hookform/resolvers/zod';
5 | import * as z from 'zod';
6 | import toast from 'react-hot-toast';
7 |
8 | import { useSalesmanStore } from '@/store';
9 | import salesmanService from '@/api/salesman';
10 | import {
11 | AlertDialog,
12 | AlertDialogContent,
13 | AlertDialogHeader,
14 | AlertDialogTitle,
15 | AlertDialogTrigger,
16 | } from '@/components/ui/alert-dialog';
17 | import { Button } from '@/components/ui/button';
18 | import { Input } from '@/components/ui/input';
19 | import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
20 | import { Label } from '@/components/ui/label';
21 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form';
22 |
23 | export function WithdrawalDialog({ children }: { children: React.ReactNode }) {
24 | const [open, setOpen] = useState(false);
25 | const [statistics, getSalesmanStatistics] = useSalesmanStore((state) => [
26 | state.statistics,
27 | state.getSalesmanStatistics,
28 | ]);
29 | const formSchema = z.object({
30 | price: z.string().refine(
31 | (val) => {
32 | return !(!Number(val) || Number(val) > Number(statistics.balance) || Number(val) * 1 < 0.1);
33 | },
34 | {
35 | message: '错误的提现金额',
36 | },
37 | ),
38 | channel: z.string().min(1, {
39 | message: '请选择提现方式',
40 | }),
41 | account: z.string().refine((val) => /^(?:(?:\+|00)86)?1\d{10}$/.test(val), {
42 | message: '错误的手机号码',
43 | }),
44 | name: z.string().min(2, {
45 | message: '错误的收款人姓名',
46 | }),
47 | });
48 |
49 | const form = useForm>({
50 | resolver: zodResolver(formSchema),
51 | defaultValues: {
52 | channel: 'alipay',
53 | price: '',
54 | account: '',
55 | name: '',
56 | },
57 | });
58 |
59 | useEffect(() => {
60 | const getSalesmanWithdrawalLast = async () => {
61 | const { config } = await salesmanService.getSalesmanWithdrawalLast();
62 | form.setValue('account', config.account || '');
63 | form.setValue('name', config.name || '');
64 | };
65 |
66 | if (open) {
67 | getSalesmanWithdrawalLast();
68 | }
69 | });
70 |
71 | const onSubmit = async (values: z.infer) => {
72 | try {
73 | const { account, channel, name, price } = values;
74 | await salesmanService.withdrawal({
75 | price,
76 | channel,
77 | config: {
78 | account,
79 | name,
80 | },
81 | });
82 | toast.success('申请成功');
83 | getSalesmanStatistics();
84 | setOpen(false);
85 | } catch (e) {
86 | toast.error(e as string);
87 | }
88 | };
89 |
90 | return (
91 | setOpen(val)}>
92 | {children}
93 |
94 |
95 |
96 | 提现申请
97 |
100 |
101 |
102 |
178 |
179 |
180 | );
181 | }
182 |
--------------------------------------------------------------------------------
/src/pages/salesman/WithdrawalListDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { X } from 'lucide-react';
3 | import InfiniteScroll from 'react-infinite-scroll-component';
4 |
5 | import salesmanService, { SalesmanWithdrawalType } from '@/api/salesman';
6 | import {
7 | AlertDialog,
8 | AlertDialogContent,
9 | AlertDialogHeader,
10 | AlertDialogTitle,
11 | AlertDialogTrigger,
12 | } from '@/components/ui/alert-dialog';
13 | import { Button } from '@/components/ui/button';
14 |
15 | const StatusMap: { [key: number]: string } = {
16 | 1: '待审核',
17 | 2: '审核通过',
18 | 3: '已提现',
19 | };
20 |
21 | export function WithdrawalListDialog({ children }: { children: React.ReactNode }) {
22 | const [open, setOpen] = useState(false);
23 | const [list, setList] = useState([]);
24 | const [perPage] = useState(20);
25 | const [page, setPage] = useState(1);
26 | const [hasMore, setHasMore] = useState(true);
27 |
28 | const fetchMoreData = async () => {
29 | const data: SalesmanWithdrawalType[] = await salesmanService.getSalesmanWithdrawalList(perPage, page);
30 | if (data.length < perPage) {
31 | setHasMore(false);
32 | } else {
33 | setPage(page + 1);
34 | }
35 | setList(list.concat(data));
36 | };
37 |
38 | useEffect(() => {
39 | fetchMoreData();
40 | }, []);
41 |
42 | return (
43 | setOpen(val)}>
44 | {children}
45 |
46 |
47 |
48 | 提现记录
49 |
52 |
53 |
54 |
55 |
加载中...}
58 | dataLength={list.length}
59 | endMessage={没有数据了~
}
60 | next={fetchMoreData}
61 | >
62 |
63 | {list.map((item, index) => (
64 | -
65 |
66 |
67 |
提现金额:{item.price}元
68 |
69 |
70 | 提现状态:{StatusMap[item.status]}
71 |
72 |
73 |
74 |
75 |
76 |
77 | 申请时间:{item.created_at}
78 |
79 | {item.status === 3 && item.serial_no ? (
80 |
81 | 支付宝订单号:{item.serial_no}
82 |
83 | ) : (
84 | <>>
85 | )}
86 |
87 |
88 |
89 |
90 | ))}
91 |
92 |
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/pages/salesman/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Loader2 } from 'lucide-react';
3 | import toast from 'react-hot-toast';
4 | import classNames from 'classnames';
5 | import { ClipboardList } from 'lucide-react';
6 |
7 | import TaskService, { SalesmanConfigType } from '@/api/task';
8 | import { Button } from '@/components/ui/button';
9 | import { useUserStore, useSalesmanStore } from '@/store';
10 | import userService from '@/api/user';
11 |
12 | import { ListScroll, TypeEnums } from './ListScroll';
13 | import { WithdrawalDialog } from './WithdrawalDialog';
14 | import { WithdrawalListDialog } from './WithdrawalListDialog';
15 |
16 | export default function Salesman() {
17 | const [salesmanConfig, setSalesmanConfig] = useState(null);
18 | const [isLoading, setIsLoading] = useState(false);
19 | const [currentType, setCurrentType] = useState(TypeEnums.CHILD);
20 | const [setUserInfo, { identity }] = useUserStore((state) => [state.setUserInfo, state.userInfo]);
21 | const [statistics, getSalesmanStatistics] = useSalesmanStore((state) => [
22 | state.statistics,
23 | state.getSalesmanStatistics,
24 | ]);
25 |
26 | useEffect(() => {
27 | const getSalesmanConfig = async () => {
28 | setIsLoading(true);
29 | const res = await TaskService.getSalesmanConfig();
30 | setSalesmanConfig(res);
31 | setIsLoading(false);
32 | };
33 |
34 | getSalesmanConfig();
35 | getSalesmanStatistics();
36 | }, []);
37 |
38 | // 成为分销员
39 | const handleBecomeSalesman = async () => {
40 | await userService.becomeSalesman();
41 | toast.success('申请分销员成功');
42 | const res = await userService.getUserProfile();
43 | setUserInfo(res);
44 | };
45 |
46 | return (
47 |
48 |
53 | {isLoading ? (
54 |
55 | ) : identity === 1 ? (
56 |
57 |
58 | {salesmanConfig?.enable && salesmanConfig?.open && (
59 |
62 | )}
63 |
64 | ) : (
65 |
66 |
67 |
68 |
69 |
70 |
71 | 剩余可提现:{statistics.balance}
72 |
73 |
74 |
77 |
78 |
79 |
80 |
当前佣金比例:{statistics.ratio}%
81 |
82 |
83 |
setCurrentType(TypeEnums.CHILD)}
91 | >
92 | {statistics.custom_num}
93 | 成功邀请客户数
94 |
95 |
setCurrentType(TypeEnums.ORDER)}
103 | >
104 | {statistics.order_price}
105 | 获得总佣金(元)
106 |
107 |
108 |
109 |
110 |
114 |
118 |
119 |
120 | )}
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/src/pages/user/ShareDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useState } from 'react';
2 | import { QRCodeCanvas } from 'qrcode.react';
3 | import { toast } from 'react-hot-toast';
4 | import { toSvg, toPng } from 'html-to-image';
5 | import { saveAs } from 'file-saver';
6 |
7 | import poster from '@/assets/poster.png';
8 | import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
9 | import { Input } from '@/components/ui/input';
10 | import { Button } from '@/components/ui/button';
11 | import { useMobileScreen } from '@/hooks/use-mobile-screen';
12 | import useWechat from '@/hooks/use-wechat';
13 | import useTask from '@/hooks/use-task';
14 | import { copyToClipboard } from '@/utils';
15 | import appService, { ShareConfigType } from '@/api/app';
16 | import { TaskTypeEnums } from '@/api/task';
17 |
18 | type ShareDialogProps = {
19 | open: boolean;
20 | shareUrl: string;
21 | type: TaskTypeEnums | undefined;
22 | handleOpenChange: (val: boolean) => void;
23 | };
24 |
25 | export function ShareDialog({ open, shareUrl, handleOpenChange, type }: ShareDialogProps) {
26 | const posterRef = useRef(null);
27 | const isMobileScreen = useMobileScreen();
28 | const [dataUrl, setDataUrl] = useState('');
29 | const [shareConfig, setShareConfig] = useState(Object.create(null));
30 | const { shareCallback } = useTask();
31 | const { isWeixinBrowser } = useWechat();
32 |
33 | const drawImage = async () => {
34 | if (!posterRef.current) return;
35 | // 微信浏览器中 toPng 方法,偶发生成失败,所以使用 toSvg 方法
36 | const drawImageFn = isWeixinBrowser ? toSvg : toPng;
37 | const res = await drawImageFn(posterRef.current, { style: { opacity: '1' } });
38 |
39 | setDataUrl(res);
40 | };
41 |
42 | useEffect(() => {
43 | if (open) {
44 | setTimeout(() => {
45 | drawImage();
46 | }, 500);
47 | }
48 | }, [open]);
49 |
50 | useEffect(() => {
51 | const handleGetShareConfig = async () => {
52 | const res = await appService.getShareConfig();
53 | setShareConfig(res);
54 | };
55 | handleGetShareConfig();
56 | }, []);
57 |
58 | return (
59 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/src/pages/user/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { Share2Icon, UserPlus2, JapaneseYenIcon } from 'lucide-react';
4 |
5 | import TaskService, { TaskType, TaskTypeEnums } from '@/api/task';
6 | import { useUserStore, useBillingStore, useAppStore } from '@/store';
7 | import { Button } from '@/components/ui/button';
8 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
9 | import { useMobileScreen } from '@/hooks/use-mobile-screen';
10 |
11 | import { ShareDialog } from './ShareDialog';
12 |
13 | const TypeActionMap = {
14 | [TaskTypeEnums.INVITE]: {
15 | button: '立即邀请',
16 | icon: ,
17 | completed: '立即邀请',
18 | },
19 | [TaskTypeEnums.SHARE]: {
20 | button: '立即分享',
21 | icon: ,
22 | completed: '立即分享',
23 | },
24 | [TaskTypeEnums.SALESMAN]: {
25 | button: '成为分销员',
26 | icon: ,
27 | completed: '立即前往',
28 | },
29 | };
30 |
31 | export default function User() {
32 | const [taskList, setTaskList] = useState([]);
33 | const [shareDialogShow, setShareDialogShow] = useState(false);
34 | const [activeType, setActiveType] = useState();
35 | const [{ avatar, nickname, openid, identity }, signOut] = useUserStore((state) => [state.userInfo, state.signOut]);
36 | const [currentBill, remaining, getCurrentBilling] = useBillingStore((state) => [
37 | state.currentBill,
38 | state.remaining(),
39 | state.getCurrentBilling,
40 | ]);
41 | const [appConfig] = useAppStore((state) => [state.appConfig]);
42 | const navigate = useNavigate();
43 | const isMobileScreen = useMobileScreen();
44 |
45 | const handleSignOut = () => {
46 | signOut();
47 | navigate('/login');
48 | };
49 |
50 | useEffect(() => {
51 | const getTaskList = async () => {
52 | const salesman = await getSalesmanConfig();
53 | const res = await TaskService.getTaskList(1);
54 | const list: TaskType[] = [...(salesman || []), ...res];
55 | setTaskList(list.filter((val) => val.type !== TaskTypeEnums.REGISTER));
56 | };
57 | // 获取分销配置
58 | const getSalesmanConfig = async (): Promise => {
59 | const { open, enable } = await TaskService.getSalesmanConfig();
60 | return (enable && open) || (!open && identity === 2)
61 | ? [
62 | {
63 | id: 0,
64 | type: TaskTypeEnums.SALESMAN,
65 | title: '分销赚钱',
66 | desc: '邀请好友,赚取佣金',
67 | is_completed: identity === 2,
68 | is_subscribe: true,
69 | model_count: 0,
70 | },
71 | ]
72 | : null;
73 | };
74 | getTaskList();
75 | getCurrentBilling();
76 | }, []);
77 |
78 | const getTypeActionButton = (type: TaskTypeEnums) => {
79 | return TypeActionMap[type as Exclude];
80 | };
81 |
82 | const handleDialogClick = async (type: TaskTypeEnums) => {
83 | setShareDialogShow(true);
84 | setActiveType(type);
85 | };
86 |
87 | return (
88 |
89 |
90 |
91 |
92 |
93 | {nickname.slice(0, 1)?.toUpperCase()}
94 |
95 |
{nickname}
96 |
97 |
98 |
99 |
100 | {remaining > 0 || currentBill?.num === -1
101 | ? `🎉 有效次数:${currentBill?.num === -1 ? '无限' : remaining}次`
102 | : '☹️ 可用余额不足'}
103 | {/* 时长类型的,num 为 -1 */}
104 | {currentBill?.expired_at && currentBill.num === -1 && (
105 |
{`⏰ 有效期至:${currentBill.expired_at}`}
106 | )}
107 |
108 |
111 |
112 |
113 |
114 |
任务列表
115 |
116 | {taskList.map((item, index) => (
117 |
118 |
119 | {getTypeActionButton(item.type).icon}
120 |
121 |
{item.title}
122 |
{item.desc}
123 |
124 |
135 |
136 |
137 | ))}
138 |
139 |
140 | {isMobileScreen && (
141 |
144 | )}
145 |
146 |
setShareDialogShow(val)}
151 | />
152 |
153 | );
154 | }
155 |
--------------------------------------------------------------------------------
/src/router/index.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter, redirect } from 'react-router-dom';
2 |
3 | import { StoreKey } from '@/constants';
4 | import Login from '@/pages/login';
5 | import Chat from '@/pages/chat';
6 | import User from '@/pages/user';
7 | import Billing from '@/pages/billing';
8 | import Salesman from '@/pages/salesman';
9 | import Layout from '@/layout/index';
10 | import ErrorPage from '@/pages/error-page';
11 | import toast from 'react-hot-toast';
12 |
13 | const authRedirect = () => {
14 | if (!localStorage.getItem(StoreKey.AccessToken)) {
15 | toast.error('请登录');
16 | return redirect('/login');
17 | }
18 | return null;
19 | };
20 |
21 | const router = createBrowserRouter([
22 | {
23 | path: '/',
24 | element: ,
25 | errorElement: ,
26 | children: [
27 | {
28 | path: 'chat',
29 | element: ,
30 | index: true,
31 | },
32 | {
33 | path: 'user',
34 | element: ,
35 | loader: authRedirect,
36 | },
37 | {
38 | path: 'billing',
39 | element: ,
40 | loader: authRedirect,
41 | },
42 | {
43 | path: 'salesman',
44 | element: ,
45 | loader: authRedirect,
46 | },
47 | ],
48 | },
49 | {
50 | path: '/login',
51 | element: ,
52 | errorElement: ,
53 | },
54 | ]);
55 |
56 | export default router;
57 |
--------------------------------------------------------------------------------
/src/store/app.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { persist } from 'zustand/middleware';
3 | import { AppConfigType, LoginTypeEnum } from '@/api/app';
4 |
5 | import { StoreKey } from '../constants';
6 |
7 | export enum ThemeModeType {
8 | DARK = 'dark',
9 | LIGHT = 'light',
10 | SYSTEM = 'system',
11 | }
12 |
13 | export enum LanguagesType {
14 | EN = 'en',
15 | ZH = 'zh',
16 | }
17 |
18 | interface AppState {
19 | theme: ThemeModeType;
20 | language: LanguagesType;
21 | loginType: LoginTypeEnum;
22 | appConfig: AppConfigType;
23 | setTheme: (theme: ThemeModeType) => void;
24 | setAppConfig: (theme: AppConfigType) => void;
25 | setLanguage: (language: LanguagesType) => void;
26 | setLoginType: (loginType: LoginTypeEnum) => void;
27 | }
28 |
29 | const initialState = {
30 | theme: ThemeModeType.SYSTEM,
31 | language: LanguagesType.ZH,
32 | loginType: LoginTypeEnum.WECHAT,
33 | appConfig: Object.create(null),
34 | };
35 |
36 | export const useAppStore = create()(
37 | persist(
38 | (set) => ({
39 | ...initialState,
40 |
41 | setTheme: (theme: ThemeModeType) => {
42 | const systemMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
43 | if (theme === ThemeModeType.DARK || (theme === ThemeModeType.SYSTEM && systemMode)) {
44 | document.documentElement.classList.add('dark');
45 | } else {
46 | document.documentElement.classList.remove('dark');
47 | }
48 | set({ theme });
49 | },
50 | setLanguage: (language: LanguagesType) => set({ language }),
51 | setLoginType: (loginType: LoginTypeEnum) => set({ loginType }),
52 | setAppConfig: (appConfig: AppConfigType) => set({ appConfig, loginType: appConfig.login_type }),
53 | }),
54 | {
55 | name: StoreKey.Config,
56 | },
57 | ),
58 | );
59 |
--------------------------------------------------------------------------------
/src/store/billing.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import userService, { BillType } from '@/api/user';
3 |
4 | interface BillState {
5 | currentBill: BillType | null;
6 | getCurrentBilling: () => void;
7 | remaining: () => number;
8 | }
9 |
10 | const initData = {
11 | currentBill: null,
12 | };
13 |
14 | export const useBillingStore = create()((set, get) => ({
15 | ...initData,
16 | getCurrentBilling: async () => {
17 | const res = await userService.getUserBill();
18 | set({ currentBill: res });
19 | },
20 | remaining: () => {
21 | const currentBill = get().currentBill;
22 | if (!currentBill || !currentBill.num) return 0;
23 | return currentBill.num - currentBill.used;
24 | },
25 | }));
26 |
--------------------------------------------------------------------------------
/src/store/chat.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { persist } from 'zustand/middleware';
3 | import { v4 as uuidv4 } from 'uuid';
4 | import { StoreKey } from '@/constants';
5 | import { streamAPI, StatusEnum } from '@/utils/stream-api';
6 |
7 | export type ConversationType = {
8 | icon: string;
9 | uuid: string;
10 | title: string;
11 | system: string;
12 | modelId: string;
13 | };
14 |
15 | export enum RoleTypeEnum {
16 | USER = 'user',
17 | ASSISTANT = 'assistant',
18 | SYSTEM = 'system',
19 | }
20 |
21 | export type ChatItemType = {
22 | id: string;
23 | text: string;
24 | error?: string;
25 | role: RoleTypeEnum;
26 | dateTime: string;
27 | messageId?: string;
28 | requestId: string;
29 | status?: StatusEnum;
30 | };
31 |
32 | interface ChatState {
33 | isStream: boolean;
34 | currentConversation: ConversationType;
35 | conversationList: ConversationType[];
36 | chatDataMap: Record;
37 | addConversation: (title?: string, icon?: string, system?: string, modelId?: string) => void;
38 | switchConversation: (id: string) => void;
39 | clearCurrentConversation: () => void;
40 | editConversation: (id: string, data: Partial) => void;
41 | delConversation: (id: string) => void;
42 | sendUserMessage: (message: string) => void;
43 | regenerateChat: (requestId: string) => void;
44 | currentChatData: () => ChatItemType[];
45 | stopStream: () => void;
46 | setStream: (val: boolean) => void;
47 | chatProgress: (message: string, requestId: string, lastId: string, chatIndex: number, id: string) => void;
48 | }
49 |
50 | const DefaultConversation = {
51 | icon: '',
52 | uuid: uuidv4(),
53 | title: '新话题',
54 | system: '',
55 | modelId: '',
56 | };
57 |
58 | const initialState = {
59 | isStream: false,
60 | currentConversation: DefaultConversation,
61 | conversationList: [DefaultConversation],
62 | chatDataMap: {},
63 | };
64 |
65 | export const useChatStore = create()(
66 | persist(
67 | (set, get) => ({
68 | ...initialState,
69 | addConversation(title = '新话题', icon = '', system = '', modelId = '') {
70 | const uuid = uuidv4();
71 |
72 | const newConversation = { title, uuid, icon, system, modelId };
73 |
74 | set((state) => ({
75 | currentConversation: newConversation,
76 | conversationList: [newConversation, ...state.conversationList],
77 | chatDataMap: {
78 | [uuid]: system
79 | ? [
80 | {
81 | id: uuidv4(),
82 | text: system,
83 | role: RoleTypeEnum.SYSTEM,
84 | dateTime: new Date().toISOString(),
85 | requestId: '',
86 | },
87 | ]
88 | : [],
89 | ...state.chatDataMap,
90 | },
91 | }));
92 | },
93 | switchConversation(id: string) {
94 | set((state) => ({
95 | currentConversation: state.conversationList.find((item) => item.uuid === id),
96 | }));
97 | },
98 | clearCurrentConversation() {
99 | const { uuid } = get().currentConversation;
100 | set((state) => ({
101 | chatDataMap: {
102 | ...state.chatDataMap,
103 | [uuid]: [],
104 | },
105 | }));
106 | },
107 | editConversation(id: string, data: Partial) {
108 | const newConversationList = get().conversationList.map((item) => {
109 | if (item.uuid == id) {
110 | return {
111 | ...item,
112 | ...data,
113 | };
114 | } else {
115 | return item;
116 | }
117 | });
118 |
119 | set(() => ({
120 | conversationList: newConversationList,
121 | }));
122 | },
123 | delConversation(id: string) {
124 | let newConversationList = get().conversationList.filter((item) => item.uuid !== id);
125 | if (newConversationList.length === 0) {
126 | newConversationList = [DefaultConversation];
127 | }
128 | const newChatDataMap = get().chatDataMap;
129 |
130 | delete newChatDataMap[id];
131 |
132 | set(() => ({
133 | chatDataMap: newChatDataMap,
134 | conversationList: newConversationList,
135 | }));
136 |
137 | if (id === get().currentConversation.uuid) {
138 | set({ currentConversation: newConversationList[0] });
139 | }
140 | },
141 | chatProgress(message: string, requestId: string, lastId = '', chatIndex: number, id: string) {
142 | const currentChatData = get().currentChatData();
143 | const chatData = get().currentChatData();
144 | const chatDataMap = get().chatDataMap;
145 | const currentConversationId = get().currentConversation.uuid;
146 | chatDataMap[currentConversationId] = chatData;
147 |
148 | streamAPI.send({
149 | message: message,
150 | modelId: get().currentConversation.modelId,
151 | requestId,
152 | lastId,
153 | onProgress: (data) => {
154 | currentChatData[currentChatData.length - 1] = {
155 | id,
156 | text: data.messages,
157 | role: RoleTypeEnum.ASSISTANT,
158 | status: StatusEnum.PENDING,
159 | dateTime: new Date().toISOString(),
160 | messageId: data.id,
161 | requestId,
162 | };
163 | set({ chatDataMap });
164 | },
165 | onFinish: (data) => {
166 | currentChatData[chatIndex] = {
167 | ...currentChatData[chatIndex],
168 | text: data.messages,
169 | status: StatusEnum.SUCCESS,
170 | };
171 | set({ chatDataMap, isStream: false });
172 | },
173 | onError: (reason) => {
174 | currentChatData[chatIndex] = {
175 | ...currentChatData[chatIndex],
176 | error: reason,
177 | status: StatusEnum.ERROR,
178 | };
179 | set({ chatDataMap, isStream: false });
180 | },
181 | });
182 | },
183 | sendUserMessage(message: string) {
184 | const chatData = get().currentChatData();
185 | const requestId = uuidv4();
186 | const id = uuidv4();
187 |
188 | const newChatData = [
189 | ...chatData,
190 | { text: message, role: RoleTypeEnum.USER, dateTime: new Date().toISOString(), requestId, id: uuidv4() },
191 | {
192 | id,
193 | text: '',
194 | role: RoleTypeEnum.ASSISTANT,
195 | dateTime: new Date().toISOString(),
196 | requestId,
197 | status: StatusEnum.START,
198 | },
199 | ];
200 | const chatDataMap = get().chatDataMap;
201 | const currentConversationId = get().currentConversation.uuid;
202 | chatDataMap[currentConversationId] = newChatData;
203 | set({ chatDataMap, isStream: true });
204 |
205 | const lastId = chatData.filter((item) => item.role === RoleTypeEnum.ASSISTANT)?.pop()?.messageId || '';
206 |
207 | get().chatProgress(message, requestId, lastId, newChatData.length - 1, id);
208 | },
209 | regenerateChat(requestId) {
210 | const chatData = get().currentChatData();
211 | const chatDataMap = get().chatDataMap;
212 | const currentConversationId = get().currentConversation.uuid;
213 | const id = uuidv4();
214 |
215 | const userInputIndex = chatData.findIndex(
216 | (item) => item.role === RoleTypeEnum.USER && item.requestId === requestId,
217 | );
218 | const userInputText = chatData[userInputIndex].text;
219 |
220 | const lastId =
221 | chatData
222 | .slice(0, userInputIndex)
223 | .filter((item) => item.role === RoleTypeEnum.ASSISTANT)
224 | ?.pop()?.messageId || '';
225 |
226 | const assistantIndex = chatData.findIndex(
227 | (item) => item.role === RoleTypeEnum.ASSISTANT && item.requestId === requestId,
228 | );
229 |
230 | chatData[assistantIndex] = {
231 | ...chatData[assistantIndex],
232 | status: StatusEnum.START,
233 | };
234 | chatDataMap[currentConversationId] = chatData;
235 | set({ chatDataMap });
236 |
237 | get().chatProgress(userInputText, requestId, lastId, assistantIndex, id);
238 | },
239 | currentChatData() {
240 | return get().chatDataMap[get().currentConversation.uuid] || [];
241 | },
242 | stopStream() {
243 | streamAPI.abort();
244 | set({ isStream: false });
245 | },
246 | setStream(val) {
247 | set({ isStream: val });
248 | },
249 | }),
250 | {
251 | name: StoreKey.Chat,
252 | },
253 | ),
254 | );
255 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | export * from './app';
2 | export * from './user';
3 | export * from './chat';
4 | export * from './billing';
5 | export * from './salesman';
6 |
--------------------------------------------------------------------------------
/src/store/salesman.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | import salesmanService, { SalesmanStatisticsType } from '@/api/salesman';
4 |
5 | interface SalesmanState {
6 | statistics: SalesmanStatisticsType;
7 | getSalesmanStatistics: () => void;
8 | }
9 |
10 | const initialState = {
11 | statistics: {
12 | order_num: 0,
13 | order_price: '0.00',
14 | custom_num: 0,
15 | balance: '0.00',
16 | ratio: 0,
17 | },
18 | };
19 |
20 | export const useSalesmanStore = create()((set) => ({
21 | ...initialState,
22 | getSalesmanStatistics: async () => {
23 | const res = await salesmanService.getSalesmanStatistics();
24 | set({ statistics: res });
25 | },
26 | }));
27 |
--------------------------------------------------------------------------------
/src/store/user.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { persist } from 'zustand/middleware';
3 |
4 | import { StoreKey } from '@/constants';
5 | import { UserInfoType } from '@/api/user';
6 |
7 | interface UserState {
8 | access_token: string;
9 | userInfo: UserInfoType;
10 | setUserInfo: (val: UserInfoType) => void;
11 | setAccessToken: (val: string) => void;
12 | signOut: () => void;
13 | isLogin: () => boolean;
14 | }
15 |
16 | const initialState = {
17 | access_token: '',
18 | userInfo: {
19 | nickname: '',
20 | avatar: '',
21 | identity: 1,
22 | openid: '',
23 | },
24 | };
25 |
26 | export const useUserStore = create()(
27 | persist(
28 | (set) => ({
29 | ...initialState,
30 | setUserInfo: (val: UserInfoType) => set({ userInfo: val }),
31 | setAccessToken: (val: string) => {
32 | localStorage.setItem(StoreKey.AccessToken, val);
33 | set({ access_token: val });
34 | },
35 | signOut() {
36 | set({ ...initialState });
37 | localStorage.removeItem(StoreKey.AccessToken);
38 | },
39 | isLogin() {
40 | return !!localStorage.getItem(StoreKey.AccessToken);
41 | },
42 | }),
43 | {
44 | name: StoreKey.User,
45 | },
46 | ),
47 | );
48 |
--------------------------------------------------------------------------------
/src/styles/tailwind.less:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 47.4% 11.2%;
9 |
10 | --muted: 210 40% 96.1%;
11 | --muted-foreground: 215.4 16.3% 46.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 47.4% 11.2%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 222.2 47.4% 11.2%;
18 |
19 | --border: 214.3 31.8% 91.4%;
20 | --input: 214.3 31.8% 91.4%;
21 |
22 | --primary: 222.2 47.4% 11.2%;
23 | --primary-foreground: 210 40% 98%;
24 |
25 | --secondary: 210 40% 96.1%;
26 | --secondary-foreground: 222.2 47.4% 11.2%;
27 |
28 | --accent: 210 40% 96.1%;
29 | --accent-foreground: 222.2 47.4% 11.2%;
30 |
31 | --destructive: 0 100% 50%;
32 | --destructive-foreground: 210 40% 98%;
33 |
34 | --ring: 215 20.2% 65.1%;
35 |
36 | --radius: 0.5rem;
37 | }
38 |
39 | .dark {
40 | --background: 224 71% 4%;
41 | --foreground: 213 31% 91%;
42 |
43 | --muted: 223 47% 11%;
44 | --muted-foreground: 215.4 16.3% 56.9%;
45 |
46 | --popover: 224 71% 4%;
47 | --popover-foreground: 215 20.2% 65.1%;
48 |
49 | --card: 224 71% 4%;
50 | --card-foreground: 213 31% 91%;
51 |
52 | --border: 216 34% 17%;
53 | --input: 216 34% 17%;
54 |
55 | --primary: 210 40% 98%;
56 | --primary-foreground: 222.2 47.4% 1.2%;
57 |
58 | --secondary: 222.2 47.4% 11.2%;
59 | --secondary-foreground: 210 40% 98%;
60 |
61 | --accent: 216 34% 17%;
62 | --accent-foreground: 210 40% 98%;
63 |
64 | --destructive: 0 63% 31%;
65 | --destructive-foreground: 210 40% 98%;
66 |
67 | --ring: 216 34% 17%;
68 |
69 | --radius: 0.5rem;
70 | }
71 | }
72 |
73 | @layer base {
74 | * {
75 | @apply border-border;
76 | }
77 | body {
78 | @apply bg-background text-foreground;
79 | font-feature-settings: "rlig" 1, "calt" 1;
80 | }
81 | }
82 |
83 | @layer components {
84 | .markdown-table {
85 | @apply prose dark:prose-invert prose-th:border prose-th:text-center prose-th:border-gray-300 prose-td:border prose-td:text-center prose-td:border-gray-300;
86 | }
87 | }
88 |
89 | .scroll-bar-none {
90 | &::-webkit-scrollbar {
91 | display: none;
92 | }
93 | }
94 |
95 | .markdown-table :where(p):not(:where([class~="not-prose"] *)){
96 | margin: 0;
97 | }
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { twMerge } from 'tailwind-merge';
2 | import { ClassValue, clsx } from 'clsx';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export async function copyToClipboard(text: string, targetDom?: Element | null) {
9 | try {
10 | await navigator.clipboard.writeText(text.trim());
11 | } catch (error) {
12 | const textarea = document.createElement('textarea');
13 | textarea.value = text.trim();
14 | document.body.appendChild(textarea);
15 | if (targetDom) {
16 | targetDom.appendChild(textarea);
17 | } else {
18 | document.body.appendChild(textarea);
19 | }
20 | textarea.select();
21 | document.execCommand('copy');
22 |
23 | if (targetDom) {
24 | targetDom.removeChild(textarea);
25 | } else {
26 | document.body.removeChild(textarea);
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/request.ts:
--------------------------------------------------------------------------------
1 | import { StoreKey } from '@/constants';
2 | export const API_DOMAIN = import.meta.env.VITE_API_DOMAIN;
3 |
4 | const request = async (url: string, options: RequestInit = {}) => {
5 | const access_token = localStorage.getItem(StoreKey.AccessToken);
6 |
7 | options.headers = {
8 | 'Content-Type': 'application/json',
9 | Authorization: `Bearer ${access_token}`,
10 | };
11 |
12 | const response = await fetch(`${API_DOMAIN}/${url}`, options);
13 |
14 | if (response.status >= 200 && response.status <= 300) {
15 | const data = await response.json();
16 | if (data.err_code > 0) {
17 | throw data.err_msg;
18 | }
19 | return data.data;
20 | }
21 | };
22 |
23 | export default request;
24 |
--------------------------------------------------------------------------------
/src/utils/stream-api.ts:
--------------------------------------------------------------------------------
1 | import { API_DOMAIN } from './request';
2 | import { StoreKey } from '@/constants';
3 | import { isEmpty } from 'lodash-es';
4 |
5 | export enum StatusEnum {
6 | START = 'start',
7 | PENDING = 'pending',
8 | SUCCESS = 'success',
9 | ERROR = 'error',
10 | ABORT = 'abort',
11 | }
12 |
13 | interface StreamResponseType {
14 | messages: string;
15 | id: string;
16 | }
17 |
18 | type SendMessageType = {
19 | message: string;
20 | modelId?: string;
21 | requestId?: string;
22 | lastId?: string;
23 | onProgress: (_: StreamResponseType) => void;
24 | onFinish: (_: StreamResponseType) => void;
25 | onError: (_: string) => void;
26 | };
27 |
28 | export default class StreamAPI {
29 | private _status: string;
30 | constructor() {
31 | this._status = StatusEnum.START;
32 | }
33 |
34 | set status(status) {
35 | this._status = status;
36 | }
37 |
38 | get status() {
39 | return this._status;
40 | }
41 |
42 | abort() {
43 | this.status = StatusEnum.ABORT;
44 | }
45 |
46 | async send({ message, modelId, requestId, lastId, onProgress, onFinish, onError }: SendMessageType) {
47 | this.status = StatusEnum.START;
48 |
49 | const access_token = localStorage.getItem(StoreKey.AccessToken);
50 |
51 | const response = await fetch(`${API_DOMAIN}/openai/chat-process`, {
52 | method: 'POST',
53 | headers: {
54 | 'Content-Type': 'application/json',
55 | Authorization: `Bearer ${access_token}`,
56 | },
57 | body: JSON.stringify({
58 | message,
59 | model_id: modelId,
60 | request_id: requestId,
61 | last_id: lastId,
62 | }),
63 | });
64 |
65 | if (!response.ok) {
66 | onError('连接失败,请重试');
67 | this.status = StatusEnum.ERROR;
68 | return;
69 | }
70 |
71 | const data = response.body;
72 | if (!data) {
73 | onError('无响应数据,请重试');
74 | this.status = StatusEnum.ERROR;
75 | return;
76 | }
77 |
78 | if (this.status === StatusEnum.START) {
79 | this.status = StatusEnum.PENDING;
80 | }
81 |
82 | const reader = data.getReader();
83 | const decoder = new TextDecoder('utf-8');
84 | let done = false;
85 |
86 | let resChunkValue;
87 |
88 | while (!done && this.status === StatusEnum.PENDING) {
89 | const { value, done: doneReading } = await reader.read();
90 | done = doneReading;
91 |
92 | const chunkValue = decoder.decode(value);
93 |
94 | try {
95 | const dataList = chunkValue.split('\n\ndata :');
96 | const chunk = dataList[dataList.length - 2] || dataList[dataList.length - 1];
97 | if (chunk) {
98 | resChunkValue = JSON.parse(chunk);
99 | if (resChunkValue.err_code > 0) {
100 | onError(resChunkValue.err_msg);
101 | return;
102 | }
103 | onProgress(resChunkValue);
104 | }
105 | } catch (e) {
106 | console.log(e);
107 | }
108 | }
109 | if (this.status === StatusEnum.ABORT) {
110 | this.status = StatusEnum.SUCCESS;
111 | resChunkValue.messages = resChunkValue.messages + '\n[您中断了回答,若继续请刷新重试!]';
112 | onFinish(resChunkValue);
113 | }
114 |
115 | if (done || this.status === StatusEnum.SUCCESS) {
116 | this.status = StatusEnum.SUCCESS;
117 | if (isEmpty(resChunkValue)) {
118 | onError('无响应数据,请重试');
119 | } else {
120 | onFinish(resChunkValue);
121 | }
122 | }
123 | }
124 | }
125 |
126 | export const streamAPI = new StreamAPI();
127 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require('tailwindcss/defaultTheme');
2 |
3 | export default {
4 | darkMode: 'class',
5 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
6 | theme: {
7 | container: {
8 | center: true,
9 | padding: '2rem',
10 | screens: {
11 | '2xl': '1400px',
12 | },
13 | },
14 | extend: {
15 | colors: {
16 | border: 'hsl(var(--border))',
17 | input: 'hsl(var(--input))',
18 | ring: 'hsl(var(--ring))',
19 | background: 'hsl(var(--background))',
20 | foreground: 'hsl(var(--foreground))',
21 | primary: {
22 | DEFAULT: 'hsl(var(--primary))',
23 | foreground: 'hsl(var(--primary-foreground))',
24 | },
25 | secondary: {
26 | DEFAULT: 'hsl(var(--secondary))',
27 | foreground: 'hsl(var(--secondary-foreground))',
28 | },
29 | destructive: {
30 | DEFAULT: 'hsl(var(--destructive))',
31 | foreground: 'hsl(var(--destructive-foreground))',
32 | },
33 | muted: {
34 | DEFAULT: 'hsl(var(--muted))',
35 | foreground: 'hsl(var(--muted-foreground))',
36 | },
37 | accent: {
38 | DEFAULT: 'hsl(var(--accent))',
39 | foreground: 'hsl(var(--accent-foreground))',
40 | },
41 | popover: {
42 | DEFAULT: 'hsl(var(--popover))',
43 | foreground: 'hsl(var(--popover-foreground))',
44 | },
45 | card: {
46 | DEFAULT: 'hsl(var(--card))',
47 | foreground: 'hsl(var(--card-foreground))',
48 | },
49 | },
50 | borderRadius: {
51 | lg: `var(--radius)`,
52 | md: `calc(var(--radius) - 2px)`,
53 | sm: 'calc(var(--radius) - 4px)',
54 | },
55 | fontFamily: {
56 | sans: ['var(--font-sans)', ...fontFamily.sans],
57 | },
58 | keyframes: {
59 | 'accordion-down': {
60 | from: { height: 0 },
61 | to: { height: 'var(--radix-accordion-content-height)' },
62 | },
63 | 'accordion-up': {
64 | from: { height: 'var(--radix-accordion-content-height)' },
65 | to: { height: 0 },
66 | },
67 | },
68 | animation: {
69 | 'accordion-down': 'accordion-down 0.2s ease-out',
70 | 'accordion-up': 'accordion-up 0.2s ease-out',
71 | },
72 | },
73 | },
74 | plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
75 | };
76 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "ES2020",
7 | "DOM",
8 | "DOM.Iterable"
9 | ],
10 | "module": "ESNext",
11 | "skipLibCheck": true,
12 | /* Bundler mode */
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | "types": [
20 | "vite/client"
21 | ],
22 | /* Linting */
23 | "strict": true,
24 | "noUnusedLocals": true,
25 | "noUnusedParameters": true,
26 | "noFallthroughCasesInSwitch": true,
27 | "paths": {
28 | "@/*": [
29 | "./src/*"
30 | ]
31 | }
32 | },
33 | "include": [
34 | "src"
35 | ],
36 | "references": [
37 | {
38 | "path": "./tsconfig.node.json"
39 | }
40 | ],
41 | }
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/(.*)",
5 | "destination": "/"
6 | }
7 | ]
8 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react-swc';
3 | import path from 'path';
4 |
5 | export default defineConfig({
6 | plugins: [react()],
7 | resolve: {
8 | alias: {
9 | '@': path.resolve(__dirname, './src'),
10 | },
11 | },
12 | server: {
13 | host: '0.0.0.0',
14 | },
15 | });
16 |
--------------------------------------------------------------------------------