From c58ca91d54baf59cbc8299c39e9aa2af858ff7ba Mon Sep 17 00:00:00 2001 From: hyrious Date: Tue, 7 Jun 2022 16:53:07 +0800 Subject: [PATCH] feat(project): upload avatar --- .../src/api-middleware/flatServer/index.ts | 43 ++++++++ .../GeneralSettingPage/ConfirmButtons.tsx | 2 +- .../GeneralSettingPage/UploadAvatar.tsx | 102 ++++++++++++++++++ .../GeneralSettingPage/index.tsx | 41 +++++-- .../GeneralSettingPage/style.less | 53 ++++++++- .../renderer-app/src/stores/global-store.ts | 6 ++ packages/flat-i18n/locales/en.json | 4 +- packages/flat-i18n/locales/zh-CN.json | 4 +- .../src/api-middleware/flatServer/index.ts | 43 ++++++++ .../GeneralSettingPage/ConfirmButtons.tsx | 2 +- .../GeneralSettingPage/UploadAvatar.tsx | 102 ++++++++++++++++++ .../GeneralSettingPage/index.less | 53 ++++++++- .../GeneralSettingPage/index.tsx | 44 ++++++-- web/flat-web/src/stores/GlobalStore.ts | 6 ++ 14 files changed, 478 insertions(+), 27 deletions(-) create mode 100644 desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx create mode 100644 web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx diff --git a/desktop/renderer-app/src/api-middleware/flatServer/index.ts b/desktop/renderer-app/src/api-middleware/flatServer/index.ts index 4dcb05feb7a..b76b75f5357 100644 --- a/desktop/renderer-app/src/api-middleware/flatServer/index.ts +++ b/desktop/renderer-app/src/api-middleware/flatServer/index.ts @@ -564,3 +564,46 @@ export async function rename(name: string): Promise { name, }); } + +export interface UploadAvatarStartPayload { + fileName: string; + fileSize: number; + region: Region; +} + +export interface UploadAvatarResult { + fileUUID: string; + filePath: string; + policy: string; + policyURL: string; + signature: string; +} + +export async function uploadAvatarStart( + fileName: string, + fileSize: number, + region: Region, +): Promise { + return await post("user/upload-avatar/start", { + fileName, + fileSize, + region, + }); +} + +export interface UploadAvatarFinishPayload { + fileUUID: string; +} + +export interface UploadAvatarFinishResult { + avatarURL: string; +} + +export async function uploadAvatarFinish(fileUUID: string): Promise { + return await post( + "user/upload-avatar/finish", + { + fileUUID, + }, + ); +} diff --git a/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx index c1b19bf6606..bfd2e20b923 100644 --- a/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx +++ b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx @@ -15,7 +15,7 @@ export const ConfirmButtons: React.FC = ({ onConfirm }) => const confirm = useCallback(async () => { setLoading(true); - await sp(onConfirm()); + await sp(onConfirm().catch(console.error)); setLoading(false); setPhase("idle"); }, [onConfirm, sp]); diff --git a/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx new file mode 100644 index 00000000000..74ac0db1d0f --- /dev/null +++ b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx @@ -0,0 +1,102 @@ +import Axios from "axios"; +import React, { ChangeEvent, useContext, useRef, useState } from "react"; +import classNames from "classnames"; +import { Region } from "flat-components"; +import { observer } from "mobx-react-lite"; +import { useTranslation } from "react-i18next"; + +import { GlobalStoreContext } from "../../../components/StoreProvider"; +import { useSafePromise } from "../../../utils/hooks/lifecycle"; +import { CLOUD_STORAGE_OSS_ALIBABA_CONFIG } from "../../../constants/process"; +import { uploadAvatarFinish, uploadAvatarStart } from "../../../api-middleware/flatServer"; +import { globalStore } from "../../../stores/global-store"; + +export interface UploadAvatarProps { + fileRef?: React.MutableRefObject; +} + +export function useFileRef(): React.MutableRefObject { + return useRef(); +} + +export const UploadAvatar = observer(function UploadAvatar({ fileRef }) { + const globalStore = useContext(GlobalStoreContext); + const sp = useSafePromise(); + const { t } = useTranslation(); + + const [loading, setLoading] = useState(false); + const [imageUrl, setImageUrl] = useState(globalStore.userInfo?.avatar || ""); + + const updateInput = (event: ChangeEvent): void => { + const file: File | undefined = (event.target.files || [])[0]; + if (fileRef) { + fileRef.current = file; + } + if (file) { + setLoading(true); + sp(fileToDataUrl(file)).then(url => { + setLoading(false); + setImageUrl(url); + }); + } else { + setImageUrl(globalStore.userInfo?.avatar || ""); + } + }; + + return ( +
+ + {imageUrl ? ( + avatar + ) : ( + {t("upload-avatar")} + )} +
+ ); +}); + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +export async function uploadAvatar(file: File): Promise { + const ticket = await uploadAvatarStart( + file.name, + file.size, + globalStore.region ?? Region.CN_HZ, + ); + + const formData = new FormData(); + const encodedFileName = encodeURIComponent(file.name); + formData.append("key", ticket.filePath); + formData.append("name", file.name); + formData.append("policy", ticket.policy); + formData.append("OSSAccessKeyId", CLOUD_STORAGE_OSS_ALIBABA_CONFIG.accessKey); + formData.append("success_action_status", "200"); + formData.append("callback", ""); + formData.append("signature", ticket.signature); + formData.append( + "Content-Disposition", + `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`, + ); + formData.append("file", file); + + await Axios.post(ticket.policyURL, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + const { avatarURL } = await uploadAvatarFinish(ticket.fileUUID); + + globalStore.updateUserAvatar(avatarURL); +} diff --git a/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/index.tsx b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/index.tsx index a5e6fc0489b..ef8ac1e9352 100644 --- a/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/index.tsx +++ b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/index.tsx @@ -1,7 +1,7 @@ import "./style.less"; import React, { useContext, useEffect, useState } from "react"; -import { Checkbox, Input, Radio, RadioChangeEvent } from "antd"; +import { Checkbox, Input, message, Radio, RadioChangeEvent } from "antd"; import { UserSettingLayoutContainer } from "../UserSettingLayoutContainer"; import { ipcSyncByApp, ipcAsyncByApp } from "../../../utils/ipc"; import { useTranslation } from "react-i18next"; @@ -10,6 +10,7 @@ import { ConfigStoreContext, GlobalStoreContext } from "../../../components/Stor import { useSafePromise } from "../../../utils/hooks/lifecycle"; import { loginCheck, rename } from "../../../api-middleware/flatServer"; import { ConfirmButtons } from "./ConfirmButtons"; +import { UploadAvatar, uploadAvatar, useFileRef } from "./UploadAvatar"; enum SelectLanguage { Chinese, @@ -25,6 +26,7 @@ export const GeneralSettingPage = (): React.ReactElement => { const [name, setName] = useState(globalStore.userName || ""); const [isRenaming, setRenaming] = useState(false); + const fileRef = useFileRef(); async function changeUserName(): Promise { if (name !== globalStore.userName) { @@ -38,6 +40,19 @@ export const GeneralSettingPage = (): React.ReactElement => { } } + async function changeAvatar(): Promise { + if (fileRef.current) { + try { + await uploadAvatar(fileRef.current); + } catch (error) { + console.error(error); + message.info(t("upload-avatar-failed")); + } finally { + fileRef.current = undefined; + } + } + } + useEffect(() => { ipcSyncByApp("get-open-at-login") .then(data => { @@ -76,15 +91,21 @@ export const GeneralSettingPage = (): React.ReactElement => {
- {t("user-profile")} - setName(ev.currentTarget.value)} - /> - + {t("user-profile")} +
+ + +
+
+ setName(ev.currentTarget.value)} + /> + +
{t("language-settings")} diff --git a/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/style.less b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/style.less index 575246afdef..6b4bbc3871e 100644 --- a/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/style.less +++ b/desktop/renderer-app/src/pages/UserSettingPage/GeneralSettingPage/style.less @@ -6,7 +6,7 @@ } .general-setting-user-profile { - > span { + .general-setting-title { display: block; padding-bottom: 4px; } @@ -24,6 +24,57 @@ } } +.general-setting-user-avatar-wrapper { + display: flex; + align-items: center; + padding: 8px 0; +} + +.general-setting-user-avatar { + display: inline-block; + width: 96px; + height: 96px; + margin-right: 16px; + border-radius: 50%; + border: 1px solid var(--grey-6); + overflow: hidden; + position: relative; + transition: all 0.2s ease-in-out; + + &.is-loading { + opacity: 0.5; + } + + &:hover { + border-color: var(--primary); + } + + .user-avatar-input { + display: block; + position: absolute; + width: 100%; + height: 100%; + z-index: 100; + opacity: 0; + cursor: pointer; + } + + .user-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + } + + .user-avatar-text { + position: absolute; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } +} + .general-setting-checkbox { margin-bottom: 8px; } diff --git a/desktop/renderer-app/src/stores/global-store.ts b/desktop/renderer-app/src/stores/global-store.ts index e61e673a791..57c55fcec19 100644 --- a/desktop/renderer-app/src/stores/global-store.ts +++ b/desktop/renderer-app/src/stores/global-store.ts @@ -52,6 +52,12 @@ export class GlobalStore { this.userInfo = userInfo; }; + public updateUserAvatar = (avatarURL: string): void => { + if (this.userInfo) { + this.userInfo.avatar = avatarURL; + } + }; + public updateLastLoginCheck = (val: number | null): void => { this.lastLoginCheck = val; }; diff --git a/packages/flat-i18n/locales/en.json b/packages/flat-i18n/locales/en.json index 101d65f78b8..ff8b5a7f233 100644 --- a/packages/flat-i18n/locales/en.json +++ b/packages/flat-i18n/locales/en.json @@ -454,5 +454,7 @@ "english_1": "Four Lines and Three Grids", "chinese_1": "Tin Word Format" }, - "user-profile": "User Profile" + "user-profile": "User Profile", + "upload-avatar": "Upload Avatar", + "upload-avatar-failed": "Upload avatar failed" } diff --git a/packages/flat-i18n/locales/zh-CN.json b/packages/flat-i18n/locales/zh-CN.json index 46db3c355d8..3e72418a7b3 100644 --- a/packages/flat-i18n/locales/zh-CN.json +++ b/packages/flat-i18n/locales/zh-CN.json @@ -454,5 +454,7 @@ "english_1": "四线三格", "chinese_1": "田字格" }, - "user-profile": "用户资料" + "user-profile": "用户资料", + "upload-avatar": "上传头像", + "upload-avatar-failed": "上传头像失败" } diff --git a/web/flat-web/src/api-middleware/flatServer/index.ts b/web/flat-web/src/api-middleware/flatServer/index.ts index adfa89f6b09..83a5e5f6a0f 100644 --- a/web/flat-web/src/api-middleware/flatServer/index.ts +++ b/web/flat-web/src/api-middleware/flatServer/index.ts @@ -564,3 +564,46 @@ export async function rename(name: string): Promise { name, }); } + +export interface UploadAvatarStartPayload { + fileName: string; + fileSize: number; + region: Region; +} + +export interface UploadAvatarResult { + fileUUID: string; + filePath: string; + policy: string; + policyURL: string; + signature: string; +} + +export async function uploadAvatarStart( + fileName: string, + fileSize: number, + region: Region, +): Promise { + return await post("user/upload-avatar/start", { + fileName, + fileSize, + region, + }); +} + +export interface UploadAvatarFinishPayload { + fileUUID: string; +} + +export interface UploadAvatarFinishResult { + avatarURL: string; +} + +export async function uploadAvatarFinish(fileUUID: string): Promise { + return await post( + "user/upload-avatar/finish", + { + fileUUID, + }, + ); +} diff --git a/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx index c1b19bf6606..bfd2e20b923 100644 --- a/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx +++ b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/ConfirmButtons.tsx @@ -15,7 +15,7 @@ export const ConfirmButtons: React.FC = ({ onConfirm }) => const confirm = useCallback(async () => { setLoading(true); - await sp(onConfirm()); + await sp(onConfirm().catch(console.error)); setLoading(false); setPhase("idle"); }, [onConfirm, sp]); diff --git a/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx new file mode 100644 index 00000000000..c0c3c5fc390 --- /dev/null +++ b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/UploadAvatar.tsx @@ -0,0 +1,102 @@ +import Axios from "axios"; +import React, { ChangeEvent, useContext, useRef, useState } from "react"; +import classNames from "classnames"; +import { Region } from "flat-components"; +import { observer } from "mobx-react-lite"; +import { useTranslation } from "react-i18next"; + +import { GlobalStoreContext } from "../../../components/StoreProvider"; +import { useSafePromise } from "../../../utils/hooks/lifecycle"; +import { uploadAvatarFinish, uploadAvatarStart } from "../../../api-middleware/flatServer"; +import { globalStore } from "../../../stores/GlobalStore"; +import { CLOUD_STORAGE_OSS_ALIBABA_CONFIG } from "../../../constants/process"; + +export interface UploadAvatarProps { + fileRef?: React.MutableRefObject; +} + +export function useFileRef(): React.MutableRefObject { + return useRef(); +} + +export const UploadAvatar = observer(function UploadAvatar({ fileRef }) { + const globalStore = useContext(GlobalStoreContext); + const sp = useSafePromise(); + const { t } = useTranslation(); + + const [loading, setLoading] = useState(false); + const [imageUrl, setImageUrl] = useState(globalStore.userInfo?.avatar || ""); + + const updateInput = (event: ChangeEvent): void => { + const file: File | undefined = (event.target.files || [])[0]; + if (fileRef) { + fileRef.current = file; + } + if (file) { + setLoading(true); + sp(fileToDataUrl(file)).then(url => { + setLoading(false); + setImageUrl(url); + }); + } else { + setImageUrl(globalStore.userInfo?.avatar || ""); + } + }; + + return ( +
+ + {imageUrl ? ( + avatar + ) : ( + {t("upload-avatar")} + )} +
+ ); +}); + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +export async function uploadAvatar(file: File): Promise { + const ticket = await uploadAvatarStart( + file.name, + file.size, + globalStore.region ?? Region.CN_HZ, + ); + + const formData = new FormData(); + const encodedFileName = encodeURIComponent(file.name); + formData.append("key", ticket.filePath); + formData.append("name", file.name); + formData.append("policy", ticket.policy); + formData.append("OSSAccessKeyId", CLOUD_STORAGE_OSS_ALIBABA_CONFIG.accessKey); + formData.append("success_action_status", "200"); + formData.append("callback", ""); + formData.append("signature", ticket.signature); + formData.append( + "Content-Disposition", + `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`, + ); + formData.append("file", file); + + await Axios.post(ticket.policyURL, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + const { avatarURL } = await uploadAvatarFinish(ticket.fileUUID); + + globalStore.updateUserAvatar(avatarURL); +} diff --git a/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.less b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.less index c728f4375eb..41ceded6efd 100644 --- a/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.less +++ b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.less @@ -6,7 +6,7 @@ } .general-setting-user-profile { - > span { + .general-setting-title { display: block; padding-bottom: 4px; } @@ -24,6 +24,57 @@ } } +.general-setting-user-avatar-wrapper { + display: flex; + align-items: center; + padding: 8px 0; +} + +.general-setting-user-avatar { + display: inline-block; + width: 96px; + height: 96px; + margin-right: 16px; + border-radius: 50%; + border: 1px solid var(--grey-6); + overflow: hidden; + position: relative; + transition: all 0.2s ease-in-out; + + &.is-loading { + opacity: 0.5; + } + + &:hover { + border-color: var(--primary); + } + + .user-avatar-input { + display: block; + position: absolute; + width: 100%; + height: 100%; + z-index: 100; + opacity: 0; + cursor: pointer; + } + + .user-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + } + + .user-avatar-text { + position: absolute; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } +} + .general-setting-select-language { > span { display: block; diff --git a/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.tsx b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.tsx index e13afbde344..16a156f5109 100644 --- a/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.tsx +++ b/web/flat-web/src/pages/UserSettingPage/GeneralSettingPage/index.tsx @@ -1,15 +1,17 @@ +import type { CheckboxChangeEvent } from "antd/lib/checkbox"; + import "./index.less"; import React, { useContext, useState } from "react"; -import { Checkbox, Input, Radio } from "antd"; +import { Checkbox, Input, message, Radio } from "antd"; import { FlatPrefersColorScheme, AppearancePicker } from "flat-components"; import { UserSettingLayoutContainer } from "../UserSettingLayoutContainer"; import { useTranslation } from "react-i18next"; -import type { CheckboxChangeEvent } from "antd/lib/checkbox"; import { ConfigStoreContext, GlobalStoreContext } from "../../../components/StoreProvider"; import { useSafePromise } from "../../../utils/hooks/lifecycle"; import { loginCheck, rename } from "../../../api-middleware/flatServer"; import { ConfirmButtons } from "./ConfirmButtons"; +import { uploadAvatar, UploadAvatar, useFileRef } from "./UploadAvatar"; enum SelectLanguage { Chinese, @@ -25,6 +27,7 @@ export const GeneralSettingPage = (): React.ReactElement => { const [name, setName] = useState(globalStore.userName || ""); const [isRenaming, setRenaming] = useState(false); + const fileRef = useFileRef(); async function changeUserName(): Promise { if (name !== globalStore.userName) { @@ -38,6 +41,19 @@ export const GeneralSettingPage = (): React.ReactElement => { } } + async function changeAvatar(): Promise { + if (fileRef.current) { + try { + await uploadAvatar(fileRef.current); + } catch (error) { + console.error(error); + message.info(t("upload-avatar-failed")); + } finally { + fileRef.current = undefined; + } + } + } + function changeLanguage(event: CheckboxChangeEvent): void { const language: SelectLanguage = event.target.value; void i18n.changeLanguage(language === SelectLanguage.Chinese ? "zh-CN" : "en"); @@ -52,15 +68,21 @@ export const GeneralSettingPage = (): React.ReactElement => {
- {t("user-profile")} - setName(ev.currentTarget.value)} - /> - + {t("user-profile")} +
+ + +
+
+ setName(ev.currentTarget.value)} + /> + +
{t("language-settings")} diff --git a/web/flat-web/src/stores/GlobalStore.ts b/web/flat-web/src/stores/GlobalStore.ts index 0b5c51ff368..10f514ddad2 100644 --- a/web/flat-web/src/stores/GlobalStore.ts +++ b/web/flat-web/src/stores/GlobalStore.ts @@ -53,6 +53,12 @@ export class GlobalStore { this.userInfo = userInfo; }; + public updateUserAvatar = (avatarURL: string): void => { + if (this.userInfo) { + this.userInfo.avatar = avatarURL; + } + }; + public updateLastLoginCheck = (val: number | null): void => { this.lastLoginCheck = val; };