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