You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

374 lines
12 KiB

<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
definePageMeta({ title: '资料' })
const { refresh: refreshAuthSession } = useAuthSession()
const { fetchData, getApiErrorMessage } = useClientApi()
const toast = useToast()
type ProfileGet = {
profile: {
nickname: string | null
avatar: string | null
avatarVisibility: string
bioMarkdown: string | null
bioVisibility: string
socialLinks: { label: string; url: string; visibility: string; icon?: string }[]
publicSlug: string | null
}
}
type MeConfigGet = {
config: Record<string, unknown>
}
const state = reactive({
nickname: '',
avatar: '',
avatarVisibility: 'private',
bioMarkdown: '',
bioVisibility: 'private',
publicSlug: '',
linksJson: '[]',
publicHomeHeaderTitle: '',
publicHomeHeaderIconUrl: '',
})
const loading = ref(true)
const saving = ref(false)
const uploadingAvatar = ref(false)
const uploadingHeaderIcon = ref(false)
const avatarFileInput = ref<HTMLInputElement | null>(null)
const headerIconFileInput = ref<HTMLInputElement | null>(null)
const BIO_PREVIEW_MAX_CHARS = 200
const bioModalOpen = ref(false)
const bioDraft = ref('')
function truncateBioSource(md: string): string {
const s = md.trim()
if (!s) {
return ''
}
if (s.length <= BIO_PREVIEW_MAX_CHARS) {
return s
}
return `${s.slice(0, BIO_PREVIEW_MAX_CHARS)}`
}
const bioPreviewTruncated = computed(() => truncateBioSource(state.bioMarkdown))
function openBioModal() {
bioDraft.value = state.bioMarkdown
bioModalOpen.value = true
}
function applyBioFromModal(close: () => void) {
state.bioMarkdown = bioDraft.value
close()
}
function openAvatarPicker() {
avatarFileInput.value?.click()
}
function openHeaderIconPicker() {
headerIconFileInput.value?.click()
}
async function onAvatarFileChange(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
uploadingAvatar.value = true
try {
const form = new FormData()
form.append('file', file)
const { files } = await fetchData<{ files: { url: string }[] }>('/api/file/upload', {
method: 'POST',
body: form,
notify: false,
})
const url = files[0]?.url
if (!url) {
toast.add({ title: '上传未返回地址', color: 'error' })
return
}
state.avatar = url
toast.add({ title: '头像已上传,请保存资料', color: 'success' })
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
uploadingAvatar.value = false
}
}
async function onHeaderIconFileChange(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
uploadingHeaderIcon.value = true
try {
const form = new FormData()
form.append('file', file)
const { files } = await fetchData<{ files: { url: string }[] }>('/api/file/upload', {
method: 'POST',
body: form,
notify: false,
})
const url = files[0]?.url
if (!url) {
toast.add({ title: '上传未返回地址', color: 'error' })
return
}
state.publicHomeHeaderIconUrl = url
toast.add({ title: '顶栏图标已上传,请保存资料', color: 'success' })
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
uploadingHeaderIcon.value = false
}
}
async function load() {
loading.value = true
try {
const [profilePayload, meCfgPayload] = await Promise.all([
fetchData<ProfileGet>('/api/me/profile'),
fetchData<MeConfigGet>('/api/config/me'),
])
const p = profilePayload.profile
const cfg = meCfgPayload.config
state.nickname = p.nickname ?? ''
state.avatar = p.avatar ?? ''
state.avatarVisibility = p.avatarVisibility
state.bioMarkdown = p.bioMarkdown ?? ''
state.bioVisibility = p.bioVisibility
state.publicSlug = p.publicSlug ?? ''
state.linksJson = JSON.stringify(p.socialLinks ?? [], null, 2)
state.publicHomeHeaderTitle = typeof cfg.publicHomeHeaderTitle === 'string' ? cfg.publicHomeHeaderTitle : ''
state.publicHomeHeaderIconUrl =
typeof cfg.publicHomeHeaderIconUrl === 'string' ? cfg.publicHomeHeaderIconUrl : ''
} finally {
loading.value = false
}
}
onMounted(load)
async function save() {
saving.value = true
try {
let links: { label: string; url: string; visibility: string; icon?: string }[] = []
try {
links = JSON.parse(state.linksJson) as { label: string; url: string; visibility: string; icon?: string }[]
} catch {
toast.add({ title: '社交链接 JSON 无效', color: 'error' })
return
}
await fetchData('/api/me/profile', {
method: 'PUT',
notify: false,
body: {
nickname: state.nickname || null,
avatar: state.avatar || null,
avatarVisibility: state.avatarVisibility,
bioMarkdown: state.bioMarkdown || null,
bioVisibility: state.bioVisibility,
publicSlug: state.publicSlug || null,
socialLinks: links,
},
})
await Promise.all([
fetchData('/api/config/me', {
method: 'PUT',
notify: false,
body: { key: 'publicHomeHeaderTitle', value: state.publicHomeHeaderTitle },
}),
fetchData('/api/config/me', {
method: 'PUT',
notify: false,
body: { key: 'publicHomeHeaderIconUrl', value: state.publicHomeHeaderIconUrl },
}),
])
toast.add({ title: '资料已保存', color: 'success' })
try {
await refreshAuthSession(true)
} catch {
/* 顶栏会话与表单仍可能不一致,但不把已成功写入误判为保存失败 */
}
await load()
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
saving.value = false
}
}
</script>
<template>
<UContainer class="py-8 max-w-2xl space-y-6">
<h1 class="text-2xl font-semibold">
个人资料
</h1>
<div class="relative min-h-[28rem]">
<div
v-show="loading"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-default/75 backdrop-blur-[2px]"
aria-live="polite"
aria-busy="true"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-primary" />
<span class="text-sm text-muted">加载中…</span>
</div>
<UForm
:state="state"
class="space-y-4"
:class="loading ? 'pointer-events-none select-none opacity-50' : ''"
@submit.prevent="save"
>
<UFormField label="公开主页 slug(/@slug)" name="publicSlug">
<UInput v-model="state.publicSlug" placeholder="例如 my-id" />
</UFormField>
<UFormField label="昵称" name="nickname">
<UInput v-model="state.nickname" />
</UFormField>
<UFormField
label="公开主页顶栏名称"
name="publicHomeHeaderTitle"
description="在 /@你的 slug 页面左上角显示。留空则使用全站站点名称。"
>
<UInput v-model="state.publicHomeHeaderTitle" maxlength="64" placeholder="可选,默认与站点名称相同" />
</UFormField>
<UFormField
label="公开主页顶栏图标"
name="publicHomeHeaderIconUrl"
description="留空则显示默认图标(与站点顶栏一致)。支持站内路径或 http(s) 链接。"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
<span class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary/10 text-primary ring-1 ring-default">
<img
v-if="state.publicHomeHeaderIconUrl"
:src="state.publicHomeHeaderIconUrl"
alt=""
class="h-full w-full object-cover"
>
<UIcon v-else name="i-lucide-orbit" class="size-5" />
</span>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<input
ref="headerIconFileInput"
type="file"
class="sr-only"
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="onHeaderIconFileChange"
>
<UButton type="button" :loading="uploadingHeaderIcon" @click="openHeaderIconPicker">
上传图片
</UButton>
<span class="text-xs text-muted">PNG / JPG / WebP,单张最大 5MB</span>
</div>
<UInput v-model="state.publicHomeHeaderIconUrl" placeholder="或直接填写图片 URL" />
</div>
</div>
</UFormField>
<UFormField label="头像" name="avatar">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
<UAvatar :src="state.avatar || undefined" size="xl" class="ring-1 ring-default shrink-0" />
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<input
ref="avatarFileInput"
type="file"
class="sr-only"
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="onAvatarFileChange"
>
<UButton type="button" :loading="uploadingAvatar" @click="openAvatarPicker">
上传图片
</UButton>
<span class="text-xs text-muted">PNG / JPG / WebP,单张最大 5MB</span>
</div>
<UInput v-model="state.avatar" placeholder="或直接填写图片 URL" />
</div>
</div>
</UFormField>
<UFormField label="头像可见性" name="avatarVisibility">
<USelect
v-model="state.avatarVisibility"
:items="[
{ label: '私密', value: 'private' },
{ label: '公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
]"
/>
</UFormField>
<UFormField
label="生平 / 简介(Markdown)"
name="bioMarkdown"
description="在弹窗中使用 Markdown 编辑器修改;下方仅展示原文前若干字的截断预览。"
>
<div class="space-y-2 rounded-lg border border-default bg-elevated/30 p-3">
<p
class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words font-mono text-sm text-muted"
>
{{ bioPreviewTruncated || '(未填写)' }}
</p>
<UButton type="button" variant="outline" size="sm" icon="i-lucide-square-pen" @click="openBioModal">
在编辑器中打开
</UButton>
</div>
</UFormField>
<UFormField label="简介可见性" name="bioVisibility">
<USelect
v-model="state.bioVisibility"
:items="[
{ label: '私密', value: 'private' },
{ label: '公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
]"
/>
</UFormField>
<UFormField
label="社交链接(JSON 数组)"
name="linksJson"
description="每条可含可选 icon,须为 Nuxt Icon 全名(小写),如 i-lucide-heart、i-simple-icons-github;省略则按 URL 自动匹配图标。"
>
<UTextarea v-model="state.linksJson" :rows="8" class="w-full font-mono text-sm" />
</UFormField>
<UButton type="submit" :loading="saving" :disabled="loading">
保存
</UButton>
</UForm>
</div>
<UModal
v-model:open="bioModalOpen"
title="编辑生平 / 简介"
description="支持 Markdown;图片可粘贴或上传至站内。"
class="w-[calc(100vw-2rem)] sm:max-w-4xl"
>
<template #body>
<PostBodyMarkdownEditor v-model="bioDraft" />
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2">
<UButton type="button" variant="outline" color="neutral" @click="close">
取消
</UButton>
<UButton type="button" @click="applyBioFromModal(close)">
完成
</UButton>
</div>
</template>
</UModal>
</UContainer>
</template>