feat: 添加钉钉登录

This commit is contained in:
Gavan
2025-07-01 21:09:53 +08:00
parent e32a9efe49
commit 0da806d4f1
16 changed files with 470 additions and 131 deletions

View File

@@ -1,64 +1,23 @@
const fs = require('fs')
const path = require('path')
const Axios = require('axios')
const fs = require("fs");
const path = require("path");
async function downloadFile(url) {
try {
const iconPath = path.resolve(__dirname, '../src/assets/fonts/iconfont.js')
const iconDir = path.dirname(iconPath)
// 检查目录是否存在,如果不存在则创建
if (!fs.existsSync(iconDir)) {
console.log(`目录 ${iconDir} 不存在,正在创建...`)
fs.mkdirSync(iconDir, { recursive: true })
console.log('目录创建成功')
}
console.log(`开始下载图标文件到: ${iconPath}`)
const writer = fs.createWriteStream(iconPath)
const response = await Axios({
url: `https:${url}`,
method: 'GET',
responseType: 'stream',
timeout: 30000, // 30秒超时
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => {
console.log('图标文件下载成功!')
resolve()
})
writer.on('error', (err) => {
console.error('写入文件时出错:', err.message)
reject(err)
})
})
} catch (error) {
console.error('下载过程中出错:', error.message)
throw error
}
}
const iconPath = path.resolve(__dirname, "../src/assets/fonts/iconfont.js");
const iconDir = path.dirname(iconPath);
async function main() {
const argument = process.argv.splice(2)
if (!argument[0]) {
console.error('错误: 请提供下载URL作为参数')
console.log('使用方法: node downLoadIcon.cjs <url>')
process.exit(1)
// 检查目录是否存在,不存在则创建
if (!fs.existsSync(iconDir)) {
fs.mkdirSync(iconDir, { recursive: true });
console.log(`目录 ${iconDir} 已创建`);
}
try {
await downloadFile(argument[0])
console.log('所有操作完成!')
} catch (error) {
console.error('脚本执行失败:', error.message)
process.exit(1)
}
}
main()
const response = await fetch(`https:${url}`, {
method: "GET",
// responseType: "stream", // fetch 不支持此参数
}).then((res) => res.text());
fs.writeFileSync(iconPath, response);
console.log("Download Icon Success");
}
let argument = process.argv.splice(2);
downloadFile(argument[0]);

View File

@@ -23,6 +23,7 @@ import {
DomainListUserResp,
DomainLoginReq,
DomainLoginResp,
DomainOAuthURLResp,
DomainRegisterReq,
DomainSetting,
DomainUpdateSettingReq,
@@ -32,6 +33,8 @@ import {
GetListAdminUserParams,
GetListUserParams,
GetLoginHistoryParams,
GetUserOauthCallbackParams,
GetUserOauthSignupOrInParams,
WebResp,
} from "./types";
@@ -401,6 +404,66 @@ export const getLoginHistory = (
...params,
});
/**
* @description 用户 OAuth 回调
*
* @tags User
* @name GetUserOauthCallback
* @summary 用户 OAuth 回调
* @request GET:/api/v1/user/oauth/callback
* @response `200` `(WebResp & {
data?: string,
})` OK
*/
export const getUserOauthCallback = (
query: GetUserOauthCallbackParams,
params: RequestParams = {},
) =>
request<
WebResp & {
data?: string;
}
>({
path: `/api/v1/user/oauth/callback`,
method: "GET",
query: query,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description 用户 OAuth 登录或注册
*
* @tags User
* @name GetUserOauthSignupOrIn
* @summary 用户 OAuth 登录或注册
* @request GET:/api/v1/user/oauth/signup-or-in
* @response `200` `(WebResp & {
data?: DomainOAuthURLResp,
})` OK
*/
export const getUserOauthSignupOrIn = (
query: GetUserOauthSignupOrInParams,
params: RequestParams = {},
) =>
request<
WebResp & {
data?: DomainOAuthURLResp;
}
>({
path: `/api/v1/user/oauth/signup-or-in`,
method: "GET",
query: query,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description 注册用户
*

View File

@@ -16,6 +16,11 @@ export enum ConstsUserStatus {
UserStatusLocked = "locked",
}
export enum ConstsUserPlatform {
UserPlatformEmail = "email",
UserPlatformDingTalk = "dingtalk",
}
export enum ConstsModelType {
ModelTypeLLM = "llm",
ModelTypeCoder = "coder",
@@ -312,6 +317,10 @@ export interface DomainModelTokenUsageResp {
total_output?: number;
}
export interface DomainOAuthURLResp {
url?: string;
}
export interface DomainProviderModel {
/** 模型列表 */
models?: DomainModelBasic[];
@@ -333,6 +342,8 @@ export interface DomainSetting {
created_at?: number;
/** 是否禁用密码登录 */
disable_password_login?: boolean;
/** 是否开启钉钉OAuth */
enable_dingtalk_oauth?: boolean;
/** 是否开启SSO */
enable_sso?: boolean;
/** 是否强制两步验证 */
@@ -419,8 +430,14 @@ export interface DomainUpdateModelReq {
}
export interface DomainUpdateSettingReq {
/** 钉钉客户端ID */
dingtalk_client_id?: string;
/** 钉钉客户端密钥 */
dingtalk_client_secret?: string;
/** 是否禁用密码登录 */
disable_password_login?: boolean;
/** 是否开启钉钉OAuth */
enable_dingtalk_oauth?: boolean;
/** 是否开启SSO */
enable_sso?: boolean;
/** 是否强制两步验证 */
@@ -637,3 +654,17 @@ export interface GetLoginHistoryParams {
/** 每页多少条记录 */
size?: number;
}
export interface GetUserOauthCallbackParams {
code: string;
state: string;
}
export interface GetUserOauthSignupOrInParams {
/** 第三方平台 dingtalk */
platform: "email" | "dingtalk";
/** 登录成功后跳转的 URL */
redirect_url?: string;
/** 会话ID */
session_id?: string;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,10 @@
'use client';
import { styled, FormLabel } from '@mui/material';
export const StyledFormLabel = styled(FormLabel)(({ theme }) => ({
display: 'block',
color: theme.vars.palette.text.primary,
fontSize: 16,
fontWeight: 500,
fontSize: 14,
fontWeight: 400,
marginBottom: theme.spacing(1),
[theme.breakpoints.down('sm')]: {
fontSize: 14,

View File

@@ -97,6 +97,10 @@ const MarkDown = ({
const answer = processContent(content);
console.log(answer);
console.log(content);
if (content.length === 0) return null;
return (
@@ -446,12 +450,13 @@ const MarkDown = ({
...rest
}: React.HTMLAttributes<HTMLElement>) {
const match = /language-(\w+)/.exec(className || '');
console.log(children, rest);
return match ? (
<SyntaxHighlighter
showLineNumbers
{...rest}
language={match[1] || 'bash'}
style={github}
style={anOldHope}
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(

View File

@@ -12,17 +12,20 @@ import {
Grid2 as Grid,
InputAdornment,
IconButton,
Divider,
Stack,
} from '@mui/material';
import { Icon } from '@c-x/ui';
import { Icon, message } from '@c-x/ui';
// @ts-ignore
import { AestheticFluidBg } from '@/assets/jsm/AestheticFluidBg.module.js';
import { useSearchParams } from 'react-router-dom';
import { postLogin } from '@/api/User';
import { postLogin, getUserOauthSignupOrIn, getGetSetting } from '@/api/User';
import { useForm, Controller } from 'react-hook-form';
import { styled } from '@mui/material/styles';
import { useRequest } from 'ahooks';
// 样式化组件
const StyledContainer = styled(Container)(({ theme }) => ({
@@ -111,6 +114,7 @@ const AuthPage = () => {
const [showPassword, setShowPassword] = useState(false);
const [searchParams] = useSearchParams();
const { data: loginSetting = {} } = useRequest(getGetSetting);
const {
control,
@@ -132,7 +136,8 @@ const AuthPage = () => {
try {
const sessionId = searchParams.get('session_id');
if (!sessionId) {
throw new Error('缺少会话ID参数');
message.error('缺少会话ID参数');
return;
}
// 用户登录
@@ -245,17 +250,6 @@ const AuthPage = () => {
/>
);
// 渲染错误提示
const renderErrorAlert = () => {
if (!error) return null;
return (
<Grid size={12}>
<Alert severity='error'>{error}</Alert>
</Grid>
);
};
// 渲染登录按钮
const renderLoginButton = () => (
<Grid size={12}>
@@ -271,6 +265,32 @@ const AuthPage = () => {
</Grid>
);
const onDingdingLogin = () => {
getUserOauthSignupOrIn({
platform: 'dingtalk',
redirect_url: window.location.origin + window.location.pathname,
// @ts-ignore
session_id: searchParams.get('session_id') || null,
}).then((res) => {
if (res.url) {
window.location.href = res.url;
}
});
};
const dingdingLogin = () => {
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>
</Stack>
);
};
// 渲染登录表单
const renderLoginForm = () => (
<>
@@ -284,19 +304,27 @@ const AuthPage = () => {
<Box component='form' onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={4}>
<Grid size={12}>{renderUsernameField()}</Grid>
<Grid size={12}>{renderPasswordField()}</Grid>
{renderErrorAlert()}
{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}>{renderLoginForm()}</StyledPaper>
<StyledPaper elevation={3}>
{!loginSetting.disable_password_login && renderLoginForm()}
{loginSetting.enable_dingtalk_oauth && dingdingLogin()}
</StyledPaper>
</StyledContainer>
);
};

View File

@@ -78,7 +78,7 @@ const ChatDetailModal = ({
width: 700,
}}
>
{data?.question?.replace(/^<task>|<\/task>$/g, '') || '-'}
-{data?.user?.username}
</Ellipsis>
}
width={800}

View File

@@ -56,7 +56,7 @@ const Chat = () => {
dataIndex: 'question',
title: '任务',
render(value: string, record) {
const cleanValue = value?.replace(/^<task>|<\/task>$/g, '') || value;
const cleanValue = value?.replace(/<\/?task>/g, '') || value;
return (
<Box
onClick={() => setChatDetailModal(record)}

View File

@@ -33,9 +33,9 @@ const ChatDetailModal = ({
if (!data) return;
getChatInfo({ id: data.id! }).then((res) => {
setContent(
`<code class="language-${data.program_language}">${
res.content || ''
}</code>`
data.program_language
? `\`\`\`${data.program_language}\n${res.content || ''}\n\`\`\``
: res.content || ''
);
});
// getConversationChatDetailModal({ id }).then((res) => {

View File

@@ -19,9 +19,14 @@ import {
InputAdornment,
IconButton,
CircularProgress,
Stack,
} from '@mui/material';
import { useRequest } from 'ahooks';
import { postRegister } from '@/api/User';
import {
postRegister,
getUserOauthSignupOrIn,
getGetSetting,
} from '@/api/User';
import { Icon } from '@c-x/ui';
import DownloadIcon from '@mui/icons-material/Download';
@@ -63,9 +68,9 @@ const StepCard = styled(Box)(({ theme }) => ({
}));
const Invite = () => {
const { id } = useParams();
const { id, step } = useParams();
const [showPassword, setShowPassword] = useState(false);
const { data: loginSetting = {} } = useRequest(getGetSetting);
const {
control,
handleSubmit,
@@ -80,7 +85,7 @@ const Invite = () => {
const { runAsync: register, loading } = useRequest(postRegister, {
manual: true,
});
const [activeStep, setActiveStep] = useState(0);
const [activeStep, setActiveStep] = useState(step ? parseInt(step) : 1);
const onNext = () => {
setActiveStep(activeStep + 1);
@@ -107,10 +112,19 @@ const Invite = () => {
});
}, []);
const onDingdingLogin = () => {
getUserOauthSignupOrIn({
platform: 'dingtalk',
redirect_url: `${window.location.origin}/invite/${id}/2`,
}).then((res) => {
window.location.href = res.url!;
});
};
const renderStepContent = () => {
switch (activeStep) {
case 0:
return (
case 1:
return !loginSetting.enable_dingtalk_oauth ? (
<Box component='form' onSubmit={onRegister}>
<Grid container spacing={3}>
<Grid size={12}>
@@ -229,9 +243,21 @@ const Invite = () => {
</Grid>
</Grid>
</Box>
) : (
<Stack>
<Button
size='large'
variant='contained'
sx={{ alignSelf: 'center' }}
onClick={onDingdingLogin}
>
<Icon type='icon-dingding' sx={{ fontSize: 20, mr: 1 }} />
使
</Button>
</Stack>
);
case 1:
case 2:
return (
<StepCard>
<DownloadIcon sx={{ fontSize: 60, color: 'primary.main', mb: 2 }} />
@@ -262,7 +288,7 @@ const Invite = () => {
</StepCard>
);
case 2:
case 3:
return (
<StepCard>
<MenuBookIcon sx={{ fontSize: 60, color: 'primary.main', mb: 2 }} />

View File

@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react';
import { Modal, message, Loading } from '@c-x/ui';
import { useForm, Controller } from 'react-hook-form';
import { StyledFormLabel } from '@/components/form';
import { putUpdateSetting } from '@/api/User';
import {
Box,
Typography,
IconButton,
Paper,
TextField,
Stack,
} from '@mui/material';
const DingingLoginSettingModal = ({
open,
onClose,
onOk,
}: {
open: boolean;
onClose: () => void;
onOk: () => void;
}) => {
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm({
defaultValues: {
dingtalk_client_id: '',
dingtalk_client_secret: '',
// title: '',
},
});
useEffect(() => {
if (open) {
reset();
}
}, [open]);
const onSubmit = handleSubmit((data) => {
putUpdateSetting({ ...data, enable_dingtalk_oauth: true }).then(() => {
message.success('设置成功');
onClose();
onOk();
});
});
return (
<Modal
title='钉钉登录设置'
width={800}
open={open}
onOk={onSubmit}
onCancel={onClose}
>
<Stack gap={2}>
<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 DingingLoginSettingModal;

View File

@@ -1,16 +1,30 @@
import React from 'react';
import React, { useState } from 'react';
import Card from '@/components/card';
import { Grid2 as Grid, Stack, styled, Switch } from '@mui/material';
import {
Grid2 as Grid,
Stack,
styled,
Switch,
Button,
Box,
Select,
MenuItem,
Radio,
} from '@mui/material';
import { Icon, Modal } from '@c-x/ui';
import { useRequest } from 'ahooks';
import { getGetSetting, putUpdateSetting } from '@/api/User';
import MemberManage from './memberManage';
import LoginHistory from './loginHistory';
import { message } from '@c-x/ui';
import DingingLoginSettingModal from './dingdingLoginSettingModal';
const StyledCard = styled(Card)({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow:
'0px 0px 10px 0px rgba(68, 80, 91, 0.1), 0px 0px 2px 0px rgba(68, 80, 91, 0.1)',
});
const StyledLabel = styled('div')(({ theme }) => ({
@@ -20,60 +34,143 @@ const StyledLabel = styled('div')(({ theme }) => ({
}));
const User = () => {
const [dingdingLoginSettingModalOpen, setDingdingLoginSettingModalOpen] =
useState(false);
const [dingdingCheck, setDingdingCheck] = useState(false);
const {
data = {
enable_sso: false,
force_two_factor_auth: false,
disable_password_login: false,
enable_dingtalk_oauth: false,
},
refresh,
} = useRequest(() => getGetSetting());
const { run: updateSetting } = useRequest(putUpdateSetting, {
} = useRequest(getGetSetting, {
onSuccess: (data) => {
setDingdingCheck(data.enable_dingtalk_oauth!);
},
});
const { runAsync: updateSetting } = useRequest(putUpdateSetting, {
manual: true,
onSuccess: () => {
refresh();
message.success('设置更新成功');
},
});
const onDisabledDingdingLogin = () => {
Modal.confirm({
title: '提示',
content: '确定要关闭钉钉登录吗?',
onOk: () => {
updateSetting({ enable_dingtalk_oauth: false }).then(() => {
refresh();
});
},
});
};
return (
<Stack gap={2}>
<Grid container spacing={2}>
<Grid size={4}>
<StyledCard>
<StyledLabel></StyledLabel>
<Switch
checked={data?.enable_sso}
onChange={(e) => updateSetting({ enable_sso: e.target.checked })}
/>
</StyledCard>
<Grid size={6} container>
<Grid size={12}>
<StyledCard>
<StyledLabel></StyledLabel>
<Switch
checked={data?.force_two_factor_auth}
onChange={(e) => {
updateSetting({ force_two_factor_auth: e.target.checked });
}}
/>
</StyledCard>
</Grid>
<Grid size={12}>
<StyledCard>
<StyledLabel>使</StyledLabel>
<Switch
checked={data?.disable_password_login}
onChange={(e) =>
updateSetting({ disable_password_login: e.target.checked })
}
/>
</StyledCard>
</Grid>
</Grid>
<Grid size={4}>
<StyledCard>
<StyledLabel></StyledLabel>
<Switch
checked={data?.force_two_factor_auth}
onChange={(e) => {
console.log(e.target.checked);
updateSetting({ force_two_factor_auth: e.target.checked });
}}
/>
</StyledCard>
</Grid>
<Grid size={4}>
<StyledCard>
<StyledLabel>使</StyledLabel>
<Switch
checked={data?.disable_password_login}
onChange={(e) =>
updateSetting({ disable_password_login: e.target.checked })
}
/>
</StyledCard>
<Grid size={6} container>
<Grid size={12}>
<Card sx={{ height: '100%' }}>
<StyledLabel></StyledLabel>
<Stack
direction='row'
alignItems='center'
spacing={2}
sx={{ mt: 2, height: 'calc(100% - 40px)' }}
>
<Button
variant='outlined'
color='primary'
sx={{ gap: 3 }}
onClick={() => {
if (dingdingCheck) {
onDisabledDingdingLogin();
} else {
setDingdingLoginSettingModalOpen(true);
}
}}
>
<Radio size='small' sx={{ p: 0.5 }} checked={dingdingCheck} />
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
<Icon type='icon-dingding' sx={{ fontSize: 18 }}></Icon>
</Stack>
</Stack>
</Button>
<Button
variant='outlined'
color='primary'
sx={{ gap: 3 }}
disabled
>
<Radio size='small' sx={{ p: 0.5 }} disabled />
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
<Icon type='icon-weixin' sx={{ fontSize: 18 }}></Icon>
</Stack>
</Stack>
</Button>
<Button
variant='outlined'
color='primary'
sx={{ gap: 3 }}
disabled
>
<Radio size='small' sx={{ p: 0.5 }} disabled />
<Stack direction='row' alignItems='center' gap={2}>
<Stack direction='row' alignItems='center' gap={1}>
<Icon type='icon-github' sx={{ fontSize: 18 }}></Icon>
GitHub
</Stack>
</Stack>
</Button>
</Stack>
</Card>
</Grid>
</Grid>
</Grid>
<MemberManage />
<LoginHistory />
<DingingLoginSettingModal
open={dingdingLoginSettingModalOpen}
onClose={() => setDingdingLoginSettingModalOpen(false)}
onOk={() => {
refresh();
}}
/>
</Stack>
);
};

View File

@@ -17,7 +17,7 @@ const InviteUserModal = ({
const { loading, refresh } = useRequest(getInvite, {
manual: true,
onSuccess: (data) => {
setInviteUrl(location.origin + '/invite/' + data?.code);
setInviteUrl(location.origin + '/invite/' + data?.code + '/1');
},
});

View File

@@ -80,7 +80,7 @@ const routerConfig = [
],
},
{
path: '/invite/:id',
path: '/invite/:id/:step?',
element: <Invite />,
},
{

View File

@@ -54,7 +54,7 @@ const lightTheme = createTheme(
auxiliary: 'rgba(33,34,45, 0.5)',
disabled: 'rgba(33,34,45, 0.2)',
},
divider: '#ECEEF1',
// divider: '#ECEEF1',
},
shadows: [
...defaultTheme.shadows.slice(0, 8),