diff --git a/web/src/components/OperationSetting.js b/web/src/components/OperationSetting.js
new file mode 100644
index 00000000..3f3a4ab0
--- /dev/null
+++ b/web/src/components/OperationSetting.js
@@ -0,0 +1,282 @@
+import React, { useEffect, useState } from 'react';
+import { Divider, Form, Grid, Header } from 'semantic-ui-react';
+import { API, showError, verifyJSON } from '../helpers';
+
+const OperationSetting = () => {
+ let [inputs, setInputs] = useState({
+ QuotaForNewUser: 0,
+ QuotaForInviter: 0,
+ QuotaForInvitee: 0,
+ QuotaRemindThreshold: 0,
+ PreConsumedQuota: 0,
+ ModelRatio: '',
+ GroupRatio: '',
+ TopUpLink: '',
+ ChatLink: '',
+ AutomaticDisableChannelEnabled: '',
+ ChannelDisableThreshold: 0,
+ LogConsumeEnabled: ''
+ });
+ const [originInputs, setOriginInputs] = useState({});
+ let [loading, setLoading] = useState(false);
+
+ const getOptions = async () => {
+ const res = await API.get('/api/option/');
+ const { success, message, data } = res.data;
+ if (success) {
+ let newInputs = {};
+ data.forEach((item) => {
+ if (item.key === 'ModelRatio' || item.key === 'GroupRatio') {
+ item.value = JSON.stringify(JSON.parse(item.value), null, 2);
+ }
+ newInputs[item.key] = item.value;
+ });
+ setInputs(newInputs);
+ setOriginInputs(newInputs);
+ } else {
+ showError(message);
+ }
+ };
+
+ useEffect(() => {
+ getOptions().then();
+ }, []);
+
+ const updateOption = async (key, value) => {
+ setLoading(true);
+ if (key.endsWith('Enabled')) {
+ value = inputs[key] === 'true' ? 'false' : 'true';
+ }
+ const res = await API.put('/api/option/', {
+ key,
+ value
+ });
+ const { success, message } = res.data;
+ if (success) {
+ setInputs((inputs) => ({ ...inputs, [key]: value }));
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ };
+
+ const handleInputChange = async (e, { name, value }) => {
+ if (name.endsWith('Enabled')) {
+ await updateOption(name, value);
+ } else {
+ setInputs((inputs) => ({ ...inputs, [name]: value }));
+ }
+ };
+
+ const submitConfig = async (group) => {
+ switch (group) {
+ case 'monitor':
+ if (originInputs['AutomaticDisableChannelEnabled'] !== inputs.AutomaticDisableChannelEnabled) {
+ await updateOption('AutomaticDisableChannelEnabled', inputs.AutomaticDisableChannelEnabled);
+ }
+ if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) {
+ await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold);
+ }
+ if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) {
+ await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold);
+ }
+ break;
+ case 'ratio':
+ if (originInputs['ModelRatio'] !== inputs.ModelRatio) {
+ if (!verifyJSON(inputs.ModelRatio)) {
+ showError('模型倍率不是合法的 JSON 字符串');
+ return;
+ }
+ await updateOption('ModelRatio', inputs.ModelRatio);
+ }
+ if (originInputs['GroupRatio'] !== inputs.GroupRatio) {
+ if (!verifyJSON(inputs.GroupRatio)) {
+ showError('分组倍率不是合法的 JSON 字符串');
+ return;
+ }
+ await updateOption('GroupRatio', inputs.GroupRatio);
+ }
+ break;
+ case 'quota':
+ if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
+ await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
+ }
+ if (originInputs['QuotaForInvitee'] !== inputs.QuotaForInvitee) {
+ await updateOption('QuotaForInvitee', inputs.QuotaForInvitee);
+ }
+ if (originInputs['QuotaForInviter'] !== inputs.QuotaForInviter) {
+ await updateOption('QuotaForInviter', inputs.QuotaForInviter);
+ }
+ if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) {
+ await updateOption('PreConsumedQuota', inputs.PreConsumedQuota);
+ }
+ break;
+ case 'general':
+ if (originInputs['TopUpLink'] !== inputs.TopUpLink) {
+ await updateOption('TopUpLink', inputs.TopUpLink);
+ }
+ if (originInputs['ChatLink'] !== inputs.ChatLink) {
+ await updateOption('ChatLink', inputs.ChatLink);
+ }
+ break;
+ }
+ };
+
+ return (
+