Browse Source
- Implemented GET endpoint for listing ideas with pagination. - Implemented POST endpoint for creating new ideas with validation. - Added service functions for listing and creating ideas, including random color assignment. - Created database schema for ideas in the migration files. - Updated migration journal to reflect new versioning and changes.acas
17 changed files with 3534 additions and 41 deletions
@ -0,0 +1,207 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
export interface IdeaItem { |
||||
|
id: number |
||||
|
author: string | null |
||||
|
content: string |
||||
|
platform: string | null |
||||
|
color: string | null |
||||
|
createdAt: string |
||||
|
} |
||||
|
|
||||
|
const props = defineProps<{ |
||||
|
idea: IdeaItem |
||||
|
index: number |
||||
|
focused: boolean |
||||
|
}>() |
||||
|
|
||||
|
const emit = defineEmits<{ |
||||
|
select: [id: number] |
||||
|
}>() |
||||
|
|
||||
|
const cardColor = computed(() => props.idea.color || '#cc785c') |
||||
|
|
||||
|
// Convert hex to lighter variant for background |
||||
|
function hexToRgba(hex: string, alpha: number): string { |
||||
|
const r = parseInt(hex.slice(1, 3), 16) |
||||
|
const g = parseInt(hex.slice(3, 5), 16) |
||||
|
const b = parseInt(hex.slice(5, 7), 16) |
||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})` |
||||
|
} |
||||
|
|
||||
|
const bgColor = computed(() => hexToRgba(cardColor.value, 0.12)) |
||||
|
const borderColor = computed(() => hexToRgba(cardColor.value, 0.3)) |
||||
|
const accentDot = computed(() => cardColor.value) |
||||
|
|
||||
|
// Random subtle rotation for sticky-note feel |
||||
|
const tilt = computed(() => { |
||||
|
// Use id as seed for consistent rotation |
||||
|
const seed = props.idea.id * 7 + 3 |
||||
|
return (seed % 5) - 2 // -2 to +2 degrees |
||||
|
}) |
||||
|
|
||||
|
const formattedDate = computed(() => { |
||||
|
const d = new Date(props.idea.createdAt) |
||||
|
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div |
||||
|
:class="['idea-card', { 'idea-card--focused': focused }]" |
||||
|
:style="{ |
||||
|
'--card-bg': bgColor, |
||||
|
'--card-border': borderColor, |
||||
|
'--card-accent': accentDot, |
||||
|
'--card-tilt': tilt + 'deg', |
||||
|
'--card-delay': index * 60 + 'ms', |
||||
|
}" |
||||
|
@click="emit('select', idea.id)" |
||||
|
> |
||||
|
<div class="card-inner"> |
||||
|
<div class="card-accent-dot" /> |
||||
|
<span v-if="idea.platform" class="card-platform">{{ idea.platform }}</span> |
||||
|
<p class="card-content">{{ idea.content }}</p> |
||||
|
<div class="card-footer"> |
||||
|
<span class="card-author">{{ idea.author || '匿名' }}</span> |
||||
|
<span class="card-date">{{ formattedDate }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.idea-card { |
||||
|
--card-bg: rgba(204, 120, 92, 0.12); |
||||
|
--card-border: rgba(204, 120, 92, 0.3); |
||||
|
--card-accent: #cc785c; |
||||
|
--card-tilt: 0deg; |
||||
|
--card-delay: 0ms; |
||||
|
|
||||
|
background: var(--card-bg); |
||||
|
border: 1px solid var(--card-border); |
||||
|
border-radius: 12px; |
||||
|
cursor: pointer; |
||||
|
transform: rotate(var(--card-tilt)); |
||||
|
transition: all 0.35s var(--ease-out-quart, cubic-bezier(0.25, 1, 0.5, 1)); |
||||
|
animation: card-enter 0.5s var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both; |
||||
|
animation-delay: var(--card-delay); |
||||
|
position: relative; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.idea-card::before { |
||||
|
content: ''; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background: linear-gradient(135deg, var(--card-accent) 0%, transparent 60%); |
||||
|
opacity: 0; |
||||
|
transition: opacity 0.35s ease; |
||||
|
border-radius: 12px; |
||||
|
z-index: 0; |
||||
|
} |
||||
|
|
||||
|
.idea-card:hover { |
||||
|
transform: rotate(0deg) translateY(-4px) scale(1.03); |
||||
|
border-color: var(--card-accent); |
||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08); |
||||
|
} |
||||
|
|
||||
|
.idea-card:hover::before { |
||||
|
opacity: 0.06; |
||||
|
} |
||||
|
|
||||
|
.idea-card--focused { |
||||
|
transform: rotate(0deg) scale(1.02); |
||||
|
border-color: var(--card-accent); |
||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12); |
||||
|
z-index: 10; |
||||
|
background: var(--card-bg); |
||||
|
} |
||||
|
|
||||
|
.idea-card--focused::before { |
||||
|
opacity: 0.1; |
||||
|
} |
||||
|
|
||||
|
.card-inner { |
||||
|
position: relative; |
||||
|
z-index: 1; |
||||
|
padding: 20px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
min-height: 120px; |
||||
|
} |
||||
|
|
||||
|
.card-accent-dot { |
||||
|
width: 8px; |
||||
|
height: 8px; |
||||
|
border-radius: 50%; |
||||
|
background: var(--card-accent); |
||||
|
margin-bottom: 12px; |
||||
|
opacity: 0.8; |
||||
|
} |
||||
|
|
||||
|
.card-platform { |
||||
|
display: inline-block; |
||||
|
align-self: flex-start; |
||||
|
padding: 2px 10px; |
||||
|
font-size: 11px; |
||||
|
font-weight: 600; |
||||
|
color: var(--card-accent); |
||||
|
background: var(--card-bg); |
||||
|
border: 1px solid var(--card-border); |
||||
|
border-radius: 12px; |
||||
|
margin-bottom: 10px; |
||||
|
letter-spacing: 0.02em; |
||||
|
} |
||||
|
|
||||
|
.card-content { |
||||
|
font-size: 15px; |
||||
|
line-height: 1.6; |
||||
|
color: var(--color-body, #3d3d3a); |
||||
|
margin: 0 0 14px; |
||||
|
flex: 1; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 4; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
word-break: break-word; |
||||
|
} |
||||
|
|
||||
|
.card-footer { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.card-author { |
||||
|
color: var(--card-accent); |
||||
|
font-weight: 600; |
||||
|
letter-spacing: 0.02em; |
||||
|
} |
||||
|
|
||||
|
.card-date { |
||||
|
color: var(--color-muted-soft, #8e8b82); |
||||
|
} |
||||
|
|
||||
|
@keyframes card-enter { |
||||
|
from { |
||||
|
opacity: 0; |
||||
|
transform: rotate(var(--card-tilt)) translateY(20px) scale(0.95); |
||||
|
} |
||||
|
to { |
||||
|
opacity: 1; |
||||
|
transform: rotate(var(--card-tilt)) translateY(0) scale(1); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@media (prefers-reduced-motion: reduce) { |
||||
|
.idea-card { |
||||
|
animation: none; |
||||
|
transition: none; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
Binary file not shown.
@ -1 +0,0 @@ |
|||||
ALTER TABLE `cards` ADD `content` text; |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
CREATE TABLE `ideas` ( |
||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, |
||||
|
`author` text(30) NOT NULL, |
||||
|
`content` text NOT NULL, |
||||
|
`color` text(30), |
||||
|
`created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL |
||||
|
); |
||||
|
--> statement-breakpoint |
||||
|
CREATE INDEX `idx_idea_created` ON `ideas` (`created_at`); |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE ideas ADD platform text(30); |
||||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,10 @@ |
|||||
|
import { listIdeas } from "../../service/ideas"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const query = getQuery(event); |
||||
|
const page = Math.max(1, parseInt(String(query.page || "1"))); |
||||
|
const pageSize = Math.min(50, Math.max(1, parseInt(String(query.pageSize || "30")))); |
||||
|
|
||||
|
const result = await listIdeas({ page, pageSize }); |
||||
|
return R.success(result); |
||||
|
}); |
||||
@ -0,0 +1,18 @@ |
|||||
|
import { z } from "zod"; |
||||
|
import { validate } from "#server/utils/validation"; |
||||
|
import { createIdea } from "../../service/ideas"; |
||||
|
|
||||
|
const createSchema = z.object({ |
||||
|
author: z.string().max(30, "昵称不能超过 30 字").nullable().optional(), |
||||
|
content: z.string().min(1, "想法不能为空").max(500, "想法不能超过 500 字"), |
||||
|
platform: z.string().max(30).nullable().optional(), |
||||
|
color: z.string().max(30).nullable().optional(), |
||||
|
}); |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const body = await readBody(event); |
||||
|
const data = validate(createSchema, body); |
||||
|
|
||||
|
const idea = await createIdea(data); |
||||
|
return R.success(idea); |
||||
|
}); |
||||
@ -0,0 +1,106 @@ |
|||||
|
import { dbGlobal } from "drizzle-pkg/lib/db"; |
||||
|
import { ideas } from "drizzle-pkg/lib/schema/content"; |
||||
|
import { desc, sql } from "drizzle-orm"; |
||||
|
|
||||
|
// ============ Types ============
|
||||
|
export interface IdeaItem { |
||||
|
id: number; |
||||
|
author: string | null; |
||||
|
content: string; |
||||
|
platform: string | null; |
||||
|
color: string | null; |
||||
|
createdAt: Date; |
||||
|
} |
||||
|
|
||||
|
export interface CreateIdeaInput { |
||||
|
author: string | null; |
||||
|
content: string; |
||||
|
platform?: string | null; |
||||
|
color?: string | null; |
||||
|
} |
||||
|
|
||||
|
// ============ Color palette for random assignment ============
|
||||
|
const IDEA_COLORS = [ |
||||
|
"#cc785c", // primary coral
|
||||
|
"#5db8a6", // accent teal
|
||||
|
"#e8a55a", // accent amber
|
||||
|
"#8b7ec8", // soft purple
|
||||
|
"#6fa8dc", // soft blue
|
||||
|
"#e07b7b", // soft red
|
||||
|
"#7eb77f", // soft green
|
||||
|
"#c9a0dc", // lavender
|
||||
|
]; |
||||
|
|
||||
|
export function randomIdeaColor(): string { |
||||
|
return IDEA_COLORS[Math.floor(Math.random() * IDEA_COLORS.length)]; |
||||
|
} |
||||
|
|
||||
|
// ============ CRUD ============
|
||||
|
|
||||
|
export async function listIdeas(opts: { |
||||
|
page?: number; |
||||
|
pageSize?: number; |
||||
|
}): Promise<{ items: IdeaItem[]; total: number; page: number; pageSize: number; hasMore: boolean }> { |
||||
|
const page = opts.page ?? 1; |
||||
|
const pageSize = opts.pageSize ?? 30; |
||||
|
|
||||
|
const [rows, countResult] = await Promise.all([ |
||||
|
dbGlobal |
||||
|
.select() |
||||
|
.from(ideas) |
||||
|
.orderBy(desc(ideas.createdAt)) |
||||
|
.limit(pageSize) |
||||
|
.offset((page - 1) * pageSize), |
||||
|
dbGlobal |
||||
|
.select({ count: sql<number>`count(*)` }) |
||||
|
.from(ideas), |
||||
|
]); |
||||
|
|
||||
|
// $count returns differently — use sql count as fallback
|
||||
|
let total = 0; |
||||
|
if (countResult && countResult.length > 0) { |
||||
|
total = (countResult[0] as any).count ?? 0; |
||||
|
} |
||||
|
|
||||
|
const items: IdeaItem[] = rows.map((row) => ({ |
||||
|
id: row.id, |
||||
|
author: row.author, |
||||
|
content: row.content, |
||||
|
platform: row.platform, |
||||
|
color: row.color, |
||||
|
createdAt: row.createdAt, |
||||
|
})); |
||||
|
|
||||
|
return { |
||||
|
items, |
||||
|
total, |
||||
|
page, |
||||
|
pageSize, |
||||
|
hasMore: page * pageSize < total, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export async function createIdea(input: CreateIdeaInput): Promise<IdeaItem> { |
||||
|
const color = input.color || randomIdeaColor(); |
||||
|
|
||||
|
const [inserted] = await dbGlobal |
||||
|
.insert(ideas) |
||||
|
.values({ |
||||
|
author: input.author || '', |
||||
|
content: input.content, |
||||
|
platform: input.platform || null, |
||||
|
color, |
||||
|
}) |
||||
|
.returning({ id: ideas.id }); |
||||
|
|
||||
|
if (!inserted) throw new Error("Failed to create idea"); |
||||
|
|
||||
|
return { |
||||
|
id: inserted.id, |
||||
|
author: input.author, |
||||
|
content: input.content, |
||||
|
platform: input.platform || null, |
||||
|
color, |
||||
|
createdAt: new Date(), |
||||
|
}; |
||||
|
} |
||||
Loading…
Reference in new issue