💄refactor: enhance EditUser and AddUser form validation & UX

Changes in `web/src/pages/User/EditUser.js`:
• Added `rules` to
  – `Form.Select group`: now required with error “Please select group”.
  – `Form.InputNumber quota`: now required with error “Please enter quota”.
• Added `step={500000}` to quota `InputNumber` for quicker numeric input.
• Replaced invalid `readonly` with React-correct `readOnly`, and added descriptive placeholders for all binding-info fields (GitHub/OIDC/WeChat/Email/Telegram).
• Removed unused `downloadTextAsFile` import.

These updates tighten form validation, improve data entry ergonomics, and restore clear read-only indicators for third-party bindings.
This commit is contained in:
t0ng7u
2025-06-27 09:44:18 +08:00
parent ab32e15a86
commit 38d3ab5acf
6 changed files with 290 additions and 382 deletions

View File

@@ -967,7 +967,7 @@ const PersonalSetting = () => {
{systemToken && (
<div className="mt-3">
<Input
readOnly
readonly
value={systemToken}
onClick={handleSystemTokenClick}
size="large"

View File

@@ -471,10 +471,11 @@
"请输入新的密码": "Please enter a new password",
"显示名称": "Display Name",
"请输入新的显示名称": "Please enter a new display name",
"已绑定的 GitHub 账户": "GitHub Account Bound",
"此项只读要用户通过个人设置页面的相关绑<EFBFBD><EFBFBD>按钮进<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
"已绑定的微信账户": "WeChat Account Bound",
"已绑定的邮箱账户": "Email Account Bound",
"已绑定的 GITHUB 账户": "Bound GitHub Account",
"已绑定的 WECHAT 账户": "Bound WeChat Account",
"已绑定的 EMAIL 账户": "Bound Email Account",
"已绑定的 TELEGRAM 账户": "Bound Telegram Account",
"此项只读,要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
"用户信息更新成功!": "User information updated successfully!",
"使用明细(总消耗额度:{renderQuota(stat.quota)}": "Usage Details (Total Consumption Quota: {renderQuota(stat.quota)})",
"用户名称": "User Name",

View File

@@ -130,7 +130,7 @@ const Home = () => {
{/* BASE URL 与端点选择 */}
<div className="flex flex-col md:flex-row items-center justify-center gap-4 w-full mt-4 md:mt-6 max-w-md">
<Input
readOnly
readonly
value={serverAddress}
className="flex-1 !rounded-full"
size={isMobile() ? 'default' : 'large'}

View File

@@ -931,7 +931,7 @@ const TopUp = () => {
<Title heading={6}>{t('邀请链接')}</Title>
<Input
value={affLink}
readOnly
readonly
size='large'
suffix={
<Button

View File

@@ -1,15 +1,17 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { API, isMobile, showError, showSuccess } from '../../helpers';
import {
Button,
Input,
SideSheet,
Space,
Spin,
Typography,
Card,
Tag,
Avatar
Avatar,
Form,
Row,
Col,
} from '@douyinfe/semi-ui';
import {
IconSave,
@@ -22,32 +24,23 @@ const { Text, Title } = Typography;
const AddUser = (props) => {
const { t } = useTranslation();
const originInputs = {
const formApiRef = useRef(null);
const [loading, setLoading] = useState(false);
const getInitValues = () => ({
username: '',
display_name: '',
password: '',
remark: '',
};
const [inputs, setInputs] = useState(originInputs);
const [loading, setLoading] = useState(false);
const { username, display_name, password, remark } = inputs;
});
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submit = async () => {
const submit = async (values) => {
setLoading(true);
if (inputs.username === '' || inputs.password === '') {
setLoading(false);
showError(t('用户名和密码不能为空!'));
return;
}
const res = await API.post(`/api/user/`, inputs);
const res = await API.post(`/api/user/`, values);
const { success, message } = res.data;
if (success) {
showSuccess(t('用户账户创建成功!'));
setInputs(originInputs);
formApiRef.current?.setValues(getInitValues());
props.refresh();
props.handleClose();
} else {
@@ -85,7 +78,7 @@ const AddUser = (props) => {
<Button
theme="solid"
className="!rounded-full"
onClick={submit}
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
@@ -107,71 +100,60 @@ const AddUser = (props) => {
onCancel={() => handleCancel()}
>
<Spin spinning={loading}>
<div className="p-6">
<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">
<IconUserAdd size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('用户信息')}</Text>
<div className="text-xs text-gray-600">{t('创建新用户账户')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('用户名')}</Text>
<Input
placeholder={t('请输入用户名')}
onChange={(value) => handleInputChange('username', value)}
value={username}
autoComplete="off"
className="!rounded-lg"
showClear
required
/>
<Form
initValues={getInitValues()}
getFormApi={(api) => formApiRef.current = api}
onSubmit={submit}
onSubmitFail={(errs) => {
const first = Object.values(errs)[0];
if (first) showError(Array.isArray(first) ? first[0] : first);
formApiRef.current?.scrollToError();
}}
>
<div className="p-6 space-y-6">
<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">
<IconUserAdd size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('用户信息')}</Text>
<div className="text-xs text-gray-600">{t('创建新用户账户')}</div>
</div>
</div>
<div>
<Text strong className="block mb-2">{t('显示名称')}</Text>
<Input
placeholder={t('请输入显示名称')}
onChange={(value) => handleInputChange('display_name', value)}
value={display_name}
autoComplete="off"
className="!rounded-lg"
showClear
/>
</div>
<div>
<Text strong className="block mb-2">{t('密码')}</Text>
<Input
type="password"
placeholder={t('请输入密码')}
onChange={(value) => handleInputChange('password', value)}
value={password}
autoComplete="off"
className="!rounded-lg"
required
/>
</div>
<div>
<Text strong className="block mb-2">{t('备注')}</Text>
<Input
placeholder={t('请输入备注(仅管理员可见)')}
onChange={(value) => handleInputChange('remark', value)}
value={remark}
autoComplete="off"
className="!rounded-lg"
showClear
/>
</div>
</div>
</Card>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='username'
label={t('用户名')}
placeholder={t('请输入用户名')}
rules={[{ required: true, message: t('请输入用户名') }]} />
</Col>
<Col span={24}>
<Form.Input
field='display_name'
label={t('显示名称')}
placeholder={t('请输入显示名称')} />
</Col>
<Col span={24}>
<Form.Input
field='password'
label={t('密码')}
type='password'
placeholder={t('请输入密码')}
rules={[{ required: true, message: t('请输入密码') }]} />
</Col>
<Col span={24}>
<Form.Input
field='remark'
label={t('备注')}
placeholder={t('请输入备注(仅管理员可见)')} />
</Col>
</Row>
</Card>
</div>
</Form>
</Spin>
</SideSheet>
</>

View File

@@ -1,18 +1,27 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { API, isMobile, showError, showSuccess, renderQuota, renderQuotaWithPrompt } from '../../helpers';
import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
isMobile,
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
} from '../../helpers';
import {
Button,
Input,
Modal,
Select,
SideSheet,
Space,
Spin,
Typography,
Card,
Tag,
Form,
Avatar,
Row,
Col,
Input,
} from '@douyinfe/semi-ui';
import {
IconUser,
@@ -22,73 +31,55 @@ import {
IconUserGroup,
IconPlus,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
const { Text, Title } = Typography;
const EditUser = (props) => {
const { t } = useTranslation();
const userId = props.editingUser.id;
const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState('');
const [inputs, setInputs] = useState({
const [addQuotaLocal, setAddQuotaLocal] = useState('0');
const [groupOptions, setGroupOptions] = useState([]);
const formApiRef = useRef(null);
const isEdit = Boolean(userId);
const getInitValues = () => ({
username: '',
display_name: '',
password: '',
github_id: '',
oidc_id: '',
wechat_id: '',
telegram_id: '',
email: '',
quota: 0,
group: 'default',
remark: '',
});
const [groupOptions, setGroupOptions] = useState([]);
const {
username,
display_name,
password,
github_id,
oidc_id,
wechat_id,
telegram_id,
email,
quota,
group,
remark,
} = inputs;
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(
res.data.data.map((group) => ({
label: group,
value: group,
})),
res.data.data.map((g) => ({ label: g, value: g }))
);
} catch (error) {
showError(error.message);
} catch (e) {
showError(e.message);
}
};
const navigate = useNavigate();
const handleCancel = () => {
props.handleClose();
};
const handleCancel = () => props.handleClose();
const loadUser = async () => {
setLoading(true);
let res = undefined;
if (userId) {
res = await API.get(`/api/user/${userId}`);
} else {
res = await API.get(`/api/user/self`);
}
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 = '';
setInputs(data);
formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else {
showError(message);
}
@@ -96,27 +87,23 @@ const EditUser = (props) => {
};
useEffect(() => {
loadUser().then();
if (userId) {
fetchGroups().then();
}
loadUser();
if (userId) fetchGroups();
}, [props.editingUser.id]);
const submit = async () => {
/* ----------------------- submit ----------------------- */
const submit = async (values) => {
setLoading(true);
let res = undefined;
let payload = { ...values };
if (typeof payload.quota === 'string') payload.quota = parseInt(payload.quota) || 0;
if (userId) {
let data = { ...inputs, id: parseInt(userId) };
if (typeof data.quota === 'string') {
data.quota = parseInt(data.quota);
}
res = await API.put(`/api/user/`, data);
} else {
res = await API.put(`/api/user/self`, inputs);
payload.id = parseInt(userId);
}
const url = userId ? `/api/user/` : `/api/user/self`;
const res = await API.put(url, payload);
const { success, message } = res.data;
if (success) {
showSuccess('用户信息更新成功!');
showSuccess(t('用户信息更新成功!'));
props.refresh();
props.handleClose();
} else {
@@ -125,53 +112,48 @@ const EditUser = (props) => {
setLoading(false);
};
/* --------------------- quota helper -------------------- */
const addLocalQuota = () => {
let newQuota = parseInt(quota) + parseInt(addQuotaLocal);
setInputs((inputs) => ({ ...inputs, quota: newQuota }));
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
const delta = parseInt(addQuotaLocal) || 0;
formApiRef.current?.setValue('quota', current + delta);
};
const openAddQuotaModal = () => {
setAddQuotaLocal('0');
setIsModalOpen(true);
};
const { t } = useTranslation();
/* --------------------------- UI --------------------------- */
return (
<>
<SideSheet
placement={'right'}
placement='right'
title={
<Space>
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
<Title heading={4} className="m-0">
{t('编辑用户')}
<Tag color='blue' shape='circle'>
{t(isEdit ? '编辑' : '新建')}
</Tag>
<Title heading={4} className='m-0'>
{isEdit ? t('编辑用户') : t('创建用户')}
</Title>
</Space>
}
headerStyle={{
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
}}
bodyStyle={{ padding: '0' }}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)', padding: '24px' }}
bodyStyle={{ padding: 0 }}
visible={props.visible}
width={isMobile() ? '100%' : 600}
footer={
<div className="flex justify-end bg-white">
<div className='flex justify-end bg-white'>
<Space>
<Button
theme="solid"
className="!rounded-full"
onClick={submit}
theme='solid'
className='!rounded-full'
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme="light"
className="!rounded-full"
type="primary"
theme='light'
className='!rounded-full'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
>
@@ -181,215 +163,154 @@ const EditUser = (props) => {
</div>
}
closeIcon={null}
onCancel={() => handleCancel()}
onCancel={handleCancel}
>
<Spin spinning={loading}>
<div className="p-6">
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
{/* Header: Basic Info */}
<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>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('用户')}</Text>
<Input
placeholder={t('请输入新的用户名')}
onChange={(value) => handleInputChange('username', value)}
value={username}
autoComplete="new-password"
className="!rounded-lg"
showClear
/>
</div>
<div>
<Text strong className="block mb-2">{t('密码')}</Text>
<Input
type="password"
placeholder={t('请输入新的密码,最短 8 位')}
onChange={(value) => handleInputChange('password', value)}
value={password}
autoComplete="new-password"
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('显示名称')}</Text>
<Input
placeholder={t('请输入新的显示名称')}
onChange={(value) => handleInputChange('display_name', value)}
value={display_name}
autoComplete="new-password"
className="!rounded-lg"
showClear
/>
</div>
<div>
<Text strong className="block mb-2">{t('备注')}</Text>
<Input
placeholder={t('请输入备注(仅管理员可见)')}
onChange={(value) => handleInputChange('remark', value)}
value={remark}
autoComplete="off"
className="!rounded-lg"
showClear
/>
</div>
</div>
</Card>
{userId && (
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
{/* Header: Permission Settings */}
<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>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('分组')}</Text>
<Select
placeholder={t('请选择分组')}
search
allowAdditions
additionLabel={t(
'请在系统设置页面编辑分组倍率以添加新的分组:',
)}
onChange={(value) => handleInputChange('group', value)}
value={inputs.group}
autoComplete="new-password"
optionList={groupOptions}
className="w-full !rounded-lg"
/>
</div>
<div>
<div className="flex justify-between mb-2">
<Text strong>{t('剩余额度')}</Text>
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
<Form
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-6 space-y-6'>
{/* 基本信息 */}
<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 className="flex gap-2">
<Input
placeholder={t('请输入新的剩余额度')}
onChange={(value) => handleInputChange('quota', value)}
value={quota}
type="number"
autoComplete="new-password"
className="flex-1 !rounded-lg"
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='username'
label={t('用户名')}
placeholder={t('请输入新的用户名')}
rules={[{ required: true, message: t('请输入用户名') }]}
showClear
/>
<Button
onClick={openAddQuotaModal}
className="!rounded-lg"
icon={<IconPlus />}
>
{t('添加额度')}
</Button>
</Col>
<Col span={24}>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('请输入新的密码,最短 8 位')}
mode='password'
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='display_name'
label={t('显示名称')}
placeholder={t('请输入新的显示名称')}
showClear
/>
</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('请输入新的剩余额度')}
min={0}
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>
)}
{/* 绑定信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
<IconLink size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('绑定信息')}</Text>
<div className='text-xs text-gray-600'>{t('第三方账户绑定状态(只读)')}</div>
</div>
</div>
</div>
</Card>
<Row gutter={12}>
{['github_id', 'oidc_id', 'wechat_id', 'email', 'telegram_id'].map((field) => (
<Col span={24} key={field}>
<Form.Input
field={field}
label={t(`已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`)}
readonly
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
/>
</Col>
))}
</Row>
</Card>
</div>
)}
<Card className="!rounded-2xl shadow-sm border-0">
{/* Header: Bindings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="purple" className="mr-2 shadow-md">
<IconLink size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('绑定信息')}</Text>
<div className="text-xs text-gray-600">{t('第三方账户绑定状态(只读)')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('已绑定的 GitHub 账户')}</Text>
<Input
value={github_id}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的 OIDC 账户')}</Text>
<Input
value={oidc_id}
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的微信账户')}</Text>
<Input
value={wechat_id}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的邮箱账户')}</Text>
<Input
value={email}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
className="!rounded-lg"
/>
</div>
<div>
<Text strong className="block mb-2">{t('已绑定的 Telegram 账户')}</Text>
<Input
value={telegram_id}
autoComplete="new-password"
placeholder={t(
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
)}
readonly
className="!rounded-lg"
/>
</div>
</div>
</Card>
</div>
</Form>
</Spin>
</SideSheet>
{/* 添加额度模态框 */}
<Modal
centered={true}
centered
visible={addQuotaModalOpen}
onOk={() => {
addLocalQuota();
@@ -398,26 +319,30 @@ const EditUser = (props) => {
onCancel={() => setIsModalOpen(false)}
closable={null}
title={
<div className="flex items-center">
<IconPlus className="mr-2" />
<div className='flex items-center'>
<IconPlus className='mr-2' />
{t('添加额度')}
</div>
}
>
<div className="mb-4">
<Text type="secondary" className="block mb-2">
{`${t('新额度')}${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal || 0))}`}
</Text>
<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>
<Input
placeholder={t('需要添加的额度(支持负数)')}
onChange={(value) => {
setAddQuotaLocal(value);
}}
type='number'
value={addQuotaLocal}
type="number"
autoComplete="new-password"
className="!rounded-lg"
onChange={setAddQuotaLocal}
showClear
/>
</Modal>
</>