Browse Source

feat: add defaultCardType to categories and update related endpoints

- Introduced defaultCardType field in categories schema and updated migrations.
- Removed slug validation from category creation and update schemas.
- Updated category service to handle defaultCardType in create and update operations.
- Adjusted API endpoints to reflect changes in category schema.
aaaaa
npmrun 4 months ago
parent
commit
5bdb0532e5
  1. 2
      app/components/TopNav.vue
  2. 6
      app/components/index/CardFormModal.vue
  3. 145
      app/components/index/CategoryFormModal.vue
  4. 1
      app/components/index/CategoryTreeNode.vue
  5. 207
      app/components/index/WaterfallDigestCard.vue
  6. 16
      app/config/cardTypes.ts
  7. 48
      app/pages/collect/index.vue
  8. BIN
      packages/drizzle-pkg/db.sqlite
  9. 4
      packages/drizzle-pkg/lib/schema/content.ts
  10. 1
      packages/drizzle-pkg/migrations/0008_handy_grey_gargoyle.sql
  11. 2
      packages/drizzle-pkg/migrations/0009_eminent_mikhail_rasputin.sql
  12. 1249
      packages/drizzle-pkg/migrations/meta/0008_snapshot.json
  13. 1235
      packages/drizzle-pkg/migrations/meta/0009_snapshot.json
  14. 14
      packages/drizzle-pkg/migrations/meta/_journal.json
  15. 7
      server/api/categories/[id].put.ts
  16. 6
      server/api/categories/index.post.ts
  17. 14
      server/service/category/index.ts

2
app/components/TopNav.vue

@ -9,7 +9,7 @@ const { loggedIn, user, clear, initialized } = useAuthSession()
const links: any = [ const links: any = [
{ to: "/articles", label: "文章" }, { to: "/articles", label: "文章" },
{ to: "/collect", label: "收藏" }, { to: "/collect", label: "卡片" },
{ to: "/test", label: "测试" }, { to: "/test", label: "测试" },
] ]

6
app/components/index/CardFormModal.vue

@ -17,6 +17,8 @@ const props = defineProps<{
card: CardDetail | null card: CardDetail | null
categories: CategoryNode[] categories: CategoryNode[]
visible: boolean visible: boolean
defaultType?: string
defaultCategoryId?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -148,12 +150,12 @@ watch(() => props.visible, (v) => {
selectedArticleIds.value = [] selectedArticleIds.value = []
fetchCardArticles(props.card.id) fetchCardArticles(props.card.id)
} else { } else {
formType.value = 'image-text' formType.value = props.defaultType ?? 'image-text'
title.value = '' title.value = ''
description.value = '' description.value = ''
content.value = '' content.value = ''
aspectRatio.value = 1.33 aspectRatio.value = 1.33
categoryId.value = null categoryId.value = props.defaultCategoryId ?? null
imageUrl.value = '' imageUrl.value = ''
selectedTagIds.value = [] selectedTagIds.value = []
selectedArticleIds.value = [] selectedArticleIds.value = []

145
app/components/index/CategoryFormModal.vue

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CategoryNode } from './CategoryTreeNode.vue' import type { CategoryNode } from './CategoryTreeNode.vue'
import { CARD_TYPE_OPTIONS, type CardType } from '~/config/cardTypes'
export type CategoryFormMode = 'create' | 'rename' | 'cover' | 'delete' | 'move' export type CategoryFormMode = 'create' | 'rename' | 'cover' | 'delete' | 'move'
@ -13,15 +14,14 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:visible': [v: boolean] 'update:visible': [v: boolean]
submit: [data: { mode: CategoryFormMode; nodeId: string | null; name?: string; slug?: string; image?: string; targetParentId?: string | null }] submit: [data: { mode: CategoryFormMode; nodeId: string | null; name?: string; image?: string; targetParentId?: string | null; defaultCardType?: string | null }]
}>() }>()
// ── Form state ── // ── Form state ──
const name = ref('') const name = ref('')
const image = ref('') const image = ref('')
const slugManual = ref('') const defaultCardType = ref<string | null>(null)
const slugTouched = ref(false)
const saving = ref(false) const saving = ref(false)
const error = ref('') const error = ref('')
const moveTargetId = ref<string | null>(null) const moveTargetId = ref<string | null>(null)
@ -39,34 +39,6 @@ const title = computed(() => {
} }
}) })
const autoSlug = computed(() => {
return name.value
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.slice(0, 100)
})
// Sync auto-slug to manual field if user hasn't touched it
watch(autoSlug, (val) => {
if (!slugTouched.value && val) {
slugManual.value = val
}
})
function onSlugInput() {
slugTouched.value = true
}
function fallbackSlug(): string {
return `cat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
const effectiveSlug = computed(() => {
return slugManual.value.trim() || autoSlug.value || fallbackSlug()
})
// Flatten tree for move picker // Flatten tree for move picker
interface FlatOption { id: string; name: string; depth: number } interface FlatOption { id: string; name: string; depth: number }
const flatOptions = computed<FlatOption[]>(() => { const flatOptions = computed<FlatOption[]>(() => {
@ -91,17 +63,16 @@ watch(() => props.visible, (v) => {
saving.value = false saving.value = false
moveTargetId.value = null moveTargetId.value = null
slugManual.value = ''
slugTouched.value = false
switch (props.mode) { switch (props.mode) {
case 'create': case 'create':
name.value = '' name.value = ''
image.value = '' image.value = ''
defaultCardType.value = null
break break
case 'rename': case 'rename':
name.value = props.node?.name ?? '' name.value = props.node?.name ?? ''
image.value = '' image.value = ''
defaultCardType.value = props.node?.defaultCardType ?? null
break break
case 'cover': case 'cover':
name.value = '' name.value = ''
@ -147,7 +118,6 @@ async function onSubmit() {
break break
} }
case 'cover': { case 'cover': {
if (!image.value.trim()) { error.value = '请输入封面图片 URL'; return }
break break
} }
case 'delete': case 'delete':
@ -162,9 +132,9 @@ async function onSubmit() {
mode: props.mode, mode: props.mode,
nodeId: props.node?.id ?? null, nodeId: props.node?.id ?? null,
name: props.mode === 'create' || props.mode === 'rename' ? name.value.trim() : undefined, name: props.mode === 'create' || props.mode === 'rename' ? name.value.trim() : undefined,
slug: props.mode === 'create' ? effectiveSlug.value : (props.mode === 'rename' ? effectiveSlug.value : undefined),
image: props.mode === 'cover' ? image.value.trim() : undefined, image: props.mode === 'cover' ? image.value.trim() : undefined,
targetParentId: props.mode === 'move' ? moveTargetId.value : (props.mode === 'create' ? (props.parentNode?.id ?? null) : undefined), targetParentId: props.mode === 'move' ? moveTargetId.value : (props.mode === 'create' ? (props.parentNode?.id ?? null) : undefined),
defaultCardType: (props.mode === 'create' || props.mode === 'rename') ? defaultCardType.value : undefined,
}) })
} finally { } finally {
saving.value = false saving.value = false
@ -211,27 +181,29 @@ function close() {
/> />
</div> </div>
<div class="cat-field"> <div class="cat-field">
<label class="cat-label">标识(slug)</label> <label class="cat-label">默认卡片类型</label>
<input <span class="cat-hint">新建卡片时自动选中此类型,留空则不预设</span>
v-model="slugManual" <div class="type-grid">
type="text" <button
class="cat-input" v-for="ct in CARD_TYPE_OPTIONS"
:class="{ 'cat-input-muted': !slugTouched && !!autoSlug }" :key="ct.value"
placeholder="自动生成或手动输入" type="button"
:disabled="saving" class="type-chip"
@input="onSlugInput" :class="{ active: defaultCardType === ct.value }"
/> :disabled="saving"
<span class="cat-hint"> @click="defaultCardType = defaultCardType === ct.value ? null : ct.value"
<template v-if="!slugManual.trim() && !autoSlug">名称无法自动生成,将随机生成标识</template> >
<template v-else>用于 URL 路径,可手动修改</template> <Icon :name="ct.icon" class="type-chip-icon" />
</span> <span>{{ ct.label }}</span>
</button>
</div>
</div> </div>
</template> </template>
<!-- Change Cover --> <!-- Change Cover -->
<template v-if="mode === 'cover'"> <template v-if="mode === 'cover'">
<div class="cat-field"> <div class="cat-field">
<label class="cat-label"><span class="cat-required">*</span> 封面图片 URL</label> <label class="cat-label">封面图片 URL</label>
<input <input
v-model="image" v-model="image"
type="url" type="url"
@ -241,10 +213,21 @@ function close() {
autofocus autofocus
@keyup.enter="onSubmit" @keyup.enter="onSubmit"
/> />
<span class="cat-hint">留空并保存可清除封面</span>
</div> </div>
<div v-if="image" class="cat-cover-preview"> <div v-if="image" class="cat-cover-preview">
<img :src="image" alt="封面预览" @error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }" /> <img :src="image" alt="封面预览" @error="(e) => { (e.target as HTMLImageElement).style.display = 'none' }" />
</div> </div>
<button
v-if="node?.image"
type="button"
class="cat-btn cat-btn-clear"
:disabled="saving"
@click="image = ''; onSubmit()"
>
<Icon name="lucide:trash-2" />
清除封面
</button>
</template> </template>
<!-- Delete --> <!-- Delete -->
@ -546,4 +529,64 @@ function close() {
.cat-btn-danger:hover:not(:disabled) { .cat-btn-danger:hover:not(:disabled) {
background: #a83838; background: #a83838;
} }
.cat-btn-clear {
width: 100%;
margin-top: 4px;
color: var(--color-muted);
background: transparent;
border: 1px dashed var(--color-hairline);
justify-content: center;
}
.cat-btn-clear:hover:not(:disabled) {
color: var(--color-error, #c64545);
border-color: var(--color-error, #c64545);
background: rgba(198, 69, 69, 0.06);
}
/* ── Card type selector ── */
.type-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.type-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 10px;
border: 1.5px solid var(--color-hairline);
background: var(--color-canvas);
color: var(--color-body);
cursor: pointer;
transition: all 0.15s ease;
font-family: var(--font-body);
}
.type-chip:hover:not(:disabled) {
border-color: var(--color-muted);
}
.type-chip.active {
border-color: var(--color-primary);
background: rgba(204, 120, 92, 0.08);
color: var(--color-primary);
}
.type-chip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.type-chip-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
</style> </style>

1
app/components/index/CategoryTreeNode.vue

@ -4,6 +4,7 @@ export interface CategoryNode {
name: string name: string
image?: string image?: string
count?: number count?: number
defaultCardType?: string | null
children?: CategoryNode[] children?: CategoryNode[]
} }

207
app/components/index/WaterfallDigestCard.vue

@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
const props = defineProps<{
title: string
description: string
content: string
tags: string[]
aspectRatio: number
categoryName?: string
}>()
const contentHtml = computed(() => {
if (!props.content) return ''
return marked(props.content, { breaks: true }) as string
})
</script>
<template>
<div class="card">
<span v-if="categoryName" class="card-cat-chip">{{ categoryName }}</span>
<!-- 上部分:正文内容预览 -->
<div class="card-preview">
<div class="preview-text" v-html="contentHtml" />
<div class="content-fade" />
</div>
<!-- 下部分:标签 / 标题 / 描述 -->
<div class="card-footer">
<div v-if="tags && tags.length" class="tags">
<span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<h3>{{ title }}</h3>
<p v-if="description" class="desc">{{ description }}</p>
</div>
</div>
</template>
<style scoped>
.card-cat-chip {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 9999px;
background: rgba(204, 120, 92, 0.12);
color: var(--color-primary);
letter-spacing: 0.3px;
}
.card {
position: relative;
border-radius: 12px;
background: var(--color-surface-card);
overflow: hidden;
border: 1px solid var(--color-hairline-soft);
display: flex;
flex-direction: column;
transition: transform 0.4s var(--ease-out-expo), box-shadow 0.4s var(--ease-out-expo);
}
.card:hover {
transform: translateY(-2px) scale(1.005);
box-shadow: 0 8px 24px rgba(20, 20, 19, 0.08);
}
/* ── 上部分:正文预览 ── */
.card-preview {
position: relative;
padding: 20px 20px 16px;
}
.preview-text {
font-size: 13px;
font-weight: 400;
line-height: 1.7;
color: var(--color-body);
max-height: 96px;
overflow: hidden;
}
/* markdown 元素降级样式(预览态,不让标题/代码过于抢眼) */
.preview-text :deep(h1),
.preview-text :deep(h2),
.preview-text :deep(h3) {
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: var(--color-ink);
margin: 0 0 4px;
}
.preview-text :deep(p) {
margin: 0 0 4px;
}
.preview-text :deep(ul),
.preview-text :deep(ol) {
margin: 0 0 4px;
padding-left: 18px;
}
.preview-text :deep(li) {
margin-bottom: 2px;
}
.preview-text :deep(code) {
font-size: 12px;
background: var(--color-surface-cream-strong);
padding: 1px 5px;
border-radius: 4px;
}
.preview-text :deep(pre) {
font-size: 12px;
background: var(--color-surface-cream-strong);
padding: 8px 12px;
border-radius: 6px;
margin: 0 0 6px;
overflow: hidden;
}
.preview-text :deep(blockquote) {
margin: 0 0 4px;
padding-left: 12px;
border-left: 2px solid var(--color-hairline);
color: var(--color-muted);
}
.preview-text :deep(a) {
color: var(--color-primary);
pointer-events: none;
}
.preview-text :deep(img) {
display: none;
}
.content-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 52px;
background: linear-gradient(
to bottom,
transparent 0%,
var(--color-surface-card) 100%
);
pointer-events: none;
}
/* ── 下部分:标签 / 标题 / 描述 ── */
.card-footer {
padding: 0 20px 20px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.tag {
font-size: 11px;
font-weight: 500;
color: var(--color-primary);
background: rgba(204, 120, 92, 0.08);
padding: 3px 10px;
border-radius: 9999px;
line-height: 1.3;
}
.card-footer h3 {
font-family: var(--font-display);
font-size: 15.5px;
font-weight: 400;
line-height: 1.3;
color: var(--color-ink);
margin: 0 0 6px;
letter-spacing: -0.01em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 1.55;
color: var(--color-muted);
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

16
app/config/cardTypes.ts

@ -13,6 +13,7 @@ import WaterfallCard from '~/components/index/WaterfallCard.vue'
import WaterfallTextCard from '~/components/index/WaterfallTextCard.vue' import WaterfallTextCard from '~/components/index/WaterfallTextCard.vue'
import WaterfallImageCard from '~/components/index/WaterfallImageCard.vue' import WaterfallImageCard from '~/components/index/WaterfallImageCard.vue'
import WaterfallProjectCard from '~/components/index/WaterfallProjectCard.vue' import WaterfallProjectCard from '~/components/index/WaterfallProjectCard.vue'
import WaterfallDigestCard from '~/components/index/WaterfallDigestCard.vue'
// ============ CardType 常量(与 drizzle schema 保持同步) ============ // ============ CardType 常量(与 drizzle schema 保持同步) ============
export const CardTypes = [ export const CardTypes = [
@ -20,6 +21,7 @@ export const CardTypes = [
'image', 'image',
'image-text', 'image-text',
'project', 'project',
'digest',
] as const ] as const
export type CardType = (typeof CardTypes)[number] export type CardType = (typeof CardTypes)[number]
@ -117,6 +119,20 @@ export const cardTypeRegistry: Record<CardType, CardTypeConfig> = {
categoryName: item.categoryName, categoryName: item.categoryName,
}), }),
}, },
digest: {
component: WaterfallDigestCard,
label: '摘要',
icon: 'lucide:scroll-text',
formFields: { body: true, description: true, tags: true },
mapProps: (item) => ({
title: item.title,
description: item.description ?? '',
content: item.content ?? '',
tags: item.tags ?? [],
aspectRatio: item.aspectRatio,
categoryName: item.categoryName,
}),
},
} }
// ============ 派生数据(供表单等使用) ============ // ============ 派生数据(供表单等使用) ============

48
app/pages/collect/index.vue

@ -38,11 +38,11 @@ function onConfirm() {
interface ServerCategory { interface ServerCategory {
id: string id: string
name: string name: string
slug: string
image: string | null image: string | null
parentId: string | null parentId: string | null
sortOrder: number sortOrder: number
count: number count: number
defaultCardType: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
children: ServerCategory[] children: ServerCategory[]
@ -100,6 +100,7 @@ function mapCategoryTree(server: ServerCategory[]): CategoryNode[] {
name: node.name, name: node.name,
image: node.image ?? undefined, image: node.image ?? undefined,
count: node.count, count: node.count,
defaultCardType: node.defaultCardType,
children: node.children.length > 0 ? node.children.map(map) : undefined, children: node.children.length > 0 ? node.children.map(map) : undefined,
} }
} }
@ -108,9 +109,12 @@ function mapCategoryTree(server: ServerCategory[]): CategoryNode[] {
// ── Sidebar state ── // ── Sidebar state ──
const activeToolKey = ref<string>('home') const tabFromUrl = route.query.tab as string | undefined
const activeToolKey = ref<string>(
tabFromUrl && ['home', 'search', 'collect'].includes(tabFromUrl) ? tabFromUrl : 'home',
)
const activeCategoryId = ref<string>('all') const activeCategoryId = ref<string>('all')
const searchQuery = ref('') const searchQuery = ref((route.query.q as string) ?? '')
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const categories = ref<CategoryNode[]>([]) const categories = ref<CategoryNode[]>([])
@ -164,6 +168,19 @@ const activeCategoryName = computed(() => {
return find(categories.value)?.name ?? '全部卡片' return find(categories.value)?.name ?? '全部卡片'
}) })
const defaultCardType = computed(() => {
if (activeToolKey.value !== 'home') return undefined
if (activeCategoryId.value === 'all') return undefined
const node = findNode(categories.value, activeCategoryId.value)
return node?.defaultCardType || undefined
})
const defaultCardCategoryId = computed(() => {
if (activeToolKey.value !== 'home') return null
if (activeCategoryId.value === 'all' || activeCategoryId.value === '__uncategorized__') return null
return activeCategoryId.value
})
// ── Tool selection ── // ── Tool selection ──
const showTagsModal = ref(false) const showTagsModal = ref(false)
@ -196,9 +213,11 @@ function onSelectTool(key: string) {
switch (key) { switch (key) {
case 'home': case 'home':
activeCategoryId.value = 'all' activeCategoryId.value = 'all'
searchQuery.value = ''
break break
case 'collect': case 'collect':
activeCategoryId.value = 'all' activeCategoryId.value = 'all'
searchQuery.value = ''
break break
case 'tags': case 'tags':
showTagsModal.value = true showTagsModal.value = true
@ -296,9 +315,9 @@ async function onCatFormSubmit(data: {
mode: CategoryFormMode mode: CategoryFormMode
nodeId: string | null nodeId: string | null
name?: string name?: string
slug?: string
image?: string image?: string
targetParentId?: string | null targetParentId?: string | null
defaultCardType?: string | null
}) { }) {
try { try {
switch (data.mode) { switch (data.mode) {
@ -307,8 +326,8 @@ async function onCatFormSubmit(data: {
method: 'POST', method: 'POST',
body: { body: {
name: data.name!, name: data.name!,
slug: data.slug!,
parentId: data.targetParentId || null, parentId: data.targetParentId || null,
defaultCardType: data.defaultCardType ?? null,
}, },
}) })
$toast.success(`已创建分类「${data.name}」`) $toast.success(`已创建分类「${data.name}」`)
@ -319,7 +338,7 @@ async function onCatFormSubmit(data: {
method: 'PUT', method: 'PUT',
body: { body: {
name: data.name!, name: data.name!,
...(data.slug ? { slug: data.slug } : {}), defaultCardType: data.defaultCardType ?? null,
}, },
}) })
$toast.success('已重命名') $toast.success('已重命名')
@ -328,7 +347,7 @@ async function onCatFormSubmit(data: {
case 'cover': { case 'cover': {
await request(`/api/categories/${data.nodeId}`, { await request(`/api/categories/${data.nodeId}`, {
method: 'PUT', method: 'PUT',
body: { image: data.image! }, body: { image: data.image || null },
}) })
$toast.success('封面已更新') $toast.success('封面已更新')
break break
@ -721,6 +740,19 @@ watch(activeToolKey, () => {
resetAndReload() resetAndReload()
}) })
// Sync activeToolKey to URL so refresh restores the tab
watch(activeToolKey, (val) => {
const currentTab = route.query.tab as string | undefined
if (val === 'home') {
if (currentTab) {
const { tab, ...rest } = route.query
router.replace({ query: rest })
}
} else if (currentTab !== val) {
router.replace({ query: { ...route.query, tab: val } })
}
})
// Debounced sync from search input to route.query.q // Debounced sync from search input to route.query.q
let searchDebounce: ReturnType<typeof setTimeout> | null = null let searchDebounce: ReturnType<typeof setTimeout> | null = null
watch(searchQuery, (val) => { watch(searchQuery, (val) => {
@ -1033,6 +1065,8 @@ onUnmounted(() => {
:mode="cardFormMode" :mode="cardFormMode"
:card="cardFormEditData" :card="cardFormEditData"
:categories="categories" :categories="categories"
:default-type="defaultCardType"
:default-category-id="defaultCardCategoryId"
@update:visible="(v) => showCardFormModal = v" @update:visible="(v) => showCardFormModal = v"
@saved="onCardSaved" @saved="onCardSaved"
/> />

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

4
packages/drizzle-pkg/lib/schema/content.ts

@ -15,6 +15,7 @@ export const CardTypes = [
"image", "image",
"image-text", "image-text",
"project", "project",
"digest",
] as const; ] as const;
export type CardType = (typeof CardTypes)[number]; export type CardType = (typeof CardTypes)[number];
@ -24,11 +25,11 @@ export const categories = sqliteTable(
{ {
id: text("id").primaryKey(), // UUID id: text("id").primaryKey(), // UUID
name: text("name", { length: 100 }).notNull(), name: text("name", { length: 100 }).notNull(),
slug: text("slug", { length: 100 }).notNull(),
image: text("image", { length: 500 }), image: text("image", { length: 500 }),
parentId: text("parent_id"), // 自引用 FK,NULL=顶级 parentId: text("parent_id"), // 自引用 FK,NULL=顶级
sortOrder: integer("sort_order").default(0).notNull(), sortOrder: integer("sort_order").default(0).notNull(),
count: integer("count").default(0).notNull(), count: integer("count").default(0).notNull(),
defaultCardType: text("default_card_type", { enum: CardTypes }),
createdAt: integer("created_at", { mode: "timestamp_ms" }) createdAt: integer("created_at", { mode: "timestamp_ms" })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
@ -38,7 +39,6 @@ export const categories = sqliteTable(
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
uniqueIndex("idx_category_slug").on(table.slug),
index("idx_category_parent").on(table.parentId), index("idx_category_parent").on(table.parentId),
], ],
); );

1
packages/drizzle-pkg/migrations/0008_handy_grey_gargoyle.sql

@ -0,0 +1 @@
ALTER TABLE `categories` ADD `default_card_type` text;

2
packages/drizzle-pkg/migrations/0009_eminent_mikhail_rasputin.sql

@ -0,0 +1,2 @@
DROP INDEX `idx_category_slug`;--> statement-breakpoint
ALTER TABLE `categories` DROP COLUMN `slug`;

1249
packages/drizzle-pkg/migrations/meta/0008_snapshot.json

File diff suppressed because it is too large

1235
packages/drizzle-pkg/migrations/meta/0009_snapshot.json

File diff suppressed because it is too large

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

@ -57,6 +57,20 @@
"when": 1780705829924, "when": 1780705829924,
"tag": "0007_gigantic_franklin_storm", "tag": "0007_gigantic_franklin_storm",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1780902819250,
"tag": "0008_handy_grey_gargoyle",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1780903757294,
"tag": "0009_eminent_mikhail_rasputin",
"breakpoints": true
} }
] ]
} }

7
server/api/categories/[id].put.ts

@ -5,15 +5,10 @@ import { updateCategory, getCategoryById } from "../../service/category";
const updateSchema = z.object({ const updateSchema = z.object({
name: z.string().min(1, "名称不能为空").max(100, "名称不能超过 100 字").optional(), name: z.string().min(1, "名称不能为空").max(100, "名称不能超过 100 字").optional(),
slug: z
.string()
.min(1, "标识不能为空")
.max(100)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "标识只能包含小写字母、数字和连字符")
.optional(),
image: z.string().max(500, "图片地址不能超过 500 字").nullable().optional(), image: z.string().max(500, "图片地址不能超过 500 字").nullable().optional(),
parentId: z.string().nullable().optional(), parentId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
defaultCardType: z.enum(['text', 'image', 'image-text', 'project', 'digest']).nullable().optional(),
}); });
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {

6
server/api/categories/index.post.ts

@ -5,13 +5,9 @@ import { createCategory } from "../../service/category";
const createSchema = z.object({ const createSchema = z.object({
name: z.string().min(1, "名称不能为空").max(100, "名称不能超过 100 字"), name: z.string().min(1, "名称不能为空").max(100, "名称不能超过 100 字"),
slug: z
.string()
.min(1, "标识不能为空")
.max(100)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "标识只能包含小写字母、数字和连字符"),
parentId: z.string().nullable().optional(), parentId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
defaultCardType: z.enum(['text', 'image', 'image-text', 'project', 'digest']).nullable().optional(),
}); });
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {

14
server/service/category/index.ts

@ -1,16 +1,16 @@
import { dbGlobal } from "drizzle-pkg/lib/db"; import { dbGlobal } from "drizzle-pkg/lib/db";
import { categories, cards } from "drizzle-pkg/lib/schema/content"; import { categories, cards, type CardType } from "drizzle-pkg/lib/schema/content";
import { eq, asc } from "drizzle-orm"; import { eq, asc } from "drizzle-orm";
// ============ Types ============ // ============ Types ============
export interface CategoryTreeNode { export interface CategoryTreeNode {
id: string; id: string;
name: string; name: string;
slug: string;
image: string | null; image: string | null;
parentId: string | null; parentId: string | null;
sortOrder: number; sortOrder: number;
count: number; count: number;
defaultCardType: CardType | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
children: CategoryTreeNode[]; children: CategoryTreeNode[];
@ -18,19 +18,19 @@ export interface CategoryTreeNode {
export interface CreateCategoryInput { export interface CreateCategoryInput {
name: string; name: string;
slug: string;
image?: string; image?: string;
parentId?: string | null; parentId?: string | null;
sortOrder?: number; sortOrder?: number;
defaultCardType?: CardType | null;
} }
export interface UpdateCategoryInput { export interface UpdateCategoryInput {
name?: string; name?: string;
slug?: string;
image?: string | null; image?: string | null;
parentId?: string | null; parentId?: string | null;
sortOrder?: number; sortOrder?: number;
count?: number; count?: number;
defaultCardType?: CardType | null;
} }
// ============ Helpers ============ // ============ Helpers ============
@ -67,11 +67,11 @@ export async function createCategory(input: CreateCategoryInput) {
await dbGlobal.insert(categories).values({ await dbGlobal.insert(categories).values({
id, id,
name: input.name, name: input.name,
slug: input.slug,
image: input.image ?? null, image: input.image ?? null,
parentId: input.parentId ?? null, parentId: input.parentId ?? null,
sortOrder: input.sortOrder ?? 0, sortOrder: input.sortOrder ?? 0,
count: 0, count: 0,
defaultCardType: input.defaultCardType ?? null,
}); });
return getCategoryById(id); return getCategoryById(id);
} }
@ -84,11 +84,11 @@ export async function updateCategory(id: string, input: UpdateCategoryInput) {
.update(categories) .update(categories)
.set({ .set({
...(input.name !== undefined && { name: input.name }), ...(input.name !== undefined && { name: input.name }),
...(input.slug !== undefined && { slug: input.slug }),
...(input.image !== undefined && { image: input.image }), ...(input.image !== undefined && { image: input.image }),
...(input.parentId !== undefined && { parentId: input.parentId }), ...(input.parentId !== undefined && { parentId: input.parentId }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }), ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
...(input.count !== undefined && { count: input.count }), ...(input.count !== undefined && { count: input.count }),
...(input.defaultCardType !== undefined && { defaultCardType: input.defaultCardType }),
updatedAt: now(), updatedAt: now(),
}) })
.where(eq(categories.id, id)); .where(eq(categories.id, id));
@ -118,11 +118,11 @@ function buildTree(
map.set(row.id, { map.set(row.id, {
id: row.id, id: row.id,
name: row.name, name: row.name,
slug: row.slug,
image: row.image, image: row.image,
parentId: row.parentId, parentId: row.parentId,
sortOrder: row.sortOrder, sortOrder: row.sortOrder,
count: row.count, count: row.count,
defaultCardType: row.defaultCardType,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
children: [], children: [],

Loading…
Cancel
Save