Browse Source

feat(profile): add optional icon support for social links and update related components

main
npmrun 9 hours ago
parent
commit
1776f229fc
  1. 23
      app/components/PostBodyMarkdownEditor.vue
  2. 2
      app/composables/usePublicHomeLayout.ts
  3. 441
      app/pages/@[publicSlug]/index.vue
  4. 13
      app/pages/me/posts/[id].vue
  5. 12
      app/pages/me/profile/index.vue
  6. 87
      app/utils/social-link-icon.ts
  7. BIN
      packages/drizzle-pkg/db.sqlite
  8. 2
      server/api/me/profile.put.ts
  9. 17
      server/service/profile/index.ts

23
app/components/PostBodyMarkdownEditor.vue

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type MarkdownIt from 'markdown-it' import { MdEditor, config as mdEditorGlobalConfig } from 'md-editor-v3'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css' import 'md-editor-v3/lib/style.css'
import { attachMarkdownItStripFrontMatter } from '../utils/markdown-front-matter' import { attachMarkdownItStripFrontMatter } from '../utils/markdown-front-matter'
@ -15,11 +14,24 @@ const emit = defineEmits<{
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const toast = useToast() const toast = useToast()
const editorId = `post-body-md-${useId()}` const editorId = `post-body-md-${useId()}`
const previewFmRule = `strip_fm_${useId().replace(/[^a-zA-Z0-9_]/g, '_')}`
function markdownItConfig(md: MarkdownIt) { /**
attachMarkdownItStripFrontMatter(md, previewFmRule) * md-editor-v3 不会把 `markdown-it-config` 传给内部预览组件预览用的 MarkdownIt 只读全局 `config()`
* @see node_modules/md-editor-v3/lib/es/MdEditor.mjs ContentPreview 未转发 markdownItConfig
*/
let mdEditorStripFmInstalled = false
function ensureMdEditorStripFrontMatter() {
if (mdEditorStripFmInstalled) {
return
}
mdEditorStripFmInstalled = true
mdEditorGlobalConfig({
markdownItConfig(md) {
attachMarkdownItStripFrontMatter(md, 'person_panel_strip_fm')
},
})
} }
ensureMdEditorStripFrontMatter()
const local = computed({ const local = computed({
get: () => props.modelValue, get: () => props.modelValue,
@ -53,7 +65,6 @@ async function onUploadImg(files: File[], callback: (urls: string[]) => void) {
:preview="true" :preview="true"
preview-theme="github" preview-theme="github"
theme="light" theme="light"
:markdown-it-config="markdownItConfig"
:on-upload-img="onUploadImg" :on-upload-img="onUploadImg"
:style="{ height: 'min(72vh, 720px)' }" :style="{ height: 'min(72vh, 720px)' }"
class="w-full rounded-lg overflow-hidden ring ring-default" class="w-full rounded-lg overflow-hidden ring ring-default"

2
app/composables/usePublicHomeLayout.ts

@ -8,7 +8,7 @@ export const publicHomeLayoutModeKey: InjectionKey<Ref<PublicHomeLayoutMode>> =
export function usePublicProfileLayoutMode() { export function usePublicProfileLayoutMode() {
const mode = inject(publicHomeLayoutModeKey, null) const mode = inject(publicHomeLayoutModeKey, null)
if (!mode) { if (!mode) {
return { mode: ref<PublicHomeLayoutMode>('showcase') } return { mode: ref<PublicHomeLayoutMode>('detailed') }
} }
return { mode } return { mode }
} }

441
app/pages/@[publicSlug]/index.vue

@ -40,7 +40,7 @@ type PublicRssListItem = { title?: string | null; canonicalUrl?: string | null;
type Payload = { type Payload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null } user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string } | null bio: { markdown: string } | null
links: { label: string; url: string; visibility: string }[] links: { label: string; url: string; visibility: string; icon?: string }[]
posts: { items: PublicPostListItem[]; total: number } posts: { items: PublicPostListItem[]; total: number }
timeline: { items: PublicTimelineItem[]; total: number } timeline: { items: PublicTimelineItem[]; total: number }
rssItems: { items: PublicRssListItem[]; total: number } rssItems: { items: PublicRssListItem[]; total: number }
@ -137,7 +137,7 @@ const bioHtml = computed(() => {
return renderSafeMarkdown(md) return renderSafeMarkdown(md)
}) })
/** 「查看全文」:正文在去掉 FM 后仍有内容时才显示(避免仅有 desc 时链到空页) */ /** 简介整块可点进 about:去掉 FM 后仍有正文时才包链接(避免仅有 desc 时链到空页) */
const showBioReadMore = computed(() => { const showBioReadMore = computed(() => {
const md = data.value?.bio?.markdown const md = data.value?.bio?.markdown
if (!md?.trim()) { if (!md?.trim()) {
@ -155,200 +155,253 @@ const showBioReadMore = computed(() => {
<UAlert color="error" title="无法加载主页" /> <UAlert color="error" title="无法加载主页" />
</UContainer> </UContainer>
<!-- 展示居中窄栏原版卡片 + 时间轴 --> <!-- 展示居中窄栏编辑式层级 + 轻量卡片 -->
<UContainer <UContainer
v-else-if="data && mode === 'showcase'" v-else-if="data && mode === 'showcase'"
class="py-10 space-y-10 max-w-2xl" class="max-w-2xl py-10 sm:py-14"
> >
<div class="flex flex-col gap-2"> <div class="mx-auto flex w-full max-w-xl flex-col gap-12 sm:gap-14">
<div v-if="data.user.avatar" class="flex justify-center"> <section
<img class="w-full rounded-2xl border border-default/80 bg-elevated/25 px-6 py-8 text-center shadow-sm ring-1 ring-black/5 dark:ring-white/10 sm:px-10 sm:py-9"
:src="data.user.avatar" aria-label="个人资料"
alt="" >
class="h-20 w-20 rounded-full object-cover border border-default" <div class="flex flex-col items-center gap-4">
> <div v-if="data.user.avatar" class="flex justify-center">
</div> <img
<h1 class="text-2xl font-semibold text-center"> :src="data.user.avatar"
{{ data.user.nickname || data.user.publicSlug || slug }} alt=""
</h1> class="h-24 w-24 rounded-full border-2 border-default object-cover shadow-md ring-4 ring-primary/10"
</div> >
</div>
<div class="space-y-1">
<h1 class="text-balance text-2xl font-semibold tracking-tight text-highlighted sm:text-3xl">
{{ data.user.nickname || data.user.publicSlug || slug }}
</h1>
<p
v-if="data.user.nickname && data.user.publicSlug"
class="text-sm font-medium tabular-nums text-muted"
>
@{{ data.user.publicSlug }}
</p>
</div>
<ul
v-if="data.links.length"
class="flex flex-wrap items-center justify-center gap-2.5"
aria-label="社交链接"
>
<li v-for="(l, i) in data.links" :key="i">
<a
:href="l.url"
target="_blank"
rel="noopener noreferrer"
:title="l.label"
:aria-label="`${l.label}(新窗口打开)`"
class="flex size-10 items-center justify-center rounded-full border border-default/80 bg-default/40 text-primary transition-colors hover:border-primary/35 hover:bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<UIcon
:name="socialLinkIconName(l)"
class="size-5 shrink-0 opacity-90"
/>
</a>
</li>
</ul>
</div>
</section>
<div v-if="data.bio?.markdown && bioHtml" class="space-y-3"> <section
<div v-if="data.bio?.markdown && bioHtml"
class="prose prose-neutral dark:prose-invert max-w-none prose-img:rounded-lg" class="w-full"
v-html="bioHtml" >
/>
<div v-if="showBioReadMore" class="not-prose">
<NuxtLink <NuxtLink
v-if="showBioReadMore"
:to="`/@${slug}/about`" :to="`/@${slug}/about`"
class="text-sm font-medium text-primary hover:underline" class="block rounded-2xl border border-default/70 bg-elevated/20 p-5 transition-colors hover:border-primary/25 hover:bg-elevated/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:p-6"
> >
查看全文 <div
class="prose prose-sm prose-neutral max-w-none dark:prose-invert prose-p:leading-relaxed prose-img:rounded-lg"
v-html="bioHtml"
/>
</NuxtLink> </NuxtLink>
</div> <div
</div> v-else
class="rounded-2xl border border-default/70 bg-elevated/20 p-5 sm:p-6"
<div v-if="data.links.length" class="space-y-2">
<h2 class="text-lg font-medium">
链接
</h2>
<ul class="space-y-1">
<li v-for="(l, i) in data.links" :key="i">
<a
:href="l.url"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>{{ l.label }}</a>
</li>
</ul>
</div>
<div v-if="data.posts.total" class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium">
文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.posts.total }}
</UButton>
</div>
<ul class="space-y-2">
<li
v-for="p in data.posts.items"
:key="p.slug"
class="border border-default rounded-lg overflow-hidden transition-colors hover:bg-elevated/50"
>
<NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="group block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<time
v-if="p.publishedAt"
class="block text-xs font-medium tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<div class="mt-1 font-medium text-highlighted">
{{ p.title }}
</div>
<div v-if="p.excerpt" class="text-sm text-muted mt-1">
{{ p.excerpt }}
</div>
<div class="mt-1.5 text-sm font-medium text-primary group-hover:underline">
查看全文
</div>
</NuxtLink>
</li>
</ul>
</div>
<div v-if="data.timeline.total" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium text-highlighted">
时光机
</h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.timeline.total }}
</UButton>
</div>
<ul class="relative space-y-0">
<li
v-for="(e, i) in data.timeline.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0"
> >
<div <div
v-if="i < data.timeline.items.length - 1" class="prose prose-sm prose-neutral max-w-none dark:prose-invert prose-p:leading-relaxed prose-img:rounded-lg"
class="absolute left-[11px] top-5 bottom-0 w-px bg-default" v-html="bioHtml"
aria-hidden="true"
/> />
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-0.5"> </div>
<span class="size-2.5 rounded-full bg-primary ring-4 ring-primary/15" /> </section>
</div>
<article <section v-if="data.posts.total" class="w-full space-y-4">
class="min-w-0 flex-1 rounded-lg border border-default bg-elevated/40 px-3 py-3 shadow-sm" <div class="flex flex-wrap items-end justify-between gap-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.posts.total }}
</UButton>
</div>
<ul class="divide-y divide-default overflow-hidden rounded-xl border border-default">
<li
v-for="p in data.posts.items"
:key="p.slug"
class="bg-default/30 transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-elevated/40"
>
<NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="group flex cursor-pointer gap-4 p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary sm:gap-5"
>
<div
v-if="p.coverUrl"
class="relative h-[4.5rem] w-[6.5rem] shrink-0 overflow-hidden rounded-lg border border-default/80"
>
<img
:src="p.coverUrl"
alt=""
class="size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
>
</div>
<div class="min-w-0 flex-1">
<time
v-if="p.publishedAt"
class="block text-xs font-medium tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<div class="mt-1 text-pretty font-semibold text-highlighted transition-colors group-hover:text-primary">
{{ p.title }}
</div>
<p
v-if="p.excerpt"
class="mt-1.5 line-clamp-2 text-sm leading-relaxed text-muted"
>
{{ p.excerpt }}
</p>
</div>
</NuxtLink>
</li>
</ul>
</section>
<section v-if="data.timeline.total" class="w-full space-y-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
时光机
</h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.timeline.total }}
</UButton>
</div>
<ul class="relative space-y-0">
<li
v-for="(e, i) in data.timeline.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-8 pl-1 last:pb-0 sm:pb-9"
> >
<time <div
class="block text-xs font-medium tabular-nums text-muted" v-if="i < data.timeline.items.length - 1"
:datetime="e.occurredOn ? occurredOnToIsoAttr(e.occurredOn) : undefined" class="absolute bottom-0 left-[11px] top-5 w-px bg-default"
>{{ formatOccurredOnDisplay(e.occurredOn ?? '') }}</time> aria-hidden="true"
<div class="mt-1.5 font-medium text-highlighted"> />
{{ e.title }} <div class="relative z-[1] flex shrink-0 flex-col items-center pt-0.5">
<span class="size-2.5 rounded-full bg-primary shadow-sm ring-[6px] ring-primary/12" />
</div> </div>
<article
class="min-w-0 flex-1 rounded-xl border border-default/80 bg-elevated/30 px-4 py-4 shadow-sm sm:px-5 sm:py-4"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<h3 class="order-2 text-pretty text-base font-semibold text-highlighted sm:order-1 sm:min-w-0 sm:flex-1">
{{ e.title }}
</h3>
<time
class="order-1 shrink-0 text-xs font-medium tabular-nums text-muted sm:order-2 sm:text-right"
:datetime="e.occurredOn ? occurredOnToIsoAttr(e.occurredOn) : undefined"
>{{ formatOccurredOnDisplay(e.occurredOn ?? '') }}</time>
</div>
<p
v-if="e.bodyMarkdown && e.bodyMarkdown.trim()"
class="mt-3 line-clamp-3 border-t border-default/50 pt-3 text-sm leading-relaxed text-muted"
>
{{ e.bodyMarkdown }}
</p>
<a
v-if="e.linkUrl"
:href="e.linkUrl"
target="_blank"
rel="noopener noreferrer"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
<span>相关链接</span>
<UIcon name="i-lucide-external-link" class="size-3.5 opacity-80" />
</a>
</article>
</li>
</ul>
</section>
<section v-if="data.rssItems.total" class="w-full space-y-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.rssItems.total }}
</UButton>
</div>
<ul class="divide-y divide-default overflow-hidden rounded-xl border border-default">
<li
v-for="(it, i) in data.rssItems.items"
:key="i"
class="bg-default/30 transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-elevated/40"
>
<a <a
v-if="e.linkUrl" v-if="rssPublicHref(it)"
:href="e.linkUrl" :href="rssPublicHref(it)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="mt-2 inline-block text-sm text-primary hover:underline" class="group block p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
>相关链接</a> >
</article> <div class="text-pretty font-semibold text-primary transition-colors group-hover:underline">
</li> {{ rssPublicTitle(it) }}
</ul> </div>
</div> <div class="mt-1 break-all text-sm text-muted">
{{ rssHostname(rssPublicHref(it)) }}
<div v-if="data.rssItems.total" class="space-y-2"> </div>
<div class="flex flex-wrap items-center justify-between gap-2"> </a>
<h2 class="text-lg font-medium"> <div v-else class="p-4 text-muted">
阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.rssItems.total }}
</UButton>
</div>
<ul class="space-y-2">
<li
v-for="(it, i) in data.rssItems.items"
:key="i"
class="rounded-lg border border-default transition-colors hover:bg-elevated/40"
>
<a
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
target="_blank"
rel="noopener noreferrer"
class="block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<div class="font-medium text-primary">
{{ rssPublicTitle(it) }} {{ rssPublicTitle(it) }}
</div> </div>
<div class="mt-1 text-sm text-muted break-all"> </li>
{{ rssHostname(rssPublicHref(it)) }} </ul>
</div> </section>
</a>
<div v-else class="p-3 text-muted"> <UEmpty
{{ rssPublicTitle(it) }} v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
</div> title="这里还没有公开内容"
</li> description="站主尚未发布任何公开文章或动态。"
</ul> />
</div> </div>
<UEmpty
v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</UContainer> </UContainer>
<!-- 阅读侧栏 + 与展示一致的预览块 --> <!-- 阅读侧栏 + 与展示一致的预览块 -->
@ -371,30 +424,41 @@ const showBioReadMore = computed(() => {
{{ data.user.nickname || data.user.publicSlug || slug }} {{ data.user.nickname || data.user.publicSlug || slug }}
</h1> </h1>
<div v-if="data.bio?.markdown && bioHtml" class="space-y-2"> <div v-if="data.bio?.markdown && bioHtml" class="space-y-2">
<div
class="bio-preview-scroll max-h-36 overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-xs leading-relaxed text-muted prose prose-neutral dark:prose-invert max-w-none prose-p:my-1 prose-img:rounded"
v-html="bioHtml"
/>
<NuxtLink <NuxtLink
v-if="showBioReadMore" v-if="showBioReadMore"
:to="`/@${slug}/about`" :to="`/@${slug}/about`"
class="text-xs font-medium text-primary hover:underline" class="block cursor-pointer rounded-lg transition-colors hover:bg-elevated/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
> >
查看全文 <div
class="bio-preview-scroll max-h-36 overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-xs leading-relaxed text-muted prose prose-neutral dark:prose-invert max-w-none prose-p:my-1 prose-img:rounded"
v-html="bioHtml"
/>
</NuxtLink> </NuxtLink>
<div
v-else
class="bio-preview-scroll max-h-36 overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-xs leading-relaxed text-muted prose prose-neutral dark:prose-invert max-w-none prose-p:my-1 prose-img:rounded"
v-html="bioHtml"
/>
</div> </div>
<div v-if="data.links.length" class="space-y-1.5"> <div v-if="data.links.length" class="space-y-1.5">
<div class="text-xs font-medium uppercase tracking-wide text-muted"> <div class="text-xs font-medium uppercase tracking-wide text-muted">
链接 链接
</div> </div>
<ul class="space-y-1 text-sm"> <ul class="flex flex-col gap-1">
<li v-for="(l, i) in data.links" :key="i"> <li v-for="(l, i) in data.links" :key="i">
<a <a
:href="l.url" :href="l.url"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary hover:underline break-all" class="inline-flex max-w-full items-center gap-1.5 rounded py-0.5 text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>{{ l.label }}</a> >
<UIcon
:name="socialLinkIconName(l)"
class="size-3.5 shrink-0 text-primary opacity-90"
aria-hidden="true"
/>
<span class="min-w-0 break-words text-highlighted">{{ l.label }}</span>
</a>
</li> </li>
</ul> </ul>
</div> </div>
@ -495,7 +559,7 @@ const showBioReadMore = computed(() => {
> >
<NuxtLink <NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`" :to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="group flex flex-col gap-4 py-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default rounded-lg sm:flex-row sm:gap-5" class="group flex cursor-pointer flex-col gap-4 py-6 transition-colors hover:bg-elevated/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default rounded-lg sm:flex-row sm:gap-5"
> >
<div <div
v-if="p.coverUrl" v-if="p.coverUrl"
@ -522,9 +586,6 @@ const showBioReadMore = computed(() => {
> >
{{ p.excerpt }} {{ p.excerpt }}
</p> </p>
<p class="mt-2 text-sm font-medium text-primary group-hover:underline">
查看全文
</p>
</div> </div>
</NuxtLink> </NuxtLink>
</li> </li>

13
app/pages/me/posts/[id].vue

@ -28,8 +28,11 @@ const publicPostHref = computed(() => {
return `/@${ps}/posts/${encodeURIComponent(state.slug)}` return `/@${ps}/posts/${encodeURIComponent(state.slug)}`
}) })
async function load() { async function load(options?: { silent?: boolean }) {
loading.value = true const silent = options?.silent === true
if (!silent) {
loading.value = true
}
try { try {
const { post: p } = await fetchData<{ post: typeof state }>(`/api/me/posts/${id.value}`) const { post: p } = await fetchData<{ post: typeof state }>(`/api/me/posts/${id.value}`)
Object.assign(state, { Object.assign(state, {
@ -41,7 +44,9 @@ async function load() {
shareToken: p.shareToken ?? null, shareToken: p.shareToken ?? null,
}) })
} finally { } finally {
loading.value = false if (!silent) {
loading.value = false
}
} }
} }
@ -67,7 +72,7 @@ async function save() {
visibility: state.visibility, visibility: state.visibility,
}, },
}) })
await load() await load({ silent: true })
toast.add({ title: '文章已保存', color: 'success' }) toast.add({ title: '文章已保存', color: 'success' })
} finally { } finally {
saving.value = false saving.value = false

12
app/pages/me/profile/index.vue

@ -14,7 +14,7 @@ type ProfileGet = {
avatarVisibility: string avatarVisibility: string
bioMarkdown: string | null bioMarkdown: string | null
bioVisibility: string bioVisibility: string
socialLinks: { label: string; url: string; visibility: string }[] socialLinks: { label: string; url: string; visibility: string; icon?: string }[]
publicSlug: string | null publicSlug: string | null
} }
} }
@ -166,9 +166,9 @@ onMounted(load)
async function save() { async function save() {
saving.value = true saving.value = true
try { try {
let links: { label: string; url: string; visibility: string }[] = [] let links: { label: string; url: string; visibility: string; icon?: string }[] = []
try { try {
links = JSON.parse(state.linksJson) as { label: string; url: string; visibility: string }[] links = JSON.parse(state.linksJson) as { label: string; url: string; visibility: string; icon?: string }[]
} catch { } catch {
toast.add({ title: '社交链接 JSON 无效', color: 'error' }) toast.add({ title: '社交链接 JSON 无效', color: 'error' })
return return
@ -337,7 +337,11 @@ async function save() {
]" ]"
/> />
</UFormField> </UFormField>
<UFormField label="社交链接(JSON 数组)" name="linksJson"> <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" /> <UTextarea v-model="state.linksJson" :rows="8" class="w-full font-mono text-sm" />
</UFormField> </UFormField>
<UButton type="submit" :loading="saving" :disabled="loading"> <UButton type="submit" :loading="saving" :disabled="loading">

87
app/utils/social-link-icon.ts

@ -0,0 +1,87 @@
export type SocialLinkIconInput = {
label: string
url: string
icon?: string | null
}
/** 优先使用条目中的 icon;否则按域名与标题推断 */
export function socialLinkIconName(link: SocialLinkIconInput): string {
const raw = link.icon?.trim()
if (raw) {
return raw
}
return inferSocialLinkIconName(link.label, link.url)
}
function inferSocialLinkIconName(label: string, url: string): string {
const lowerLabel = label.toLowerCase()
try {
const u = new URL(url.trim())
if (u.protocol === 'mailto:') {
return 'i-lucide-mail'
}
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
const byHost: [string, string][] = [
['github.com', 'i-simple-icons-github'],
['gist.github.com', 'i-simple-icons-github'],
['github.io', 'i-simple-icons-github'],
['twitter.com', 'i-simple-icons-x'],
['x.com', 'i-simple-icons-x'],
['linkedin.com', 'i-simple-icons-linkedin'],
['youtube.com', 'i-simple-icons-youtube'],
['youtu.be', 'i-simple-icons-youtube'],
['gitlab.com', 'i-simple-icons-gitlab'],
['reddit.com', 'i-simple-icons-reddit'],
['discord.com', 'i-simple-icons-discord'],
['discord.gg', 'i-simple-icons-discord'],
['t.me', 'i-simple-icons-telegram'],
['telegram.org', 'i-simple-icons-telegram'],
['medium.com', 'i-simple-icons-medium'],
['stackoverflow.com', 'i-simple-icons-stackoverflow'],
['bsky.app', 'i-simple-icons-bluesky'],
['mastodon.social', 'i-simple-icons-mastodon'],
['bilibili.com', 'i-simple-icons-bilibili'],
['space.bilibili.com', 'i-simple-icons-bilibili'],
['weibo.com', 'i-simple-icons-sinaweibo'],
['zhihu.com', 'i-simple-icons-zhihu'],
]
for (const [h, icon] of byHost) {
if (host === h || host.endsWith(`.${h}`)) {
return icon
}
}
if (host.endsWith('.github.io')) {
return 'i-simple-icons-github'
}
if (host.includes('mastodon')) {
return 'i-simple-icons-mastodon'
}
}
catch {
// fall through to label heuristics
}
if (/\bgithub\b/i.test(lowerLabel)) {
return 'i-simple-icons-github'
}
if (/\b(x|twitter)\b/i.test(lowerLabel)) {
return 'i-simple-icons-x'
}
if (/\blinkedin\b/i.test(lowerLabel)) {
return 'i-simple-icons-linkedin'
}
if (/\byoutube\b|\byt\b/i.test(lowerLabel)) {
return 'i-simple-icons-youtube'
}
if (/\bmail\b|邮箱|电子邮件/i.test(lowerLabel)) {
return 'i-lucide-mail'
}
return 'i-lucide-link-2'
}

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

2
server/api/me/profile.put.ts

@ -10,7 +10,7 @@ export default defineWrappedResponseHandler(async (event) => {
avatarVisibility?: string; avatarVisibility?: string;
bioMarkdown?: string | null; bioMarkdown?: string | null;
bioVisibility?: string; bioVisibility?: string;
socialLinks?: { label: string; url: string; visibility: string }[]; socialLinks?: { label: string; url: string; visibility: string; icon?: string }[];
publicSlug?: string | null; publicSlug?: string | null;
}>(event); }>(event);

17
server/service/profile/index.ts

@ -11,10 +11,27 @@ const publicSlugValue = z
.min(3) .min(3)
.max(30); .max(30);
/** Nuxt UI / Iconify icon id, e.g. i-lucide-heart, i-simple-icons-github */
const socialLinkIconSchema = z.preprocess(
(v) => {
if (v === "" || v === null || v === undefined) {
return undefined;
}
return typeof v === "string" ? v.trim() : v;
},
z
.string()
.min(4)
.max(120)
.regex(/^i-[a-z0-9]+(?:-[a-z0-9]+)*$/, "icon 须为小写 Nuxt Icon 名,例如 i-lucide-mail")
.optional(),
);
const linkItemSchema = z.object({ const linkItemSchema = z.object({
label: z.string().min(1).max(80), label: z.string().min(1).max(80),
url: z.string().url().max(2000), url: z.string().url().max(2000),
visibility: visibilitySchema, visibility: visibilitySchema,
icon: socialLinkIconSchema.optional(),
}); });
export type SocialLinkItem = z.infer<typeof linkItemSchema>; export type SocialLinkItem = z.infer<typeof linkItemSchema>;

Loading…
Cancel
Save