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
|
||||
class="justify-start px-2 py-2"
|
||||
:tooltip="displayTitle"
|
||||
@click="onUserAction"
|
||||
@click="router.push({ name: 'settings' })"
|
||||
>
|
||||
<Avatar class="size-7 shrink-0">
|
||||
<AvatarImage
|
||||
@@ -66,10 +66,6 @@
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="truncate text-sm">{{ displayNameLabel }}</span>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'gear']"
|
||||
class="ml-auto size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</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>
|
||||
|
||||
@@ -1,121 +1,589 @@
|
||||
<template>
|
||||
<div class="max-w-187 m-auto">
|
||||
<h6 class="mt-6 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>
|
||||
<section class="h-full max-w-7xl mx-auto p-6">
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<!-- Avatar & name -->
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<Label>{{ $t('settings.theme') }}</Label>
|
||||
<Select
|
||||
:model-value="theme"
|
||||
@update:model-value="(v) => v && setTheme(v as 'light' | 'dark')"
|
||||
<!-- Change Password -->
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
<template #trigger>
|
||||
<Button variant="outline">
|
||||
{{ $t('auth.logout') }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-80">
|
||||
<p class="mb-4">
|
||||
{{ $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>
|
||||
</template>
|
||||
</ConfirmPopover>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectValue,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
Label,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Separator,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
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 { 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 { useSettingsStore } from '@/store/settings'
|
||||
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 userStore = useUserStore()
|
||||
const { userInfo, exitLogin, patchUserInfo } = userStore
|
||||
|
||||
// ---- Display settings ----
|
||||
const settingsStore = useSettingsStore()
|
||||
const { language, theme } = storeToRefs(settingsStore)
|
||||
const { setLanguage, setTheme } = settingsStore
|
||||
|
||||
const { exitLogin } = useUserStore()
|
||||
const exit = () => {
|
||||
// ---- User data ----
|
||||
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()
|
||||
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>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'settings-user',
|
||||
path: '/settings/user',
|
||||
component: () => import('@/pages/settings/user.vue'),
|
||||
meta: {
|
||||
breadcrumb: i18nRef('settings.user'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'platform',
|
||||
path: '/platform',
|
||||
|
||||
Reference in New Issue
Block a user