feat: 头像上传
This commit is contained in:
268
src/views/setting/components/AvatarCropDialog.vue
Normal file
268
src/views/setting/components/AvatarCropDialog.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div v-if="visible" class="avatar-crop-dialog" @click.self="handleCancel">
|
||||
<div class="avatar-crop-dialog__panel">
|
||||
<button type="button" class="avatar-crop-dialog__close" @click="handleCancel">
|
||||
<SvgIcon name="close" size="24" />
|
||||
</button>
|
||||
|
||||
<div class="avatar-crop-dialog__title">{{ t('Settings.avatarCrop.title') }}</div>
|
||||
|
||||
<div class="avatar-crop-dialog__body">
|
||||
<div class="cropper-shell">
|
||||
<VueCropper
|
||||
ref="cropperRef"
|
||||
:img="imageUrl"
|
||||
:wrapper="{ width: 420, height: 420 }"
|
||||
:crop-layout="{ width: 260, height: 260 }"
|
||||
:center-box="true"
|
||||
:output-type="outputType"
|
||||
:output-size="0.92"
|
||||
mode="cover"
|
||||
crop-color="#ffffff"
|
||||
@real-time="handlePreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="preview-column">
|
||||
<div class="avatar-preview">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:style="previewImageStyle"
|
||||
alt="avatar preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cropper-tools">
|
||||
<button type="button" class="tool-btn" @click="cropperRef?.zoomOut?.()">
|
||||
-
|
||||
</button>
|
||||
<button type="button" class="tool-btn" @click="cropperRef?.zoomIn?.()">
|
||||
+
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="avatar-crop-dialog__actions">
|
||||
<button type="button" class="secondary-btn" :disabled="submitting" @click="handleCancel">
|
||||
{{ t('Settings.buttons.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="primary-btn" :disabled="submitting" @click="handleConfirm">
|
||||
{{ submitting ? t('Settings.avatarCrop.processing') : t('Settings.avatarCrop.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { VueCropper } from 'cropper-next-vue'
|
||||
import 'cropper-next-vue/style.css'
|
||||
|
||||
type CropperInstance = InstanceType<typeof VueCropper> & {
|
||||
getCropBlob?: () => Promise<Blob>
|
||||
zoomIn?: (step?: number) => void
|
||||
zoomOut?: (step?: number) => void
|
||||
rotateLeft?: () => void
|
||||
rotateRight?: () => void
|
||||
}
|
||||
|
||||
interface PreviewPayload {
|
||||
url: string
|
||||
img: Record<string, string>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
imageUrl: string
|
||||
fileName: string
|
||||
fileType: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'cancel'): void
|
||||
(event: 'confirm', file: File): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cropperRef = shallowRef<CropperInstance | null>(null)
|
||||
const submitting = shallowRef(false)
|
||||
const previewUrl = shallowRef('')
|
||||
const previewImageStyle = shallowRef<Record<string, string>>({})
|
||||
|
||||
const outputType = computed(() => {
|
||||
const subtype = props.fileType.split('/')[1]
|
||||
return subtype === 'jpeg' || subtype === 'jpg' || subtype === 'webp' ? subtype : 'png'
|
||||
})
|
||||
|
||||
const cropFileType = computed(() =>
|
||||
outputType.value === 'jpg' ? 'image/jpeg' : `image/${outputType.value}`
|
||||
)
|
||||
|
||||
const cropFileName = computed(() => {
|
||||
const baseName = props.fileName.replace(/\.[^.]+$/, '') || 'avatar'
|
||||
const extension = outputType.value === 'jpeg' ? 'jpg' : outputType.value
|
||||
return `${baseName}-cropped.${extension}`
|
||||
})
|
||||
|
||||
const handlePreview = (payload: PreviewPayload) => {
|
||||
previewUrl.value = payload.url
|
||||
previewImageStyle.value = payload.img
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting.value) return
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!cropperRef.value?.getCropBlob) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const blob = await cropperRef.value.getCropBlob()
|
||||
const file = new File([blob], cropFileName.value, {
|
||||
type: cropFileType.value,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
emit('confirm', file)
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
ElMessage.error(t('Settings.messages.avatarCropFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.avatar-crop-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.avatar-crop-dialog__panel {
|
||||
position: relative;
|
||||
width: 78rem;
|
||||
padding: 4rem 4.8rem 3.6rem;
|
||||
background: #efefef;
|
||||
box-shadow: 0 2rem 6rem rgba(35, 35, 35, 0.14);
|
||||
}
|
||||
|
||||
.avatar-crop-dialog__close {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #585858;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-crop-dialog__title {
|
||||
font-family: 'KaiseiOpti-Bold';
|
||||
font-size: 3rem;
|
||||
line-height: 3.6rem;
|
||||
text-align: center;
|
||||
color: #232323;
|
||||
}
|
||||
|
||||
.avatar-crop-dialog__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 3.2rem;
|
||||
margin-top: 3.2rem;
|
||||
}
|
||||
|
||||
.cropper-shell {
|
||||
width: 42rem;
|
||||
height: 42rem;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.preview-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
row-gap: 2.4rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
overflow: hidden;
|
||||
border: 0.1rem solid #d8d0c7;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
|
||||
img {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tool-btn,
|
||||
.primary-btn,
|
||||
.secondary-btn {
|
||||
height: 4rem;
|
||||
border: 0.1rem solid #c4c4c4;
|
||||
background: #ffffff;
|
||||
font-family: 'KaiseiOpti-Bold';
|
||||
font-size: 1.4rem;
|
||||
line-height: 2.4rem;
|
||||
color: #232323;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
min-width: 5.2rem;
|
||||
padding: 0 1.2rem;
|
||||
}
|
||||
|
||||
.avatar-crop-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
column-gap: 1.2rem;
|
||||
margin-top: 3.2rem;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
width: 18rem;
|
||||
border-color: #232323;
|
||||
background: #232323;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
width: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,235 +1,359 @@
|
||||
<template>
|
||||
<SettingsSection
|
||||
:title="t('Settings.profile.title')"
|
||||
:description="t('Settings.profile.description')"
|
||||
>
|
||||
<div class="profile-header">
|
||||
<div class="avatar relative">
|
||||
<SvgIcon name="user_0" size="46" class="avatar-icon" />
|
||||
<img src="@/assets/images/edit.png" class="avatar-edit-icon" />
|
||||
</div>
|
||||
<div class="profile-summary">
|
||||
<div class="profile-name">{{ fullName }}</div>
|
||||
<div class="profile-email">{{ displayData.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSection
|
||||
:title="t('Settings.profile.title')"
|
||||
:description="t('Settings.profile.description')"
|
||||
>
|
||||
<div class="profile-header">
|
||||
<div class="avatar relative" @click="handleEditAvatar">
|
||||
<img
|
||||
v-if="displayData.avatarUrl"
|
||||
:src="displayData.avatarUrl"
|
||||
alt="avatar"
|
||||
class="avatar-img"
|
||||
/>
|
||||
<SvgIcon v-else name="user_0" size="46" class="avatar-icon" />
|
||||
<img src="@/assets/images/edit.png" class="avatar-edit-icon" />
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
style="display:none"
|
||||
accept="image/*"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="profile-summary">
|
||||
<div class="profile-name">{{ fullName }}</div>
|
||||
<div class="profile-email">{{ displayData.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="read-section">
|
||||
<div class="read-row two-column">
|
||||
<div class="read-item">
|
||||
<div class="read-label">{{ t('Settings.profile.firstName') }}</div>
|
||||
<div v-show="!isEditing" class="read-box">{{ displayData.firstName }}</div>
|
||||
<div v-show="isEditing" class="form-item-value name">
|
||||
<el-input
|
||||
:model-value="firstName"
|
||||
:placeholder="t('Settings.profile.firstNamePlaceholder')"
|
||||
@update:model-value="emit('update:firstName', String($event))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-item">
|
||||
<div class="read-label">{{ t('Settings.profile.lastName') }}</div>
|
||||
<div v-show="!isEditing" class="read-box">{{ displayData.lastName }}</div>
|
||||
<div v-show="isEditing" class="form-item-value name">
|
||||
<el-input
|
||||
:model-value="lastName"
|
||||
:placeholder="t('Settings.profile.lastNamePlaceholder')"
|
||||
@update:model-value="emit('update:lastName', String($event))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-section">
|
||||
<div class="read-row two-column">
|
||||
<div class="read-item">
|
||||
<div class="read-label">{{ t('Settings.profile.firstName') }}</div>
|
||||
<div v-show="!isEditing" class="read-box">{{ displayData.firstName }}</div>
|
||||
<div v-show="isEditing" class="form-item-value name">
|
||||
<el-input
|
||||
:model-value="firstName"
|
||||
:placeholder="t('Settings.profile.firstNamePlaceholder')"
|
||||
@update:model-value="emit('update:firstName', String($event))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-item">
|
||||
<div class="read-label">{{ t('Settings.profile.lastName') }}</div>
|
||||
<div v-show="!isEditing" class="read-box">{{ displayData.lastName }}</div>
|
||||
<div v-show="isEditing" class="form-item-value name">
|
||||
<el-input
|
||||
:model-value="lastName"
|
||||
:placeholder="t('Settings.profile.lastNamePlaceholder')"
|
||||
@update:model-value="emit('update:lastName', String($event))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="read-row">
|
||||
<div class="read-label">{{ t('Settings.profile.username') }}</div>
|
||||
<div v-show="!isEditing" class="read-box">{{ displayData.username }}</div>
|
||||
<div v-show="isEditing" class="form-item-value">
|
||||
<el-input
|
||||
:model-value="username"
|
||||
:placeholder="t('Settings.profile.usernamePlaceholder')"
|
||||
@update:model-value="emit('update:username', String($event))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-tip">{{ t('Settings.profile.usernameTip') }}</div>
|
||||
<div class="read-row">
|
||||
<div class="read-label">{{ t('Settings.profile.username') }}</div>
|
||||
<div v-show="!isEditing" class="read-box">{{ displayData.username }}</div>
|
||||
<div v-show="isEditing" class="form-item-value">
|
||||
<el-input
|
||||
:model-value="username"
|
||||
:placeholder="t('Settings.profile.usernamePlaceholder')"
|
||||
@update:model-value="emit('update:username', String($event))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-tip">{{ t('Settings.profile.usernameTip') }}</div>
|
||||
|
||||
<div class="read-row role-row">
|
||||
<div class="read-label">{{ t('Settings.profile.role') }}</div>
|
||||
<div :class="{ 'readonly-radio-group': !isEditing }">
|
||||
<Radio multiple :max="2" v-model="roleSelection" :options="roleOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-tip">{{ t('Settings.profile.roleTip') }}</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<div class="read-row role-row">
|
||||
<div class="read-label">{{ t('Settings.profile.role') }}</div>
|
||||
<div :class="{ 'readonly-radio-group': !isEditing }">
|
||||
<Radio multiple :max="2" v-model="roleSelection" :options="roleOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-tip">{{ t('Settings.profile.roleTip') }}</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<AvatarCropDialog
|
||||
:visible="isAvatarCropDialogVisible"
|
||||
:image-url="selectedAvatarImageUrl"
|
||||
:file-name="selectedAvatarFile?.name || 'avatar.png'"
|
||||
:file-type="selectedAvatarFile?.type || 'image/png'"
|
||||
@cancel="closeAvatarCropDialog"
|
||||
@confirm="handleAvatarCropConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
import Radio from './Radio.vue'
|
||||
import type { RoleOption, RoleValue, SettingsData } from '../types'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
import Radio from './Radio.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AvatarCropDialog from './AvatarCropDialog.vue'
|
||||
import type { RoleOption, RoleValue, SettingsData } from '../types'
|
||||
import { uploadFile, updateUserAvatar } from '@/api/user'
|
||||
|
||||
const props = defineProps<{
|
||||
displayData: SettingsData
|
||||
firstName: string
|
||||
lastName: string
|
||||
username: string
|
||||
fullName: string
|
||||
isEditing: boolean
|
||||
roleModel: RoleValue[]
|
||||
roleOptions: RoleOption[]
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
displayData: SettingsData
|
||||
firstName: string
|
||||
lastName: string
|
||||
username: string
|
||||
fullName: string
|
||||
isEditing: boolean
|
||||
roleModel: RoleValue[]
|
||||
roleOptions: RoleOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:firstName', value: string): void
|
||||
(event: 'update:lastName', value: string): void
|
||||
(event: 'update:username', value: string): void
|
||||
(event: 'update:roleModel', value: RoleValue[]): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:firstName', value: string): void
|
||||
(event: 'update:lastName', value: string): void
|
||||
(event: 'update:username', value: string): void
|
||||
(event: 'update:roleModel', value: RoleValue[]): void
|
||||
(event: 'avatar-updated', url: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t } = useI18n()
|
||||
|
||||
const roleSelection = computed<RoleValue[]>({
|
||||
get: () => props.roleModel,
|
||||
set: (value) => {
|
||||
emit('update:roleModel', value)
|
||||
}
|
||||
})
|
||||
const roleSelection = computed<RoleValue[]>({
|
||||
get: () => props.roleModel,
|
||||
set: (value) => {
|
||||
emit('update:roleModel', value)
|
||||
}
|
||||
})
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isAvatarCropDialogVisible = shallowRef(false)
|
||||
const selectedAvatarFile = shallowRef<File | null>(null)
|
||||
const selectedAvatarImageUrl = shallowRef('')
|
||||
|
||||
const handleEditAvatar = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const resetFileInput = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const revokeSelectedAvatarImageUrl = () => {
|
||||
if (!selectedAvatarImageUrl.value) return
|
||||
|
||||
URL.revokeObjectURL(selectedAvatarImageUrl.value)
|
||||
selectedAvatarImageUrl.value = ''
|
||||
}
|
||||
|
||||
const closeAvatarCropDialog = () => {
|
||||
isAvatarCropDialogVisible.value = false
|
||||
selectedAvatarFile.value = null
|
||||
revokeSelectedAvatarImageUrl()
|
||||
resetFileInput()
|
||||
}
|
||||
|
||||
const getUploadedFileUrl = (response: unknown): string | undefined => {
|
||||
if (typeof response === 'string') return response
|
||||
|
||||
const data = response as Record<string, unknown> | null
|
||||
if (!data) return undefined
|
||||
|
||||
if (typeof data.url === 'string') return data.url
|
||||
if (typeof data.fileUrl === 'string') return data.fileUrl
|
||||
const nestedData = data.data as Record<string, unknown> | undefined
|
||||
if (nestedData && typeof nestedData.url === 'string') return nestedData.url
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const uploadAvatarFile = async (file: File) => {
|
||||
try {
|
||||
const res = await uploadFile(file)
|
||||
const url = getUploadedFileUrl(res)
|
||||
|
||||
if (!url) {
|
||||
ElMessage.error(t('Settings.messages.avatarUploadUrlMissing'))
|
||||
return
|
||||
}
|
||||
|
||||
await updateUserAvatar({ avatarUrl: url })
|
||||
ElMessage.success(t('Settings.messages.avatarUpdated'))
|
||||
emit('avatar-updated', url)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
ElMessage.error(t('Settings.messages.avatarUploadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files && input.files[0]
|
||||
if (!file) return
|
||||
|
||||
// optional: limit file size to 5MB
|
||||
const maxSize = 5 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
ElMessage.warning(t('Settings.messages.avatarTooLarge'))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
revokeSelectedAvatarImageUrl()
|
||||
selectedAvatarFile.value = file
|
||||
selectedAvatarImageUrl.value = URL.createObjectURL(file)
|
||||
isAvatarCropDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleAvatarCropConfirm = async (file: File) => {
|
||||
closeAvatarCropDialog()
|
||||
await uploadAvatarFile(file)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokeSelectedAvatarImageUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.field-text() {
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.4rem;
|
||||
color: #232323;
|
||||
}
|
||||
.field-text() {
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.4rem;
|
||||
color: #232323;
|
||||
}
|
||||
|
||||
.field-frame() {
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
border: 0.1rem solid #979797;
|
||||
}
|
||||
.field-frame() {
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
border: 0.1rem solid #979797;
|
||||
}
|
||||
|
||||
.control-wrapper() {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
.control-wrapper() {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 2.6rem;
|
||||
margin-bottom: 3.6rem;
|
||||
}
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 2.6rem;
|
||||
margin-bottom: 3.6rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border-radius: 50%;
|
||||
border: 0.1rem solid #d8d0c7;
|
||||
}
|
||||
.avatar {
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border-radius: 50%;
|
||||
border: 0.1rem solid #d8d0c7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-edit-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0.1rem solid #fff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 0.6rem;
|
||||
}
|
||||
.avatar-edit-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0.1rem solid #fff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-family: 'KaiseiOpti-Medium';
|
||||
font-size: 2.4rem;
|
||||
line-height: 3.6rem;
|
||||
color: #232323;
|
||||
}
|
||||
.profile-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 0.6rem;
|
||||
}
|
||||
|
||||
.profile-email {
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
font-size: 1.8rem;
|
||||
line-height: 2.4rem;
|
||||
color: #979797;
|
||||
}
|
||||
.profile-name {
|
||||
font-family: 'KaiseiOpti-Medium';
|
||||
font-size: 2.4rem;
|
||||
line-height: 3.6rem;
|
||||
color: #232323;
|
||||
}
|
||||
|
||||
.read-section {
|
||||
width: 58rem;
|
||||
}
|
||||
.profile-email {
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
font-size: 1.8rem;
|
||||
line-height: 2.4rem;
|
||||
color: #979797;
|
||||
}
|
||||
|
||||
.read-label {
|
||||
margin-bottom: 0.8rem;
|
||||
font-family: 'KaiseiOpti-Medium';
|
||||
font-size: 1.4rem;
|
||||
line-height: 2.4rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: #585858;
|
||||
}
|
||||
.read-section {
|
||||
width: 58rem;
|
||||
}
|
||||
|
||||
.read-tip {
|
||||
margin-top: 0.8rem;
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6rem;
|
||||
color: #9f9f9f;
|
||||
}
|
||||
.read-label {
|
||||
margin-bottom: 0.8rem;
|
||||
font-family: 'KaiseiOpti-Medium';
|
||||
font-size: 1.4rem;
|
||||
line-height: 2.4rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: #585858;
|
||||
}
|
||||
|
||||
.read-row + .read-row {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.read-tip {
|
||||
margin-top: 0.8rem;
|
||||
font-family: 'KaiseiOpti-Regular';
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6rem;
|
||||
color: #9f9f9f;
|
||||
}
|
||||
|
||||
.read-row.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 28.4rem 28.4rem;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
.read-row + .read-row {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.read-box {
|
||||
.field-frame();
|
||||
.field-text();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.8rem 2rem;
|
||||
}
|
||||
.read-row.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 28.4rem 28.4rem;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.form-item-value {
|
||||
.field-frame();
|
||||
.read-box {
|
||||
.field-frame();
|
||||
.field-text();
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.8rem 2rem;
|
||||
}
|
||||
|
||||
&.name {
|
||||
width: 28.4rem;
|
||||
}
|
||||
.form-item-value {
|
||||
.field-frame();
|
||||
|
||||
:deep(.el-input) {
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
}
|
||||
&.name {
|
||||
width: 28.4rem;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
.control-wrapper();
|
||||
min-height: 4rem;
|
||||
}
|
||||
:deep(.el-input) {
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
.field-text();
|
||||
}
|
||||
}
|
||||
:deep(.el-input__wrapper) {
|
||||
.control-wrapper();
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.readonly-radio-group {
|
||||
pointer-events: none;
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
.field-text();
|
||||
}
|
||||
}
|
||||
|
||||
.role-row {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.readonly-radio-group {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.role-row {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,16 +50,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
import type { LanguageValue, RegionValue, SettingOption } from '../types'
|
||||
import type { LanguageValue, RegionOption, RegionValue, SettingOption } from '../types'
|
||||
|
||||
defineProps<{
|
||||
language: LanguageValue
|
||||
language: LanguageValue | ''
|
||||
region: RegionValue
|
||||
displayLanguageLabel: string
|
||||
displayRegionLabel: string
|
||||
isEditing: boolean
|
||||
languageOptions: SettingOption<LanguageValue>[]
|
||||
regionOptions: SettingOption<RegionValue>[]
|
||||
regionOptions: RegionOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
Reference in New Issue
Block a user