🎁 refactor(ui/redemption): migrate EditRedemption page to Semi Form & enhance UX

SUMMARY
• Re-implemented `web/src/pages/Redemption/EditRedemption.js` with Semi Form components, removing legacy local-state handling.
• Added `formApiRef` for centralized control; external “Submit” button now triggers `formApi.submitForm()`.
• Replaced `Input/AutoComplete/DatePicker` etc. with `<Form.*>` fields, leveraging built-in validation & accessibility.
• Field validations:
  – `name` (create only), `quota`, `count` → required with localized messages.
• Expiration-time flow:
  – Default value `null` (no more 1970-01-01).
  – When loading data, convert 0 → null, timestamp → Date.
  – On submit, Date → unix seconds; empty → 0.
• Responsive grid layout (`Row/Col`) for tidy alignment.
• Added helpful `showClear` & full-width styling for inputs; quota presets retained.
• Cleaned unused imports & handlers; fixed linter issues.

RESULT
The Redemption form now benefits from higher performance, clearer validation, and a cleaner codebase consistent with Semi Design best practices.
This commit is contained in:
t0ng7u
2025-06-27 07:25:46 +08:00
parent aa15d45a3d
commit d07224e658
2 changed files with 146 additions and 129 deletions

View File

@@ -1745,5 +1745,7 @@
"已复制 ${count} 个模型": "Copied ${count} models", "已复制 ${count} 个模型": "Copied ${count} models",
"复制失败,请手动复制": "Copy failed, please copy manually", "复制失败,请手动复制": "Copy failed, please copy manually",
"快捷设置": "Quick settings", "快捷设置": "Quick settings",
"批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name" "批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name",
"额度必须大于0": "Quota must be greater than 0",
"生成数量必须大于0": "Generation quantity must be greater than 0"
} }

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
API, API,
@@ -7,12 +7,10 @@ import {
showError, showError,
showSuccess, showSuccess,
renderQuota, renderQuota,
renderQuotaWithPrompt renderQuotaWithPrompt,
} from '../../helpers'; } from '../../helpers';
import { import {
AutoComplete,
Button, Button,
Input,
Modal, Modal,
SideSheet, SideSheet,
Space, Space,
@@ -21,8 +19,9 @@ import {
Card, Card,
Tag, Tag,
Form, Form,
DatePicker,
Avatar, Avatar,
Row,
Col,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconCreditCard, IconCreditCard,
@@ -37,30 +36,30 @@ const EditRedemption = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isEdit = props.editingRedemption.id !== undefined; const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const formApiRef = useRef(null);
const originInputs = { const getInitValues = () => ({
name: '', name: '',
quota: 100000, quota: 100000,
count: 1, count: 1,
expired_time: 0, expired_time: null,
}; });
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count, expired_time } = inputs;
const handleCancel = () => { const handleCancel = () => {
props.handleClose(); props.handleClose();
}; };
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const loadRedemption = async () => { const loadRedemption = async () => {
setLoading(true); setLoading(true);
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`); let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setInputs(data); if (data.expired_time === 0) {
data.expired_time = null;
} else {
data.expired_time = new Date(data.expired_time * 1000);
}
formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else { } else {
showError(message); showError(message);
} }
@@ -68,28 +67,30 @@ const EditRedemption = (props) => {
}; };
useEffect(() => { useEffect(() => {
if (isEdit) { if (formApiRef.current) {
loadRedemption().then(() => { if (isEdit) {
// console.log(inputs); loadRedemption();
}); } else {
} else { formApiRef.current.setValues(getInitValues());
setInputs(originInputs); }
} }
}, [props.editingRedemption.id]); }, [props.editingRedemption.id]);
const submit = async () => { const submit = async (values) => {
let name = inputs.name; let name = values.name;
if (!isEdit && inputs.name === '') { if (!isEdit && values.name === '') {
// set default name
name = renderQuota(quota); name = renderQuota(values.quota);
} }
setLoading(true); setLoading(true);
let localInputs = inputs; let localInputs = { ...values };
localInputs.count = parseInt(localInputs.count); localInputs.count = parseInt(localInputs.count) || 0;
localInputs.quota = parseInt(localInputs.quota); localInputs.quota = parseInt(localInputs.quota) || 0;
localInputs.name = name; localInputs.name = name;
if (localInputs.expired_time === null || localInputs.expired_time === undefined) { if (!localInputs.expired_time) {
localInputs.expired_time = 0; localInputs.expired_time = 0;
} else {
localInputs.expired_time = Math.floor(localInputs.expired_time.getTime() / 1000);
} }
let res; let res;
if (isEdit) { if (isEdit) {
@@ -110,8 +111,8 @@ const EditRedemption = (props) => {
props.handleClose(); props.handleClose();
} else { } else {
showSuccess(t('兑换码创建成功!')); showSuccess(t('兑换码创建成功!'));
setInputs(originInputs);
props.refresh(); props.refresh();
formApiRef.current?.setValues(getInitValues());
props.handleClose(); props.handleClose();
} }
} else { } else {
@@ -131,7 +132,7 @@ const EditRedemption = (props) => {
</div> </div>
), ),
onOk: () => { onOk: () => {
downloadTextAsFile(text, `${inputs.name}.txt`); downloadTextAsFile(text, `${localInputs.name}.txt`);
}, },
}); });
} }
@@ -166,7 +167,7 @@ const EditRedemption = (props) => {
<Button <Button
theme="solid" theme="solid"
className="!rounded-full" className="!rounded-full"
onClick={submit} onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />} icon={<IconSave />}
loading={loading} loading={loading}
> >
@@ -188,105 +189,119 @@ const EditRedemption = (props) => {
onCancel={() => handleCancel()} onCancel={() => handleCancel()}
> >
<Spin spinning={loading}> <Spin spinning={loading}>
<div className="p-6"> <Form
<Card className="!rounded-2xl shadow-sm border-0 mb-6"> initValues={getInitValues()}
{/* Header: Basic Info */} getFormApi={(api) => formApiRef.current = api}
<div className="flex items-center mb-2"> onSubmit={submit}
<Avatar size="small" color="blue" className="mr-2 shadow-md"> >
<IconGift size={16} /> {({ values }) => (
</Avatar> <div className="p-6 space-y-6">
<div> <Card className="!rounded-2xl shadow-sm border-0 mb-6">
<Text className="text-lg font-medium">{t('基本信息')}</Text> {/* Header: Basic Info */}
<div className="text-xs text-gray-600">{t('设置兑换码的基本信息')}</div> <div className="flex items-center mb-2">
</div> <Avatar size="small" color="blue" className="mr-2 shadow-md">
</div> <IconGift size={16} />
</Avatar>
<div className="space-y-4"> <div>
<div> <Text className="text-lg font-medium">{t('基本信息')}</Text>
<Text strong className="block mb-2">{t('名称')}</Text> <div className="text-xs text-gray-600">{t('设置兑换码的基本信息')}</div>
<Input </div>
placeholder={t('请输入名称')}
onChange={(value) => handleInputChange('name', value)}
value={name}
autoComplete="new-password"
className="!rounded-lg"
showClear
required={!isEdit}
/>
</div>
<div>
<Text strong className="block mb-2">{t('过期时间')}</Text>
<DatePicker
type="dateTime"
placeholder={t('选择过期时间(可选,留空为永久)')}
showClear
value={expired_time ? new Date(expired_time * 1000) : null}
onChange={(value) => {
if (value === null || value === undefined) {
handleInputChange('expired_time', 0);
} else {
const timestamp = Math.floor(value.getTime() / 1000);
handleInputChange('expired_time', timestamp);
}
}}
className="!rounded-lg w-full"
/>
</div>
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
{/* Header: Quota Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<IconCreditCard 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>
<div className="flex justify-between mb-2">
<Text strong>{t('额度')}</Text>
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
</div> </div>
<AutoComplete
placeholder={t('请输入额度')}
onChange={(value) => handleInputChange('quota', value)}
value={quota}
autoComplete="new-password"
type="number"
className="w-full !rounded-lg"
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
/>
</div>
{!isEdit && ( <Row gutter={12}>
<div> <Col span={24}>
<Text strong className="block mb-2">{t('生成数量')}</Text> <Form.Input
<Input field='name'
placeholder={t('请输入生成数量')} label={t('名称')}
onChange={(value) => handleInputChange('count', value)} placeholder={t('请输入名称')}
value={count} style={{ width: '100%' }}
autoComplete="new-password" rules={isEdit ? [] : [{ required: true, message: t('请输入名称') }]}
type="number" showClear
className="!rounded-lg" />
/> </Col>
<Col span={24}>
<Form.DatePicker
field='expired_time'
label={t('过期时间')}
type='dateTime'
placeholder={t('选择过期时间(可选,留空为永久)')}
style={{ width: '100%' }}
showClear
/>
</Col>
</Row>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
{/* Header: Quota Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('额度设置')}</Text>
<div className="text-xs text-gray-600">{t('设置兑换码的额度和数量')}</div>
</div>
</div> </div>
)}
<Row gutter={12}>
<Col span={12}>
<Form.AutoComplete
field='quota'
label={t('额度')}
placeholder={t('请输入额度')}
style={{ width: '100%' }}
type='number'
rules={[
{ required: true, message: t('请输入额度') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('额度必须大于0'));
},
},
]}
extraText={renderQuotaWithPrompt(Number(values.quota) || 0)}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
showClear
/>
</Col>
{!isEdit && (
<Col span={12}>
<Form.InputNumber
field='count'
label={t('生成数量')}
min={1}
rules={[
{ required: true, message: t('请输入生成数量') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('生成数量必须大于0'));
},
},
]}
style={{ width: '100%' }}
showClear
/>
</Col>
)}
</Row>
</Card>
</div> </div>
</Card> )}
</div> </Form>
</Spin> </Spin>
</SideSheet> </SideSheet>
</> </>