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.
370 lines
12 KiB
370 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 }[]
|
|
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 }[] = []
|
|
try {
|
|
links = JSON.parse(state.linksJson) as { label: string; url: string; visibility: 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">
|
|
<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>
|
|
|