mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
refactor(web): merge /settings/user to /settings
This commit is contained in:
@@ -53,7 +53,7 @@
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
class="justify-start px-2 py-2"
|
class="justify-start px-2 py-2"
|
||||||
:tooltip="displayTitle"
|
:tooltip="displayTitle"
|
||||||
@click="onUserAction"
|
@click="router.push({ name: 'settings' })"
|
||||||
>
|
>
|
||||||
<Avatar class="size-7 shrink-0">
|
<Avatar class="size-7 shrink-0">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
@@ -66,10 +66,6 @@
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span class="truncate text-sm">{{ displayNameLabel }}</span>
|
<span class="truncate text-sm">{{ displayNameLabel }}</span>
|
||||||
<FontAwesomeIcon
|
|
||||||
:icon="['fas', 'gear']"
|
|
||||||
class="ml-auto size-3.5 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -147,11 +143,4 @@ const sidebarInfo = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function onUserAction() {
|
|
||||||
if (route.name === 'settings-user') {
|
|
||||||
void router.push({ name: 'settings' }).catch(() => undefined)
|
|
||||||
} else {
|
|
||||||
void router.push({ name: 'settings-user' }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,121 +1,589 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-187 m-auto">
|
<section class="h-full max-w-7xl mx-auto p-6">
|
||||||
<h6 class="mt-6 mb-2 flex items-center">
|
<div class="max-w-3xl mx-auto space-y-8">
|
||||||
<FontAwesomeIcon
|
<!-- Avatar & name -->
|
||||||
:icon="['fas', 'gear']"
|
<div class="flex items-center gap-4">
|
||||||
class="mr-2"
|
<Avatar class="size-14 shrink-0">
|
||||||
/>
|
<AvatarImage
|
||||||
{{ $t('settings.display') }}
|
v-if="profileForm.avatar_url"
|
||||||
</h6>
|
:src="profileForm.avatar_url"
|
||||||
<Separator />
|
:alt="displayTitle"
|
||||||
|
/>
|
||||||
<div class="mt-4 space-y-4">
|
<AvatarFallback>
|
||||||
<div class="flex items-center justify-between">
|
{{ avatarFallback }}
|
||||||
<Label>{{ $t('settings.language') }}</Label>
|
</AvatarFallback>
|
||||||
<Select
|
</Avatar>
|
||||||
:model-value="language"
|
<div class="min-w-0">
|
||||||
@update:model-value="(v) => v && setLanguage(v as Locale)"
|
<h4 class="font-semibold truncate">
|
||||||
>
|
{{ displayTitle }}
|
||||||
<SelectTrigger class="w-40">
|
</h4>
|
||||||
<SelectValue :placeholder="$t('settings.languagePlaceholder')" />
|
<p class="text-sm text-muted-foreground truncate">
|
||||||
</SelectTrigger>
|
{{ displayUserID }}
|
||||||
<SelectContent>
|
</p>
|
||||||
<SelectGroup>
|
</div>
|
||||||
<SelectItem value="zh">
|
|
||||||
{{ $t('settings.langZh') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="en">
|
|
||||||
{{ $t('settings.langEn') }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<!-- User Profile -->
|
||||||
|
<section>
|
||||||
|
<h6 class="mb-2 flex items-center">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="['fas', 'user']"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ $t('settings.userProfile') }}
|
||||||
|
</h6>
|
||||||
|
<Separator />
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.userID') }}</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="displayUserID"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('auth.username') }}</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="displayUsername"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.displayName') }}</Label>
|
||||||
|
<Input v-model="profileForm.display_name" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.avatarUrl') }}</Label>
|
||||||
|
<Input
|
||||||
|
v-model="profileForm.avatar_url"
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
:disabled="savingProfile || loadingInitial"
|
||||||
|
@click="onSaveProfile"
|
||||||
|
>
|
||||||
|
<Spinner v-if="savingProfile" />
|
||||||
|
{{ $t('settings.saveProfile') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<!-- Change Password -->
|
||||||
<Label>{{ $t('settings.theme') }}</Label>
|
<section>
|
||||||
<Select
|
<h6 class="mb-2 flex items-center">
|
||||||
:model-value="theme"
|
<FontAwesomeIcon
|
||||||
@update:model-value="(v) => v && setTheme(v as 'light' | 'dark')"
|
:icon="['fas', 'gear']"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ $t('settings.changePassword') }}
|
||||||
|
</h6>
|
||||||
|
<Separator />
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.currentPassword') }}</Label>
|
||||||
|
<Input
|
||||||
|
v-model="passwordForm.currentPassword"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.newPassword') }}</Label>
|
||||||
|
<Input
|
||||||
|
v-model="passwordForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.confirmPassword') }}</Label>
|
||||||
|
<Input
|
||||||
|
v-model="passwordForm.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
:disabled="savingPassword || loadingInitial"
|
||||||
|
@click="onUpdatePassword"
|
||||||
|
>
|
||||||
|
<Spinner v-if="savingPassword" />
|
||||||
|
{{ $t('settings.updatePassword') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Linked Channels -->
|
||||||
|
<section>
|
||||||
|
<h6 class="mb-2 flex items-center">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="['fas', 'network-wired']"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ $t('settings.linkedChannels') }}
|
||||||
|
</h6>
|
||||||
|
<Separator />
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<p
|
||||||
|
v-if="loadingIdentities"
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ $t('common.loading') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="identities.length === 0"
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ $t('settings.noLinkedChannels') }}
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="identity in identities"
|
||||||
|
:key="identity.id"
|
||||||
|
class="border rounded-md p-3 space-y-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="font-medium truncate">
|
||||||
|
{{ identity.display_name || identity.channel_subject_id }}
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ platformLabel(identity.channel) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
|
{{ identity.channel_subject_id }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
|
{{ identity.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Bind Code -->
|
||||||
|
<section>
|
||||||
|
<h6 class="mb-2 flex items-center">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="['fas', 'plug']"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ $t('settings.bindCode') }}
|
||||||
|
</h6>
|
||||||
|
<Separator />
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-3 items-end">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.platform') }}</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="bindForm.platform || anyPlatformValue"
|
||||||
|
@update:model-value="onPlatformChange"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-56">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem :value="anyPlatformValue">
|
||||||
|
{{ $t('settings.platformAny') }}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="platform in platformOptions"
|
||||||
|
:key="platform"
|
||||||
|
:value="platform"
|
||||||
|
>
|
||||||
|
{{ platformLabel(platform) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ $t('settings.bindCodeTTL') }}</Label>
|
||||||
|
<Input
|
||||||
|
v-model.number="bindForm.ttlSeconds"
|
||||||
|
type="number"
|
||||||
|
min="60"
|
||||||
|
class="w-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:disabled="generatingBindCode || loadingInitial"
|
||||||
|
@click="onGenerateBindCode"
|
||||||
|
>
|
||||||
|
<Spinner v-if="generatingBindCode" />
|
||||||
|
{{ $t('settings.generateBindCode') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="bindCode"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<Label>{{ $t('settings.bindCodeValue') }}</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="bindCode.token"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="copyBindCode"
|
||||||
|
>
|
||||||
|
{{ $t('settings.copyBindCode') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ $t('settings.bindCodeExpiresAt') }}: {{ formatDate(bindCode.expires_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Display Settings -->
|
||||||
|
<section>
|
||||||
|
<h6 class="mb-2 flex items-center">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="['fas', 'gear']"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ $t('settings.display') }}
|
||||||
|
</h6>
|
||||||
|
<Separator />
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>{{ $t('settings.language') }}</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="language"
|
||||||
|
@update:model-value="(v) => v && setLanguage(v as Locale)"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-40">
|
||||||
|
<SelectValue :placeholder="$t('settings.languagePlaceholder')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="zh">
|
||||||
|
{{ $t('settings.langZh') }}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="en">
|
||||||
|
{{ $t('settings.langEn') }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>{{ $t('settings.theme') }}</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="theme"
|
||||||
|
@update:model-value="(v) => v && setTheme(v as 'light' | 'dark')"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-40">
|
||||||
|
<SelectValue :placeholder="$t('settings.themePlaceholder')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="light">
|
||||||
|
{{ $t('settings.themeLight') }}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="dark">
|
||||||
|
{{ $t('settings.themeDark') }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<section>
|
||||||
|
<Separator class="mb-4" />
|
||||||
|
<ConfirmPopover
|
||||||
|
:message="$t('auth.logoutConfirm')"
|
||||||
|
@confirm="onLogout"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-40">
|
<template #trigger>
|
||||||
<SelectValue :placeholder="$t('settings.themePlaceholder')" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="light">
|
|
||||||
{{ $t('settings.themeLight') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="dark">
|
|
||||||
{{ $t('settings.themeDark') }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<Popover>
|
|
||||||
<template #default="{ close }">
|
|
||||||
<PopoverTrigger as-child>
|
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
{{ $t('auth.logout') }}
|
{{ $t('auth.logout') }}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</template>
|
||||||
<PopoverContent class="w-80">
|
</ConfirmPopover>
|
||||||
<p class="mb-4">
|
</section>
|
||||||
{{ $t('auth.logoutConfirm') }}
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
{{ $t('common.cancel') }}
|
|
||||||
</Button>
|
|
||||||
<Button @click="exit(); close()">
|
|
||||||
{{ $t('common.confirm') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectValue,
|
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
Label,
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
Separator,
|
Separator,
|
||||||
Popover,
|
Spinner,
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@memoh/ui'
|
} from '@memoh/ui'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||||
|
import { getUsersMe, putUsersMe, putUsersMePassword, getUsersMeIdentities } from '@memoh/sdk'
|
||||||
|
import { client } from '@memoh/sdk/client'
|
||||||
|
import type { AccountsAccount, AccountsUpdateProfileRequest, AccountsUpdatePasswordRequest } from '@memoh/sdk'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { useSettingsStore } from '@/store/settings'
|
import { useSettingsStore } from '@/store/settings'
|
||||||
import type { Locale } from '@/i18n'
|
import type { Locale } from '@/i18n'
|
||||||
|
|
||||||
|
interface ChannelIdentity {
|
||||||
|
id: string
|
||||||
|
user_id?: string
|
||||||
|
channel: string
|
||||||
|
channel_subject_id: string
|
||||||
|
display_name?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueBindCodeResponse {
|
||||||
|
token: string
|
||||||
|
platform?: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAccount = AccountsAccount
|
||||||
|
|
||||||
|
const anyPlatformValue = '__all__'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { userInfo, exitLogin, patchUserInfo } = userStore
|
||||||
|
|
||||||
|
// ---- Display settings ----
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const { language, theme } = storeToRefs(settingsStore)
|
const { language, theme } = storeToRefs(settingsStore)
|
||||||
const { setLanguage, setTheme } = settingsStore
|
const { setLanguage, setTheme } = settingsStore
|
||||||
|
|
||||||
const { exitLogin } = useUserStore()
|
// ---- User data ----
|
||||||
const exit = () => {
|
const account = ref<UserAccount | null>(null)
|
||||||
|
const identities = ref<ChannelIdentity[]>([])
|
||||||
|
const bindCode = ref<IssueBindCodeResponse | null>(null)
|
||||||
|
|
||||||
|
const loadingInitial = ref(false)
|
||||||
|
const loadingIdentities = ref(false)
|
||||||
|
const savingProfile = ref(false)
|
||||||
|
const savingPassword = ref(false)
|
||||||
|
const generatingBindCode = ref(false)
|
||||||
|
|
||||||
|
const profileForm = reactive({
|
||||||
|
display_name: '',
|
||||||
|
avatar_url: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordForm = reactive({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const bindForm = reactive({
|
||||||
|
platform: '',
|
||||||
|
ttlSeconds: 3600,
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayUserID = computed(() => account.value?.id || userInfo.id || '')
|
||||||
|
const displayUsername = computed(() => account.value?.username || userInfo.username || '')
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
return profileForm.display_name.trim() || displayUsername.value || displayUserID.value || t('settings.user')
|
||||||
|
})
|
||||||
|
const avatarFallback = computed(() => {
|
||||||
|
const source = displayTitle.value.trim()
|
||||||
|
return source.slice(0, 2).toUpperCase() || 'U'
|
||||||
|
})
|
||||||
|
|
||||||
|
function platformLabel(platformKey: string): string {
|
||||||
|
if (!platformKey?.trim()) return platformKey ?? ''
|
||||||
|
const key = platformKey.trim().toLowerCase()
|
||||||
|
const i18nKey = `bots.channels.types.${key}`
|
||||||
|
const out = t(i18nKey)
|
||||||
|
return out !== i18nKey ? out : platformKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformOptions = computed(() => {
|
||||||
|
const options = new Set<string>(['telegram', 'feishu'])
|
||||||
|
for (const identity of identities.value) {
|
||||||
|
const platform = identity.channel.trim()
|
||||||
|
if (platform) {
|
||||||
|
options.add(platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadPageData()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPageData() {
|
||||||
|
loadingInitial.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([loadMyAccount(), loadMyIdentities()])
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.loadUserFailed'))
|
||||||
|
} finally {
|
||||||
|
loadingInitial.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyAccount() {
|
||||||
|
const { data } = await getUsersMe({ throwOnError: true })
|
||||||
|
account.value = data
|
||||||
|
profileForm.display_name = data.display_name || ''
|
||||||
|
profileForm.avatar_url = data.avatar_url || ''
|
||||||
|
patchUserInfo({
|
||||||
|
id: data.id,
|
||||||
|
username: data.username,
|
||||||
|
role: data.role,
|
||||||
|
displayName: data.display_name || '',
|
||||||
|
avatarUrl: data.avatar_url || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyIdentities() {
|
||||||
|
loadingIdentities.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await getUsersMeIdentities({ throwOnError: true })
|
||||||
|
identities.value = data.items ?? []
|
||||||
|
} finally {
|
||||||
|
loadingIdentities.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveProfile() {
|
||||||
|
savingProfile.value = true
|
||||||
|
try {
|
||||||
|
const body: AccountsUpdateProfileRequest = {
|
||||||
|
display_name: profileForm.display_name.trim(),
|
||||||
|
avatar_url: profileForm.avatar_url.trim(),
|
||||||
|
}
|
||||||
|
const { data } = await putUsersMe({ body, throwOnError: true })
|
||||||
|
account.value = data
|
||||||
|
profileForm.display_name = data.display_name || ''
|
||||||
|
profileForm.avatar_url = data.avatar_url || ''
|
||||||
|
patchUserInfo({
|
||||||
|
displayName: data.display_name || '',
|
||||||
|
avatarUrl: data.avatar_url || '',
|
||||||
|
})
|
||||||
|
toast.success(t('settings.profileUpdated'))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(resolveErrorMessage(error, t('settings.profileUpdateFailed')))
|
||||||
|
} finally {
|
||||||
|
savingProfile.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpdatePassword() {
|
||||||
|
const currentPassword = passwordForm.currentPassword.trim()
|
||||||
|
const newPassword = passwordForm.newPassword.trim()
|
||||||
|
const confirmPassword = passwordForm.confirmPassword.trim()
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
toast.error(t('settings.passwordRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast.error(t('settings.passwordNotMatch'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
savingPassword.value = true
|
||||||
|
try {
|
||||||
|
const body: AccountsUpdatePasswordRequest = {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}
|
||||||
|
await putUsersMePassword({ body, throwOnError: true })
|
||||||
|
passwordForm.currentPassword = ''
|
||||||
|
passwordForm.newPassword = ''
|
||||||
|
passwordForm.confirmPassword = ''
|
||||||
|
toast.success(t('settings.passwordUpdated'))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(resolveErrorMessage(error, t('settings.passwordUpdateFailed')))
|
||||||
|
} finally {
|
||||||
|
savingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlatformChange(value: string) {
|
||||||
|
bindForm.platform = value === anyPlatformValue ? '' : value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGenerateBindCode() {
|
||||||
|
generatingBindCode.value = true
|
||||||
|
try {
|
||||||
|
const ttl = Number.isFinite(bindForm.ttlSeconds) ? Math.max(60, Number(bindForm.ttlSeconds)) : 3600
|
||||||
|
const { data } = await client.post({
|
||||||
|
url: '/users/me/bind_codes',
|
||||||
|
body: {
|
||||||
|
platform: bindForm.platform || undefined,
|
||||||
|
ttl_seconds: ttl,
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
}) as { data: IssueBindCodeResponse }
|
||||||
|
bindCode.value = data
|
||||||
|
toast.success(t('settings.bindCodeGenerated'))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(resolveErrorMessage(error, t('settings.bindCodeGenerateFailed')))
|
||||||
|
} finally {
|
||||||
|
generatingBindCode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyBindCode() {
|
||||||
|
if (!bindCode.value?.token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(bindCode.value.token)
|
||||||
|
toast.success(t('settings.bindCodeCopied'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.bindCodeCopyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLogout() {
|
||||||
exitLogin()
|
exitLogin()
|
||||||
router.replace({ name: 'Login' })
|
void router.replace({ name: 'Login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown, fallback: string) {
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
|
const body = error as { message?: string; error?: string; detail?: string }
|
||||||
|
const detail = body.message || body.error || body.detail
|
||||||
|
if (detail) {
|
||||||
|
return `${fallback}: ${detail}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,517 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="h-full max-w-7xl mx-auto p-6">
|
|
||||||
<div class="max-w-3xl mx-auto space-y-8">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Avatar class="size-14 shrink-0">
|
|
||||||
<AvatarImage
|
|
||||||
v-if="profileForm.avatar_url"
|
|
||||||
:src="profileForm.avatar_url"
|
|
||||||
:alt="displayTitle"
|
|
||||||
/>
|
|
||||||
<AvatarFallback>
|
|
||||||
{{ avatarFallback }}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h4 class="font-semibold truncate">
|
|
||||||
{{ displayTitle }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-muted-foreground truncate">
|
|
||||||
{{ displayUserID }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h6 class="mb-2 flex items-center">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
:icon="['fas', 'user']"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
{{ $t('settings.userProfile') }}
|
|
||||||
</h6>
|
|
||||||
<Separator />
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.userID') }}</Label>
|
|
||||||
<Input
|
|
||||||
:model-value="displayUserID"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('auth.username') }}</Label>
|
|
||||||
<Input
|
|
||||||
:model-value="displayUsername"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.displayName') }}</Label>
|
|
||||||
<Input v-model="profileForm.display_name" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.avatarUrl') }}</Label>
|
|
||||||
<Input
|
|
||||||
v-model="profileForm.avatar_url"
|
|
||||||
type="url"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
:disabled="savingProfile || loadingInitial"
|
|
||||||
@click="onSaveProfile"
|
|
||||||
>
|
|
||||||
<Spinner v-if="savingProfile" />
|
|
||||||
{{ $t('settings.saveProfile') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h6 class="mb-2 flex items-center">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
:icon="['fas', 'gear']"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
{{ $t('settings.changePassword') }}
|
|
||||||
</h6>
|
|
||||||
<Separator />
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.currentPassword') }}</Label>
|
|
||||||
<Input
|
|
||||||
v-model="passwordForm.currentPassword"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.newPassword') }}</Label>
|
|
||||||
<Input
|
|
||||||
v-model="passwordForm.newPassword"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.confirmPassword') }}</Label>
|
|
||||||
<Input
|
|
||||||
v-model="passwordForm.confirmPassword"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
:disabled="savingPassword || loadingInitial"
|
|
||||||
@click="onUpdatePassword"
|
|
||||||
>
|
|
||||||
<Spinner v-if="savingPassword" />
|
|
||||||
{{ $t('settings.updatePassword') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h6 class="mb-2 flex items-center">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
:icon="['fas', 'network-wired']"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
{{ $t('settings.linkedChannels') }}
|
|
||||||
</h6>
|
|
||||||
<Separator />
|
|
||||||
<div class="mt-4 space-y-3">
|
|
||||||
<p
|
|
||||||
v-if="loadingIdentities"
|
|
||||||
class="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ $t('common.loading') }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-else-if="identities.length === 0"
|
|
||||||
class="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ $t('settings.noLinkedChannels') }}
|
|
||||||
</p>
|
|
||||||
<template v-else>
|
|
||||||
<div
|
|
||||||
v-for="identity in identities"
|
|
||||||
:key="identity.id"
|
|
||||||
class="border rounded-md p-3 space-y-1"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<p class="font-medium truncate">
|
|
||||||
{{ identity.display_name || identity.channel_subject_id }}
|
|
||||||
</p>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{{ platformLabel(identity.channel) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
|
||||||
{{ identity.channel_subject_id }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
|
||||||
{{ identity.id }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h6 class="mb-2 flex items-center">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
:icon="['fas', 'plug']"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
{{ $t('settings.bindCode') }}
|
|
||||||
</h6>
|
|
||||||
<Separator />
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<div class="flex flex-wrap gap-3 items-end">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.platform') }}</Label>
|
|
||||||
<Select
|
|
||||||
:model-value="bindForm.platform || anyPlatformValue"
|
|
||||||
@update:model-value="onPlatformChange"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="w-56">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem :value="anyPlatformValue">
|
|
||||||
{{ $t('settings.platformAny') }}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
v-for="platform in platformOptions"
|
|
||||||
:key="platform"
|
|
||||||
:value="platform"
|
|
||||||
>
|
|
||||||
{{ platformLabel(platform) }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>{{ $t('settings.bindCodeTTL') }}</Label>
|
|
||||||
<Input
|
|
||||||
v-model.number="bindForm.ttlSeconds"
|
|
||||||
type="number"
|
|
||||||
min="60"
|
|
||||||
class="w-40"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
:disabled="generatingBindCode || loadingInitial"
|
|
||||||
@click="onGenerateBindCode"
|
|
||||||
>
|
|
||||||
<Spinner v-if="generatingBindCode" />
|
|
||||||
{{ $t('settings.generateBindCode') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="bindCode"
|
|
||||||
class="space-y-2"
|
|
||||||
>
|
|
||||||
<Label>{{ $t('settings.bindCodeValue') }}</Label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Input
|
|
||||||
:model-value="bindCode.token"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
@click="copyBindCode"
|
|
||||||
>
|
|
||||||
{{ $t('settings.copyBindCode') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ $t('settings.bindCodeExpiresAt') }}: {{ formatDate(bindCode.expires_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<Separator class="mb-4" />
|
|
||||||
<ConfirmPopover
|
|
||||||
:message="$t('auth.logoutConfirm')"
|
|
||||||
@confirm="onLogout"
|
|
||||||
>
|
|
||||||
<template #trigger>
|
|
||||||
<Button variant="outline">
|
|
||||||
{{ $t('auth.logout') }}
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</ConfirmPopover>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Separator,
|
|
||||||
Spinner,
|
|
||||||
} from '@memoh/ui'
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { toast } from 'vue-sonner'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
|
||||||
import { getUsersMe, putUsersMe, putUsersMePassword, getUsersMeIdentities } from '@memoh/sdk'
|
|
||||||
import { client } from '@memoh/sdk/client'
|
|
||||||
import type { AccountsAccount, AccountsUpdateProfileRequest, AccountsUpdatePasswordRequest } from '@memoh/sdk'
|
|
||||||
import { useUserStore } from '@/store/user'
|
|
||||||
|
|
||||||
interface ChannelIdentity {
|
|
||||||
id: string
|
|
||||||
user_id?: string
|
|
||||||
channel: string
|
|
||||||
channel_subject_id: string
|
|
||||||
display_name?: string
|
|
||||||
metadata?: Record<string, unknown>
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IssueBindCodeResponse {
|
|
||||||
token: string
|
|
||||||
platform?: string
|
|
||||||
expires_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserAccount = AccountsAccount
|
|
||||||
|
|
||||||
const anyPlatformValue = '__all__'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const { userInfo, exitLogin, patchUserInfo } = userStore
|
|
||||||
|
|
||||||
const account = ref<UserAccount | null>(null)
|
|
||||||
const identities = ref<ChannelIdentity[]>([])
|
|
||||||
const bindCode = ref<IssueBindCodeResponse | null>(null)
|
|
||||||
|
|
||||||
const loadingInitial = ref(false)
|
|
||||||
const loadingIdentities = ref(false)
|
|
||||||
const savingProfile = ref(false)
|
|
||||||
const savingPassword = ref(false)
|
|
||||||
const generatingBindCode = ref(false)
|
|
||||||
|
|
||||||
const profileForm = reactive({
|
|
||||||
display_name: '',
|
|
||||||
avatar_url: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const passwordForm = reactive({
|
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const bindForm = reactive({
|
|
||||||
platform: '',
|
|
||||||
ttlSeconds: 3600,
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayUserID = computed(() => account.value?.id || userInfo.id || '')
|
|
||||||
const displayUsername = computed(() => account.value?.username || userInfo.username || '')
|
|
||||||
const displayTitle = computed(() => {
|
|
||||||
return profileForm.display_name.trim() || displayUsername.value || displayUserID.value || t('settings.user')
|
|
||||||
})
|
|
||||||
const avatarFallback = computed(() => {
|
|
||||||
const source = displayTitle.value.trim()
|
|
||||||
return source.slice(0, 2).toUpperCase() || 'U'
|
|
||||||
})
|
|
||||||
|
|
||||||
function platformLabel(platformKey: string): string {
|
|
||||||
if (!platformKey?.trim()) return platformKey ?? ''
|
|
||||||
const key = platformKey.trim().toLowerCase()
|
|
||||||
const i18nKey = `bots.channels.types.${key}`
|
|
||||||
const out = t(i18nKey)
|
|
||||||
return out !== i18nKey ? out : platformKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformOptions = computed(() => {
|
|
||||||
const options = new Set<string>(['telegram', 'feishu'])
|
|
||||||
for (const identity of identities.value) {
|
|
||||||
const platform = identity.channel.trim()
|
|
||||||
if (platform) {
|
|
||||||
options.add(platform)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(options)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadPageData()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadPageData() {
|
|
||||||
loadingInitial.value = true
|
|
||||||
try {
|
|
||||||
await Promise.all([loadMyAccount(), loadMyIdentities()])
|
|
||||||
} catch {
|
|
||||||
toast.error(t('settings.loadUserFailed'))
|
|
||||||
} finally {
|
|
||||||
loadingInitial.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMyAccount() {
|
|
||||||
const { data } = await getUsersMe({ throwOnError: true })
|
|
||||||
account.value = data
|
|
||||||
profileForm.display_name = data.display_name || ''
|
|
||||||
profileForm.avatar_url = data.avatar_url || ''
|
|
||||||
patchUserInfo({
|
|
||||||
id: data.id,
|
|
||||||
username: data.username,
|
|
||||||
role: data.role,
|
|
||||||
displayName: data.display_name || '',
|
|
||||||
avatarUrl: data.avatar_url || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMyIdentities() {
|
|
||||||
loadingIdentities.value = true
|
|
||||||
try {
|
|
||||||
const { data } = await getUsersMeIdentities({ throwOnError: true })
|
|
||||||
identities.value = data.items ?? []
|
|
||||||
} finally {
|
|
||||||
loadingIdentities.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaveProfile() {
|
|
||||||
savingProfile.value = true
|
|
||||||
try {
|
|
||||||
const body: AccountsUpdateProfileRequest = {
|
|
||||||
display_name: profileForm.display_name.trim(),
|
|
||||||
avatar_url: profileForm.avatar_url.trim(),
|
|
||||||
}
|
|
||||||
const { data } = await putUsersMe({ body, throwOnError: true })
|
|
||||||
account.value = data
|
|
||||||
profileForm.display_name = data.display_name || ''
|
|
||||||
profileForm.avatar_url = data.avatar_url || ''
|
|
||||||
patchUserInfo({
|
|
||||||
displayName: data.display_name || '',
|
|
||||||
avatarUrl: data.avatar_url || '',
|
|
||||||
})
|
|
||||||
toast.success(t('settings.profileUpdated'))
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(resolveErrorMessage(error, t('settings.profileUpdateFailed')))
|
|
||||||
} finally {
|
|
||||||
savingProfile.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onUpdatePassword() {
|
|
||||||
const currentPassword = passwordForm.currentPassword.trim()
|
|
||||||
const newPassword = passwordForm.newPassword.trim()
|
|
||||||
const confirmPassword = passwordForm.confirmPassword.trim()
|
|
||||||
if (!currentPassword || !newPassword) {
|
|
||||||
toast.error(t('settings.passwordRequired'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
toast.error(t('settings.passwordNotMatch'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
savingPassword.value = true
|
|
||||||
try {
|
|
||||||
const body: AccountsUpdatePasswordRequest = {
|
|
||||||
current_password: currentPassword,
|
|
||||||
new_password: newPassword,
|
|
||||||
}
|
|
||||||
await putUsersMePassword({ body, throwOnError: true })
|
|
||||||
passwordForm.currentPassword = ''
|
|
||||||
passwordForm.newPassword = ''
|
|
||||||
passwordForm.confirmPassword = ''
|
|
||||||
toast.success(t('settings.passwordUpdated'))
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(resolveErrorMessage(error, t('settings.passwordUpdateFailed')))
|
|
||||||
} finally {
|
|
||||||
savingPassword.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPlatformChange(value: string) {
|
|
||||||
bindForm.platform = value === anyPlatformValue ? '' : value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onGenerateBindCode() {
|
|
||||||
generatingBindCode.value = true
|
|
||||||
try {
|
|
||||||
const ttl = Number.isFinite(bindForm.ttlSeconds) ? Math.max(60, Number(bindForm.ttlSeconds)) : 3600
|
|
||||||
const { data } = await client.post({
|
|
||||||
url: '/users/me/bind_codes',
|
|
||||||
body: {
|
|
||||||
platform: bindForm.platform || undefined,
|
|
||||||
ttl_seconds: ttl,
|
|
||||||
},
|
|
||||||
throwOnError: true,
|
|
||||||
}) as { data: IssueBindCodeResponse }
|
|
||||||
bindCode.value = data
|
|
||||||
toast.success(t('settings.bindCodeGenerated'))
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(resolveErrorMessage(error, t('settings.bindCodeGenerateFailed')))
|
|
||||||
} finally {
|
|
||||||
generatingBindCode.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyBindCode() {
|
|
||||||
if (!bindCode.value?.token) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(bindCode.value.token)
|
|
||||||
toast.success(t('settings.bindCodeCopied'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('settings.bindCodeCopyFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLogout() {
|
|
||||||
exitLogin()
|
|
||||||
void router.replace({ name: 'Login' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveErrorMessage(error: unknown, fallback: string) {
|
|
||||||
if (error && typeof error === 'object') {
|
|
||||||
const body = error as { message?: string; error?: string; detail?: string }
|
|
||||||
const detail = body.message || body.error || body.detail
|
|
||||||
if (detail) {
|
|
||||||
return `${fallback}: ${detail}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -75,14 +75,6 @@ const routes = [
|
|||||||
breadcrumb: i18nRef('sidebar.settings'),
|
breadcrumb: i18nRef('sidebar.settings'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'settings-user',
|
|
||||||
path: '/settings/user',
|
|
||||||
component: () => import('@/pages/settings/user.vue'),
|
|
||||||
meta: {
|
|
||||||
breadcrumb: i18nRef('settings.user'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'platform',
|
name: 'platform',
|
||||||
path: '/platform',
|
path: '/platform',
|
||||||
|
|||||||
Reference in New Issue
Block a user