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.
 
 
 

306 lines
10 KiB

<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
import { request, unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
definePageMeta({ title: '资料' })
const { refresh: refreshAuthSession } = useAuthSession()
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 message = ref('')
const avatarFileInput = ref<HTMLInputElement | null>(null)
const headerIconFileInput = ref<HTMLInputElement | null>(null)
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
message.value = ''
try {
const form = new FormData()
form.append('file', file)
const res = await request<ApiResponse<{ files: { url: string }[] }>>('/api/file/upload', {
method: 'POST',
body: form,
})
const { files } = unwrapApiBody(res)
const url = files[0]?.url
if (!url) {
message.value = '上传未返回地址'
return
}
state.avatar = url
message.value = '头像已上传,请点击下方「保存」写入资料'
} catch (e: unknown) {
message.value =
typeof e === 'object' && e !== null && 'statusMessage' in e
? String((e as { statusMessage: string }).statusMessage)
: '头像上传失败'
} 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
message.value = ''
try {
const form = new FormData()
form.append('file', file)
const res = await request<ApiResponse<{ files: { url: string }[] }>>('/api/file/upload', {
method: 'POST',
body: form,
})
const { files } = unwrapApiBody(res)
const url = files[0]?.url
if (!url) {
message.value = '上传未返回地址'
return
}
state.publicHomeHeaderIconUrl = url
message.value = '顶栏图标已上传,请点击下方「保存」写入'
} catch (e: unknown) {
message.value =
typeof e === 'object' && e !== null && 'statusMessage' in e
? String((e as { statusMessage: string }).statusMessage)
: '顶栏图标上传失败'
} finally {
uploadingHeaderIcon.value = false
}
}
async function load() {
loading.value = true
try {
const [profileRes, meCfgRes] = await Promise.all([
request<ApiResponse<ProfileGet>>('/api/me/profile'),
request<ApiResponse<MeConfigGet>>('/api/config/me'),
])
const p = unwrapApiBody(profileRes).profile
const cfg = unwrapApiBody(meCfgRes).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
message.value = ''
try {
let links: { label: string; url: string; visibility: string }[] = []
try {
links = JSON.parse(state.linksJson) as { label: string; url: string; visibility: string }[]
} catch {
message.value = '社交链接 JSON 无效'
return
}
await request('/api/me/profile', {
method: 'PUT',
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([
request('/api/config/me', {
method: 'PUT',
body: { key: 'publicHomeHeaderTitle', value: state.publicHomeHeaderTitle },
}),
request('/api/config/me', {
method: 'PUT',
body: { key: 'publicHomeHeaderIconUrl', value: state.publicHomeHeaderIconUrl },
}),
])
message.value = '已保存'
try {
await refreshAuthSession(true)
} catch {
/* 顶栏会话与表单仍可能不一致,但不把已成功写入误判为保存失败 */
}
await load()
} catch (e: unknown) {
message.value =
typeof e === 'object' && e !== null && 'statusMessage' in e
? String((e as { statusMessage: string }).statusMessage)
: '保存失败'
} finally {
saving.value = false
}
}
</script>
<template>
<UContainer class="py-8 max-w-2xl space-y-6">
<h1 class="text-2xl font-semibold">
个人资料
</h1>
<div v-if="loading" class="text-muted">
加载中…
</div>
<UForm v-else :state="state" class="space-y-4" @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">
<UTextarea v-model="state.bioMarkdown" autoresize :rows="6" class="w-full" />
</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">
保存
</UButton>
</UForm>
<UAlert v-if="message" :title="message" />
</UContainer>
</template>