diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1314c7af..12a59cb0 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,3 @@ { - "recommendations": [ - "golang.go" - ] + "recommendations": ["golang.go"] } diff --git a/.vscode/project.code-workspace b/.vscode/project.code-workspace index c7ce4442..b27ee896 100644 --- a/.vscode/project.code-workspace +++ b/.vscode/project.code-workspace @@ -7,6 +7,6 @@ { "name": "web", "path": "../web" - }, - ], + } + ] } diff --git a/api/system.go b/api/system.go index d5d87f38..18c397ce 100644 --- a/api/system.go +++ b/api/system.go @@ -18,4 +18,5 @@ type SystemStatus struct { AdditionalScript string `json:"additionalScript"` // Customized server profile, including server name and external url. CustomizedProfile CustomizedProfile `json:"customizedProfile"` + StorageServiceID int `json:"storageServiceId"` } diff --git a/api/system_setting.go b/api/system_setting.go index 8571c14a..60545db3 100644 --- a/api/system_setting.go +++ b/api/system_setting.go @@ -25,8 +25,8 @@ const ( SystemSettingAdditionalScriptName SystemSettingName = "additionalScript" // SystemSettingCustomizedProfileName is the key type of customized server profile. SystemSettingCustomizedProfileName SystemSettingName = "customizedProfile" - // SystemSettingStorageServiceName is the key type of sotrage service name. - SystemSettingStorageServiceName SystemSettingName = "storageServiceName" + // SystemSettingStorageServiceID is the key type of sotrage service ID. + SystemSettingStorageServiceID SystemSettingName = "storageServiceId" ) // CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item. @@ -61,8 +61,8 @@ func (key SystemSettingName) String() string { return "additionalScript" case SystemSettingCustomizedProfileName: return "customizedProfile" - case SystemSettingStorageServiceName: - return "storageServiceName" + case SystemSettingStorageServiceID: + return "storageServiceId" } return "" } @@ -154,7 +154,7 @@ func (upsert SystemSettingUpsert) Validate() error { if !slices.Contains(UserSettingAppearanceValue, customizedProfile.Appearance) { return fmt.Errorf("invalid appearance value") } - } else if upsert.Name == SystemSettingStorageServiceName { + } else if upsert.Name == SystemSettingStorageServiceID { return nil } else { return fmt.Errorf("invalid system setting name") diff --git a/server/resource.go b/server/resource.go index bb55466a..8b5c5fd8 100644 --- a/server/resource.go +++ b/server/resource.go @@ -86,12 +86,25 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { defer src.Close() var resourceCreate *api.ResourceCreate - systemSettingStorageServiceName := api.SystemSettingStorageServiceName + systemSettingStorageServiceName := api.SystemSettingStorageServiceID systemSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{Name: &systemSettingStorageServiceName}) if err != nil && common.ErrorCode(err) != common.NotFound { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err) } - if common.ErrorCode(err) == common.NotFound || systemSetting.Value == "" { + storeLocal := false + if common.ErrorCode(err) == common.NotFound { + storeLocal = true + } else { + var value int + err = json.Unmarshal([]byte(systemSetting.Value), &value) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err) + } + if value == 0 { + storeLocal = true + } + } + if storeLocal { fileBytes, err := io.ReadAll(src) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err) @@ -104,7 +117,12 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { Blob: fileBytes, } } else { - storage, err := s.Store.FindStorage(ctx, &api.StorageFind{Name: &systemSetting.Value}) + storageID, err := strconv.Atoi(systemSetting.Value) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storageID").SetInternal(err) + } + + storage, err := s.Store.FindStorage(ctx, &api.StorageFind{ID: &storageID}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err) } diff --git a/server/system.go b/server/system.go index 6f25f2e5..7096f70a 100644 --- a/server/system.go +++ b/server/system.go @@ -57,6 +57,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) { Appearance: "system", ExternalURL: "", }, + StorageServiceID: 0, } systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{}) @@ -64,7 +65,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err) } for _, systemSetting := range systemSettingList { - if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName || systemSetting.Name == api.SystemSettingStorageServiceName { + if systemSetting.Name == api.SystemSettingServerID || systemSetting.Name == api.SystemSettingSecretSessionName { continue } @@ -103,6 +104,8 @@ func (s *Server) registerSystemRoutes(g *echo.Group) { if v := valueMap["externalUrl"]; v != nil { systemStatus.CustomizedProfile.ExternalURL = v.(string) } + } else if systemSetting.Name == api.SystemSettingStorageServiceID { + systemStatus.StorageServiceID = int(value.(float64)) } } diff --git a/store/storage.go b/store/storage.go index 5638dab8..2233dff8 100644 --- a/store/storage.go +++ b/store/storage.go @@ -227,6 +227,9 @@ func patchStorageRaw(ctx context.Context, tx *sql.Tx, patch *api.StoragePatch) ( func findStorageRawList(ctx context.Context, tx *sql.Tx, find *api.StorageFind) ([]*storageRaw, error) { where, args := []string{"1 = 1"}, []interface{}{} + if v := find.ID; v != nil { + where, args = append(where, "id = ?"), append(args, *v) + } if v := find.Name; v != nil { where, args = append(where, "name = ?"), append(args, *v) } diff --git a/web/src/components/CreateStorageServiceDialog.tsx b/web/src/components/CreateStorageServiceDialog.tsx new file mode 100644 index 00000000..f0916d86 --- /dev/null +++ b/web/src/components/CreateStorageServiceDialog.tsx @@ -0,0 +1,169 @@ +import { useTranslation } from "react-i18next"; +import Icon from "./Icon"; +import { generateDialog } from "./Dialog"; +import { Button, Input, Typography } from "@mui/joy"; +import { useState } from "react"; +import { useStorageStore } from "../store/module"; +import toastHelper from "./Toast"; + +type Props = DialogProps; + +const CreateStorageServiceDialog: React.FC = (props: Props) => { + const { destroy } = props; + const { t } = useTranslation(); + const storageStore = useStorageStore(); + const [storageCreate, setStorageCreate] = useState({ + name: "", + endPoint: "", + region: "", + accessKey: "", + secretKey: "", + bucket: "", + urlPrefix: "", + }); + + const handleCloseBtnClick = () => { + destroy(); + }; + + const allowConfirmAction = () => { + if ( + storageCreate.name === "" || + storageCreate.endPoint === "" || + storageCreate.region === "" || + storageCreate.accessKey === "" || + storageCreate.bucket === "" || + storageCreate.bucket === "" + ) { + return false; + } + return true; + }; + + const handleConfirmBtnClick = async () => { + try { + await storageStore.createStorage(storageCreate); + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.message); + } + destroy(); + }; + + const handleNameChange = (event: React.ChangeEvent) => { + const name = event.target.value; + setStorageCreate({ + ...storageCreate, + name, + }); + }; + + const handleEndPointChange = (event: React.ChangeEvent) => { + const endPoint = event.target.value; + setStorageCreate({ + ...storageCreate, + endPoint, + }); + }; + + const handleRegionChange = (event: React.ChangeEvent) => { + const region = event.target.value; + setStorageCreate({ + ...storageCreate, + region, + }); + }; + + const handleAccessKeyChange = (event: React.ChangeEvent) => { + const accessKey = event.target.value; + setStorageCreate({ + ...storageCreate, + accessKey, + }); + }; + + const handleSecretKeyChange = (event: React.ChangeEvent) => { + const secretKey = event.target.value; + setStorageCreate({ + ...storageCreate, + secretKey, + }); + }; + + const handleBucketChange = (event: React.ChangeEvent) => { + const bucket = event.target.value; + setStorageCreate({ + ...storageCreate, + bucket, + }); + }; + + const handleURLPrefixChange = (event: React.ChangeEvent) => { + const urlPrefix = event.target.value; + setStorageCreate({ + ...storageCreate, + urlPrefix, + }); + }; + + return ( + <> +
+

{t("setting.storage-section.create-a-service")}

+ +
+
+ + Name + + + + EndPoint + + + + Region + + + + AccessKey + + + + SecretKey + + + + Bucket + + + + URLPrefix + + +
+ + +
+
+ + ); +}; + +function showCreateStorageServiceDialog() { + generateDialog( + { + className: "create-storage-service-dialog", + dialogName: "create-storage-service-dialog", + }, + CreateStorageServiceDialog + ); +} + +export default showCreateStorageServiceDialog; diff --git a/web/src/components/SettingDialog.tsx b/web/src/components/SettingDialog.tsx index 961c3fdd..20d08639 100644 --- a/web/src/components/SettingDialog.tsx +++ b/web/src/components/SettingDialog.tsx @@ -7,11 +7,12 @@ import MyAccountSection from "./Settings/MyAccountSection"; import PreferencesSection from "./Settings/PreferencesSection"; import MemberSection from "./Settings/MemberSection"; import SystemSection from "./Settings/SystemSection"; +import StorageSection from "./Settings/StorageSection"; import "../less/setting-dialog.less"; type Props = DialogProps; -type SettingSection = "my-account" | "preferences" | "member" | "system"; +type SettingSection = "my-account" | "preferences" | "storage" | "member" | "system"; interface State { selectedSection: SettingSection; @@ -57,6 +58,12 @@ const SettingDialog: React.FC = (props: Props) => { <> {t("common.admin")}
+ handleSectionSelectorItemClick("storage")} + className={`section-item ${state.selectedSection === "storage" ? "selected" : ""}`} + > + 🗃️ {t("setting.storage")} + handleSectionSelectorItemClick("member")} className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`} @@ -78,6 +85,8 @@ const SettingDialog: React.FC = (props: Props) => { ) : state.selectedSection === "preferences" ? ( + ) : state.selectedSection === "storage" ? ( + ) : state.selectedSection === "member" ? ( ) : state.selectedSection === "system" ? ( diff --git a/web/src/components/Settings/StorageSection.tsx b/web/src/components/Settings/StorageSection.tsx new file mode 100644 index 00000000..c34422fb --- /dev/null +++ b/web/src/components/Settings/StorageSection.tsx @@ -0,0 +1,65 @@ +import { Radio } from "@mui/joy"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useGlobalStore, useStorageStore } from "../../store/module"; +import * as api from "../../helpers/api"; +import showCreateStorageServiceDialog from "../CreateStorageServiceDialog"; +import showUpdateStorageServiceDialog from "../UpdateStorageServiceDialog"; +import "../../less/settings/storage-section.less"; + +const StorageSection = () => { + const { t } = useTranslation(); + const storageStore = useStorageStore(); + const storages = storageStore.state.storages; + const globalStore = useGlobalStore(); + const systemStatus = globalStore.state.systemStatus; + const [storageServiceId, setStorageServiceId] = useState(systemStatus.storageServiceId); + + useEffect(() => { + storageStore.fetchStorages(); + globalStore.fetchSystemStatus(); + }, []); + + useEffect(() => { + setStorageServiceId(systemStatus.storageServiceId); + }, [systemStatus]); + + const handleActiveStorageServiceChanged = async (event: React.ChangeEvent) => { + const value = parseInt(event.target.value); + setStorageServiceId(value); + await api.upsertSystemSetting({ + name: "storageServiceId", + value: JSON.stringify(value), + }); + }; + + const handleStorageServiceUpdate = async (event: React.MouseEvent, storage: Storage) => { + event.preventDefault(); + showUpdateStorageServiceDialog(storage); + }; + + return ( +
+

{t("setting.storage-section.storage-services-list")}

+ {storages.map((storage) => ( + + ))} + +
+ +
+
+ ); +}; + +export default StorageSection; diff --git a/web/src/components/UpdateStorageServiceDialog.tsx b/web/src/components/UpdateStorageServiceDialog.tsx new file mode 100644 index 00000000..e7ba2038 --- /dev/null +++ b/web/src/components/UpdateStorageServiceDialog.tsx @@ -0,0 +1,189 @@ +import { Button, Input, Typography } from "@mui/joy"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import Icon from "./Icon"; +import { generateDialog } from "./Dialog"; +import { showCommonDialog } from "./Dialog/CommonDialog"; +import { useStorageStore } from "../store/module"; +import toastHelper from "./Toast"; + +interface Props extends DialogProps { + storage: StoragePatch; +} + +const UpdateStorageServiceDialog: React.FC = (props: Props) => { + const { storage, destroy } = props; + const { t } = useTranslation(); + const storageStore = useStorageStore(); + const [storagePatch, setStoragePatch] = useState(storage); + + const handleCloseBtnClick = () => { + destroy(); + }; + + const allowConfirmAction = () => { + if ( + storagePatch.name === "" || + storagePatch.endPoint === "" || + storagePatch.region === "" || + storagePatch.accessKey === "" || + storagePatch.bucket === "" || + storagePatch.bucket === "" + ) { + return false; + } + return true; + }; + + const handleConfirmBtnClick = async () => { + try { + await storageStore.patchStorage(storagePatch); + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.message); + } + destroy(); + }; + + const handleDeleteBtnClick = async () => { + const warningText = t("setting.storage-section.warning-text"); + showCommonDialog({ + title: t("setting.storage-section.delete-storage"), + content: warningText, + style: "warning", + dialogName: "delete-storage-dialog", + onConfirm: async () => { + try { + await storageStore.deleteStorageById(storagePatch.id); + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.message); + } + destroy(); + }, + }); + }; + + const handleNameChange = (event: React.ChangeEvent) => { + const name = event.target.value; + setStoragePatch({ + ...storagePatch, + name, + }); + }; + + const handleEndPointChange = (event: React.ChangeEvent) => { + const endPoint = event.target.value; + setStoragePatch({ + ...storagePatch, + endPoint, + }); + }; + + const handleRegionChange = (event: React.ChangeEvent) => { + const region = event.target.value; + setStoragePatch({ + ...storagePatch, + region, + }); + }; + + const handleAccessKeyChange = (event: React.ChangeEvent) => { + const accessKey = event.target.value; + setStoragePatch({ + ...storagePatch, + accessKey, + }); + }; + + const handleSecretKeyChange = (event: React.ChangeEvent) => { + const secretKey = event.target.value; + setStoragePatch({ + ...storagePatch, + secretKey, + }); + }; + + const handleBucketChange = (event: React.ChangeEvent) => { + const bucket = event.target.value; + setStoragePatch({ + ...storagePatch, + bucket, + }); + }; + + const handleURLPrefixChange = (event: React.ChangeEvent) => { + const urlPrefix = event.target.value; + setStoragePatch({ + ...storagePatch, + urlPrefix, + }); + }; + + return ( + <> +
+

{t("setting.storage-section.update-a-service")}

+ +
+
+ + Name + + + + EndPoint + + + + Region + + + + AccessKey + + + + SecretKey + + + + Bucket + + + + URLPrefix + + +
+ +
+ + +
+
+
+ + ); +}; + +function showUpdateStorageServiceDialog(storage: Storage) { + generateDialog( + { + className: "update-storage-service-dialog", + dialogName: "update-storage-service-dialog", + }, + UpdateStorageServiceDialog, + { storage } + ); +} + +export default showUpdateStorageServiceDialog; diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index ff16371a..90852c6f 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -206,6 +206,22 @@ export function deleteTag(tagName: string) { }); } +export function getStorageList() { + return axios.get>(`/api/storage`); +} + +export function createStorage(storageCreate: StorageCreate) { + return axios.post>(`/api/storage`, storageCreate); +} + +export function patchStorage(storagePatch: StoragePatch) { + return axios.patch>(`/api/storage/${storagePatch.id}`, storagePatch); +} + +export function deleteStorage(storageId: StorageId) { + return axios.delete(`/api/storage/${storageId}`); +} + export async function getRepoStarCount() { const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, { headers: { diff --git a/web/src/less/settings/storage-section.less b/web/src/less/settings/storage-section.less new file mode 100644 index 00000000..4389a9b5 --- /dev/null +++ b/web/src/less/settings/storage-section.less @@ -0,0 +1,14 @@ +.storage-section-container { + > .title-text { + @apply mt-4 first:mt-1; + } + + > .form-label.selector { + @apply mb-2 flex flex-row justify-between items-center; + + > .normal-text { + @apply mr-2 text-sm; + } + } + } + \ No newline at end of file diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 498b39ef..fd24ae78 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -47,7 +47,8 @@ "image": "Image", "link": "Link", "vacuum": "Vacuum", - "select": "Select" + "select": "Select", + "database": "Database" }, "slogan": "An open-source, self-hosted memo hub with knowledge management and social networking.", "auth": { @@ -150,6 +151,7 @@ "setting": { "my-account": "My Account", "preference": "Preference", + "storage": "Storage", "member": "Member", "member-list": "Member list", "system": "System", @@ -169,6 +171,13 @@ "created_ts": "Created Time", "updated_ts": "Updated Time" }, + "storage-section": { + "storage-services-list": "Storage service list", + "create-a-service": "Create a service", + "update-a-service": "Update a service", + "warning-text": "Are you sure to delete this storage service? THIS ACTION IS IRREVERSIABLE❗", + "delete-storage": "Delete Storage" + }, "member-section": { "create-a-member": "Create a member" }, diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 9c847599..a63da5ae 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -47,7 +47,8 @@ "image": "图片", "link": "链接", "vacuum": "清理", - "select": "选择" + "select": "选择", + "database": "数据库" }, "slogan": "An open-source, self-hosted memo hub with knowledge management and social networking.", "auth": { @@ -149,6 +150,7 @@ "setting": { "my-account": "我的账号", "preference": "偏好设置", + "storage": "存储设置", "member": "成员", "member-list": "成员列表", "system": "系统", @@ -161,13 +163,20 @@ "theme": "主题", "default-memo-visibility": "默认 Memo 可见性", "enable-folding-memo": "开启折叠 Memo", - "enable-double-click":"开启双击编辑", + "enable-double-click": "开启双击编辑", "editor-font-style": "编辑器字体样式", "mobile-editor-style": "移动端编辑器样式", "default-memo-sort-option": "Memo 显示时间", "created_ts": "创建时间", "updated_ts": "更新时间" }, + "storage-section": { + "storage-services-list": "存储服务列表", + "create-a-service": "新建服务", + "update-a-service": "更新服务", + "delete-storage": "删除存储服务", + "warning-text": "确定删除这个存储服务么?此操作不可逆❗" + }, "member-section": { "create-a-member": "创建成员" }, diff --git a/web/src/store/index.ts b/web/src/store/index.ts index f19af158..9d5e8bb6 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -9,6 +9,7 @@ import locationReducer from "./reducer/location"; import resourceReducer from "./reducer/resource"; import dialogReducer from "./reducer/dialog"; import tagReducer from "./reducer/tag"; +import storageReducer from "./reducer/storage"; const store = configureStore({ reducer: { @@ -21,6 +22,7 @@ const store = configureStore({ location: locationReducer, resource: resourceReducer, dialog: dialogReducer, + storage: storageReducer, }, }); diff --git a/web/src/store/module/index.ts b/web/src/store/module/index.ts index 803eb374..8c23d20b 100644 --- a/web/src/store/module/index.ts +++ b/web/src/store/module/index.ts @@ -7,3 +7,4 @@ export * from "./resource"; export * from "./shortcut"; export * from "./user"; export * from "./dialog"; +export * from "./storage"; diff --git a/web/src/store/module/storage.ts b/web/src/store/module/storage.ts new file mode 100644 index 00000000..27c8263b --- /dev/null +++ b/web/src/store/module/storage.ts @@ -0,0 +1,31 @@ +import store, { useAppSelector } from ".."; +import * as api from "../../helpers/api"; +import { setStorages, createStorage, patchStorage, deleteStorage } from "../reducer/storage"; + +export const useStorageStore = () => { + const state = useAppSelector((state) => state.storage); + return { + state, + getState: () => { + return store.getState().storage; + }, + fetchStorages: async () => { + const { data } = (await api.getStorageList()).data; + store.dispatch(setStorages(data)); + }, + createStorage: async (storageCreate: StorageCreate) => { + const { data: storage } = (await api.createStorage(storageCreate)).data; + store.dispatch(createStorage(storage)); + return storage; + }, + patchStorage: async (storagePatch: StoragePatch) => { + const { data: storage } = (await api.patchStorage(storagePatch)).data; + store.dispatch(patchStorage(storage)); + return storage; + }, + deleteStorageById: async (storageId: StorageId) => { + await api.deleteStorage(storageId); + store.dispatch(deleteStorage(storageId)); + }, + }; +}; diff --git a/web/src/store/reducer/storage.ts b/web/src/store/reducer/storage.ts new file mode 100644 index 00000000..b0eaefac --- /dev/null +++ b/web/src/store/reducer/storage.ts @@ -0,0 +1,53 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +interface State { + storages: Storage[]; +} + +const storageSlice = createSlice({ + name: "storage", + initialState: { + storages: [], + } as State, + reducers: { + setStorages: (state, action: PayloadAction) => { + return { + ...state, + storages: action.payload, + }; + }, + createStorage: (state, action: PayloadAction) => { + return { + ...state, + storages: [action.payload].concat(state.storages), + }; + }, + patchStorage: (state, action: PayloadAction>) => { + return { + ...state, + storages: state.storages.map((storage) => { + if (storage.id === action.payload.id) { + return { + ...storage, + ...action.payload, + }; + } else { + return storage; + } + }), + }; + }, + deleteStorage: (state, action: PayloadAction) => { + return { + ...state, + storages: state.storages.filter((storage) => { + return storage.id !== action.payload; + }), + }; + }, + }, +}); + +export const { setStorages, createStorage, patchStorage, deleteStorage } = storageSlice.actions; + +export default storageSlice.reducer; diff --git a/web/src/types/modules/storage.d.ts b/web/src/types/modules/storage.d.ts new file mode 100644 index 00000000..f5b9eea1 --- /dev/null +++ b/web/src/types/modules/storage.d.ts @@ -0,0 +1,36 @@ +type StorageId = number; + +interface Storage { + id: StorageId; + creatorId: UserId; + createdTs: TimeStamp; + updatedTs: TimeStamp; + name: string; + endPoint: string; + region: string; + accessKey: string; + secretKey: string; + bucket: string; + urlPrefix: string; +} + +interface StorageCreate { + name: string; + endPoint: string; + region: string; + accessKey: string; + secretKey: string; + bucket: string; + urlPrefix: string; +} + +interface StoragePatch { + id: StorageId; + name: string; + endPoint: string; + region: string; + accessKey: string; + secretKey: string; + bucket: string; + urlPrefix: string; +} diff --git a/web/src/types/modules/system.d.ts b/web/src/types/modules/system.d.ts index c04d2357..18e99d8f 100644 --- a/web/src/types/modules/system.d.ts +++ b/web/src/types/modules/system.d.ts @@ -22,6 +22,7 @@ interface SystemStatus { additionalStyle: string; additionalScript: string; customizedProfile: CustomizedProfile; + storageServiceId: number; } interface SystemSetting {