tokenFactory/web/src/components/table/users/modals/EditUserModal.jsx

533 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
getCurrencyConfig,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Button,
Modal,
SideSheet,
Space,
Spin,
Typography,
Card,
Tag,
Form,
Avatar,
Row,
Col,
InputNumber,
} from '@douyinfe/semi-ui';
import {
IconUser,
IconSave,
IconClose,
IconLink,
IconUserGroup,
IconPlus,
} from '@douyinfe/semi-icons';
import UserBindingManagementModal from './UserBindingManagementModal';
import { buildAdminUserPhoneFieldRules } from './userPhoneFormRules';
import { buildAdminUserEmailFieldRules } from './userEmailFormRules';
const { Text, Title } = Typography;
const EditUserModal = (props) => {
const { t } = useTranslation();
const userId = props.editingUser.id;
const tagOptions = props.tagOptions || [];
const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState('');
const [addAmountLocal, setAddAmountLocal] = useState('');
const isMobile = useIsMobile();
const [groupOptions, setGroupOptions] = useState([]);
const [bindingModalVisible, setBindingModalVisible] = useState(false);
const formApiRef = useRef(null);
const isEdit = Boolean(userId);
const mergedTagOptions = useMemo(() => {
const seen = new Set();
return tagOptions
.map(o => o.value || o)
.filter(tag => {
if (!tag || seen.has(tag)) return false;
seen.add(tag);
return true;
})
.map(tag => ({ label: tag, value: tag }));
}, [tagOptions]);
/** 编辑用户表单初始字段(加载后与接口返回数据合并)。 */
const getInitValues = () => ({
username: '',
display_name: '',
phone: '',
password: '',
github_id: '',
oidc_id: '',
discord_id: '',
wechat_id: '',
telegram_id: '',
linux_do_id: '',
email: '',
quota: 0,
group: 'default',
tags: [],
remark: '',
});
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((g) => ({ label: g, value: g })));
} catch (e) {
showError(e.message);
}
};
const handleCancel = () => props.handleClose();
const loadUser = async () => {
setLoading(true);
const url = userId ? `/api/user/${userId}` : `/api/user/self`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
data.password = '';
// Convert tags string to array for TagInput
if (data.tags && typeof data.tags === 'string') {
data.tags = data.tags.split(',').map(tag => tag.trim()).filter(Boolean);
} else if (!data.tags) {
data.tags = [];
}
formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadUser();
if (userId) fetchGroups();
setBindingModalVisible(false);
}, [props.editingUser.id]);
const openBindingModal = () => {
setBindingModalVisible(true);
};
const closeBindingModal = () => {
setBindingModalVisible(false);
};
/* ----------------------- submit ----------------------- */
const submit = async (values) => {
setLoading(true);
let payload = { ...values };
if (typeof payload.quota === 'string')
payload.quota = parseInt(payload.quota) || 0;
if (userId) {
payload.id = parseInt(userId);
}
// Convert tags array to comma-separated string
if (Array.isArray(payload.tags)) {
payload.tags = payload.tags.join(',');
}
const url = userId ? `/api/user/` : `/api/user/self`;
const res = await API.put(url, payload);
const { success, message } = res.data;
if (success) {
showSuccess(t('用户信息更新成功!'));
props.refresh();
props.handleClose();
} else {
showError(message);
}
setLoading(false);
};
/* --------------------- quota helper -------------------- */
const addLocalQuota = () => {
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
const delta = parseInt(addQuotaLocal) || 0;
formApiRef.current?.setValue('quota', current + delta);
};
/* --------------------------- UI --------------------------- */
return (
<>
<SideSheet
placement='right'
title={
<Space>
<Tag color='blue' shape='circle'>
{t(isEdit ? '编辑' : '新建')}
</Tag>
<Title heading={4} className='m-0'>
{isEdit ? t('编辑用户') : t('创建用户')}
</Title>
</Space>
}
bodyStyle={{ padding: 0 }}
visible={props.visible}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme='light'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={handleCancel}
>
<Spin spinning={loading}>
<Form
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-2 space-y-3'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
className='mr-2 shadow-md'
>
<IconUser size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('基本信息')}
</Text>
<div className='text-xs text-gray-600'>
{t('用户的基本账户信息')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='username'
label={t('用户名')}
placeholder={t('请输入新的用户名')}
rules={[{ required: true, message: t('请输入用户名') }]}
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('请输入新的密码8-20位')}
mode='password'
rules={[
{ min: 8, message: t('密码长度至少为8个字符') },
{ max: 20, message: t('密码长度不能超过20个字符') },
]}
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='display_name'
label={t('显示名称')}
placeholder={t('请输入新的显示名称')}
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='email'
label={t('邮箱')}
placeholder={t('请输入邮箱地址')}
showClear
rules={buildAdminUserEmailFieldRules(t)}
/>
</Col>
{/* 手机号:编辑时由 GET 回显;格式 + 异步占用校验(排除当前用户) */}
<Col span={24}>
<Form.Input
field='phone'
label={t('手机号')}
placeholder={t('请输入手机号')}
showClear
rules={buildAdminUserPhoneFieldRules(t, {
excludeUserId: () =>
userId || formApiRef.current?.getValue('id'),
})}
/>
</Col>
<Col span={24}>
<Form.Select
field='tags'
label={t('标签')}
placeholder={t('请选择或输入标签')}
multiple
allowCreate
filter
optionList={mergedTagOptions}
showClear
style={{ width: '100%' }}
/>
</Col>
<Col span={24}>
<Form.Input
field='remark'
label={t('备注')}
placeholder={t('请输入备注(仅管理员可见)')}
showClear
/>
</Col>
</Row>
</Card>
{/* 权限设置 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
className='mr-2 shadow-md'
>
<IconUserGroup size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('权限设置')}
</Text>
<div className='text-xs text-gray-600'>
{t('用户分组和额度管理')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Select
field='group'
label={t('分组')}
placeholder={t('请选择分组')}
optionList={groupOptions}
allowAdditions
search
rules={[{ required: true, message: t('请选择分组') }]}
/>
</Col>
<Col span={10}>
<Form.InputNumber
field='quota'
label={t('剩余额度')}
placeholder={t('请输入新的剩余额度')}
step={500000}
extraText={renderQuotaWithPrompt(values.quota || 0)}
rules={[{ required: true, message: t('请输入额度') }]}
style={{ width: '100%' }}
/>
</Col>
<Col span={14}>
<Form.Slot label={t('添加额度')}>
<Button
icon={<IconPlus />}
onClick={() => setIsModalOpen(true)}
/>
</Form.Slot>
</Col>
</Row>
</Card>
)}
{/* 绑定信息入口 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center min-w-0'>
<Avatar
size='small'
color='purple'
className='mr-2 shadow-md'
>
<IconLink size={16} />
</Avatar>
<div className='min-w-0'>
<Text className='text-lg font-medium'>
{t('绑定信息')}
</Text>
<div className='text-xs text-gray-600'>
{t('管理用户已绑定的第三方账户,支持筛选与解绑')}
</div>
</div>
</div>
<Button
type='primary'
theme='outline'
onClick={openBindingModal}
>
{t('管理绑定')}
</Button>
</div>
</Card>
)}
</div>
)}
</Form>
</Spin>
</SideSheet>
<UserBindingManagementModal
visible={bindingModalVisible}
onCancel={closeBindingModal}
userId={userId}
isMobile={isMobile}
formApiRef={formApiRef}
/>
{/* 添加额度模态框 */}
<Modal
centered
visible={addQuotaModalOpen}
onOk={() => {
addLocalQuota();
setIsModalOpen(false);
setAddQuotaLocal('');
setAddAmountLocal('');
}}
onCancel={() => {
setIsModalOpen(false);
}}
closable={null}
title={
<div className='flex items-center'>
<IconPlus className='mr-2' />
{t('添加额度')}
</div>
}
>
<div className='mb-4'>
{(() => {
const current = formApiRef.current?.getValue('quota') || 0;
return (
<Text type='secondary' className='block mb-2'>
{`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
</Text>
);
})()}
</div>
{getCurrencyConfig().type !== 'TOKENS' && (
<div className='mb-3'>
<div className='mb-1'>
<Text size='small'>{t('金额')}</Text>
<Text size='small' type='tertiary'>
{' '}
({t('仅用于换算,实际保存的是额度')})
</Text>
</div>
<InputNumber
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
value={addAmountLocal}
precision={2}
onChange={(val) => {
setAddAmountLocal(val);
setAddQuotaLocal(
val != null && val !== ''
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
: '',
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
)}
<div>
<div className='mb-1'>
<Text size='small'>{t('额度')}</Text>
</div>
<InputNumber
placeholder={t('输入额度')}
value={addQuotaLocal}
onChange={(val) => {
setAddQuotaLocal(val);
setAddAmountLocal(
val != null && val !== ''
? Number(
(
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
).toFixed(2),
)
: '',
);
}}
style={{ width: '100%' }}
showClear
step={500000}
/>
</div>
</Modal>
</>
);
};
export default EditUserModal;