feat: 增强实时会议gRPC服务和会话状态管理
- 在 `MeetingCommandServiceImpl` 中更新 `saveRealtimeTranscriptSnapshot` 方法,仅保存最终结果 - 在 `GrpcServerLifecycle` 中添加 `GrpcExceptionLoggingInterceptor` - 在 `RealtimeMeetingSessionStateServiceImpl` 中添加终端状态处理逻辑 - 在 `RealtimeMeetingGrpcService` 中增强错误处理和流关闭逻辑 - 添加 `saveRealtimeTranscriptSnapshotShouldIgnoreNonFinalTranscript` 测试用例 - 在 `MeetingAuthorizationServiceImpl` 中添加匿名访问支持 - 在 `RealtimeMeetingGrpcSessionServiceImpl` 中添加异常处理和清理逻辑dev_na
parent
b2e2f2c46a
commit
1c82365e97
|
|
@ -6,7 +6,109 @@ const http = axios.create({
|
|||
timeout: 15000
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const refreshClient = axios.create({
|
||||
baseURL: "/",
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
const AUTH_WHITELIST = ["/sys/auth/login", "/sys/auth/refresh", "/sys/auth/captcha", "/sys/auth/device-code"];
|
||||
const API_SUCCESS_CODE = "200";
|
||||
const REFRESH_AHEAD_MS = 60 * 1000;
|
||||
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
function isApiSuccessCode(code: unknown): boolean {
|
||||
return String(code) === API_SUCCESS_CODE;
|
||||
}
|
||||
|
||||
function getTokenPayload(token: string): Record<string, any> | null {
|
||||
try {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return JSON.parse(decodeURIComponent(escape(window.atob(normalized))));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenExpireAt(token: string): number | null {
|
||||
const payload = getTokenPayload(token);
|
||||
if (!payload || typeof payload.exp !== "number") {
|
||||
return null;
|
||||
}
|
||||
return payload.exp * 1000;
|
||||
}
|
||||
|
||||
function isTokenExpiringSoon(token: string): boolean {
|
||||
const expireAt = getTokenExpireAt(token);
|
||||
if (!expireAt) {
|
||||
return false;
|
||||
}
|
||||
return expireAt - Date.now() <= REFRESH_AHEAD_MS;
|
||||
}
|
||||
|
||||
function clearAuthStorage() {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
sessionStorage.removeItem("userProfile");
|
||||
}
|
||||
|
||||
function persistTokens(data: { accessToken: string; refreshToken: string }) {
|
||||
localStorage.setItem("accessToken", data.accessToken);
|
||||
localStorage.setItem("refreshToken", data.refreshToken);
|
||||
|
||||
const payload = getTokenPayload(data.accessToken);
|
||||
if (payload && payload.tenantId !== undefined && payload.tenantId !== null) {
|
||||
localStorage.setItem("activeTenantId", String(payload.tenantId));
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthWhitelistRequest(url?: string) {
|
||||
return AUTH_WHITELIST.some((path) => (url || "").includes(path));
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
const refreshToken = localStorage.getItem("refreshToken");
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
refreshPromise = refreshClient
|
||||
.post("/sys/auth/refresh", { refreshToken })
|
||||
.then((resp) => {
|
||||
const body = resp.data;
|
||||
if (!body || !isApiSuccessCode(body.code) || !body.data?.accessToken || !body.data?.refreshToken) {
|
||||
throw new Error(body?.msg || "刷新登录态失败");
|
||||
}
|
||||
persistTokens(body.data);
|
||||
return body.data.accessToken as string;
|
||||
})
|
||||
.catch(() => {
|
||||
clearAuthStorage();
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
http.interceptors.request.use(async (config) => {
|
||||
if (!isAuthWhitelistRequest(config.url)) {
|
||||
const currentToken = localStorage.getItem("accessToken");
|
||||
if (currentToken && isTokenExpiringSoon(currentToken)) {
|
||||
await refreshAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("accessToken");
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
|
|
@ -18,10 +120,9 @@ http.interceptors.request.use((config) => {
|
|||
http.interceptors.response.use(
|
||||
(resp) => {
|
||||
const body = resp.data;
|
||||
// 如果返回的 code 不是 0,表示业务错误
|
||||
if (body && body.code !== "0") {
|
||||
if (body && !isApiSuccessCode(body.code)) {
|
||||
const errorMsg = body.msg || "请求失败";
|
||||
message.error(errorMsg); // 自动展示后端错误消息
|
||||
message.error(errorMsg);
|
||||
const err = new Error(errorMsg);
|
||||
(err as any).code = body.code;
|
||||
(err as any).msg = body.msg;
|
||||
|
|
@ -29,27 +130,37 @@ http.interceptors.response.use(
|
|||
}
|
||||
return resp;
|
||||
},
|
||||
(error) => {
|
||||
// 处理 HTTP 状态码错误 (4xx, 5xx)
|
||||
async (error) => {
|
||||
const originalRequest = error.config || {};
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!isAuthWhitelistRequest(originalRequest.url)
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return http(originalRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
sessionStorage.removeItem("userProfile");
|
||||
clearAuthStorage();
|
||||
window.location.href = "/login?timeout=1";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const body = error.response?.data;
|
||||
const errorMsg = body?.msg || error.message || "网络异常";
|
||||
|
||||
// 防止重复弹出相同的提示(可选逻辑,根据需要调整)
|
||||
message.error(errorMsg);
|
||||
|
||||
if (body && body.msg) {
|
||||
const err = new Error(body.msg);
|
||||
(err as any).code = body.code;
|
||||
(err as any).msg = body.msg;
|
||||
return Promise.reject(err);
|
||||
const err = new Error(body.msg);
|
||||
(err as any).code = body.code;
|
||||
(err as any).msg = body.msg;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@ function resolveMenuIcon(icon?: string): ReactNode {
|
|||
type PermissionMenuNode = SysPermission & {
|
||||
children?: PermissionMenuNode[];
|
||||
};
|
||||
type CachedUserProfile = { displayName?: string; username?: string; avatar_url?: string };
|
||||
|
||||
function getAvatarUrl(profile?: CachedUserProfile | null) {
|
||||
return profile?.avatar_url?.trim() || "";
|
||||
}
|
||||
export default function AppLayout() {
|
||||
const { message } = App.useApp();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
|
@ -79,7 +83,7 @@ export default function AppLayout() {
|
|||
try {
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
if (profileStr) {
|
||||
const profile = JSON.parse(profileStr) as { displayName?: string; username?: string };
|
||||
const profile = JSON.parse(profileStr) as CachedUserProfile;
|
||||
return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -88,7 +92,14 @@ export default function AppLayout() {
|
|||
|
||||
return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin");
|
||||
}, [t]);
|
||||
|
||||
const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState<string>(() => {
|
||||
try {
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
return profileStr ? getAvatarUrl(JSON.parse(profileStr) as CachedUserProfile) : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
const fetchInitialData = useCallback(async () => {
|
||||
try {
|
||||
const storedTenants = localStorage.getItem("availableTenants");
|
||||
|
|
@ -116,7 +127,20 @@ export default function AppLayout() {
|
|||
useEffect(() => {
|
||||
fetchInitialData();
|
||||
}, [fetchInitialData]);
|
||||
useEffect(() => {
|
||||
const syncUserProfile = () => {
|
||||
try {
|
||||
const profileStr = sessionStorage.getItem("userProfile");
|
||||
if (!profileStr) return;
|
||||
const profile = JSON.parse(profileStr) as CachedUserProfile;
|
||||
setCurrentUserAvatarUrl(getAvatarUrl(profile));
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("user-profile-updated", syncUserProfile);
|
||||
return () => window.removeEventListener("user-profile-updated", syncUserProfile);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const syncPlatformConfig = () => {
|
||||
const configStr = sessionStorage.getItem("platformConfig");
|
||||
|
|
@ -299,7 +323,7 @@ export default function AppLayout() {
|
|||
<BellOutlined style={{ fontSize: "18px", color: "var(--app-text-main)", cursor: "pointer" }} />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
|
||||
<Avatar size="small" src={currentUserAvatarUrl || undefined} icon={currentUserAvatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
|
||||
<span style={{ fontWeight: 500 }}>{currentUserDisplayName}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
|
|
|
|||
|
|
@ -265,6 +265,9 @@
|
|||
"botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.",
|
||||
"botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.",
|
||||
"botBindStatus": "Binding Status",
|
||||
"avatarUrl": "Avatar URL",
|
||||
"avatarUrlPlaceholder": "Enter avatar image URL",
|
||||
"uploadAvatar": "Upload Avatar",
|
||||
"botBound": "Bound",
|
||||
"botUnbound": "Not Generated",
|
||||
"botSecretHidden": "Hidden. Generate or reset to get a new secret.",
|
||||
|
|
|
|||
|
|
@ -265,6 +265,10 @@
|
|||
"botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。",
|
||||
"botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。",
|
||||
"botBindStatus": "绑定状态",
|
||||
"avatarUrl": "\u5934\u50cf URL",
|
||||
"avatarUrlPlaceholder": "\u8bf7\u8f93\u5165\u5934\u50cf\u56fe\u7247\u5730\u5740",
|
||||
"uploadAvatar": "\u4e0a\u4f20\u5934\u50cf",
|
||||
|
||||
"botBound": "已绑定",
|
||||
"botUnbound": "未生成",
|
||||
"botSecretHidden": "已隐藏。如需查看新的 Secret,请重新生成。",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, App } from 'antd';
|
||||
import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, Upload, message,App } from "antd";
|
||||
import type { DefaultOptionType } from "antd/es/select";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser } from "@/api";
|
||||
import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser, uploadPlatformAsset } from "@/api";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
|
|
@ -68,6 +68,7 @@ export default function Users() {
|
|||
const { items: statusDict } = useDict("sys_common_status");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [data, setData] = useState<SysUser[]>([]);
|
||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
||||
|
|
@ -229,6 +230,18 @@ export default function Users() {
|
|||
loadUsersData();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (file: File) => {
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const url = await uploadPlatformAsset(file);
|
||||
form.setFieldValue("avatar_url", url);
|
||||
message.success(t("common.success"));
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
|
@ -238,6 +251,7 @@ export default function Users() {
|
|||
displayName: values.displayName,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
avatar_url: values.avatar_url,
|
||||
status: values.status,
|
||||
isPlatformAdmin: values.isPlatformAdmin
|
||||
};
|
||||
|
|
@ -280,9 +294,7 @@ export default function Users() {
|
|||
key: "user",
|
||||
render: (_: any, record: SysUser) => (
|
||||
<Space>
|
||||
<div className="user-avatar-placeholder">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<Avatar className="user-avatar-placeholder" size={40} src={record.avatar_url || undefined} icon={record.avatar_url ? undefined : <UserOutlined />} />
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<div className="user-display-name">{record.displayName}</div>
|
||||
|
|
@ -403,6 +415,10 @@ export default function Users() {
|
|||
<Col span={12}><Form.Item label={t("users.email")} name="email"><Input placeholder={t("usersExt.emailPlaceholder")} className="tabular-nums" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item label={t("users.phone")} name="phone"><Input placeholder={t("users.phone")} className="tabular-nums" /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item label={t("profile.avatarUrl")} name="avatar_url"><Input placeholder={t("profile.avatarUrlPlaceholder")} /></Form.Item>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>{t("profile.uploadAvatar")}</Button>
|
||||
</Upload>
|
||||
<Form.Item label={t("users.password")} name="password" rules={[{ required: !editing, message: t("users.password") }]}><Input.Password placeholder={editing ? t("usersExt.passwordKeepPlaceholder") : t("usersExt.passwordInitPlaceholder")} /></Form.Item>
|
||||
<Form.Item label={t("users.roles")} name="roleIds" rules={[{ required: true, message: t("users.roles") }]}><Select mode="multiple" placeholder={t("users.roles")} options={roleOptions} optionFilterProp={isPlatformMode ? "searchText" : "label"} /></Form.Item>
|
||||
{!isPlatformMode && <Form.Item label={t("users.orgNode")} name="orgId"><TreeSelect placeholder={t("usersExt.selectOrgPlaceholder")} allowClear treeData={orgTreeData} /></Form.Item>}
|
||||
|
|
|
|||
|
|
@ -43,14 +43,14 @@ const fallbackRecentCards: RecentCard[] = [
|
|||
},
|
||||
{
|
||||
id: "sample-2",
|
||||
title: "【示例】开会用通义听悟,高效又省心",
|
||||
title: "【示例】开会用iMeeting,高效又省心",
|
||||
duration: "02:14",
|
||||
time: "2026-03-24 11:04",
|
||||
tags: ["会议日程", "笔记", "发言人", "协同", "纪要"]
|
||||
},
|
||||
{
|
||||
id: "sample-3",
|
||||
title: "【示例】上课用通义听悟,学习效率 UPUP",
|
||||
title: "【示例】上课用iMeeting,学习效率 UPUP",
|
||||
duration: "02:01",
|
||||
time: "2026-03-23 11:04",
|
||||
tags: ["转写", "笔记", "学习", "教学音频", "课程音频"]
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, App } from 'antd';
|
||||
import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, Upload, message } from "antd";
|
||||
import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile } from "@/api";
|
||||
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import type { BotCredential, UserProfile } from "@/types";
|
||||
|
||||
const { Paragraph, Title, Text } = Typography;
|
||||
|
||||
export default function Profile() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [credentialLoading, setCredentialLoading] = useState(false);
|
||||
const [credentialSaving, setCredentialSaving] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [credential, setCredential] = useState<BotCredential | null>(null);
|
||||
const [profileForm] = Form.useForm();
|
||||
|
|
@ -25,6 +25,8 @@ export default function Profile() {
|
|||
try {
|
||||
const data = await getCurrentUser();
|
||||
setUser(data);
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(data));
|
||||
window.dispatchEvent(new Event("user-profile-updated"));
|
||||
profileForm.setFieldsValue(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -58,6 +60,18 @@ export default function Profile() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (file: File) => {
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const url = await uploadPlatformAsset(file);
|
||||
profileForm.setFieldValue("avatar_url", url);
|
||||
message.success(t("common.success"));
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleUpdatePassword = async () => {
|
||||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
|
|
@ -82,6 +96,8 @@ export default function Profile() {
|
|||
};
|
||||
|
||||
const renderValue = (value?: string) => value || "-";
|
||||
const avatarUrlValue = Form.useWatch("avatar_url", profileForm) as string | undefined;
|
||||
const avatarUrl = avatarUrlValue?.trim() || undefined;
|
||||
|
||||
return (
|
||||
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
|
||||
|
|
@ -90,7 +106,7 @@ export default function Profile() {
|
|||
<Row gutter={24}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card className="app-page__content-card text-center" loading={loading}>
|
||||
<Avatar size={80} icon={<UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
|
||||
<Avatar size={80} src={avatarUrl} icon={avatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
|
||||
<Text type="secondary">@{user?.username}</Text>
|
||||
<div className="mt-4">
|
||||
|
|
@ -121,6 +137,14 @@ export default function Profile() {
|
|||
<Form.Item label={t("users.phone")} name="phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("profile.avatarUrl")} name="avatar_url">
|
||||
<Input placeholder={t("profile.avatarUrlPlaceholder")} />
|
||||
</Form.Item>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload}>
|
||||
<Button icon={<UploadOutlined />} loading={avatarUploading} style={{ marginBottom: 16 }}>
|
||||
{t("profile.uploadAvatar")}
|
||||
</Button>
|
||||
</Upload>
|
||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
|
||||
{t("profile.saveChanges")}
|
||||
|
|
|
|||
Loading…
Reference in New Issue