Merge pull request #74 from guanweiwang/main

feat: 添加 oauth 认证
This commit is contained in:
Yoko
2025-07-14 15:20:36 +08:00
committed by GitHub
35 changed files with 2611 additions and 630 deletions

View File

@@ -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
cache-to: type=gha,mode=max

21
ui/Makefile Normal file
View File

@@ -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

232
ui/src/api/Admin.ts Normal file
View File

@@ -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<string, any>,
})` OK
*/
export const deleteDeleteAdmin = (
query: DeleteDeleteAdminParams,
params: RequestParams = {},
) =>
request<
WebResp & {
data?: Record<string, any>;
}
>({
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,
});

View File

@@ -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<string, any>,
})` OK
*/
export const deleteDeleteAdmin = (
query: DeleteDeleteAdminParams,
params: RequestParams = {},
) =>
request<
WebResp & {
data?: Record<string, any>;
}
>({
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插件
*

View File

@@ -1,3 +1,4 @@
export * from './Admin'
export * from './Billing'
export * from './Dashboard'
export * from './Model'

View File

@@ -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 */

View File

@@ -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 (
<Box>
<StyledFormLabel required={required}>{label}</StyledFormLabel>
{children}
</Box>
);
};

View File

@@ -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<string, { title: string; to: string }> = {
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<string, { title: string; to: string }> = {
dashboard: { title: '仪表盘', to: '/user/dashboard' },
chat: { title: '对话记录', to: '/user/chat' },
completion: { title: '补全记录', to: '/user/completion' },
};
useEffect(() => {
const curBreads: { title: React.ReactNode; to: string }[] = [
{
title: (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
MonkeyCode
</Box>
),
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 (
<Stack
direction={'row'}
alignItems={'center'}
direction='row'
alignItems='center'
gap={1}
component='nav'
aria-label='breadcrumb'
sx={{
flexGrow: 1,
// color: 'text.auxiliary',
fontSize: '14px',
// a: { color: 'text.auxiliary' },
}}
>
{/* <KBSelect /> */}
{breads.map((it, idx) => {
return (
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
key={idx}
sx={{
color:
idx === breads.length - 1
? `${theme.palette.text.primary} !important`
: 'text.disabled',
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
...(idx === breads.length - 1 && { fontWeight: 'bold' }),
}}
>
{idx !== 0 && (
const crumbContent = (
<Stack direction='row' alignItems='center' gap={1}>
{index > 0 && (
<KeyboardArrowRightRoundedIcon sx={{ fontSize: 14 }} />
)}
{it.to === 'custom' ? (
<Box
sx={{ cursor: 'pointer', ':hover': { color: 'primary.main' } }}
>
{it.title}
</Box>
) : (
<NavLink to={it.to}>
<Box
sx={{
cursor: 'pointer',
':hover': { color: 'primary.main' },
}}
>
{it.title}
</Box>
</NavLink>
)}
<Typography
variant='body2'
sx={{
fontWeight: isLast ? 'bold' : 'normal',
}}
>
{crumb.title}
</Typography>
</Stack>
);
if (isLast) {
return (
<Box key={index} sx={{ color: 'text.primary' }}>
{crumbContent}
</Box>
);
}
if (crumb.to === 'custom') {
return (
<Box
key={index}
sx={{
color: 'text.disabled',
cursor: 'pointer',
'&:hover': {
color: 'primary.main',
},
}}
>
{crumbContent}
</Box>
);
}
return (
<NavLink
key={index}
to={crumb.to}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<Box
sx={{
color: 'text.disabled',
'&:hover': {
color: 'primary.main',
},
}}
>
{crumbContent}
</Box>
</NavLink>
);
})}
</Stack>
);

View File

@@ -108,7 +108,7 @@ const Code = ({
accessibilitySupport: 'off',
bracketPairColorization: { enabled: false },
matchBrackets: 'never',
lineNumbers: 'on',
lineNumbers: 'off',
verticalScrollbarSize: 0,
horizontalScrollbarSize: 0,
scrollbar: {

View File

@@ -61,7 +61,7 @@ const Diff: React.FC<DiffProps> = ({
fontSize: 14,
scrollBeyondLastLine: false,
wordWrap: 'off',
lineNumbers: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
overviewRulerLanes: 0,

View File

@@ -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 (
<Stack
sx={{

View File

@@ -7,9 +7,9 @@ import {
TextField,
Paper,
} from '@mui/material';
import { postCreateAdmin } from '@/api/User';
import { postCreateAdmin } from '@/api/Admin';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { deleteDeleteAdmin, getListAdminUser } from '@/api/User';
import { deleteDeleteAdmin, getListAdminUser } from '@/api/Admin';
import { Table, Modal, message } from '@c-x/ui';
import { ColumnsType } from '@c-x/ui/dist/Table';
import { useRequest } from 'ahooks';

View File

@@ -3,7 +3,7 @@ import { Stack, Box } from '@mui/material';
import { Table } from '@c-x/ui';
import dayjs from 'dayjs';
import { useRequest } from 'ahooks';
import { getAdminLoginHistory } from '@/api/User';
import { getAdminLoginHistory } from '@/api/Admin';
import { ColumnsType } from '@c-x/ui/dist/Table';
import { DomainListAdminLoginHistoryResp } from '@/api/types';
import User from '@/components/user';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import Logo from '@/assets/images/logo.png';
import {
Box,
@@ -21,11 +21,13 @@ import { Icon, message } from '@c-x/ui';
import { AestheticFluidBg } from '@/assets/jsm/AestheticFluidBg.module.js';
import { useSearchParams } from 'react-router-dom';
import { postLogin, getUserOauthSignupOrIn, getGetSetting } from '@/api/User';
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 }) => ({
@@ -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 = () => (
<Controller
@@ -265,9 +274,9 @@ const AuthPage = () => {
</Grid>
);
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 (
<Stack justifyContent='center'>
<Divider sx={{ my: 3, fontSize: 12, borderColor: 'divider' }}>
使
</Divider>
<IconButton sx={{ alignSelf: 'center' }} onClick={onDingdingLogin}>
<Icon type='icon-dingding' sx={{ fontSize: 30 }} />
</IconButton>
{dingtalk_oauth.enable && (
<IconButton
sx={{ alignSelf: 'center' }}
onClick={() => onOauthLogin('dingtalk')}
>
<Icon type='icon-dingding' sx={{ fontSize: 30 }} />
</IconButton>
)}
{custom_oauth.enable && (
<IconButton
sx={{ alignSelf: 'center' }}
onClick={() => onOauthLogin('custom')}
>
<Icon type='icon-oauth' sx={{ fontSize: 30 }} />
</IconButton>
)}
</Stack>
);
};
@@ -322,7 +344,7 @@ const AuthPage = () => {
</LogoTitle>
</LogoContainer>
{!loginSetting.disable_password_login && renderLoginForm()}
{loginSetting.enable_dingtalk_oauth && dingdingLogin()}
{oauthEnable && oauthLogin()}
</StyledPaper>
</StyledContainer>
);

View File

@@ -35,6 +35,7 @@ const Chat = () => {
useEffect(() => {
fetchData();
// eslint-disable-next-line
}, [page, size]);
const columns: ColumnsType<DomainChatRecord> = [
@@ -102,7 +103,7 @@ const Chat = () => {
};
return (
<StyledLabel color={value ? workModeMap[value]['color'] : 'default'}>
{ value ? workModeMap[value]['name'] : '未知' }
{value ? workModeMap[value]['name'] : '未知'}
</StyledLabel>
);
},

View File

@@ -123,7 +123,7 @@ const ChatDetailModal = ({
fontSize: 14,
scrollBeyondLastLine: false,
wordWrap: 'on',
lineNumbers: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
overviewRulerLanes: 0,

View File

@@ -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) => (
<MenuItem
key={item.id}
selected={memberData?.id === item.id}
onClick={() => {
onMemberChange(item);
onMemberChange?.(item);
handleClose();
}}
sx={{
@@ -127,12 +127,14 @@ const MemberInfo = ({
sx={{ mb: 1 }}
>
<Avatar name={memberData?.username || ''} />
<IconButton onClick={handleClick} size='small'>
<Icon
type='icon-qiehuan'
sx={{ fontSize: 16, color: 'text.primary' }}
/>
</IconButton>
{userList && (
<IconButton onClick={handleClick} size='small'>
<Icon
type='icon-qiehuan'
sx={{ fontSize: 16, color: 'text.primary' }}
/>
</IconButton>
)}
</Stack>
<Box sx={{ fontSize: 16, fontWeight: 700 }}>
{memberData?.username}

View File

@@ -42,6 +42,7 @@ const MemberStatistic = ({
() =>
getUserEventsDashboard({
user_id: id || '',
precision: timeDuration.precision,
}),
{
refreshDeps: [id],

View File

@@ -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 (
<Stack justifyContent='center' gap={2}>
<Divider sx={{ my: 2, fontSize: 12, borderColor: 'divider' }}>
使
</Divider>
{dingtalk_oauth.enable && (
<Button
sx={{ alignSelf: 'center' }}
onClick={() => onOauthLogin('dingtalk')}
>
<Icon type='icon-dingding' sx={{ fontSize: 30 }} />
</Button>
)}
{custom_oauth.enable && (
<IconButton
sx={{ alignSelf: 'center' }}
onClick={() => onOauthLogin('custom')}
>
<Icon type='icon-oauth' sx={{ fontSize: 30 }} />
</IconButton>
)}
</Stack>
);
};
const renderStepContent = () => {
switch (activeStep) {
case 1:
return !loginSetting.enable_dingtalk_oauth ? (
return !oauthEnable ? (
<Box component='form' onSubmit={onRegister}>
<Grid container spacing={3}>
<Grid size={12}>
@@ -285,17 +321,7 @@ const Invite = () => {
</Grid>
</Box>
) : (
<Stack>
<Button
size='large'
variant='contained'
sx={{ alignSelf: 'center' }}
onClick={onDingdingLogin}
>
<Icon type='icon-dingding' sx={{ fontSize: 20, mr: 1 }} />
使
</Button>
</Stack>
oauthLogin()
);
case 2:

View File

@@ -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';

View File

@@ -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 (
<Stack gap={2} sx={{ height: '100%' }}>
<Grid container spacing={2} sx={{ height: '100%' }}>
@@ -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}
</Box>
</StyledLabel>
<Button
@@ -108,7 +118,7 @@ const User = () => {
<ThirdPartyLoginSettingModal
open={thirdPartyLoginSettingModalOpen}
onCancel={() => setThirdPartyLoginSettingModalOpen(false)}
settingData={data}
settingData={data || {}}
onOk={() => {
refresh();
}}

View File

@@ -0,0 +1,514 @@
import {
Button,
Radio,
Stack,
TextField,
Autocomplete,
Chip,
} from '@mui/material';
import { Modal, message } from '@c-x/ui';
import { useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { FormItem } from '@/components/form';
import { putUpdateSetting } from '@/api/Admin';
import { DomainSetting, DomainUpdateSettingReq } from '@/api/types';
type LoginType = 'dingding' | 'wechat' | 'feishu' | 'oauth' | 'none';
// 登录选项配置
const loginOptions = [
{ type: 'none' as LoginType, label: '不启用', disabled: false },
{ type: 'dingding' as LoginType, label: '钉钉登录', disabled: false },
{ type: 'oauth' as LoginType, label: 'OAuth 登录', disabled: false },
{ type: 'wechat' as LoginType, label: '企业微信登录', disabled: true },
{ type: 'feishu' as LoginType, label: '飞书登录', disabled: true },
];
// 登录选项组件
const LoginOptionButton = ({
option,
isSelected,
onSelect,
}: {
option: (typeof loginOptions)[0];
isSelected: boolean;
onSelect: (type: LoginType) => void;
}) => (
<Button
variant='outlined'
color='primary'
sx={{ gap: 1 }}
disabled={option.disabled}
onClick={() => !option.disabled && onSelect(option.type)}
>
<Radio
size='small'
sx={{ p: 0.5 }}
checked={isSelected}
disabled={option.disabled}
/>
{option.label}
</Button>
);
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<LoginType>(
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 (
<>
<FormItem label='Client ID' required>
<Controller
control={control}
name='dingtalk_client_id'
rules={{
required: {
value: true,
message: 'Client Id 不能为空',
},
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.dingtalk_client_id}
helperText={errors.dingtalk_client_id?.message}
/>
)}
/>
</FormItem>
<FormItem label='Client Secret' required>
<Controller
control={control}
name='dingtalk_client_secret'
rules={{
required: {
value: true,
message: 'Client Secret 不能为空',
},
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.dingtalk_client_secret}
helperText={errors.dingtalk_client_secret?.message}
/>
)}
/>
</FormItem>
</>
);
};
const oauthForm = () => {
return (
<>
<FormItem label='Access Token URL' required>
<Controller
control={control}
rules={{
required: 'Access Token URL 不能为空',
}}
name='access_token_url'
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.access_token_url}
helperText={errors.access_token_url?.message}
/>
)}
/>
</FormItem>
<FormItem label='Authorize URL' required>
<Controller
control={control}
name='authorize_url'
rules={{
required: 'Authorize URL 不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.authorize_url}
helperText={errors.authorize_url?.message}
/>
)}
/>
</FormItem>
<FormItem label='Client ID' required>
<Controller
control={control}
name='client_id'
rules={{
required: 'Client ID 不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.client_id}
helperText={errors.client_id?.message}
/>
)}
/>
</FormItem>
<FormItem label='Client Secret' required>
<Controller
control={control}
name='client_secret'
rules={{
required: 'Client Secret 不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.client_secret}
helperText={errors.client_secret?.message}
/>
)}
/>
</FormItem>
<FormItem label='Scope' required>
<Controller
name='scopes'
control={control}
rules={{
validate: (value) => {
if (value.length === 0) {
return 'Scope 不能为空';
}
return true;
},
}}
render={({ field }) => (
<Autocomplete
multiple
id='tags-filled'
options={[]}
value={field.value}
inputValue={scopeInputValue}
onChange={(_, value) => {
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 (
<Chip
key={key}
label={label}
size='small'
{...tagProps}
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
required
placeholder='请输入(可多个, 回车键确认)'
error={Boolean(errors.scopes)}
helperText={errors.scopes?.message as string}
onBlur={() => {
// 失去焦点时自动添加当前输入的值
const trimmedValue = scopeInputValue.trim();
if (trimmedValue && !field.value.includes(trimmedValue)) {
field.onChange([...field.value, trimmedValue]);
// 清空输入框
setScopeInputValue('');
}
}}
/>
)}
/>
)}
/>
</FormItem>
<FormItem label='用户信息 URL' required>
<Controller
control={control}
name='userinfo_url'
rules={{
required: '用户信息 URL 不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.userinfo_url}
helperText={errors.userinfo_url?.message}
/>
)}
/>
</FormItem>
{userInfoUrl && (
<>
<FormItem label='ID 字段' required>
<Controller
control={control}
name='id_field'
rules={{
required: 'ID 字段 不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.id_field}
helperText={errors.id_field?.message}
/>
)}
/>
</FormItem>
<FormItem label='用户名字段' required>
<Controller
control={control}
name='name_field'
rules={{
required: '用户名字段不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.name_field}
helperText={errors.name_field?.message}
/>
)}
/>
</FormItem>
<FormItem label='头像字段' required>
<Controller
control={control}
name='avatar_field'
rules={{
required: '头像字段不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.avatar_field}
helperText={errors.avatar_field?.message}
/>
)}
/>
</FormItem>
<FormItem label='邮箱字段' required>
<Controller
control={control}
name='email_field'
rules={{
required: '邮箱字段不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.email_field}
helperText={errors.email_field?.message}
/>
)}
/>
</FormItem>
</>
)}
</>
);
};
return (
<Modal
open={open}
width={800}
onCancel={onCancel}
title='第三方登录配置'
onOk={onSubmit}
>
<Stack
direction='row'
alignItems='center'
spacing={2}
sx={{ mt: 2, height: 'calc(100% - 40px)' }}
>
{loginOptions.map((option) => (
<LoginOptionButton
key={option.type}
option={option}
isSelected={loginType === option.type}
onSelect={setLoginType}
/>
))}
</Stack>
<Stack gap={2} sx={{ mt: 4 }}>
{loginType === 'dingding' && dingdingForm()}
{loginType === 'oauth' && oauthForm()}
</Stack>
</Modal>
);
};
export default ThirdPartyLoginSettingModal;

View File

@@ -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<DomainChatContent[]>([]);
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 (
<Modal
title={
<Ellipsis
sx={{
fontWeight: 'bold',
fontSize: 20,
lineHeight: '22px',
width: 700,
}}
>
-{data?.user?.username}
</Ellipsis>
}
sx={{
'.MuiDialog-paper': {
maxWidth: 1300,
},
}}
width={1200}
open={open}
onCancel={onClose}
footer={null}
>
<Card sx={{ p: 0, background: 'transparent', boxShadow: 'none' }}>
<StyledChatList>
{content.map((item, idx) => {
const isUser = item.role === 'user';
const name = isUser ? data?.user?.username : 'MonkeyCode';
const msg = item.content || '';
return (
<StyledChatRow key={idx} isUser={isUser}>
<StyledChatUser key={idx} isUser={isUser}>
<StyledChatAvatar isUser={isUser}>
<Avatar
name={isUser ? name : undefined}
src={isUser ? undefined : logo}
sx={{
width: 24,
height: 24,
fontSize: 16,
}}
/>
</StyledChatAvatar>
<StyledChatName>{name}</StyledChatName>
</StyledChatUser>
<StyledChatBubble isUser={isUser}>
<MarkDown content={msg} />
</StyledChatBubble>
</StyledChatRow>
);
})}
</StyledChatList>
</Card>
</Modal>
);
};
export default ChatDetailModal;

View File

@@ -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<DomainChatRecord[]>([]);
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<DomainChatRecord> = [
{
dataIndex: 'user',
title: '成员',
width: 260,
render(value: DomainUser) {
return (
<User
id={value.id!}
username={value.username!}
email={value.email!}
/>
);
},
},
{
dataIndex: 'question',
title: '任务内容',
render(value: string, record) {
const cleanValue = value?.replace(/<\/?task>/g, '') || value;
return (
<Box
onClick={() => setChatDetailModal(record)}
sx={{
cursor: 'pointer',
color: 'info.main',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{cleanValue || '无标题任务'}
</Box>
);
},
},
{
dataIndex: 'work_mode',
title: '工作模式',
width: 120,
render(value: DomainChatRecord['work_mode']) {
const workModeMap: Record<string, Record<string, string>> = {
code: {
name: '编程模式',
color: 'warning',
},
ask: {
name: '问答模式',
color: 'info',
},
architect: {
name: '架构模式',
color: 'success',
},
debug: {
name: '调试模式',
color: 'error',
},
orchestrator: {
name: '编排模式',
color: 'info',
},
};
return (
<StyledLabel color={value ? workModeMap[value]['color'] : 'default'}>
{value ? workModeMap[value]['name'] : '未知'}
</StyledLabel>
);
},
},
{
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 (
<Card sx={{ flex: 1, height: '100%' }}>
<Table
height='100%'
sx={{ mx: -2 }}
PaginationProps={{
sx: {
pt: 2,
mx: 2,
},
}}
loading={loading}
columns={columns}
dataSource={dataSource}
rowKey='id'
pagination={{
page,
pageSize: size,
total,
onChange: (page: number, size: number) => {
setPage(page);
setSize(size);
},
}}
/>
<ChatDetailModal
open={!!chatDetailModal}
onClose={() => setChatDetailModal(undefined)}
data={chatDetailModal}
/>
</Card>
);
};
export default Chat;

View File

@@ -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<string>('');
const editorRef = useRef<any>(null);
const [editorReady, setEditorReady] = useState(false);
const [highlightInfo, setHighlightInfo] = useState<any>(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 (
<Modal
title='代码补全'
width={800}
open={open}
onCancel={onClose}
footer={null}
>
<Card sx={{ p: 0 }}>
<div style={{ height: 420 }}>
<MonacoEditor
height='100%'
language={getBaseLanguageId(data?.program_language || 'plaintext')}
value={editorValue}
theme='vs-dark'
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
scrollBeyondLastLine: false,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
overviewRulerLanes: 0,
guides: {
indentation: true,
highlightActiveIndentation: true,
highlightActiveBracketPair: false,
},
renderLineHighlight: 'none',
cursorStyle: 'line',
cursorBlinking: 'solid',
cursorWidth: 0,
contextmenu: false,
selectionHighlight: false,
selectOnLineNumbers: false,
occurrencesHighlight: 'off',
links: false,
hover: { enabled: false },
codeLens: false,
dragAndDrop: false,
mouseWheelZoom: false,
accessibilitySupport: 'off',
bracketPairColorization: { enabled: false },
matchBrackets: 'never',
}}
onMount={(editor) => {
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);
}
}}
/>
</div>
<style>{`
.completion-highlight {
background: #264f78 !important;
transition: background 0.2s;
}
`}</style>
</Card>
</Modal>
);
};
export default ChatDetailModal;

View File

@@ -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',
];

View File

@@ -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<DomainCompletionRecord[]>([]);
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<DomainCompletionRecord> = [
{
dataIndex: 'user',
title: '成员',
render(value: DomainUser) {
return (
<User
id={value.id!}
username={value.username!}
email={value.email!}
/>
);
},
},
{
dataIndex: 'task',
title: '补全内容',
width: 150,
render(_, record) {
return (
<Box
onClick={() => setCompletionDetailModal(record)}
sx={{ color: 'info.main', cursor: 'pointer' }}
>
</Box>
);
},
},
{
dataIndex: 'is_accept',
title: '是否采纳',
width: 130,
render(value: boolean) {
const color = value ? 'success' : 'default';
return (
<StyledLabel color={color}>{value ? '已采纳' : '未采纳'}</StyledLabel>
);
},
},
{
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 (
<Card sx={{ flex: 1, height: '100%' }}>
<Stack direction='row' spacing={2} sx={{ mb: 2 }}>
<Autocomplete
size='small'
sx={{ minWidth: 220 }}
options={LANG_OPTIONS}
getOptionLabel={(option) => option || ''}
value={filterLang || ''}
freeSolo
onChange={(_, newValue) => {
setFilterLang(newValue ? String(newValue) : '');
}}
onInputChange={(_, newInputValue) =>
debounceSetFilterLang.run(newInputValue)
}
renderInput={(params) => <TextField {...params} label='语言' />}
clearOnEscape
/>
<FormControl size='small' sx={{ minWidth: 180 }}>
<InputLabel></InputLabel>
<Select
label='是否采纳'
value={filterAccept}
onChange={(e) =>
setFilterAccept(e.target.value as 'accepted' | 'unaccepted')
}
>
<MenuItem value=''></MenuItem>
<MenuItem value='accepted'></MenuItem>
<MenuItem value='unaccepted'></MenuItem>
</Select>
</FormControl>
</Stack>
<Table
size='large'
height='calc(100% - 56px)'
loading={loading}
columns={columns}
dataSource={dataSource || []}
sx={{ mx: -2 }}
PaginationProps={{
sx: {
pt: 2,
mx: 2,
},
}}
rowKey='id'
pagination={{
pageSize: size,
total: total,
page: page,
onChange: (page, size) => {
setPage(page);
setSize(size);
fetchData({
page,
size,
});
},
}}
/>
<CompletionDetailModal
open={!!completionDetailModal}
onClose={() => setCompletionDetailModal(undefined)}
data={completionDetailModal}
/>
</Card>
);
};
export default Completion;

View File

@@ -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<TimeDuration>({
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<string, number>[],
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 (
<Grid
container
spacing={2}
sx={{
height: '100%',
overflow: 'auto',
borderRadius: '10px',
}}
>
<Grid container size={9}>
<Grid size={12}>
<MemberInfo data={userHeatmap || {}} memberData={memberData} />
</Grid>
<Grid size={6}>
<PieCharts
title='工作模式-对话任务'
extra={timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
data={userStat?.work_mode || []}
/>
</Grid>
<Grid size={6}>
<PieCharts
title='编程语言'
extra={timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
data={userStat?.program_language || []}
/>
</Grid>
</Grid>
<Grid size={3}>
<RecentActivityCard data={userEvents || []} />
</Grid>
<Grid size={6}>
<LineCharts
title='对话任务'
data={chatChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>{userStat?.total_chats || 0}</StyledHighlight>
</>
}
/>
</Grid>
<Grid size={6}>
<LineCharts
title='补全任务'
data={codeCompletionChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{userStat?.total_completions || 0}
</StyledHighlight>
</>
}
/>
</Grid>
<Grid size={6}>
<LineCharts
title='代码量'
data={codeLineChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{userStat?.total_lines_of_code || 0}
</StyledHighlight>
</>
}
/>
</Grid>
<Grid size={6}>
<LineCharts
title='补全任务采纳率'
data={acceptedPerChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{(userStat?.total_accepted_per || 0).toFixed(2)}
</StyledHighlight>
%
</>
}
/>
</Grid>
</Grid>
);
};
export default MemberStatistic;

View File

@@ -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<DomainUser | null>(null);
const [timeRange, setTimeRange] = useState<TimeRange>('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 (
<Stack gap={2} sx={{ height: '100%' }}>
<Stack direction='row' justifyContent='space-between' alignItems='center'>
<Select
labelId='time-range-label'
value={timeRange}
size='small'
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
sx={{ fontSize: 14 }}
>
<MenuItem value='24h'> 24 </MenuItem>
<MenuItem value='90d'> 90 </MenuItem>
</Select>
</Stack>
<MemberStatistic memberData={memberData} timeRange={timeRange} />
</Stack>
);
};
export default Dashboard;

View File

@@ -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<string | null>(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<LoginFormData>();
// 切换密码显示状态
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 = () => (
<Controller
name='username'
control={control}
defaultValue=''
rules={{ required: '请输入用户名' }}
render={({ field }) => (
<StyledTextField
{...field}
fullWidth
placeholder='请输入用户名'
variant='outlined'
error={!!errors.username}
helperText={errors.username?.message}
disabled={loading}
slotProps={{
input: {
startAdornment: (
<IconWrapper>
<Icon type='icon-zhanghao' />
</IconWrapper>
),
},
}}
/>
)}
/>
);
// 渲染密码输入框
const renderPasswordField = () => (
<Controller
name='password'
control={control}
defaultValue=''
rules={{ required: '请输入密码' }}
render={({ field }) => (
<StyledTextField
{...field}
fullWidth
placeholder='请输入密码'
type={showPassword ? 'text' : 'password'}
variant='outlined'
error={!!errors.password}
helperText={errors.password?.message}
disabled={loading}
slotProps={{
input: {
startAdornment: (
<IconWrapper>
<Icon type='icon-mima' />
</IconWrapper>
),
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label='切换密码显示'
onClick={togglePasswordVisibility}
edge='end'
disabled={loading}
size='small'
>
<TogglePasswordIcon
type={showPassword ? 'icon-kejian' : 'icon-bukejian'}
/>
</IconButton>
</InputAdornment>
),
},
}}
/>
)}
/>
);
// 渲染登录按钮
const renderLoginButton = () => (
<Grid size={12}>
<StyledButton
type='submit'
fullWidth
variant='contained'
size='large'
disabled={loading}
>
{loading ? <CircularProgress size={18} /> : '登录'}
</StyledButton>
</Grid>
);
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 (
<Stack justifyContent='center'>
<Divider sx={{ my: 3, fontSize: 12, borderColor: 'divider' }}>
使
</Divider>
{dingtalk_oauth.enable && (
<IconButton
sx={{ alignSelf: 'center' }}
onClick={() => onOauthLogin('dingtalk')}
>
<Icon type='icon-dingding' sx={{ fontSize: 30 }} />
</IconButton>
)}
{custom_oauth.enable && (
<IconButton
sx={{ alignSelf: 'center' }}
onClick={() => onOauthLogin('custom')}
>
<Icon type='icon-oauth' sx={{ fontSize: 30 }} />
</IconButton>
)}
</Stack>
);
};
// 渲染登录表单
const renderLoginForm = () => (
<>
<Box component='form' onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={4}>
<Grid size={12}>{renderUsernameField()}</Grid>
<Grid size={12}>{renderPasswordField()}</Grid>
{renderLoginButton()}
</Grid>
</Box>
</>
);
useEffect(() => {
const redirect_url = searchParams.get('redirect_url');
if (redirect_url) {
window.location.href = redirect_url;
}
}, []);
return (
<StyledContainer id='box'>
<StyledPaper elevation={3}>
<LogoContainer>
<LogoImage src={Logo} alt='Monkey Code Logo' />
<LogoTitle variant='h4' gutterBottom>
Monkey Code
</LogoTitle>
</LogoContainer>
{!loginSetting.disable_password_login && renderLoginForm()}
{oauthEnable && oauthLogin()}
</StyledPaper>
</StyledContainer>
);
};
export default UserLogin;

View File

@@ -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<LoginType>(
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 (
<Modal
open={open}
width={900}
onCancel={onCancel}
title='第三方登录配置'
onOk={onSubmit}
>
<Stack
direction='row'
alignItems='center'
spacing={2}
sx={{ mt: 2, height: 'calc(100% - 40px)' }}
>
<Button
variant='outlined'
color='primary'
sx={{ gap: 2 }}
onClick={() => {
setLoginType('none');
}}
>
<Radio size='small' sx={{ p: 0.5 }} checked={loginType === 'none'} />
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
</Stack>
</Stack>
</Button>
<Button
variant='outlined'
color='primary'
sx={{ gap: 2 }}
onClick={() => {
setLoginType('dingding');
}}
>
<Radio
size='small'
sx={{ p: 0.5 }}
checked={loginType === 'dingding'}
/>
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
</Stack>
</Stack>
</Button>
<Button variant='outlined' color='primary' sx={{ gap: 2 }} disabled>
<Radio size='small' sx={{ p: 0.5 }} disabled />
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
</Stack>
</Stack>
</Button>
<Button variant='outlined' color='primary' sx={{ gap: 2 }} disabled>
<Radio size='small' sx={{ p: 0.5 }} disabled />
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
</Stack>
</Stack>
</Button>
<Button variant='outlined' color='primary' sx={{ gap: 2 }} disabled>
<Radio size='small' sx={{ p: 0.5 }} disabled />
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
OAuth
</Stack>
</Stack>
</Button>
</Stack>
{loginType === 'dingding' && (
<Stack gap={2} sx={{ mt: 4 }}>
<Box>
<StyledFormLabel required>Client ID</StyledFormLabel>
<Controller
control={control}
name='dingtalk_client_id'
rules={{
required: {
value: true,
message: 'Client Id 不能为空',
},
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.dingtalk_client_id}
helperText={errors.dingtalk_client_id?.message}
/>
)}
/>
</Box>
<Box>
<StyledFormLabel required>Client Secret</StyledFormLabel>
<Controller
control={control}
name='dingtalk_client_secret'
rules={{
required: {
value: true,
message: 'Client Secret 不能为空',
},
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.dingtalk_client_secret}
helperText={errors.dingtalk_client_secret?.message}
/>
)}
/>
</Box>
{/* <Box>
<StyledFormLabel>标题名称,默认为 身份认证-钉钉登录</StyledFormLabel>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField
{...field}
fullWidth
size='small'
placeholder='请输入'
error={!!errors.title}
helperText={errors.title?.message}
onChange={(e) => {
field.onChange(e.target.value);
}}
/>
)}
/>
</Box> */}
</Stack>
)}
</Modal>
);
};
export default ThirdPartyLoginSettingModal;

View File

@@ -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: <MainLayout />,
redirect: '/dashboard',
children: [
{
index: true,
@@ -70,7 +78,7 @@ const routerConfig = [
element: <Model />,
},
{
path: 'user',
path: 'user-management',
element: <User />,
},
{
@@ -79,6 +87,29 @@ const routerConfig = [
},
],
},
{
path: '/user',
element: <MainLayout />,
children: [
{
index: true,
element: <Navigate to='/user/dashboard' replace />,
},
{
path: 'dashboard',
element: <UserDashboard />,
},
{
path: 'chat',
element: <UserChat />,
},
{
path: 'completion',
element: <UserCompletion />,
},
],
},
{
path: '/invite/:id/:step?',
element: <Invite />,
@@ -87,6 +118,10 @@ const routerConfig = [
path: '/auth',
element: <Auth />,
},
{
path: '/user/login',
element: <UserLogin />,
},
{
path: '/login',
element: <Login />,