Merge remote-tracking branch 'github/feat/1.1.2/ui' into feat/1.1.2/user-center

This commit is contained in:
LinkinStars 2023-04-18 15:50:03 +08:00
commit f50ccea7e5
14 changed files with 234 additions and 61 deletions

View File

@ -326,6 +326,16 @@ export interface AdminSettingsSmtp {
test_email_recipient?: string;
}
export interface AdminSettingsUsers {
allow_update_avatar: boolean;
allow_update_bio: boolean;
allow_update_display_name: boolean;
allow_update_location: boolean;
allow_update_username: boolean;
allow_update_website: boolean;
default_avatar: string;
}
export interface SiteSettings {
branding: AdminSettingBranding;
general: AdminSettingsGeneral;
@ -334,6 +344,7 @@ export interface SiteSettings {
custom_css_html: AdminSettingsCustom;
theme: AdminSettingsTheme;
site_seo: AdminSettingsSeo;
site_users: AdminSettingsUsers;
version: string;
revision: string;
}

View File

@ -0,0 +1,44 @@
import React, { FC, useState } from 'react';
import { Button } from 'react-bootstrap';
import { request } from '@/utils';
import type * as Type from '@/common/interface';
import type { UIAction } from '../index.d';
interface Props {
fieldName: string;
text: string;
action: UIAction | undefined;
formData: Type.FormDataType;
readOnly: boolean;
}
const Index: FC<Props> = ({
fieldName,
action,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
formData,
readOnly = false,
text = '',
}) => {
const [isLoading, setLoading] = useState(false);
const handleAction = async () => {
if (!action) {
return;
}
setLoading(true);
const method = action.method || 'get';
await request[method](action.url);
setLoading(false);
};
const disabled = isLoading || readOnly;
return (
<div className="d-flex">
<Button name={fieldName} onClick={handleAction} disabled={disabled}>
{text || fieldName}
{isLoading ? '...' : ''}
</Button>
</div>
);
};
export default Index;

View File

@ -9,6 +9,7 @@ interface Props {
fieldName: string;
onChange: (evt: React.ChangeEvent<HTMLInputElement>, ...rest) => void;
formData: Type.FormDataType;
readOnly: boolean;
}
const Index: FC<Props> = ({
type = 'text',
@ -16,6 +17,7 @@ const Index: FC<Props> = ({
fieldName,
onChange,
formData,
readOnly = false,
}) => {
const fieldObject = formData[fieldName];
return (
@ -25,8 +27,9 @@ const Index: FC<Props> = ({
type={type}
value={fieldObject?.value || ''}
onChange={onChange}
style={type === 'color' ? { width: '6rem' } : {}}
readOnly={readOnly}
isInvalid={fieldObject?.isInvalid}
style={type === 'color' ? { width: '6rem' } : {}}
/>
);
};

View File

@ -6,5 +6,16 @@ import Timezone from './Timezone';
import Upload from './Upload';
import Textarea from './Textarea';
import Input from './Input';
import Button from './Button';
export { Legend, Select, Check, Switch, Timezone, Upload, Textarea, Input };
export {
Legend,
Select,
Check,
Switch,
Timezone,
Upload,
Textarea,
Input,
Button,
};

View File

@ -0,0 +1,6 @@
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
event?: 'click' | 'change';
handler?: ({evt, formData, request}) => Promise<void>
}

View File

@ -11,6 +11,7 @@ import classnames from 'classnames';
import type * as Type from '@/common/interface';
import type { UIAction } from './index.d';
import {
Legend,
Select,
@ -20,6 +21,7 @@ import {
Upload,
Textarea,
Input,
Button as CtrlButton,
} from './components';
export interface JSONSchema {
@ -45,12 +47,14 @@ export interface BaseUIOptions {
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
@ -92,6 +96,12 @@ export interface TextareaOptions extends BaseUIOptions {
rows?: number;
}
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
}
export type UIOptions =
| InputOptions
| SelectOptions
@ -100,7 +110,8 @@ export type UIOptions =
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions;
| TextareaOptions
| ButtonOptions;
export type UIWidget =
| 'textarea'
@ -111,7 +122,8 @@ export type UIWidget =
| 'upload'
| 'timezone'
| 'switch'
| 'legend';
| 'legend'
| 'button';
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
@ -134,9 +146,13 @@ interface IRef {
/**
* TODO:
* * Normalize and document `formData[key].hidden && 'd-none'`
* * `handleXXChange` methods are placed in the concrete component
* * Improving field hints for `formData`
* - Normalize and document `formData[key].hidden && 'd-none'`
* - Normalize and document `hiddenSubmit`
* - `handleXXChange` methods are placed in the concrete component
* - Improving field hints for `formData`
* - Optimise form data updates
* * Automatic field type conversion
* * Dynamic field generation
*/
/**
@ -378,7 +394,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
} = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema[key] || {};
const fieldData = formData[key];
const fieldState = formData[key];
const uiSimplify = widget === 'legend' || uiOpt?.simplify;
let groupClassName: BaseUIOptions['fieldClassName'] = uiOpt?.simplify
? 'mb-2'
@ -389,7 +405,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
if (uiOpt?.fieldClassName) {
groupClassName = uiOpt.fieldClassName;
}
const readOnly = uiOpt?.readOnly || false;
return (
<Form.Group
key={title}
@ -472,11 +488,21 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
fieldName={key}
onChange={handleInputChange}
formData={formData}
readOnly={readOnly}
/>
) : null}
{widget === 'button' ? (
<CtrlButton
fieldName={key}
text={uiOpt && 'text' in uiOpt ? uiOpt.text : ''}
action={uiOpt && 'action' in uiOpt ? uiOpt.action : undefined}
formData={formData}
readOnly={readOnly}
/>
) : null}
{/* Unified handling of `Feedback` and `Text` */}
<Form.Control.Feedback type="invalid">
{fieldData?.errorMsg}
{fieldState?.errorMsg}
</Form.Control.Feedback>
{description ? (
<Form.Text className="text-muted">{description}</Form.Text>

View File

@ -9,6 +9,7 @@ interface IProps {
className?: string;
children?: React.ReactNode;
acceptType?: string;
disabled?: boolean;
uploadCallback: (img: string) => void;
}
@ -18,6 +19,7 @@ const Index: React.FC<IProps> = ({
children,
acceptType = '',
className,
disabled = false,
}) => {
const { t } = useTranslation();
const [status, setStatus] = useState(false);
@ -52,6 +54,7 @@ const Index: React.FC<IProps> = ({
<input
type="file"
className="d-none"
disabled={disabled}
accept={`image/jpeg,image/jpg,image/png,image/webp${acceptType}`}
onChange={onChange}
/>

View File

@ -10,7 +10,6 @@ import {
AdminSettingsPrivilege,
} from '@/services';
import { handleFormError } from '@/utils';
import * as Type from '@/common/interface';
const Index: FC = () => {
const { t } = useTranslation('translation', {
@ -18,27 +17,66 @@ const Index: FC = () => {
});
const Toast = useToast();
const [privilege, setPrivilege] = useState<AdminSettingsPrivilege>();
const schema: JSONSchema = {
const [schema, setSchema] = useState<JSONSchema>({
title: t('title'),
properties: {
properties: {},
});
const [uiSchema, setUiSchema] = useState<UISchema>({
level: {
'ui:widget': 'select',
},
});
const [formData, setFormData] = useState<FormDataType>(initFormData(schema));
const setFormConfig = (selectedLevel: number = 1) => {
selectedLevel = Number(selectedLevel);
const levelOptions = privilege?.options;
const curLevel = levelOptions?.find((li) => {
return li.level === selectedLevel;
});
if (!levelOptions || !curLevel) {
return;
}
const uiState = {
level: uiSchema.level,
};
const props: JSONSchema['properties'] = {
level: {
type: 'number',
title: t('level.label'),
description: t('level.text'),
enum: privilege?.options.map((_) => _.level),
enumNames: privilege?.options.map((_) => _.level_desc),
default: 1,
enum: levelOptions.map((_) => _.level),
enumNames: levelOptions.map((_) => _.level_desc),
default: selectedLevel,
},
},
};
const [formData, setFormData] = useState<FormDataType>(initFormData(schema));
const uiSchema: UISchema = {
level: {
'ui:widget': 'select',
},
};
curLevel.privileges.forEach((li) => {
props[li.key] = {
type: 'number',
title: li.label,
default: li.value,
};
uiState[li.key] = {
'ui:options': {
readOnly: true,
},
};
});
const schemaState = {
...schema,
properties: props,
};
const formState = initFormData(schemaState);
curLevel.privileges.forEach((li) => {
formState[li.key] = {
value: li.value,
isInvalid: false,
errorMsg: '',
};
});
setSchema(schemaState);
setUiSchema(uiState);
setFormData(formState);
};
const onSubmit = (evt: FormEvent) => {
@ -60,21 +98,19 @@ const Index: FC = () => {
});
};
useEffect(() => {
if (!privilege) {
return;
}
setFormConfig(privilege.selected_level);
}, [privilege]);
useEffect(() => {
getPrivilegeSetting().then((resp) => {
setPrivilege(resp);
const formMeta: Type.FormDataType = {};
formMeta.level = {
value: resp.selected_level,
errorMsg: '',
isInvalid: false,
};
setFormData({ ...formData, ...formMeta });
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
const handleOnChange = (state) => {
setFormConfig(state.level.value);
};
return (

View File

@ -12,13 +12,14 @@ import {
} from '@/services';
import { handleFormError } from '@/utils';
import * as Type from '@/common/interface';
import { siteInfoStore } from '@/stores';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.settings_users',
});
const Toast = useToast();
const { updateUsers: updateUsersStore } = siteInfoStore();
const schema: JSONSchema = {
title: t('title'),
properties: {
@ -129,6 +130,7 @@ const Index: FC = () => {
};
putUsersSetting(reqParams)
.then(() => {
updateUsersStore(reqParams);
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { userCenterStore } from '@/stores';
import { getUcSettings } from '@/services';
import { getUcSettings, UcSettingAgent } from '@/services';
import { ModifyEmail, ModifyPassword, MyLogins } from './components';
@ -11,17 +11,12 @@ const Index = () => {
keyPrefix: 'settings.account',
});
const { agent: ucAgent } = userCenterStore();
const [accountAgent, setAccountAgent] = useState('');
const [accountAgent, setAccountAgent] = useState<UcSettingAgent>();
const initData = () => {
if (ucAgent?.enabled) {
getUcSettings().then((resp) => {
if (
resp.account_setting_agent?.enabled &&
resp.account_setting_agent?.redirect_url
) {
setAccountAgent(resp.account_setting_agent.redirect_url);
}
setAccountAgent(resp.account_setting_agent);
});
}
};
@ -31,10 +26,12 @@ const Index = () => {
return (
<>
<h3 className="mb-4">{t('heading')}</h3>
{accountAgent ? (
<a href={accountAgent}>{t('goto_modify', { keyPrefix: 'settings' })}</a>
{accountAgent?.enabled && accountAgent?.redirect_url ? (
<a href={accountAgent.redirect_url}>
{t('goto_modify', { keyPrefix: 'settings' })}
</a>
) : null}
{!ucAgent?.enabled ? (
{!ucAgent?.enabled || accountAgent?.enabled === false ? (
<>
<ModifyEmail />
<ModifyPassword />

View File

@ -6,9 +6,14 @@ import MD5 from 'md5';
import type { FormDataType } from '@/common/interface';
import { UploadImg, Avatar, Icon } from '@/components';
import { loggedUserInfoStore, userCenterStore } from '@/stores';
import { loggedUserInfoStore, userCenterStore, siteInfoStore } from '@/stores';
import { useToast } from '@/hooks';
import { modifyUserInfo, getLoggedUserInfo, getUcSettings } from '@/services';
import {
modifyUserInfo,
getLoggedUserInfo,
getUcSettings,
UcSettingAgent,
} from '@/services';
import { handleFormError } from '@/utils';
const Index: React.FC = () => {
@ -18,9 +23,10 @@ const Index: React.FC = () => {
const toast = useToast();
const { user, update } = loggedUserInfoStore();
const { agent: ucAgent } = userCenterStore();
const { users: usersSetting } = siteInfoStore();
const [mailHash, setMailHash] = useState('');
const [count] = useState(0);
const [profileAgent, setProfileAgent] = useState('');
const [profileAgent, setProfileAgent] = useState<UcSettingAgent>();
const [formData, setFormData] = useState<FormDataType>({
display_name: {
@ -248,11 +254,9 @@ const Index: React.FC = () => {
const initData = () => {
if (ucAgent?.enabled) {
getUcSettings().then((resp) => {
if (
resp.profile_setting_agent?.enabled &&
resp.profile_setting_agent?.redirect_url
) {
setProfileAgent(resp.profile_setting_agent.redirect_url);
setProfileAgent(resp.profile_setting_agent);
if (resp.profile_setting_agent?.enabled === false) {
getProfile();
}
});
} else {
@ -266,16 +270,19 @@ const Index: React.FC = () => {
return (
<>
<h3 className="mb-4">{t('heading')}</h3>
{profileAgent ? (
<a href={profileAgent}>{t('goto_modify', { keyPrefix: 'settings' })}</a>
{profileAgent?.enabled && profileAgent?.redirect_url ? (
<a href={profileAgent.redirect_url}>
{t('goto_modify', { keyPrefix: 'settings' })}
</a>
) : null}
{!ucAgent?.enabled ? (
{!ucAgent?.enabled || profileAgent?.enabled === false ? (
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="displayName" className="mb-3">
<Form.Label>{t('display_name.label')}</Form.Label>
<Form.Control
required
type="text"
readOnly={!usersSetting.allow_update_display_name}
value={formData.display_name.value}
isInvalid={formData.display_name.isInvalid}
onChange={(e) =>
@ -298,6 +305,7 @@ const Index: React.FC = () => {
<Form.Control
required
type="text"
readOnly={!usersSetting.allow_update_username}
value={formData.username.value}
isInvalid={formData.username.isInvalid}
onChange={(e) =>
@ -321,6 +329,7 @@ const Index: React.FC = () => {
<div className="mb-3">
<Form.Select
name="avatar.type"
disabled={!usersSetting.allow_update_avatar}
value={formData.avatar.type}
onChange={handleAvatarChange}>
<option value="gravatar" key="gravatar">
@ -369,11 +378,15 @@ const Index: React.FC = () => {
className="me-2 bg-gray-300 "
/>
<ButtonGroup vertical className="fit-content">
<UploadImg type="avatar" uploadCallback={avatarUpload}>
<UploadImg
type="avatar"
disabled={!usersSetting.allow_update_avatar}
uploadCallback={avatarUpload}>
<Icon name="cloud-upload" />
</UploadImg>
<Button
variant="outline-secondary"
disabled={!usersSetting.allow_update_avatar}
onClick={removeCustomAvatar}>
<Icon name="trash" />
</Button>
@ -410,6 +423,7 @@ const Index: React.FC = () => {
required
as="textarea"
rows={5}
readOnly={!usersSetting.allow_update_bio}
value={formData.bio.value}
isInvalid={formData.bio.isInvalid}
onChange={(e) =>
@ -435,6 +449,7 @@ const Index: React.FC = () => {
required
type="url"
placeholder={t('website.placeholder')}
readOnly={!usersSetting.allow_update_website}
value={formData.website.value}
isInvalid={formData.website.isInvalid}
onChange={(e) =>
@ -460,6 +475,7 @@ const Index: React.FC = () => {
required
type="text"
placeholder={t('location.placeholder')}
readOnly={!usersSetting.allow_update_location}
value={formData.location.value}
isInvalid={formData.location.isInvalid}
onChange={(e) =>

View File

@ -19,6 +19,7 @@ interface PrivilegeLevel {
privileges: {
label: string;
value: number;
key: string;
}[];
}
export interface AdminSettingsPrivilege {

View File

@ -1,6 +1,6 @@
import create from 'zustand';
import { AdminSettingsGeneral } from '@/common/interface';
import { AdminSettingsGeneral, AdminSettingsUsers } from '@/common/interface';
import { DEFAULT_SITE_NAME } from '@/common/constants';
interface SiteInfoType {
@ -9,6 +9,8 @@ interface SiteInfoType {
revision: string;
update: (params: AdminSettingsGeneral) => void;
updateVersion: (ver: string, revision: string) => void;
users: AdminSettingsUsers;
updateUsers: (users: SiteInfoType['users']) => void;
}
const siteInfo = create<SiteInfoType>((set) => ({
@ -20,6 +22,15 @@ const siteInfo = create<SiteInfoType>((set) => ({
contact_email: '',
permalink: 1,
},
users: {
allow_update_avatar: false,
allow_update_bio: false,
allow_update_display_name: false,
allow_update_location: false,
allow_update_username: false,
allow_update_website: false,
default_avatar: 'system',
},
version: '',
revision: '',
update: (params) =>
@ -37,6 +48,11 @@ const siteInfo = create<SiteInfoType>((set) => ({
return { version: ver, revision };
});
},
updateUsers: (users) => {
set(() => {
return { users };
});
},
}));
export default siteInfo;

View File

@ -371,6 +371,7 @@ export const initAppSettingsStore = async () => {
siteInfoStore
.getState()
.updateVersion(appSettings.version, appSettings.revision);
siteInfoStore.getState().updateUsers(appSettings.site_users);
interfaceStore.getState().update(appSettings.interface);
brandingStore.getState().update(appSettings.branding);
loginSettingStore.getState().update(appSettings.login);