Files
Memoh/apps/web/src/pages/profile/index.vue
T
Acbox 6c2da4b2f5 feat(web,server): expose server version and commit hash in Profile page
Add version and commit_hash fields to the /ping endpoint response,
sourced from the existing internal/version package (ldflags or
Go build info). The frontend capabilities store reads these values
and displays them as badges at the bottom of the Profile page.
2026-03-29 17:38:33 +08:00

480 lines
15 KiB
Vue

<template>
<section class="max-w-7xl mx-auto p-4 pb-12">
<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">
<p class="text-xs font-medium truncate">
{{ displayTitle }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ displayUserID }}
</p>
</div>
</div>
<!-- Display Settings -->
<section>
<h2 class="mb-2 flex items-center text-xs font-medium">
<Settings
class="mr-2 size-3.5"
/>
{{ $t('settings.display') }}
</h2>
<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"
:aria-label="$t('settings.language')"
>
<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"
:aria-label="$t('settings.theme')"
>
<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"
>
<template #trigger>
<Button>
{{ $t('auth.logout') }}
</Button>
</template>
</ConfirmPopover>
</section>
<ProfileSection
:display-user-id="displayUserID"
:display-username="displayUsername"
:display-name="profileForm.display_name"
:avatar-url="profileForm.avatar_url"
:timezone="profileForm.timezone"
:saving="savingProfile"
:loading="loadingInitial"
@update:display-name="profileForm.display_name = $event"
@update:avatar-url="profileForm.avatar_url = $event"
@update:timezone="profileForm.timezone = $event"
@save="onSaveProfile"
/>
<PasswordSection
:current-password="passwordForm.currentPassword"
:new-password="passwordForm.newPassword"
:confirm-password="passwordForm.confirmPassword"
:saving="savingPassword"
:loading="loadingInitial"
@update:current-password="passwordForm.currentPassword = $event"
@update:new-password="passwordForm.newPassword = $event"
@update:confirm-password="passwordForm.confirmPassword = $event"
@update-password="onUpdatePassword"
/>
<!-- Linked Channels -->
<section>
<h2 class="mb-2 flex items-center text-xs font-medium">
<Network
class="mr-2 size-3.5"
/>
{{ $t('settings.linkedChannels') }}
</h2>
<Separator />
<div class="mt-4 space-y-3">
<p
v-if="loadingIdentities"
class="text-xs text-muted-foreground"
>
{{ $t('common.loading') }}
</p>
<p
v-else-if="identities.length === 0"
class="text-xs 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="text-xs 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>
<BindCodeSection
:any-platform-value="anyPlatformValue"
:platform="bindForm.platform"
:platform-options="platformOptions"
:ttl-seconds="bindForm.ttlSeconds"
:generating="generatingBindCode"
:loading="loadingInitial"
:bind-code="bindCode"
:platform-label="platformLabel"
:format-date="formatDate"
@update:platform="onPlatformChange"
@update:ttl-seconds="bindForm.ttlSeconds = $event"
@generate="onGenerateBindCode"
@copy="copyBindCode"
/>
<!-- Version -->
<section>
<Separator class="mb-4" />
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ $t('settings.version') }}</span>
<Badge
v-if="serverVersion"
variant="secondary"
>
{{ $t('settings.versionTag', { version: serverVersion }) }}
</Badge>
<Badge
v-if="commitHash"
variant="outline"
>
{{ commitHash }}
</Badge>
</div>
</section>
</div>
</section>
</template>
<script setup lang="ts">
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Button,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
} from '@memohai/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 { Settings, Network } from 'lucide-vue-next'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ProfileSection from './components/profile-section.vue'
import PasswordSection from './components/password-section.vue'
import BindCodeSection from './components/bind-code-section.vue'
import { getUsersMe, putUsersMe, putUsersMePassword, getUsersMeIdentities } from '@memohai/sdk'
import { client } from '@memohai/sdk/client'
import type { AccountsAccount, AccountsUpdateProfileRequest, AccountsUpdatePasswordRequest, IdentitiesChannelIdentity } from '@memohai/sdk'
import { useUserStore } from '@/store/user'
import { useSettingsStore } from '@/store/settings'
import { useCapabilitiesStore } from '@/store/capabilities'
import type { Locale } from '@/i18n'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatDateTime } from '@/utils/date-time'
import { useClipboard } from '@/composables/useClipboard'
import { useAvatarInitials } from '@/composables/useAvatarInitials'
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 { copyText } = useClipboard()
const { userInfo, exitLogin, patchUserInfo } = userStore
// ---- Display settings ----
const settingsStore = useSettingsStore()
const { language, theme } = storeToRefs(settingsStore)
const { setLanguage, setTheme } = settingsStore
// ---- Server version ----
const capabilitiesStore = useCapabilitiesStore()
const { serverVersion, commitHash } = storeToRefs(capabilitiesStore)
// ---- User data ----
const account = ref<UserAccount | null>(null)
const identities = ref<IdentitiesChannelIdentity[]>([])
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: '',
timezone: '',
})
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 = useAvatarInitials(() => displayTitle.value, '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', 'discord', 'qq', 'matrix'])
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 || ''
profileForm.timezone = data.timezone || 'UTC'
patchUserInfo({
id: data.id,
username: data.username,
role: data.role,
displayName: data.display_name || '',
avatarUrl: data.avatar_url || '',
timezone: data.timezone || 'UTC',
})
}
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(),
timezone: profileForm.timezone.trim(),
}
const { data } = await putUsersMe({ body, throwOnError: true })
account.value = data
profileForm.display_name = data.display_name || ''
profileForm.avatar_url = data.avatar_url || ''
profileForm.timezone = data.timezone || 'UTC'
patchUserInfo({
displayName: data.display_name || '',
avatarUrl: data.avatar_url || '',
timezone: data.timezone || 'UTC',
})
toast.success(t('settings.profileUpdated'))
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('settings.profileUpdateFailed'), { prefixFallback: true }))
} 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(resolveApiErrorMessage(error, t('settings.passwordUpdateFailed'), { prefixFallback: true }))
} 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<IssueBindCodeResponse>({
url: '/users/me/bind_codes',
body: {
platform: bindForm.platform || undefined,
ttl_seconds: ttl,
},
throwOnError: true,
})
bindCode.value = data
toast.success(t('settings.bindCodeGenerated'))
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('settings.bindCodeGenerateFailed'), { prefixFallback: true }))
} finally {
generatingBindCode.value = false
}
}
async function copyBindCode() {
if (!bindCode.value?.token) {
return
}
try {
const copied = await copyText(bindCode.value.token)
if (copied) {
toast.success(t('settings.bindCodeCopied'))
return
}
toast.error(t('settings.bindCodeCopyFailed'))
} catch {
toast.error(t('settings.bindCodeCopyFailed'))
}
}
function formatDate(value: string) {
return formatDateTime(value, { fallback: value })
}
function onLogout() {
exitLogin()
void router.replace({ name: 'Login' })
}
</script>