Browse Source

feat(public): about page, profile media refs, media_refs ownerType

Made-with: Cursor
main
npmrun 5 hours ago
parent
commit
91978987ce
  1. 73
      app/pages/@[publicSlug]/about/index.vue
  2. 40
      app/pages/@[publicSlug]/index.vue
  3. 7
      packages/drizzle-pkg/database/sqlite/schema/content.ts
  4. BIN
      packages/drizzle-pkg/db.sqlite
  5. 16
      packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql
  6. 1130
      packages/drizzle-pkg/migrations/meta/0005_snapshot.json
  7. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  8. 34
      server/api/public/profile/[publicSlug]/about.get.ts
  9. 3
      server/constants/media-refs.ts
  10. 59
      server/service/media/index.ts
  11. 8
      server/service/posts/index.ts
  12. 9
      server/service/profile/index.ts
  13. 4
      server/utils/post-media-urls.ts

73
app/pages/@[publicSlug]/about/index.vue

@ -0,0 +1,73 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
import { renderSafeMarkdown } from '../../../utils/render-markdown'
definePageMeta({
layout: 'public',
})
const route = useRoute()
const slug = computed(() => route.params.publicSlug as string)
type AboutPayload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string }
}
const { data, pending, error } = await useAsyncData(
() => `public-about-${slug.value}`,
async () => {
const res = await $fetch<ApiResponse<AboutPayload>>(
`/api/public/profile/${encodeURIComponent(slug.value)}/about`,
)
return unwrapApiBody(res)
},
{ watch: [slug] },
)
const renderedBio = computed(() => (data.value?.bio.markdown ? renderSafeMarkdown(data.value.bio.markdown) : ''))
const displayName = computed(
() => data.value?.user.nickname || data.value?.user.publicSlug || slug.value,
)
useHead(() => ({
title: data.value ? `${displayName.value} · 关于` : '关于',
}))
</script>
<template>
<div v-if="pending" class="text-muted py-10">
<UContainer>加载中</UContainer>
</div>
<UContainer v-else-if="error" class="py-10">
<UAlert color="error" title="无法加载页面" description="该用户没有公开的简介,或主页不存在。" />
</UContainer>
<UContainer v-else-if="data" class="py-10 max-w-3xl space-y-8">
<div class="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<img
v-if="data.user.avatar"
:src="data.user.avatar"
alt=""
class="h-20 w-20 shrink-0 rounded-full border border-default object-cover"
>
<div class="text-center sm:text-left">
<h1 class="text-2xl font-semibold">
{{ displayName }}
</h1>
<p class="mt-1 text-sm text-muted">
关于
</p>
</div>
</div>
<div
class="prose prose-neutral dark:prose-invert max-w-none prose-img:rounded-lg"
v-html="renderedBio"
/>
<div>
<UButton :to="`/@${slug}`" variant="ghost" icon="i-lucide-arrow-left">
返回主页
</UButton>
</div>
</UContainer>
</template>

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

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory' import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { renderSafeMarkdown } from '../../utils/render-markdown'
import { import {
formatOccurredOnDisplay, formatOccurredOnDisplay,
formatPublishedDateOnly, formatPublishedDateOnly,
@ -121,6 +122,10 @@ function selectReadingSection(s: ReadingSection) {
function timelineItemKey(e: PublicTimelineItem, i: number): string | number { function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
return e.id ?? i return e.id ?? i
} }
const bioHtml = computed(() =>
data.value?.bio?.markdown ? renderSafeMarkdown(data.value.bio.markdown) : '',
)
</script> </script>
<template> <template>
@ -149,10 +154,19 @@ function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
</h1> </h1>
</div> </div>
<div v-if="data.bio?.markdown" class="prose prose-neutral dark:prose-invert max-w-none"> <div v-if="data.bio?.markdown" class="space-y-3">
<p class="whitespace-pre-wrap"> <div
{{ data.bio.markdown }} class="prose prose-neutral dark:prose-invert max-w-none prose-img:rounded-lg"
</p> v-html="bioHtml"
/>
<div class="not-prose">
<NuxtLink
:to="`/@${slug}/about`"
class="text-sm font-medium text-primary hover:underline"
>
查看全文
</NuxtLink>
</div>
</div> </div>
<div v-if="data.links.length" class="space-y-2"> <div v-if="data.links.length" class="space-y-2">
@ -334,13 +348,17 @@ function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
<h1 class="text-center text-lg font-semibold text-highlighted lg:text-left lg:text-base"> <h1 class="text-center text-lg font-semibold text-highlighted lg:text-left lg:text-base">
{{ data.user.nickname || data.user.publicSlug || slug }} {{ data.user.nickname || data.user.publicSlug || slug }}
</h1> </h1>
<div <div v-if="data.bio?.markdown" class="space-y-2">
v-if="data.bio?.markdown" <div
class="max-h-36 overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-xs leading-relaxed text-muted" 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"
<p class="whitespace-pre-wrap text-pretty"> />
{{ data.bio.markdown }} <NuxtLink
</p> :to="`/@${slug}/about`"
class="text-xs font-medium text-primary hover:underline"
>
查看全文
</NuxtLink>
</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">

7
packages/drizzle-pkg/database/sqlite/schema/content.ts

@ -61,15 +61,14 @@ export const mediaAssets = sqliteTable(
export const mediaRefs = sqliteTable( export const mediaRefs = sqliteTable(
"media_refs", "media_refs",
{ {
postId: integer("post_id") ownerType: text("owner_type").notNull(),
.notNull() ownerId: integer("owner_id").notNull(),
.references(() => posts.id, { onDelete: "cascade" }),
assetId: integer("asset_id") assetId: integer("asset_id")
.notNull() .notNull()
.references(() => mediaAssets.id, { onDelete: "cascade" }), .references(() => mediaAssets.id, { onDelete: "cascade" }),
}, },
(table) => [ (table) => [
primaryKey({ columns: [table.postId, table.assetId] }), primaryKey({ columns: [table.ownerType, table.ownerId, table.assetId] }),
index("media_refs_asset_id_idx").on(table.assetId), index("media_refs_asset_id_idx").on(table.assetId),
], ],
); );

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

16
packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql

@ -0,0 +1,16 @@
CREATE TABLE `media_refs_new` (
`owner_type` text NOT NULL,
`owner_id` integer NOT NULL,
`asset_id` integer NOT NULL,
PRIMARY KEY(`owner_type`, `owner_id`, `asset_id`),
FOREIGN KEY (`asset_id`) REFERENCES `media_assets`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `media_refs_new` (`owner_type`, `owner_id`, `asset_id`)
SELECT 'post', `post_id`, `asset_id` FROM `media_refs`;
--> statement-breakpoint
DROP TABLE `media_refs`;
--> statement-breakpoint
ALTER TABLE `media_refs_new` RENAME TO `media_refs`;
--> statement-breakpoint
CREATE INDEX `media_refs_asset_id_idx` ON `media_refs` (`asset_id`);

1130
packages/drizzle-pkg/migrations/meta/0005_snapshot.json

File diff suppressed because it is too large

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -36,6 +36,13 @@
"when": 1776600000000, "when": 1776600000000,
"tag": "0004_rename_post_media_refs_to_media_refs", "tag": "0004_rename_post_media_refs_to_media_refs",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1776700000000,
"tag": "0005_media_refs_owner_type",
"breakpoints": true
} }
] ]
} }

34
server/api/public/profile/[publicSlug]/about.get.ts

@ -0,0 +1,34 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { and, eq } from "drizzle-orm";
export default defineEventHandler(async (event) => {
const publicSlug = event.context.params?.publicSlug;
if (!publicSlug || typeof publicSlug !== "string") {
throw createError({ statusCode: 400, statusMessage: "无效主页" });
}
const [owner] = await dbGlobal
.select()
.from(users)
.where(and(eq(users.publicSlug, publicSlug), eq(users.status, "active")))
.limit(1);
if (!owner) {
throw createError({ statusCode: 404, statusMessage: "未找到" });
}
const bioOk = owner.bioVisibility === "public" && Boolean(owner.bioMarkdown?.trim());
if (!bioOk) {
throw createError({ statusCode: 404, statusMessage: "未找到" });
}
return R.success({
user: {
publicSlug: owner.publicSlug,
nickname: owner.nickname,
avatar: owner.avatarVisibility === "public" ? owner.avatar : null,
},
bio: { markdown: owner.bioMarkdown as string },
});
});

3
server/constants/media-refs.ts

@ -0,0 +1,3 @@
export const MEDIA_REF_OWNER_POST = "post" as const;
export const MEDIA_REF_OWNER_PROFILE = "profile" as const;
export type MediaRefOwnerType = typeof MEDIA_REF_OWNER_POST | typeof MEDIA_REF_OWNER_PROFILE;

59
server/service/media/index.ts

@ -9,7 +9,8 @@ import {
POST_MEDIA_PUBLIC_PREFIX, POST_MEDIA_PUBLIC_PREFIX,
RELATIVE_ASSETS_DIR, RELATIVE_ASSETS_DIR,
} from "#server/constants/media"; } from "#server/constants/media";
import { mergePostMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls"; import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs";
import { mergePostMediaUrls, mergeProfileMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls";
import { nextIntegerId } from "#server/utils/sqlite-id"; import { nextIntegerId } from "#server/utils/sqlite-id";
const NEVER_REF_MS = MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF * 3600 * 1000; const NEVER_REF_MS = MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF * 3600 * 1000;
@ -188,13 +189,11 @@ export async function syncPostMediaRefs(
bodyMarkdown: string, bodyMarkdown: string,
coverUrl: string | null, coverUrl: string | null,
): Promise<void> { ): Promise<void> {
const beforeRows = await dbGlobal const postRef = and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, postId));
.select({ assetId: mediaRefs.assetId }) const beforeRows = await dbGlobal.select({ assetId: mediaRefs.assetId }).from(mediaRefs).where(postRef);
.from(mediaRefs)
.where(eq(mediaRefs.postId, postId));
const beforeIds = beforeRows.map((r) => r.assetId); const beforeIds = beforeRows.map((r) => r.assetId);
await dbGlobal.delete(mediaRefs).where(eq(mediaRefs.postId, postId)); await dbGlobal.delete(mediaRefs).where(postRef);
const urls = mergePostMediaUrls(bodyMarkdown, coverUrl); const urls = mergePostMediaUrls(bodyMarkdown, coverUrl);
const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))]; const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))];
@ -209,7 +208,51 @@ export async function syncPostMediaRefs(
if (afterIds.length > 0) { if (afterIds.length > 0) {
await dbGlobal await dbGlobal
.insert(mediaRefs) .insert(mediaRefs)
.values(afterIds.map((assetId) => ({ postId, assetId }))) .values(
afterIds.map((assetId) => ({
ownerType: MEDIA_REF_OWNER_POST,
ownerId: postId,
assetId,
})),
)
.onConflictDoNothing();
}
}
await reconcileAssetTimestampsAfterRefChange([...new Set([...beforeIds, ...afterIds])]);
}
export async function syncProfileMediaRefs(
userId: number,
bioMarkdown: string | null,
avatar: string | null,
): Promise<void> {
const profileRef = and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, userId));
const beforeRows = await dbGlobal.select({ assetId: mediaRefs.assetId }).from(mediaRefs).where(profileRef);
const beforeIds = beforeRows.map((r) => r.assetId);
await dbGlobal.delete(mediaRefs).where(profileRef);
const urls = mergeProfileMediaUrls(bioMarkdown, avatar);
const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))];
let afterIds: number[] = [];
if (keys.length > 0) {
const assetRows = await dbGlobal
.select({ id: mediaAssets.id })
.from(mediaAssets)
.where(and(eq(mediaAssets.userId, userId), inArray(mediaAssets.storageKey, keys)));
afterIds = assetRows.map((r) => r.id);
if (afterIds.length > 0) {
await dbGlobal
.insert(mediaRefs)
.values(
afterIds.map((assetId) => ({
ownerType: MEDIA_REF_OWNER_PROFILE,
ownerId: userId,
assetId,
})),
)
.onConflictDoNothing(); .onConflictDoNothing();
} }
} }
@ -288,7 +331,7 @@ async function assertAssetDeletableOrThrow(row: typeof mediaAssets.$inferSelect)
.from(mediaRefs) .from(mediaRefs)
.where(eq(mediaRefs.assetId, row.id)); .where(eq(mediaRefs.assetId, row.id));
if (c > 0) { if (c > 0) {
throw createError({ statusCode: 400, statusMessage: "资源仍被文章引用,无法删除" }); throw createError({ statusCode: 400, statusMessage: "资源仍被引用,无法删除" });
} }
if (!isAssetDeletable(row)) { if (!isAssetDeletable(row)) {
throw createError({ statusCode: 400, statusMessage: "资源尚在宽限期,暂不可删除" }); throw createError({ statusCode: 400, statusMessage: "资源尚在宽限期,暂不可删除" });

8
server/service/posts/index.ts

@ -3,6 +3,7 @@ import { mediaRefs, posts } from "drizzle-pkg/lib/schema/content";
import { reconcileAssetTimestampsAfterRefChange, syncPostMediaRefs } from "#server/service/media"; import { reconcileAssetTimestampsAfterRefChange, syncPostMediaRefs } from "#server/service/media";
import { users } from "drizzle-pkg/lib/schema/auth"; import { users } from "drizzle-pkg/lib/schema/auth";
import { and, count, desc, eq } from "drizzle-orm"; import { and, count, desc, eq } from "drizzle-orm";
import { MEDIA_REF_OWNER_POST } from "#server/constants/media-refs";
import { PUBLIC_LIST_PAGE_SIZE, PUBLIC_PREVIEW_LIMIT } from "#server/constants/public-profile-lists"; import { PUBLIC_LIST_PAGE_SIZE, PUBLIC_PREVIEW_LIMIT } from "#server/constants/public-profile-lists";
import { visibilitySchema, type Visibility } from "#server/constants/visibility"; import { visibilitySchema, type Visibility } from "#server/constants/visibility";
import { normalizePublicListPage } from "#server/utils/public-pagination"; import { normalizePublicListPage } from "#server/utils/public-pagination";
@ -120,11 +121,10 @@ export async function deletePost(userId: number, id: number) {
if (!existing) { if (!existing) {
return false; return false;
} }
const refRows = await dbGlobal const postRef = and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, id));
.select({ assetId: mediaRefs.assetId }) const refRows = await dbGlobal.select({ assetId: mediaRefs.assetId }).from(mediaRefs).where(postRef);
.from(mediaRefs)
.where(eq(mediaRefs.postId, id));
const touched = refRows.map((r) => r.assetId); const touched = refRows.map((r) => r.assetId);
await dbGlobal.delete(mediaRefs).where(postRef);
await dbGlobal.delete(posts).where(and(eq(posts.id, id), eq(posts.userId, userId))); await dbGlobal.delete(posts).where(and(eq(posts.id, id), eq(posts.userId, userId)));
await reconcileAssetTimestampsAfterRefChange(touched); await reconcileAssetTimestampsAfterRefChange(touched);
return true; return true;

9
server/service/profile/index.ts

@ -2,6 +2,7 @@ import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth"; import { users } from "drizzle-pkg/lib/schema/auth";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { visibilitySchema, type Visibility } from "#server/constants/visibility"; import { visibilitySchema, type Visibility } from "#server/constants/visibility";
import { syncProfileMediaRefs } from "#server/service/media";
import { z } from "zod"; import { z } from "zod";
const publicSlugValue = z const publicSlugValue = z
@ -68,8 +69,14 @@ export async function updateProfile(
return getProfileRow(userId); return getProfileRow(userId);
} }
const syncMedia = patch.bioMarkdown !== undefined || patch.avatar !== undefined;
await dbGlobal.update(users).set(updates as never).where(eq(users.id, userId)); await dbGlobal.update(users).set(updates as never).where(eq(users.id, userId));
return getProfileRow(userId); const row = await getProfileRow(userId);
if (row && syncMedia) {
await syncProfileMediaRefs(userId, row.bioMarkdown ?? null, row.avatar ?? null);
}
return row;
} }
export function parseSocialLinksJson(raw: string | null | undefined): SocialLinkItem[] { export function parseSocialLinksJson(raw: string | null | undefined): SocialLinkItem[] {

4
server/utils/post-media-urls.ts

@ -47,6 +47,10 @@ export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null
return a; return a;
} }
export function mergeProfileMediaUrls(bioMarkdown: string | null | undefined, avatar: string | null | undefined): string[] {
return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null);
}
/** `/public/assets/foo.webp` → `foo.webp` */ /** `/public/assets/foo.webp` → `foo.webp` */
export function publicAssetUrlToStorageKey(url: string): string | null { export function publicAssetUrlToStorageKey(url: string): string | null {
const t = url.trim(); const t = url.trim();

Loading…
Cancel
Save