mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
6c2da4b2f5
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.
480 lines
15 KiB
Vue
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>
|