diff --git a/.github/workflows/frontend-ci-cd.yml b/.github/workflows/frontend-ci-cd.yml index 3980766..e94c03b 100644 --- a/.github/workflows/frontend-ci-cd.yml +++ b/.github/workflows/frontend-ci-cd.yml @@ -3,7 +3,7 @@ name: Frontend CI/CD on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+*" + - 'v[0-9]+.[0-9]+.[0-9]+*' paths: - 'ui/**' - '.github/workflows/frontend-ci-cd.yml' @@ -29,12 +29,12 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '20.19.0' - name: Set up pnpm uses: pnpm/action-setup@v2 with: - version: 8 + version: 10.12.1 - name: Get version id: get_version @@ -130,4 +130,4 @@ jobs: ${{ env.REGISTRY }}/frontend:${{ needs.build.outputs.version }} ${{ env.REGISTRY }}/frontend:latest cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/ui/Makefile b/ui/Makefile new file mode 100644 index 0000000..59557ef --- /dev/null +++ b/ui/Makefile @@ -0,0 +1,21 @@ +PLATFORM=linux/amd64 +TAG=main +REGISTRY=monkeycode + + +# 构建前端代码 +build: + pnpm run build + +# 构建并加载到本地Docker +image: build + docker buildx build \ + -f .Dockerfile \ + --platform ${PLATFORM} \ + --tag ${REGISTRY}/frontend:${TAG} \ + --load \ + . + +save: image + docker save -o /tmp/monkeycode_frontend.tar monkeycode/frontend:main + \ No newline at end of file diff --git a/ui/src/api/Admin.ts b/ui/src/api/Admin.ts new file mode 100644 index 0000000..4eaed3c --- /dev/null +++ b/ui/src/api/Admin.ts @@ -0,0 +1,232 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import request, { ContentType, RequestParams } from "./httpClient"; +import { + DeleteDeleteAdminParams, + DomainAdminUser, + DomainCreateAdminReq, + DomainListAdminLoginHistoryResp, + DomainListAdminUserResp, + DomainLoginReq, + DomainSetting, + DomainUpdateSettingReq, + GetAdminLoginHistoryParams, + GetListAdminUserParams, + WebResp, +} from "./types"; + +/** + * @description 创建管理员 + * + * @tags Admin + * @name PostCreateAdmin + * @summary 创建管理员 + * @request POST:/api/v1/admin/create + * @response `200` `(WebResp & { + data?: DomainAdminUser, + +})` OK + */ + +export const postCreateAdmin = ( + param: DomainCreateAdminReq, + params: RequestParams = {}, +) => + request< + WebResp & { + data?: DomainAdminUser; + } + >({ + path: `/api/v1/admin/create`, + method: "POST", + body: param, + type: ContentType.Json, + format: "json", + ...params, + }); + +/** + * @description 删除管理员 + * + * @tags Admin + * @name DeleteDeleteAdmin + * @summary 删除管理员 + * @request DELETE:/api/v1/admin/delete + * @response `200` `(WebResp & { + data?: Record, + +})` OK + */ + +export const deleteDeleteAdmin = ( + query: DeleteDeleteAdminParams, + params: RequestParams = {}, +) => + request< + WebResp & { + data?: Record; + } + >({ + path: `/api/v1/admin/delete`, + method: "DELETE", + query: query, + type: ContentType.Json, + format: "json", + ...params, + }); + +/** + * @description 获取管理员用户列表 + * + * @tags Admin + * @name GetListAdminUser + * @summary 获取管理员用户列表 + * @request GET:/api/v1/admin/list + * @response `200` `(WebResp & { + data?: DomainListAdminUserResp, + +})` OK + */ + +export const getListAdminUser = ( + query: GetListAdminUserParams, + params: RequestParams = {}, +) => + request< + WebResp & { + data?: DomainListAdminUserResp; + } + >({ + path: `/api/v1/admin/list`, + method: "GET", + query: query, + type: ContentType.Json, + format: "json", + ...params, + }); + +/** + * @description 管理员登录 + * + * @tags Admin + * @name PostAdminLogin + * @summary 管理员登录 + * @request POST:/api/v1/admin/login + * @response `200` `(WebResp & { + data?: DomainAdminUser, + +})` OK + */ + +export const postAdminLogin = ( + param: DomainLoginReq, + params: RequestParams = {}, +) => + request< + WebResp & { + data?: DomainAdminUser; + } + >({ + path: `/api/v1/admin/login`, + method: "POST", + body: param, + type: ContentType.Json, + format: "json", + ...params, + }); + +/** + * @description 获取管理员登录历史 + * + * @tags Admin + * @name GetAdminLoginHistory + * @summary 获取管理员登录历史 + * @request GET:/api/v1/admin/login-history + * @response `200` `(WebResp & { + data?: DomainListAdminLoginHistoryResp, + +})` OK + */ + +export const getAdminLoginHistory = ( + query: GetAdminLoginHistoryParams, + params: RequestParams = {}, +) => + request< + WebResp & { + data?: DomainListAdminLoginHistoryResp; + } + >({ + path: `/api/v1/admin/login-history`, + method: "GET", + query: query, + type: ContentType.Json, + format: "json", + ...params, + }); + +/** + * @description 获取系统设置 + * + * @tags Admin + * @name GetGetSetting + * @summary 获取系统设置 + * @request GET:/api/v1/admin/setting + * @response `200` `(WebResp & { + data?: DomainSetting, + +})` OK + */ + +export const getGetSetting = (params: RequestParams = {}) => + request< + WebResp & { + data?: DomainSetting; + } + >({ + path: `/api/v1/admin/setting`, + method: "GET", + type: ContentType.Json, + format: "json", + ...params, + }); + +/** + * @description 更新系统设置 + * + * @tags Admin + * @name PutUpdateSetting + * @summary 更新系统设置 + * @request PUT:/api/v1/admin/setting + * @response `200` `(WebResp & { + data?: DomainSetting, + +})` OK + */ + +export const putUpdateSetting = ( + param: DomainUpdateSettingReq, + params: RequestParams = {}, +) => + request< + WebResp & { + data?: DomainSetting; + } + >({ + path: `/api/v1/admin/setting`, + method: "PUT", + body: param, + type: ContentType.Json, + format: "json", + ...params, + }); diff --git a/ui/src/api/User.ts b/ui/src/api/User.ts index 8646910..7ebbd1a 100644 --- a/ui/src/api/User.ts +++ b/ui/src/api/User.ts @@ -12,25 +12,16 @@ import request, { ContentType, RequestParams } from "./httpClient"; import { - DeleteDeleteAdminParams, DeleteDeleteUserParams, - DomainAdminUser, - DomainCreateAdminReq, DomainInviteResp, - DomainListAdminLoginHistoryResp, - DomainListAdminUserResp, DomainListLoginHistoryResp, DomainListUserResp, DomainLoginReq, DomainLoginResp, DomainOAuthURLResp, DomainRegisterReq, - DomainSetting, - DomainUpdateSettingReq, DomainUpdateUserReq, DomainUser, - GetAdminLoginHistoryParams, - GetListAdminUserParams, GetListUserParams, GetLoginHistoryParams, GetUserOauthCallbackParams, @@ -38,212 +29,6 @@ import { WebResp, } from "./types"; -/** - * @description 创建管理员 - * - * @tags User - * @name PostCreateAdmin - * @summary 创建管理员 - * @request POST:/api/v1/admin/create - * @response `200` `(WebResp & { - data?: DomainAdminUser, - -})` OK - */ - -export const postCreateAdmin = ( - param: DomainCreateAdminReq, - params: RequestParams = {}, -) => - request< - WebResp & { - data?: DomainAdminUser; - } - >({ - path: `/api/v1/admin/create`, - method: "POST", - body: param, - type: ContentType.Json, - format: "json", - ...params, - }); - -/** - * @description 删除管理员 - * - * @tags User - * @name DeleteDeleteAdmin - * @summary 删除管理员 - * @request DELETE:/api/v1/admin/delete - * @response `200` `(WebResp & { - data?: Record, - -})` OK - */ - -export const deleteDeleteAdmin = ( - query: DeleteDeleteAdminParams, - params: RequestParams = {}, -) => - request< - WebResp & { - data?: Record; - } - >({ - path: `/api/v1/admin/delete`, - method: "DELETE", - query: query, - type: ContentType.Json, - format: "json", - ...params, - }); - -/** - * @description 获取管理员用户列表 - * - * @tags User - * @name GetListAdminUser - * @summary 获取管理员用户列表 - * @request GET:/api/v1/admin/list - * @response `200` `(WebResp & { - data?: DomainListAdminUserResp, - -})` OK - */ - -export const getListAdminUser = ( - query: GetListAdminUserParams, - params: RequestParams = {}, -) => - request< - WebResp & { - data?: DomainListAdminUserResp; - } - >({ - path: `/api/v1/admin/list`, - method: "GET", - query: query, - type: ContentType.Json, - format: "json", - ...params, - }); - -/** - * @description 管理员登录 - * - * @tags User - * @name PostAdminLogin - * @summary 管理员登录 - * @request POST:/api/v1/admin/login - * @response `200` `(WebResp & { - data?: DomainAdminUser, - -})` OK - */ - -export const postAdminLogin = ( - param: DomainLoginReq, - params: RequestParams = {}, -) => - request< - WebResp & { - data?: DomainAdminUser; - } - >({ - path: `/api/v1/admin/login`, - method: "POST", - body: param, - type: ContentType.Json, - format: "json", - ...params, - }); - -/** - * @description 获取管理员登录历史 - * - * @tags User - * @name GetAdminLoginHistory - * @summary 获取管理员登录历史 - * @request GET:/api/v1/admin/login-history - * @response `200` `(WebResp & { - data?: DomainListAdminLoginHistoryResp, - -})` OK - */ - -export const getAdminLoginHistory = ( - query: GetAdminLoginHistoryParams, - params: RequestParams = {}, -) => - request< - WebResp & { - data?: DomainListAdminLoginHistoryResp; - } - >({ - path: `/api/v1/admin/login-history`, - method: "GET", - query: query, - type: ContentType.Json, - format: "json", - ...params, - }); - -/** - * @description 获取系统设置 - * - * @tags User - * @name GetGetSetting - * @summary 获取系统设置 - * @request GET:/api/v1/admin/setting - * @response `200` `(WebResp & { - data?: DomainSetting, - -})` OK - */ - -export const getGetSetting = (params: RequestParams = {}) => - request< - WebResp & { - data?: DomainSetting; - } - >({ - path: `/api/v1/admin/setting`, - method: "GET", - type: ContentType.Json, - format: "json", - ...params, - }); - -/** - * @description 更新系统设置 - * - * @tags User - * @name PutUpdateSetting - * @summary 更新系统设置 - * @request PUT:/api/v1/admin/setting - * @response `200` `(WebResp & { - data?: DomainSetting, - -})` OK - */ - -export const putUpdateSetting = ( - param: DomainUpdateSettingReq, - params: RequestParams = {}, -) => - request< - WebResp & { - data?: DomainSetting; - } - >({ - path: `/api/v1/admin/setting`, - method: "PUT", - body: param, - type: ContentType.Json, - format: "json", - ...params, - }); - /** * @description 下载VSCode插件 * diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 482f2a8..c9ce7b6 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -1,3 +1,4 @@ +export * from './Admin' export * from './Billing' export * from './Dashboard' export * from './Model' diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 25966d0..7b73905 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ /* tslint:disable */ // @ts-nocheck /* @@ -10,37 +11,38 @@ */ export enum ConstsUserStatus { - UserStatusActive = 'active', - UserStatusInactive = 'inactive', - UserStatusLocked = 'locked', + UserStatusActive = "active", + UserStatusInactive = "inactive", + UserStatusLocked = "locked", } export enum ConstsUserPlatform { - UserPlatformEmail = 'email', - UserPlatformDingTalk = 'dingtalk', + UserPlatformEmail = "email", + UserPlatformDingTalk = "dingtalk", + UserPlatformCustom = "custom", } export enum ConstsModelType { - ModelTypeLLM = 'llm', - ModelTypeCoder = 'coder', - ModelTypeEmbedding = 'embedding', - ModelTypeAudio = 'audio', - ModelTypeReranker = 'reranker', + ModelTypeLLM = "llm", + ModelTypeCoder = "coder", + ModelTypeEmbedding = "embedding", + ModelTypeAudio = "audio", + ModelTypeReranker = "reranker", } export enum ConstsModelStatus { - ModelStatusActive = 'active', - ModelStatusInactive = 'inactive', + ModelStatusActive = "active", + ModelStatusInactive = "inactive", } export enum ConstsChatRole { - ChatRoleUser = 'user', - ChatRoleAssistant = 'assistant', + ChatRoleUser = "user", + ChatRoleAssistant = "assistant", } export enum ConstsAdminStatus { - AdminStatusActive = 'active', - AdminStatusInactive = 'inactive', + AdminStatusActive = "active", + AdminStatusInactive = "inactive", } export interface DomainAcceptCompletionReq { @@ -183,6 +185,40 @@ export interface DomainCreateModelReq { provider?: string; } +export interface DomainCustomOAuth { + /** 自定义OAuth访问令牌URL */ + access_token_url?: string; + /** 自定义OAuth授权URL */ + authorize_url?: string; + /** 用户信息回包中的头像URL字段名` */ + avatar_field?: string; + /** 自定义客户端ID */ + client_id?: string; + /** 自定义客户端密钥 */ + client_secret?: string; + /** 用户信息回包中的邮箱字段名 */ + email_field?: string; + /** 自定义OAuth开关 */ + enable?: boolean; + /** 用户信息回包中的ID字段名 */ + id_field?: string; + /** 用户信息回包中的用户名字段名` */ + name_field?: string; + /** 自定义OAuth Scope列表 */ + scopes?: string[]; + /** 自定义OAuth用户信息URL */ + userinfo_url?: string; +} + +export interface DomainDingtalkOAuth { + /** 钉钉客户端ID */ + client_id?: string; + /** 钉钉客户端密钥 */ + client_secret?: string; + /** 钉钉OAuth开关 */ + enable?: boolean; +} + export interface DomainIPInfo { /** ASN */ asn?: string; @@ -355,10 +391,12 @@ export interface DomainRegisterReq { export interface DomainSetting { /** 创建时间 */ created_at?: number; + /** 自定义OAuth接入 */ + custom_oauth?: DomainCustomOAuth; + /** 钉钉OAuth接入 */ + dingtalk_oauth?: DomainDingtalkOAuth; /** 是否禁用密码登录 */ disable_password_login?: boolean; - /** 是否开启钉钉OAuth */ - enable_dingtalk_oauth?: boolean; /** 是否开启SSO */ enable_sso?: boolean; /** 是否强制两步验证 */ @@ -445,14 +483,12 @@ export interface DomainUpdateModelReq { } export interface DomainUpdateSettingReq { - /** 钉钉客户端ID */ - dingtalk_client_id?: string; - /** 钉钉客户端密钥 */ - dingtalk_client_secret?: string; + /** 自定义OAuth配置 */ + custom_oauth?: DomainCustomOAuth; + /** 钉钉OAuth配置 */ + dingtalk_oauth?: DomainDingtalkOAuth; /** 是否禁用密码登录 */ disable_password_login?: boolean; - /** 是否开启钉钉OAuth */ - enable_dingtalk_oauth?: boolean; /** 是否开启SSO */ enable_sso?: boolean; /** 是否强制两步验证 */ @@ -469,6 +505,8 @@ export interface DomainUpdateUserReq { } export interface DomainUser { + /** 头像URL */ + avatar_url?: string; /** 创建时间 */ created_at?: number; /** 邮箱 */ @@ -639,11 +677,15 @@ export interface GetCategoryStatDashboardParams { * 持续时间 (小时或天数)` * @min 24 * @max 90 + * @default 90 */ duration?: number; - /** 精度: "hour", "day" */ - precision: 'hour' | 'day'; - /** 用户ID,可jj */ + /** + * 精度: "hour", "day" + * @default "day" + */ + precision: "hour" | "day"; + /** 用户ID,可选参数 */ user_id?: string; } @@ -652,11 +694,15 @@ export interface GetTimeStatDashboardParams { * 持续时间 (小时或天数)` * @min 24 * @max 90 + * @default 90 */ duration?: number; - /** 精度: "hour", "day" */ - precision: 'hour' | 'day'; - /** 用户ID,可jj */ + /** + * 精度: "hour", "day" + * @default "day" + */ + precision: "hour" | "day"; + /** 用户ID,可选参数 */ user_id?: string; } @@ -665,11 +711,15 @@ export interface GetUserCodeRankDashboardParams { * 持续时间 (小时或天数)` * @min 24 * @max 90 + * @default 90 */ duration?: number; - /** 精度: "hour", "day" */ - precision: 'hour' | 'day'; - /** 用户ID,可jj */ + /** + * 精度: "hour", "day" + * @default "day" + */ + precision: "hour" | "day"; + /** 用户ID,可选参数 */ user_id?: string; } @@ -678,8 +728,15 @@ export interface GetUserEventsDashboardParams { * 持续时间 (小时或天数)` * @min 24 * @max 90 + * @default 90 */ - /** 用户ID,可jj */ + duration?: number; + /** + * 精度: "hour", "day" + * @default "day" + */ + precision: "hour" | "day"; + /** 用户ID,可选参数 */ user_id?: string; } @@ -693,22 +750,26 @@ export interface GetUserStatDashboardParams { * 持续时间 (小时或天数)` * @min 24 * @max 90 + * @default 90 */ duration?: number; - /** 精度: "hour", "day" */ - precision: 'hour' | 'day'; - /** 用户ID,可jj */ + /** + * 精度: "hour", "day" + * @default "day" + */ + precision: "hour" | "day"; + /** 用户ID,可选参数 */ user_id?: string; } export interface GetMyModelListParams { /** 模型类型 llm:对话模型 coder:代码模型 */ - model_type?: 'llm' | 'coder' | 'embedding' | 'audio' | 'reranker'; + model_type?: "llm" | "coder" | "embedding" | "audio" | "reranker"; } export interface GetGetTokenUsageParams { /** 模型类型 llm:对话模型 coder:代码模型 */ - model_type: 'llm' | 'coder' | 'embedding' | 'audio' | 'reranker'; + model_type: "llm" | "coder" | "embedding" | "audio" | "reranker"; } export interface DeleteDeleteUserParams { @@ -740,8 +801,10 @@ export interface GetUserOauthCallbackParams { } export interface GetUserOauthSignupOrInParams { + /** 邀请码 */ + inviate_code?: string; /** 第三方平台 dingtalk */ - platform: 'email' | 'dingtalk'; + platform: "email" | "dingtalk" | "custom"; /** 登录成功后跳转的 URL */ redirect_url?: string; /** 会话ID */ diff --git a/ui/src/components/form/index.tsx b/ui/src/components/form/index.tsx index d5d37b4..646717b 100644 --- a/ui/src/components/form/index.tsx +++ b/ui/src/components/form/index.tsx @@ -1,4 +1,4 @@ -import { styled, FormLabel } from '@mui/material'; +import { styled, FormLabel, Box } from '@mui/material'; export const StyledFormLabel = styled(FormLabel)(({ theme }) => ({ display: 'block', @@ -10,3 +10,20 @@ export const StyledFormLabel = styled(FormLabel)(({ theme }) => ({ fontSize: 14, }, })); + +export const FormItem = ({ + label, + children, + required, +}: { + label: string; + children: React.ReactNode; + required?: boolean; +}) => { + return ( + + {label} + {children} + + ); +}; diff --git a/ui/src/components/header/Bread.tsx b/ui/src/components/header/Bread.tsx index 82f14a0..fbf2691 100644 --- a/ui/src/components/header/Bread.tsx +++ b/ui/src/components/header/Bread.tsx @@ -1,111 +1,121 @@ import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; -import { Box, Stack, useTheme } from '@mui/material'; -import React, { useEffect, useState } from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { useMemo } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; -const HomeBread = { title: '工作台', to: '/' }; -const OtherBread = { +const ADMIN_BREADCRUMB_MAP: Record = { dashboard: { title: '仪表盘', to: '/' }, chat: { title: '对话记录', to: '/chat' }, completion: { title: '补全记录', to: '/completion' }, model: { title: '模型管理', to: '/model' }, - user: { title: '成员管理', to: '/user' }, + 'user-management': { title: '成员管理', to: '/user-management' }, admin: { title: '管理员', to: '/admin' }, }; -const Bread = () => { - const theme = useTheme(); - const { pathname } = useLocation(); - const [breads, setBreads] = useState< - { title: React.ReactNode; to: string }[] - >([]); +const USER_BREADCRUMB_MAP: Record = { + dashboard: { title: '仪表盘', to: '/user/dashboard' }, + chat: { title: '对话记录', to: '/user/chat' }, + completion: { title: '补全记录', to: '/user/completion' }, +}; - useEffect(() => { - const curBreads: { title: React.ReactNode; to: string }[] = [ - { - title: ( - - MonkeyCode - - ), - to: '/dashboard', - }, - ]; - if (pathname === '/') { - curBreads.push(HomeBread); - } else { - const pieces = pathname.split('/').filter((it) => it !== ''); - pieces.forEach((it) => { - const bread = OtherBread[it as keyof typeof OtherBread]; - if (bread) { - curBreads.push(bread); +const Bread = () => { + const { pathname } = useLocation(); + + const breadcrumbs = useMemo(() => { + const pathParts = pathname.split('/').filter(Boolean); + + const generatedCrumbs = pathParts + .map((part) => { + if (pathname.startsWith('/user/')) { + return USER_BREADCRUMB_MAP[part]; } - }); - } - // if (pageName) { - // curBreads.push({ title: pageName, to: 'custom' }) - // } - setBreads(curBreads); + return ADMIN_BREADCRUMB_MAP[part]; + }) + .filter(Boolean); + + return [ + { + title: 'MonkeyCode', + to: pathname.startsWith('/user/') ? '/user/dashboard' : '/dashboard', + }, + ...generatedCrumbs, + ]; }, [pathname]); return ( - {/* */} - {breads.map((it, idx) => { - return ( - { + const isLast = index === breadcrumbs.length - 1; - ...(idx === breads.length - 1 && { fontWeight: 'bold' }), - }} - > - {idx !== 0 && ( + const crumbContent = ( + + {index > 0 && ( )} - {it.to === 'custom' ? ( - - {it.title} - - ) : ( - - - {it.title} - - - )} + + {crumb.title} + ); + + if (isLast) { + return ( + + {crumbContent} + + ); + } + + if (crumb.to === 'custom') { + return ( + + {crumbContent} + + ); + } + + return ( + + + {crumbContent} + + + ); })} ); diff --git a/ui/src/components/markDown/code.tsx b/ui/src/components/markDown/code.tsx index 8df87c7..648732b 100644 --- a/ui/src/components/markDown/code.tsx +++ b/ui/src/components/markDown/code.tsx @@ -108,7 +108,7 @@ const Code = ({ accessibilitySupport: 'off', bracketPairColorization: { enabled: false }, matchBrackets: 'never', - lineNumbers: 'on', + lineNumbers: 'off', verticalScrollbarSize: 0, horizontalScrollbarSize: 0, scrollbar: { diff --git a/ui/src/components/markDown/diff.tsx b/ui/src/components/markDown/diff.tsx index f2e7fc1..073bb68 100644 --- a/ui/src/components/markDown/diff.tsx +++ b/ui/src/components/markDown/diff.tsx @@ -61,7 +61,7 @@ const Diff: React.FC = ({ fontSize: 14, scrollBeyondLastLine: false, wordWrap: 'off', - lineNumbers: 'on', + lineNumbers: 'off', glyphMargin: false, folding: false, overviewRulerLanes: 0, diff --git a/ui/src/components/sidebar/index.tsx b/ui/src/components/sidebar/index.tsx index c470836..98ccd9d 100644 --- a/ui/src/components/sidebar/index.tsx +++ b/ui/src/components/sidebar/index.tsx @@ -2,13 +2,12 @@ import Logo from '@/assets/images/logo.png'; import { alpha, Box, Button, Stack, useTheme, styled } from '@mui/material'; import { Icon } from '@c-x/ui'; import { NavLink, useLocation } from 'react-router-dom'; -import Avatar from '../avatar'; import { Modal } from '@c-x/ui'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Qrcode from '@/assets/images/qrcode.png'; import Version from './version'; -const menus = [ +const ADMIN_MENUS = [ { label: '仪表盘', value: '/dashboard', @@ -46,8 +45,8 @@ const menus = [ }, { label: '成员管理', - value: '/user', - pathname: 'user', + value: '/user-management', + pathname: 'user-management', icon: 'icon-yonghuguanli1', show: true, }, @@ -60,6 +59,30 @@ const menus = [ }, ]; +const USER_MENUS = [ + { + label: '仪表盘', + value: '/user/dashboard', + pathname: '/user/dashboard', + icon: 'icon-yibiaopan', + show: true, + }, + { + label: '对话记录', + value: '/user/chat', + pathname: '/user/chat', + icon: 'icon-duihuajilu1', + show: true, + }, + { + label: '补全记录', + value: '/user/completion', + pathname: '/user/completion', + icon: 'icon-buquanjilu', + show: true, + }, +]; + const SidebarButton = styled(Button)(({ theme }) => ({ fontSize: 14, flexShrink: 0, @@ -81,6 +104,13 @@ const Sidebar = () => { const { pathname } = useLocation(); const theme = useTheme(); const [showQrcode, setShowQrcode] = useState(false); + const menus = useMemo(() => { + if (pathname.startsWith('/user/')) { + return USER_MENUS; + } + return ADMIN_MENUS; + }, [pathname]); + return ( ({ @@ -114,8 +116,9 @@ const AuthPage = () => { const [showPassword, setShowPassword] = useState(false); const [searchParams] = useSearchParams(); - const { data: loginSetting = {} } = useRequest(getGetSetting); - + const { data: loginSetting = {} as DomainSetting } = + useRequest(getGetSetting); + const { custom_oauth = {}, dingtalk_oauth = {} } = loginSetting; const { control, handleSubmit, @@ -174,6 +177,12 @@ const AuthPage = () => { new AestheticFluidBg(BACKGROUND_CONFIG); }, []); + const oauthEnable = useMemo(() => { + return ( + loginSetting.custom_oauth?.enable || loginSetting.dingtalk_oauth?.enable + ); + }, [loginSetting]); + // 渲染用户名输入框 const renderUsernameField = () => ( { ); - const onDingdingLogin = () => { + const onOauthLogin = (platform: 'dingtalk' | 'custom') => { getUserOauthSignupOrIn({ - platform: 'dingtalk', + platform, redirect_url: window.location.origin + window.location.pathname, // @ts-ignore session_id: searchParams.get('session_id') || null, @@ -278,15 +287,28 @@ const AuthPage = () => { }); }; - const dingdingLogin = () => { + const oauthLogin = () => { return ( 使用其他方式登录 - - - + {dingtalk_oauth.enable && ( + onOauthLogin('dingtalk')} + > + + + )} + {custom_oauth.enable && ( + onOauthLogin('custom')} + > + + + )} ); }; @@ -322,7 +344,7 @@ const AuthPage = () => { {!loginSetting.disable_password_login && renderLoginForm()} - {loginSetting.enable_dingtalk_oauth && dingdingLogin()} + {oauthEnable && oauthLogin()} ); diff --git a/ui/src/pages/chat/index.tsx b/ui/src/pages/chat/index.tsx index 5cadcb3..c9909af 100644 --- a/ui/src/pages/chat/index.tsx +++ b/ui/src/pages/chat/index.tsx @@ -35,6 +35,7 @@ const Chat = () => { useEffect(() => { fetchData(); + // eslint-disable-next-line }, [page, size]); const columns: ColumnsType = [ @@ -102,7 +103,7 @@ const Chat = () => { }; return ( - { value ? workModeMap[value]['name'] : '未知' } + {value ? workModeMap[value]['name'] : '未知'} ); }, diff --git a/ui/src/pages/completion/completionDetailModal.tsx b/ui/src/pages/completion/completionDetailModal.tsx index 7f52357..a1fa4f8 100644 --- a/ui/src/pages/completion/completionDetailModal.tsx +++ b/ui/src/pages/completion/completionDetailModal.tsx @@ -123,7 +123,7 @@ const ChatDetailModal = ({ fontSize: 14, scrollBeyondLastLine: false, wordWrap: 'on', - lineNumbers: 'on', + lineNumbers: 'off', glyphMargin: false, folding: false, overviewRulerLanes: 0, diff --git a/ui/src/pages/dashboard/components/memberInfo.tsx b/ui/src/pages/dashboard/components/memberInfo.tsx index cbd4af3..7b1ef53 100644 --- a/ui/src/pages/dashboard/components/memberInfo.tsx +++ b/ui/src/pages/dashboard/components/memberInfo.tsx @@ -54,8 +54,8 @@ const MemberInfo = ({ }: { data: DomainUserHeatmapResp; memberData: DomainUser | null; - userList: DomainUser[]; - onMemberChange: (data: DomainUser) => void; + userList?: DomainUser[]; + onMemberChange?: (data: DomainUser) => void; }) => { const theme = useTheme(); const [blockSize, setBlockSize] = useState(8); @@ -104,12 +104,12 @@ const MemberInfo = ({ open={open} onClose={handleClose} > - {userList.map((item) => ( + {userList?.map((item) => ( { - onMemberChange(item); + onMemberChange?.(item); handleClose(); }} sx={{ @@ -127,12 +127,14 @@ const MemberInfo = ({ sx={{ mb: 1 }} > - - - + {userList && ( + + + + )} {memberData?.username} diff --git a/ui/src/pages/dashboard/components/memberStatistic.tsx b/ui/src/pages/dashboard/components/memberStatistic.tsx index 8d81f65..8a7767e 100644 --- a/ui/src/pages/dashboard/components/memberStatistic.tsx +++ b/ui/src/pages/dashboard/components/memberStatistic.tsx @@ -42,6 +42,7 @@ const MemberStatistic = ({ () => getUserEventsDashboard({ user_id: id || '', + precision: timeDuration.precision, }), { refreshDeps: [id], diff --git a/ui/src/pages/invite/index.tsx b/ui/src/pages/invite/index.tsx index ef77696..9dc76b7 100644 --- a/ui/src/pages/invite/index.tsx +++ b/ui/src/pages/invite/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import Logo from '@/assets/images/logo.png'; @@ -20,14 +20,13 @@ import { IconButton, CircularProgress, Stack, + Divider, } from '@mui/material'; import { useRequest } from 'ahooks'; -import { - postRegister, - getUserOauthSignupOrIn, - getGetSetting, -} from '@/api/User'; +import { postRegister, getUserOauthSignupOrIn } from '@/api/User'; +import { getGetSetting } from '@/api/Admin'; import { Icon } from '@c-x/ui'; +import { DomainSetting } from '@/api/types'; import DownloadIcon from '@mui/icons-material/Download'; import MenuBookIcon from '@mui/icons-material/MenuBook'; @@ -91,7 +90,9 @@ const StyledTextField = styled(TextField)(({ theme }) => ({ const Invite = () => { const { id, step } = useParams(); const [showPassword, setShowPassword] = useState(false); - const { data: loginSetting = {} } = useRequest(getGetSetting); + const { data: loginSetting = {} as DomainSetting } = + useRequest(getGetSetting); + const { custom_oauth = {}, dingtalk_oauth = {} } = loginSetting; const { control, handleSubmit, @@ -134,19 +135,54 @@ const Invite = () => { }); }, []); - const onDingdingLogin = () => { + const onOauthLogin = (platform: 'dingtalk' | 'custom') => { getUserOauthSignupOrIn({ - platform: 'dingtalk', + platform, redirect_url: `${window.location.origin}/invite/${id}/2`, + inviate_code: id, }).then((res) => { - window.location.href = res.url!; + if (res.url) { + window.location.href = res.url; + } }); }; + const oauthEnable = useMemo(() => { + return ( + loginSetting.custom_oauth?.enable || loginSetting.dingtalk_oauth?.enable + ); + }, [loginSetting]); + + const oauthLogin = () => { + return ( + + + 使用以下方式注册 + + {dingtalk_oauth.enable && ( + + )} + {custom_oauth.enable && ( + onOauthLogin('custom')} + > + + + )} + + ); + }; + const renderStepContent = () => { switch (activeStep) { case 1: - return !loginSetting.enable_dingtalk_oauth ? ( + return !oauthEnable ? ( @@ -285,17 +321,7 @@ const Invite = () => { ) : ( - - - + oauthLogin() ); case 2: diff --git a/ui/src/pages/login/index.tsx b/ui/src/pages/login/index.tsx index 7e3b548..6a2263c 100644 --- a/ui/src/pages/login/index.tsx +++ b/ui/src/pages/login/index.tsx @@ -12,7 +12,7 @@ import { InputAdornment, IconButton, } from '@mui/material'; -import { postAdminLogin } from '@/api/User'; +import { postAdminLogin } from '@/api/Admin'; import { useForm, Controller } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { styled } from '@mui/material/styles'; diff --git a/ui/src/pages/user/index.tsx b/ui/src/pages/user-management/index.tsx similarity index 77% rename from ui/src/pages/user/index.tsx rename to ui/src/pages/user-management/index.tsx index edc8cf8..b5db601 100644 --- a/ui/src/pages/user/index.tsx +++ b/ui/src/pages/user-management/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import Card from '@/components/card'; import { Grid2 as Grid, @@ -8,9 +8,8 @@ import { Button, Box, } from '@mui/material'; -import { Icon, Modal } from '@c-x/ui'; import { useRequest } from 'ahooks'; -import { getGetSetting, putUpdateSetting } from '@/api/User'; +import { getGetSetting, putUpdateSetting } from '@/api/Admin'; import MemberManage from './memberManage'; import LoginHistory from './loginHistory'; import { message } from '@c-x/ui'; @@ -30,18 +29,19 @@ const StyledLabel = styled('div')(({ theme }) => ({ color: theme.vars.palette.text.primary, })); +const OAUTH_LOGIN_TYPE_KEYS = ['dingtalk_oauth', 'custom_oauth']; + +const OAUTH_LOGIN_TYPE_LABELS = { + custom_oauth: '已开启 OAuth 登录', + dingtalk_oauth: '已开启钉钉登录', +}; + +type OAUTH_LOGIN_TYPE_KEYS = keyof typeof OAUTH_LOGIN_TYPE_LABELS; + const User = () => { const [thirdPartyLoginSettingModalOpen, setThirdPartyLoginSettingModalOpen] = useState(false); - const { - data = { - enable_sso: false, - force_two_factor_auth: false, - disable_password_login: false, - enable_dingtalk_oauth: false, - }, - refresh, - } = useRequest(getGetSetting); + const { data, refresh } = useRequest(getGetSetting); const { runAsync: updateSetting } = useRequest(putUpdateSetting, { manual: true, @@ -51,6 +51,16 @@ const User = () => { }, }); + const oauthLabel = useMemo(() => { + if (!data) return '未开启'; + const key = OAUTH_LOGIN_TYPE_KEYS.find( + (key) => data[key as OAUTH_LOGIN_TYPE_KEYS]?.enable + ); + return key + ? OAUTH_LOGIN_TYPE_LABELS[key as OAUTH_LOGIN_TYPE_KEYS] + : '未开启'; + }, [data]); + return ( @@ -84,12 +94,12 @@ const User = () => { component='span' sx={{ ml: 2, - color: data.enable_dingtalk_oauth ? 'success.main' : 'gray', + color: oauthLabel ? 'success.main' : 'gray', fontWeight: 400, fontSize: 13, }} > - {data.enable_dingtalk_oauth ? '已开启钉钉登录' : '未开启'} + {oauthLabel} +); + +const ThirdPartyLoginSettingModal = ({ + open, + onCancel, + settingData, + onOk, +}: { + open: boolean; + onCancel: () => void; + settingData: DomainSetting; + onOk: () => void; +}) => { + const { + control, + handleSubmit, + reset, + watch, + formState: { errors }, + } = useForm({ + defaultValues: { + dingtalk_client_id: '', + dingtalk_client_secret: '', + access_token_url: '', + authorize_url: '', + client_id: '', + client_secret: '', + id_field: '', + name_field: '', + scopes: [] as string[], + avatar_field: '', + userinfo_url: '', + email_field: '', + }, + }); + + const [loginType, setLoginType] = useState( + settingData?.dingtalk_oauth?.enable ? 'dingding' : 'none' + ); + + const [scopeInputValue, setScopeInputValue] = useState(''); + + const userInfoUrl = watch('userinfo_url'); + + useEffect(() => { + if (open) { + reset(); + } + }, [open]); + + useEffect(() => { + if (settingData?.dingtalk_oauth?.enable) { + setLoginType('dingding'); + reset( + { + dingtalk_client_id: settingData.dingtalk_oauth.client_id, + dingtalk_client_secret: settingData.dingtalk_oauth.client_secret, + }, + { + keepValues: true, + } + ); + } + if (settingData?.custom_oauth?.enable) { + setLoginType('oauth'); + reset( + { + access_token_url: settingData.custom_oauth.access_token_url, + authorize_url: settingData.custom_oauth.authorize_url, + client_id: settingData.custom_oauth.client_id, + id_field: settingData.custom_oauth.id_field, + name_field: settingData.custom_oauth.name_field, + scopes: settingData.custom_oauth.scopes || [], + avatar_field: settingData.custom_oauth.avatar_field, + userinfo_url: settingData.custom_oauth.userinfo_url, + email_field: settingData.custom_oauth.email_field, + }, + { + keepValues: true, + } + ); + } + }, [settingData]); + + const onSubmit = handleSubmit((data) => { + let params: DomainUpdateSettingReq = {}; + if (loginType === 'none') { + params = { + dingtalk_oauth: { + enable: false, + }, + custom_oauth: { + enable: false, + }, + }; + } else if (loginType === 'dingding') { + params = { + dingtalk_oauth: { + enable: true, + client_id: data.dingtalk_client_id, + client_secret: data.dingtalk_client_secret, + }, + custom_oauth: { + enable: false, + }, + }; + } else if (loginType === 'oauth') { + params = { + dingtalk_oauth: { + enable: false, + }, + custom_oauth: { + enable: true, + access_token_url: data.access_token_url, + authorize_url: data.authorize_url, + client_id: data.client_id, + client_secret: data.client_secret, + id_field: data.id_field, + name_field: data.name_field, + scopes: data.scopes, + avatar_field: data.avatar_field, + userinfo_url: data.userinfo_url, + email_field: data.email_field, + }, + }; + } + + putUpdateSetting(params).then(() => { + message.success('设置成功'); + onCancel(); + onOk(); + }); + }); + + const dingdingForm = () => { + return ( + <> + + ( + + )} + /> + + + ( + + )} + /> + + + ); + }; + + const oauthForm = () => { + return ( + <> + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + + { + if (value.length === 0) { + return 'Scope 不能为空'; + } + return true; + }, + }} + render={({ field }) => ( + { + field.onChange(value); + }} + onInputChange={(_, value) => { + setScopeInputValue(value); + }} + size='small' + freeSolo + renderTags={(value: readonly string[], getTagProps) => + value.map((option: string, index: number) => { + const { key, ...tagProps } = getTagProps({ index }); + const label = `${option}`; + return ( + + ); + }) + } + renderInput={(params) => ( + { + // 失去焦点时自动添加当前输入的值 + const trimmedValue = scopeInputValue.trim(); + if (trimmedValue && !field.value.includes(trimmedValue)) { + field.onChange([...field.value, trimmedValue]); + // 清空输入框 + setScopeInputValue(''); + } + }} + /> + )} + /> + )} + /> + + + ( + + )} + /> + + {userInfoUrl && ( + <> + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + )} + + ); + }; + + return ( + + + {loginOptions.map((option) => ( + + ))} + + + {loginType === 'dingding' && dingdingForm()} + {loginType === 'oauth' && oauthForm()} + + + ); +}; + +export default ThirdPartyLoginSettingModal; diff --git a/ui/src/pages/user/chat/chatDetailModal.tsx b/ui/src/pages/user/chat/chatDetailModal.tsx new file mode 100644 index 0000000..6e61428 --- /dev/null +++ b/ui/src/pages/user/chat/chatDetailModal.tsx @@ -0,0 +1,149 @@ +import Avatar from '@/components/avatar'; +import Card from '@/components/card'; +import { getChatInfo } from '@/api/Billing'; +import MarkDown from '@/components/markDown'; +import { Ellipsis, Modal } from '@c-x/ui'; +import { styled } from '@mui/material'; +import logo from '@/assets/images/logo.png'; + +import { useEffect, useState } from 'react'; +import { DomainChatContent, DomainChatRecord } from '@/api/types'; + +const StyledChatList = styled('div')(() => ({ + borderRadius: 4, + padding: 24, + minHeight: 400, + maxHeight: 600, + overflowY: 'auto', +})); + +const StyledChatRow = styled('div', { + shouldForwardProp: (prop) => prop !== 'isUser', +})<{ isUser: boolean }>(({ isUser, theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: isUser ? 'flex-end' : 'flex-start', + gap: theme.spacing(1), + marginBottom: theme.spacing(2), +})); + +const StyledChatUser = styled('div', { + shouldForwardProp: (prop) => prop !== 'isUser', +})<{ isUser: boolean }>(({ isUser }) => ({ + display: 'flex', + flexDirection: isUser ? 'row-reverse' : 'row', + alignItems: 'center', + position: 'relative', +})); + +const StyledChatName = styled('div')(({ theme }) => ({ + color: theme.vars.palette.text.primary, + fontSize: '14px', + fontWeight: 500, +})); + +const StyledChatAvatar = styled('div', { + shouldForwardProp: (prop) => prop !== 'isUser', +})<{ isUser: boolean }>(({ isUser }) => ({ + margin: isUser ? '0 0 0 12px' : '0 12px 0 0', + display: 'flex', + alignItems: 'flex-start', + position: 'relative', + top: 0, +})); + +const StyledChatBubble = styled('div', { + shouldForwardProp: (prop) => prop !== 'isUser', +})<{ isUser: boolean }>(({ isUser }) => ({ + background: isUser ? '#e6f7ff' : '#f5f5f5', + margin: isUser ? '0 36px 0 0' : '0 0 0 36px', + borderRadius: 12, + padding: '8px 12px', + minHeight: 36, + maxWidth: 1040, + wordBreak: 'break-word', + position: 'relative', +})); + +const ChatDetailModal = ({ + data, + open, + onClose, +}: { + data?: DomainChatRecord; + open: boolean; + onClose: () => void; +}) => { + const [content, setContent] = useState([]); + + const getChatDetailModal = () => { + if (!data) return; + getChatInfo({ id: data.id! }).then((res) => { + setContent(res.contents || []); + }); + }; + + useEffect(() => { + if (open) getChatDetailModal(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, open]); + + return ( + + 对话记录-{data?.user?.username} + + } + sx={{ + '.MuiDialog-paper': { + maxWidth: 1300, + }, + }} + width={1200} + open={open} + onCancel={onClose} + footer={null} + > + + + {content.map((item, idx) => { + const isUser = item.role === 'user'; + const name = isUser ? data?.user?.username : 'MonkeyCode'; + const msg = item.content || ''; + return ( + + + + + + {name} + + + + + + ); + })} + + + + ); +}; + +export default ChatDetailModal; diff --git a/ui/src/pages/user/chat/index.tsx b/ui/src/pages/user/chat/index.tsx new file mode 100644 index 0000000..c9909af --- /dev/null +++ b/ui/src/pages/user/chat/index.tsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from 'react'; +import { Table } from '@c-x/ui'; +import { getListChatRecord } from '@/api/Billing'; +import dayjs from 'dayjs'; + +import Card from '@/components/card'; +import { Box } from '@mui/material'; +import StyledLabel from '@/components/label'; + +import ChatDetailModal from './chatDetailModal'; +import { ColumnsType } from '@c-x/ui/dist/Table'; +import { DomainChatRecord, DomainUser } from '@/api/types'; +import { addCommasToNumber } from '@/utils'; +import User from '@/components/user'; + +const Chat = () => { + const [page, setPage] = useState(1); + const [size, setSize] = useState(20); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [dataSource, setDataSource] = useState([]); + const [chatDetailModal, setChatDetailModal] = useState< + DomainChatRecord | undefined + >(); + const fetchData = async () => { + setLoading(true); + const res = await getListChatRecord({ + page: page, + size: size, + }); + setLoading(false); + setTotal(res?.total_count || 0); + setDataSource(res.records || []); + }; + + useEffect(() => { + fetchData(); + // eslint-disable-next-line + }, [page, size]); + + const columns: ColumnsType = [ + { + dataIndex: 'user', + title: '成员', + width: 260, + render(value: DomainUser) { + return ( + + ); + }, + }, + { + dataIndex: 'question', + title: '任务内容', + render(value: string, record) { + const cleanValue = value?.replace(/<\/?task>/g, '') || value; + return ( + setChatDetailModal(record)} + sx={{ + cursor: 'pointer', + color: 'info.main', + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }} + > + {cleanValue || '无标题任务'} + + ); + }, + }, + { + dataIndex: 'work_mode', + title: '工作模式', + width: 120, + render(value: DomainChatRecord['work_mode']) { + const workModeMap: Record> = { + code: { + name: '编程模式', + color: 'warning', + }, + ask: { + name: '问答模式', + color: 'info', + }, + architect: { + name: '架构模式', + color: 'success', + }, + debug: { + name: '调试模式', + color: 'error', + }, + orchestrator: { + name: '编排模式', + color: 'info', + }, + }; + return ( + + {value ? workModeMap[value]['name'] : '未知'} + + ); + }, + }, + { + dataIndex: 'input_tokens', + title: '输入 Token', + width: 150, + render(value: number) { + return addCommasToNumber(value); + }, + }, + { + dataIndex: 'output_tokens', + title: '输出 Token', + width: 150, + render(value: number) { + return addCommasToNumber(value); + }, + }, + { + dataIndex: 'created_at', + title: '时间', + width: 180, + render(value: number) { + return dayjs.unix(value).format('YYYY-MM-DD HH:mm:ss'); + }, + }, + ]; + return ( + + { + setPage(page); + setSize(size); + }, + }} + /> + setChatDetailModal(undefined)} + data={chatDetailModal} + /> + + ); +}; + +export default Chat; diff --git a/ui/src/pages/user/completion/completionDetailModal.tsx b/ui/src/pages/user/completion/completionDetailModal.tsx new file mode 100644 index 0000000..a1fa4f8 --- /dev/null +++ b/ui/src/pages/user/completion/completionDetailModal.tsx @@ -0,0 +1,176 @@ +import Card from '@/components/card'; +import { getCompletionInfo } from '@/api/Billing'; +import { Modal } from '@c-x/ui'; +import MonacoEditor from '@monaco-editor/react'; + +import { useEffect, useState, useRef } from 'react'; +import { DomainCompletionRecord } from '@/api/types'; +import { getBaseLanguageId } from '@/utils'; + +// 删除 <|im_start|> 和 <|im_end|> 及其间内容的工具函数 +const removeImBlocks = (text: string) => { + // 匹配前后可能的换行符 + return text.replace( + /(^[ \t]*\r?\n)?<\|im_start\|>[\s\S]*?<\|im_end\|>(\r?\n)?/g, + '' + ); +}; + +const ChatDetailModal = ({ + data, + open, + onClose, +}: { + data?: DomainCompletionRecord; + open: boolean; + onClose: () => void; +}) => { + const [editorValue, setEditorValue] = useState(''); + const editorRef = useRef(null); + const [editorReady, setEditorReady] = useState(false); + const [highlightInfo, setHighlightInfo] = useState(null); + + const getChatDetailModal = () => { + if (!data) return; + getCompletionInfo({ id: data.id! }).then((res) => { + // 先去除 <|im_start|> 和 <|im_end|> 及其间内容 + const rawPrompt = removeImBlocks(res.prompt || ''); + const content = res.content || ''; + // 找到三个特殊标记的位置 + const prefixTag = '<|fim_prefix|>'; + const suffixTag = '<|fim_suffix|>'; + const middleTag = '<|fim_middle|>'; + const prefixIdx = rawPrompt.indexOf(prefixTag); + const suffixIdx = rawPrompt.indexOf(suffixTag); + const middleIdx = rawPrompt.indexOf(middleTag); + // 去掉特殊标记 + const prompt = rawPrompt + .replace(prefixTag, '') + .replace(suffixTag, '') + .replace(middleTag, ''); + // 重新定位插入点(因为去掉了前面的 tag,位置会变) + // 计算插入点:suffixTag 在原始 prompt 的位置,去掉 prefixTag 后的 offset + let insertIdx = suffixIdx; + if (prefixIdx !== -1 && prefixIdx < suffixIdx) { + insertIdx -= prefixTag.length; + } + if (middleIdx !== -1 && middleIdx < suffixIdx) { + insertIdx -= middleTag.length; + } + // 插入 content + const newValue = + prompt.slice(0, insertIdx) + content + prompt.slice(insertIdx); + setEditorValue(newValue); + // 计算高亮范围(行列) + const before = newValue.slice(0, insertIdx); + const contentLines = content.split('\n'); + const beforeLines = before.split('\n'); + const startLine = beforeLines.length; + const startColumn = beforeLines[beforeLines.length - 1].length + 1; + const endLine = startLine + contentLines.length - 1; + const endColumn = + contentLines.length === 1 + ? startColumn + content.length + : contentLines[contentLines.length - 1].length + 1; + setHighlightInfo({ startLine, startColumn, endLine, endColumn }); + }); + }; + + useEffect(() => { + if (editorReady && highlightInfo && editorRef.current) { + editorRef.current.deltaDecorations( + [], + [ + { + range: { + startLineNumber: highlightInfo.startLine, + startColumn: highlightInfo.startColumn, + endLineNumber: highlightInfo.endLine, + endColumn: highlightInfo.endColumn, + }, + options: { + inlineClassName: 'completion-highlight', + }, + }, + ] + ); + } + }, [editorReady, highlightInfo, editorValue]); + + useEffect(() => { + if (open) getChatDetailModal(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, open]); + + return ( + + +
+ { + editorRef.current = editor; + setEditorReady(true); + // 隐藏光标 + const editorDom = editor.getDomNode(); + if (editorDom) { + const style = document.createElement('style'); + style.innerHTML = `.monaco-editor .cursor { display: none !important; }`; + editorDom.appendChild(style); + } + }} + /> +
+ +
+
+ ); +}; + +export default ChatDetailModal; diff --git a/ui/src/pages/user/completion/constant.ts b/ui/src/pages/user/completion/constant.ts new file mode 100644 index 0000000..3d62dfb --- /dev/null +++ b/ui/src/pages/user/completion/constant.ts @@ -0,0 +1,67 @@ +export const LANG_OPTIONS = [ + 'JavaScript', + 'JavaScriptReact', + 'TypeScript', + 'TypeScriptReact', + 'Python', + 'Java', + 'C', + 'C++', + 'C#', + 'Go', + 'PHP', + 'Ruby', + 'Swift', + 'Kotlin', + 'Rust', + 'Dart', + 'Objective-C', + 'Scala', + 'Perl', + 'R', + 'Shell Script', + 'PowerShell', + 'HTML', + 'CSS', + 'SCSS', + 'Less', + 'JSON', + 'YAML', + 'XML', + 'Markdown', + 'SQL', + 'GraphQL', + 'Dockerfile', + 'Makefile', + 'Lua', + 'Haskell', + 'Elixir', + 'Erlang', + 'F#', + 'Groovy', + 'Visual Basic', + 'Assembly', + 'Matlab', + 'Fortran', + 'COBOL', + 'Prolog', + 'Scheme', + 'Lisp', + 'Julia', + 'SASS', + 'TOML', + 'INI', + 'LaTeX', + 'CMake', + 'Batch', + 'CoffeeScript', + 'Crystal', + 'OCaml', + 'Nim', + 'ReScript', + 'Solidity', + 'Vue', + 'Svelte', + 'JSX', + 'TSX', +]; diff --git a/ui/src/pages/user/completion/index.tsx b/ui/src/pages/user/completion/index.tsx new file mode 100644 index 0000000..cf85150 --- /dev/null +++ b/ui/src/pages/user/completion/index.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect } from 'react'; +import { DomainCompletionRecord, DomainUser } from '@/api/types'; +import { getListCompletionRecord } from '@/api/Billing'; +import { useRequest } from 'ahooks'; +import { Table } from '@c-x/ui'; +import Card from '@/components/card'; +import { + Box, + Stack, + MenuItem, + Select, + FormControl, + InputLabel, + Autocomplete, + TextField, +} from '@mui/material'; +import { getListUser } from '@/api/User'; +import dayjs from 'dayjs'; +import { useDebounceFn } from 'ahooks'; +import { ColumnsType } from '@c-x/ui/dist/Table'; +import { addCommasToNumber } from '@/utils'; +import CompletionDetailModal from './completionDetailModal'; +import StyledLabel from '@/components/label'; +import { LANG_OPTIONS } from './constant'; +import User from '@/components/user'; + +const Completion = () => { + const [page, setPage] = useState(1); + const [size, setSize] = useState(20); + const [total, setTotal] = useState(0); + const [dataSource, setDataSource] = useState([]); + const [loading, setLoading] = useState(false); + const [completionDetailModal, setCompletionDetailModal] = useState< + DomainCompletionRecord | undefined + >(); + + // 新增筛选项 state + const [filterUser, setFilterUser] = useState(''); + const [filterLang, setFilterLang] = useState(''); + const [filterAccept, setFilterAccept] = useState< + 'accepted' | 'unaccepted' | '' + >('accepted'); + + const { data: userOptions = { users: [] } } = useRequest(() => + getListUser({ + page: 1, + size: 10, + }) + ); + + useEffect(() => { + setPage(1); // 筛选变化时重置页码 + fetchData({ + page: 1, + language: filterLang, + author: filterUser, + is_accept: filterAccept, + }); + }, [filterUser, filterLang, filterAccept]); + + const fetchData = async (params: { + page?: number; + size?: number; + language?: string; + author?: string; + is_accept?: 'accepted' | 'unaccepted' | ''; + }) => { + setLoading(true); + const isAccept = params.is_accept || filterAccept; + const res = await getListCompletionRecord({ + page: params.page || page, + size: params.size || size, + language: params.language || filterLang, + author: params.author || filterUser, + is_accept: + isAccept === 'accepted' + ? true + : isAccept === 'unaccepted' + ? false + : undefined, + }); + setLoading(false); + setTotal(res?.total_count || 0); + setDataSource(res.records || []); + }; + + const columns: ColumnsType = [ + { + dataIndex: 'user', + title: '成员', + render(value: DomainUser) { + return ( + + ); + }, + }, + { + dataIndex: 'task', + title: '补全内容', + width: 150, + render(_, record) { + return ( + setCompletionDetailModal(record)} + sx={{ color: 'info.main', cursor: 'pointer' }} + > + 点击查看 + + ); + }, + }, + { + dataIndex: 'is_accept', + title: '是否采纳', + width: 130, + render(value: boolean) { + const color = value ? 'success' : 'default'; + return ( + {value ? '已采纳' : '未采纳'} + ); + }, + }, + { + dataIndex: 'program_language', + title: '编程语言', + width: 160, + }, + { + dataIndex: 'input_tokens', + title: '输入 Token', + width: 140, + render(value: number) { + return addCommasToNumber(value); + }, + }, + { + dataIndex: 'output_tokens', + title: '输出 Token', + width: 140, + render(value: number) { + return addCommasToNumber(value); + }, + }, + { + dataIndex: 'created_at', + title: '时间', + width: 200, + render(value: number) { + return dayjs.unix(value).format('YYYY-MM-DD HH:mm:ss'); + }, + }, + ]; + + const debounceSetFilterLang = useDebounceFn( + (val: string) => setFilterLang(val), + { + wait: 500, + } + ); + + return ( + + + option || ''} + value={filterLang || ''} + freeSolo + onChange={(_, newValue) => { + setFilterLang(newValue ? String(newValue) : ''); + }} + onInputChange={(_, newInputValue) => + debounceSetFilterLang.run(newInputValue) + } + renderInput={(params) => } + clearOnEscape + /> + + 是否采纳 + + + +
{ + setPage(page); + setSize(size); + fetchData({ + page, + size, + }); + }, + }} + /> + + setCompletionDetailModal(undefined)} + data={completionDetailModal} + /> + + ); +}; + +export default Completion; diff --git a/ui/src/pages/user/dashboard/components/memberStatistic.tsx b/ui/src/pages/user/dashboard/components/memberStatistic.tsx new file mode 100644 index 0000000..2ab70f2 --- /dev/null +++ b/ui/src/pages/user/dashboard/components/memberStatistic.tsx @@ -0,0 +1,212 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Grid2 as Grid } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import MemberInfo from '@/pages/dashboard/components/memberInfo'; +import PieCharts from '@/pages/dashboard/components/pieCharts'; +import LineCharts from '@/pages/dashboard/components/lineCharts'; +import { RecentActivityCard } from '@/pages/dashboard/components/statisticCard'; +import { useRequest } from 'ahooks'; +import { + getUserEventsDashboard, + getUserStatDashboard, + getUserHeatmapDashboard, +} from '@/api/Dashboard'; +import { StyledHighlight } from '@/pages/dashboard/components/globalStatistic'; +import { getRecent90DaysData, getRecent24HoursData } from '@/utils'; +import { DomainUser } from '@/api/types'; +import { TimeRange } from '../index'; + +interface TimeDuration { + duration: number; + precision: 'day' | 'hour'; +} + +const MemberStatistic = ({ + memberData, + timeRange, +}: { + memberData: DomainUser | null; + timeRange: TimeRange; +}) => { + const [timeDuration, setTimeDuration] = useState({ + duration: timeRange === '90d' ? 90 : 24, + precision: timeRange === '90d' ? 'day' : 'hour', + }); + + const { id } = useParams(); + const { data: userEvents } = useRequest( + () => + getUserEventsDashboard({ + user_id: id || '', + precision: timeDuration.precision, + }), + { + refreshDeps: [id], + manual: false, + ready: !!id, + } + ); + const { data: userStat } = useRequest( + () => + getUserStatDashboard({ + user_id: id || '', + ...timeDuration, + }), + { + refreshDeps: [id, timeDuration], + manual: false, + ready: !!id, + } + ); + const { data: userHeatmap } = useRequest( + () => + getUserHeatmapDashboard({ + user_id: id || '', + }), + { + refreshDeps: [id], + manual: false, + ready: !!id, + } + ); + + useEffect(() => { + setTimeDuration({ + duration: timeRange === '90d' ? 90 : 24, + precision: timeRange === '90d' ? 'day' : 'hour', + }); + }, [timeRange]); + + const getRangeData = ( + data: Record[], + timeRange: TimeRange, + label: { keyLabel?: string; valueLabel?: string } = { valueLabel: 'value' } + ) => { + return timeRange === '90d' + ? getRecent90DaysData(data, label) + : getRecent24HoursData(data, label); + }; + + const { + chatChartData, + codeCompletionChartData, + codeLineChartData, + acceptedPerChartData, + } = useMemo(() => { + const { + accepted_per = [], + chats = [], + code_completions = [], + lines_of_code = [], + } = userStat || {}; + const label = { valueLabel: 'value' }; + const chatChartData = getRangeData(chats, timeRange, label); + const codeCompletionChartData = getRangeData( + code_completions, + timeRange, + label + ); + const codeLineChartData = getRangeData(lines_of_code, timeRange, label); + const acceptedPerChartData = getRangeData(accepted_per, timeRange, label); + return { + chatChartData, + codeCompletionChartData, + codeLineChartData, + acceptedPerChartData, + }; + }, [userStat]); + return ( + + + + + + + + + + + + + + + + + + {timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}共 + {userStat?.total_chats || 0} + 个对话任务 + + } + /> + + + + {timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}共 + + {userStat?.total_completions || 0} + + 个补全任务 + + } + /> + + + + {timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}共修改 + + {userStat?.total_lines_of_code || 0} + + 行代码 + + } + /> + + + + {timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}平均采纳率为 + + {(userStat?.total_accepted_per || 0).toFixed(2)} + + % + + } + /> + + + ); +}; + +export default MemberStatistic; diff --git a/ui/src/pages/user/dashboard/index.tsx b/ui/src/pages/user/dashboard/index.tsx new file mode 100644 index 0000000..9185d88 --- /dev/null +++ b/ui/src/pages/user/dashboard/index.tsx @@ -0,0 +1,72 @@ +import { useEffect, useMemo, useState } from 'react'; +import { getListUser } from '@/api/User'; +import { Stack, MenuItem, Select } from '@mui/material'; + +import { useRequest } from 'ahooks'; +import MemberStatistic from './components/memberStatistic'; +import { useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; +import { DomainUser } from '@/api/types'; + +export type TimeRange = '90d' | '24h'; + +const Dashboard = () => { + const navigate = useNavigate(); + const { tab, id } = useParams(); + const [tabValue, setTabValue] = useState(tab || 'global'); + const [memberData, setMemberData] = useState(null); + const [timeRange, setTimeRange] = useState('24h'); + + const { data: userData, refresh } = useRequest( + () => + getListUser({ + page: 1, + size: 99999, + }), + { + manual: true, + onSuccess: (res) => { + if (id) { + setMemberData(res.users?.find((item) => item.id === id) || null); + } else { + setMemberData(res.users?.[0] || null); + navigate(`/dashboard/member/${res.users?.[0]?.id}`); + } + }, + } + ); + const userList = useMemo(() => { + return userData?.users || []; + }, [userData]); + useEffect(() => { + if (tabValue === 'member') { + refresh(); + } + }, [tabValue]); + + const onMemberChange = (data: DomainUser) => { + setMemberData(data); + navigate(`/dashboard/member/${data.id}`); + }; + + return ( + + + + + + + + ); +}; + +export default Dashboard; diff --git a/ui/src/pages/user/login/index.tsx b/ui/src/pages/user/login/index.tsx new file mode 100644 index 0000000..c47141d --- /dev/null +++ b/ui/src/pages/user/login/index.tsx @@ -0,0 +1,343 @@ +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import Logo from '@/assets/images/logo.png'; +import { + Box, + Button, + TextField, + Typography, + Container, + Paper, + CircularProgress, + Grid2 as Grid, + InputAdornment, + IconButton, + Divider, + Stack, +} from '@mui/material'; +import { Icon, message } from '@c-x/ui'; + +import { getRedirectUrl } from '@/utils'; + +// @ts-ignore +import { AestheticFluidBg } from '@/assets/jsm/AestheticFluidBg.module.js'; + +import { useSearchParams } from 'react-router-dom'; +import { postLogin, getUserOauthSignupOrIn } from '@/api/User'; +import { getGetSetting } from '@/api/Admin'; + +import { useForm, Controller } from 'react-hook-form'; +import { styled } from '@mui/material/styles'; +import { useRequest } from 'ahooks'; +import { DomainSetting } from '@/api/types'; + +// 样式化组件 +const StyledContainer = styled(Container)(({ theme }) => ({ + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + maxWidth: '100% !important', + background: theme.palette.background.paper, +})); + +const StyledPaper = styled(Paper)(({ theme }) => ({ + position: 'relative', + zIndex: 9, + padding: theme.spacing(4), + background: 'rgba(255, 255, 255, 0.85)', + backdropFilter: 'blur(10px)', + width: 458, + borderRadius: theme.spacing(2), + boxShadow: + '0px 0px 4px 0px rgba(54,59,76,0.1), 0px 20px 40px 0px rgba(54,59,76,0.1)', +})); + +const LogoContainer = styled(Box)(({ theme }) => ({ + textAlign: 'center', + marginBottom: theme.spacing(4), +})); + +const LogoImage = styled('img')({ + width: 48, + height: 48, +}); + +const LogoTitle = styled(Typography)(({ theme }) => ({ + fontSize: 28, + fontWeight: 'bold', + color: theme.palette.primary.main, +})); + +const StyledTextField = styled(TextField)(({ theme }) => ({ + '.MuiInputBase-root': { + backgroundColor: '#fff', + paddingLeft: '20px', + }, + '.MuiInputBase-input': { + paddingTop: '16px', + paddingBottom: '16px', + fontSize: 14, + }, +})); + +const StyledButton = styled(Button)(({ theme }) => ({ + height: 48, + textTransform: 'none', +})); + +const IconWrapper = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.palette.text.primary, + marginRight: theme.spacing(2), + fontSize: 16, +})); + +const TogglePasswordIcon = styled(Icon)({ + fontSize: 20, +}); + +// 表单数据类型 +interface LoginFormData { + username: string; + password: string; +} + +// 背景动画配置 +const BACKGROUND_CONFIG = { + dom: 'box', + colors: ['#FDFDFD', '#DDDDDD', '#BBBBBB', '#555555', '#343434', '#010101'], + loop: true, +} as const; + +const UserLogin = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + const [searchParams] = useSearchParams(); + const { data: loginSetting = {} as DomainSetting } = + useRequest(getGetSetting); + const { custom_oauth = {}, dingtalk_oauth = {} } = loginSetting; + const { + control, + handleSubmit, + formState: { errors }, + } = useForm(); + + // 切换密码显示状态 + const togglePasswordVisibility = useCallback(() => { + setShowPassword((prev) => !prev); + }, []); + + // 处理登录表单提交 + const onSubmit = useCallback( + async (data: LoginFormData) => { + setLoading(true); + setError(null); + + try { + const sessionId = searchParams.get('session_id'); + if (!sessionId) { + message.error('缺少会话ID参数'); + return; + } + + // 用户登录 + const loginResult = await postLogin({ + ...data, + session_id: sessionId, + }); + + window.location.href = loginResult.redirect_url!; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : '登录失败,请重试'; + setError(errorMessage); + console.error('登录失败:', err); + } finally { + setLoading(false); + } + }, + [searchParams] + ); + + // 初始化背景动画 + useEffect(() => { + new AestheticFluidBg(BACKGROUND_CONFIG); + }, []); + + const oauthEnable = useMemo(() => { + return ( + loginSetting.custom_oauth?.enable || loginSetting.dingtalk_oauth?.enable + ); + }, [loginSetting]); + + // 渲染用户名输入框 + const renderUsernameField = () => ( + ( + + + + ), + }, + }} + /> + )} + /> + ); + + // 渲染密码输入框 + const renderPasswordField = () => ( + ( + + + + ), + endAdornment: ( + + + + + + ), + }, + }} + /> + )} + /> + ); + + // 渲染登录按钮 + const renderLoginButton = () => ( + + + {loading ? : '登录'} + + + ); + + const onOauthLogin = (platform: 'dingtalk' | 'custom') => { + const redirectUrl = getRedirectUrl(); + getUserOauthSignupOrIn({ + platform, + redirect_url: redirectUrl.href, + }).then((res) => { + if (res.url) { + window.location.href = res.url; + } + }); + }; + + const oauthLogin = () => { + return ( + + + 使用其他方式登录 + + {dingtalk_oauth.enable && ( + onOauthLogin('dingtalk')} + > + + + )} + {custom_oauth.enable && ( + onOauthLogin('custom')} + > + + + )} + + ); + }; + + // 渲染登录表单 + const renderLoginForm = () => ( + <> + + + {renderUsernameField()} + {renderPasswordField()} + + {renderLoginButton()} + + + + ); + + useEffect(() => { + const redirect_url = searchParams.get('redirect_url'); + if (redirect_url) { + window.location.href = redirect_url; + } + }, []); + + return ( + + + + + + Monkey Code + + + {!loginSetting.disable_password_login && renderLoginForm()} + {oauthEnable && oauthLogin()} + + + ); +}; + +export default UserLogin; diff --git a/ui/src/pages/user/thirdPartyLoginSettingModal.tsx b/ui/src/pages/user/thirdPartyLoginSettingModal.tsx deleted file mode 100644 index bf57abc..0000000 --- a/ui/src/pages/user/thirdPartyLoginSettingModal.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { Button, Radio, Stack, Box, TextField } from '@mui/material'; -import { Modal, Icon, message } from '@c-x/ui'; -import { useState, useEffect } from 'react'; -import { useForm, Controller } from 'react-hook-form'; -import { StyledFormLabel } from '@/components/form'; -import { putUpdateSetting } from '@/api/User'; -import { DomainSetting } from '@/api/types'; - -type LoginType = 'dingding' | 'wechat' | 'feishu' | 'oauth' | 'none'; - -const ThirdPartyLoginSettingModal = ({ - open, - onCancel, - settingData, - onOk, -}: { - open: boolean; - onCancel: () => void; - settingData: DomainSetting; - onOk: () => void; -}) => { - const { - control, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ - defaultValues: { - dingtalk_client_id: '', - dingtalk_client_secret: '', - // title: '', - }, - }); - - const [loginType, setLoginType] = useState( - settingData?.enable_dingtalk_oauth ? 'dingding' : 'none' - ); - - useEffect(() => { - if (open) { - reset(); - } - }, [open]); - - useEffect(() => { - if (settingData?.enable_dingtalk_oauth) { - setLoginType('dingding'); - } - }, [settingData]); - - const onSubmit = handleSubmit((data) => { - if (loginType === 'none') { - putUpdateSetting({ ...data, enable_dingtalk_oauth: false }).then(() => { - message.success('设置成功'); - onCancel(); - onOk(); - }); - } - if (loginType === 'dingding') { - putUpdateSetting({ ...data, enable_dingtalk_oauth: true }).then(() => { - message.success('设置成功'); - onCancel(); - onOk(); - }); - } - }); - - return ( - - - - - - - - - {loginType === 'dingding' && ( - - - Client ID - ( - - )} - /> - - - Client Secret - ( - - )} - /> - - {/* - 标题名称,默认为 身份认证-钉钉登录 - ( - { - field.onChange(e.target.value); - }} - /> - )} - /> - */} - - )} - - ); -}; - -export default ThirdPartyLoginSettingModal; diff --git a/ui/src/router.tsx b/ui/src/router.tsx index cc50378..7c29808 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -32,18 +32,26 @@ const Dashboard = LazyLoadable(lazy(() => import('@/pages/dashboard'))); const Chat = LazyLoadable(lazy(() => import('@/pages/chat'))); const Completion = LazyLoadable(lazy(() => import('@/pages/completion'))); const Model = LazyLoadable(lazy(() => import('@/pages/model'))); -const User = LazyLoadable(lazy(() => import('@/pages/user'))); +const User = LazyLoadable(lazy(() => import('@/pages/user-management'))); const Admin = LazyLoadable(lazy(() => import('@/pages/admin'))); const Invite = LazyLoadable(lazy(() => import('@/pages/invite'))); const Auth = LazyLoadable(lazy(() => import('@/pages/auth'))); const Login = LazyLoadable(lazy(() => import('@/pages/login'))); +const UserLogin = LazyLoadable(lazy(() => import('@/pages/user/login'))); const Expectation = LazyLoadable(lazy(() => import('@/pages/expectation'))); +const UserChat = LazyLoadable(lazy(() => import('@/pages/user/chat'))); +const UserCompletion = LazyLoadable( + lazy(() => import('@/pages/user/completion')) +); + +const UserDashboard = LazyLoadable( + lazy(() => import('@/pages/user/dashboard')) +); const routerConfig = [ { path: '/', element: , - redirect: '/dashboard', children: [ { index: true, @@ -70,7 +78,7 @@ const routerConfig = [ element: , }, { - path: 'user', + path: 'user-management', element: , }, { @@ -79,6 +87,29 @@ const routerConfig = [ }, ], }, + + { + path: '/user', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'dashboard', + element: , + }, + { + path: 'chat', + element: , + }, + { + path: 'completion', + element: , + }, + ], + }, { path: '/invite/:id/:step?', element: , @@ -87,6 +118,10 @@ const routerConfig = [ path: '/auth', element: , }, + { + path: '/user/login', + element: , + }, { path: '/login', element: ,