auto-save 2026-04-13 18:59 (+1)

This commit is contained in:
2026-04-13 19:00:17 +08:00
parent c35c1852af
commit 4bdc14f96c
88 changed files with 11213 additions and 0 deletions

135
web/app/globals.css Normal file
View File

@@ -0,0 +1,135 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
/* Surface 层级(沿用 BrandKit */
--background: oklch(0.985 0.005 260);
--surface: oklch(0.985 0.005 260);
--surface-elevated: oklch(1 0 0);
--surface-muted: oklch(0.96 0.01 260);
--foreground: oklch(0.15 0.02 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.15 0.02 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0.02 260);
/* 主色 - 蓝紫中性(候选 A */
--primary: oklch(0.58 0.18 275);
--primary-foreground: oklch(0.98 0 0);
--primary-light: oklch(0.68 0.15 275);
--primary-subtle: oklch(0.96 0.03 275);
--secondary: oklch(0.96 0.01 260);
--secondary-foreground: oklch(0.13 0.02 260);
--muted: oklch(0.94 0.01 260);
--muted-foreground: oklch(0.50 0.02 260);
--accent: oklch(0.58 0.18 275);
--accent-foreground: oklch(0.98 0 0);
/* 状态色 */
--success: oklch(0.65 0.16 155);
--warning: oklch(0.72 0.15 75);
--danger: oklch(0.60 0.22 25);
--info: oklch(0.65 0.12 230);
--destructive: oklch(0.60 0.22 25);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.92 0.01 260);
--border-strong: oklch(0.86 0.01 260);
--input: oklch(0.92 0.01 260);
--ring: oklch(0.58 0.18 275);
--chart-1: oklch(0.58 0.18 275);
--chart-2: oklch(0.70 0.18 260);
--chart-3: oklch(0.55 0.14 200);
--chart-4: oklch(0.65 0.20 30);
--chart-5: oklch(0.50 0.15 160);
--radius: 1rem;
}
.dark {
--background: oklch(0.14 0.01 260);
--surface: oklch(0.14 0.01 260);
--surface-elevated: oklch(0.18 0.01 260);
--surface-muted: oklch(0.22 0.01 260);
--foreground: oklch(0.95 0.005 260);
--card: oklch(0.18 0.01 260);
--card-foreground: oklch(0.95 0.005 260);
--popover: oklch(0.18 0.01 260);
--popover-foreground: oklch(0.95 0.005 260);
--primary: oklch(0.70 0.18 275);
--primary-foreground: oklch(0.14 0.01 260);
--muted: oklch(0.22 0.01 260);
--muted-foreground: oklch(0.65 0.01 260);
--border: oklch(0.28 0.01 260);
--border-strong: oklch(0.36 0.01 260);
--input: oklch(0.28 0.01 260);
--ring: oklch(0.70 0.18 275);
}
@theme inline {
--font-sans: var(--font-inter), 'Noto Sans SC', system-ui, sans-serif;
--font-mono: 'Geist Mono', 'Sarasa Mono SC', ui-monospace, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 6px);
--radius-md: calc(var(--radius) - 4px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 6px);
--color-success: var(--success);
--color-warning: var(--warning);
--color-danger: var(--danger);
--color-info: var(--info);
--color-surface: var(--surface);
--color-surface-elevated: var(--surface-elevated);
--color-surface-muted: var(--surface-muted);
--color-primary-light: var(--primary-light);
--color-primary-subtle: var(--primary-subtle);
--color-border-strong: var(--border-strong);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
}
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}

34
web/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from 'next'
import { Inter, Noto_Sans_SC } from 'next/font/google'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
weight: ['400', '500', '600', '700'],
display: 'swap',
})
const notoSC = Noto_Sans_SC({
subsets: ['latin'],
variable: '--font-noto-sc',
weight: ['400', '500', '600', '700'],
display: 'swap',
})
export const metadata: Metadata = {
title: 'MeetNote · 会议转写总结',
description: '手机录音一键上传,云端 Whisper 转写 + Claude 总结,中英混说精准。',
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className={`${inter.variable} ${notoSC.variable} font-sans`} suppressHydrationWarning>
{children}
</body>
</html>
)
}

View File

@@ -0,0 +1,256 @@
'use client'
import Link from 'next/link'
import { use, useState } from 'react'
import {
ArrowLeft,
Play,
Pause,
MoreHorizontal,
Star,
CheckSquare,
Target,
Hash,
Download,
} from 'lucide-react'
import { AppShell } from '@/components/app-shell'
import { getMeeting, formatDuration, formatTime, mockTranscript } from '@/lib/mock-data'
import { cn } from '@/lib/utils'
import { notFound } from 'next/navigation'
export default function MeetingDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const meeting = getMeeting(id)
const [playing, setPlaying] = useState(false)
const [tab, setTab] = useState<'transcript' | 'summary'>('transcript')
if (!meeting) notFound()
const done = meeting.status === 'done'
const summary = meeting.summary
return (
<AppShell>
<div className="flex h-screen flex-col">
{/* Header */}
<header className="border-b border-border bg-surface-elevated/95 backdrop-blur px-5 py-4 md:px-10">
<div className="mx-auto max-w-6xl">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<Link
href="/"
className="mt-1 flex h-8 w-8 items-center justify-center rounded-lg hover:bg-surface-muted shrink-0"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div className="min-w-0">
<h1 className="text-[22px] font-semibold tracking-tight truncate">
{meeting.title}
</h1>
<p className="mt-1 text-[13px] text-muted-foreground">
{new Date(meeting.date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}
{' · '}
{formatDuration(meeting.duration)}
{meeting.participants && ' · ' + meeting.participants.join(' ')}
</p>
</div>
</div>
<button className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-surface-muted">
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
{/* Player */}
<div className="mt-5 flex items-center gap-4 rounded-2xl bg-surface-muted px-4 py-3">
<button
onClick={() => setPlaying(!playing)}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm hover:bg-primary-light"
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 translate-x-0.5" />}
</button>
<div className="flex-1">
<div className="relative h-1.5 overflow-hidden rounded-full bg-muted">
<div className="absolute inset-y-0 left-0 w-[12%] rounded-full bg-primary" />
</div>
<div className="mt-1.5 flex items-center justify-between text-[11px] font-mono text-muted-foreground">
<span>{formatTime(600)}</span>
<span>{formatTime(meeting.duration)}</span>
</div>
</div>
<button className="hidden md:block rounded-lg px-2 py-1 text-[12px] font-medium text-muted-foreground hover:bg-background">
1x
</button>
</div>
</div>
</header>
{/* Tabs mobile */}
<div className="md:hidden flex border-b border-border bg-surface-elevated">
{(['transcript', 'summary'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-3 text-[14px] font-medium transition-colors',
tab === t ? 'text-primary border-b-2 border-primary' : 'text-muted-foreground',
)}
>
{t === 'transcript' ? '转写' : '总结'}
</button>
))}
</div>
{/* Content */}
{done ? (
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl px-5 py-6 md:px-10 md:py-8">
<div className="grid gap-6 md:grid-cols-5">
{/* Transcript */}
<div
className={cn(
'md:col-span-3',
tab !== 'transcript' && 'hidden md:block',
)}
>
<div className="mb-4 flex items-center gap-2">
<h2 className="text-[15px] font-semibold"></h2>
<span className="text-[12px] text-muted-foreground">
{mockTranscript.length}
</span>
<button className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-elevated px-3 py-1.5 text-[12px] font-medium text-muted-foreground hover:bg-surface-muted">
<Download className="h-3.5 w-3.5" />
MD
</button>
</div>
<div className="space-y-5 max-w-[680px]">
{mockTranscript.map((seg, i) => (
<div key={i} className="group">
<div className="flex items-center gap-2 mb-1">
<span className="text-[13px] font-semibold text-primary">
{seg.speaker}
</span>
<button className="font-mono text-[11px] text-muted-foreground hover:text-primary">
[{formatTime(seg.time)}]
</button>
</div>
<p className="text-[15px] leading-[1.75] text-foreground">{seg.text}</p>
</div>
))}
</div>
</div>
{/* Summary - 4 cards */}
<aside
className={cn(
'md:col-span-2 space-y-4',
tab !== 'summary' && 'hidden md:block',
)}
>
{summary && (
<>
<SummaryCard icon={Star} title="要点" accent="text-primary">
<ul className="space-y-2">
{summary.keyPoints.map((p, i) => (
<li key={i} className="flex gap-2 text-[14px] leading-[1.6]">
<span className="text-primary shrink-0"></span>
<span>{p}</span>
</li>
))}
</ul>
</SummaryCard>
<SummaryCard icon={CheckSquare} title="待办" accent="text-success">
<ul className="space-y-3">
{summary.todos.map((t, i) => (
<li key={i} className="flex gap-2 text-[14px] leading-[1.5]">
<span className="mt-1 h-3.5 w-3.5 shrink-0 rounded border border-border-strong" />
<div>
<div>{t.text}</div>
{(t.owner || t.due) && (
<div className="mt-0.5 text-[12px] text-muted-foreground">
{t.owner && <span>{t.owner}</span>}
{t.owner && t.due && <span> · </span>}
{t.due && <span>{t.due}</span>}
</div>
)}
</div>
</li>
))}
</ul>
</SummaryCard>
<SummaryCard icon={Target} title="决议" accent="text-warning">
<ul className="space-y-2">
{summary.decisions.map((d, i) => (
<li key={i} className="flex gap-2 text-[14px] leading-[1.6]">
<span className="text-success shrink-0"></span>
<span>{d}</span>
</li>
))}
</ul>
</SummaryCard>
<SummaryCard icon={Hash} title="关键词" accent="text-info">
<div className="flex flex-wrap gap-1.5">
{summary.keywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center rounded-full bg-primary-subtle px-2.5 py-1 text-[12px] font-medium text-primary"
>
#{kw}
</span>
))}
</div>
</SummaryCard>
</>
)}
</aside>
</div>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-10">
<div className="text-center">
<div className="text-[15px] text-muted-foreground">
{meeting.status}
</div>
</div>
</div>
)}
</div>
</AppShell>
)
}
function SummaryCard({
icon: Icon,
title,
accent,
children,
}: {
icon: any
title: string
accent: string
children: React.ReactNode
}) {
return (
<div className="rounded-2xl border border-border bg-surface-elevated p-5">
<div className="mb-3 flex items-center gap-2">
<Icon className={cn('h-4 w-4', accent)} />
<h3 className="text-[14px] font-semibold">{title}</h3>
</div>
{children}
</div>
)
}

51
web/app/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { Plus } from 'lucide-react'
import Link from 'next/link'
import { AppShell } from '@/components/app-shell'
import { MeetingCard } from '@/components/meeting-card'
import { mockMeetings, groupByDate, dateLabel } from '@/lib/mock-data'
export default function HomePage() {
const groups = groupByDate(mockMeetings)
const days = Object.keys(groups).sort().reverse()
return (
<AppShell>
<div className="mx-auto max-w-4xl px-5 py-8 md:px-10 md:py-10">
<header className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-[26px] font-semibold tracking-tight"></h1>
<p className="mt-1 text-[14px] text-muted-foreground">
{mockMeetings.length} · {' '}
{Math.round(mockMeetings.reduce((s, m) => s + m.duration, 0) / 60)}
</p>
</div>
<Link
href="/upload"
className="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 text-[14px] font-medium text-primary-foreground transition-colors hover:bg-primary-light"
>
<Plus className="h-4 w-4" />
</Link>
</header>
<div className="space-y-8">
{days.map((day) => (
<section key={day}>
<div className="mb-3 flex items-center gap-3">
<h2 className="text-[13px] font-medium text-muted-foreground">
{dateLabel(day)}
</h2>
<div className="h-px flex-1 bg-border" />
</div>
<div className="space-y-3">
{groups[day].map((m) => (
<MeetingCard key={m.id} meeting={m} />
))}
</div>
</section>
))}
</div>
</div>
</AppShell>
)
}

146
web/app/upload/page.tsx Normal file
View File

@@ -0,0 +1,146 @@
'use client'
import { useState, useRef } from 'react'
import { UploadCloud, FileAudio, X } from 'lucide-react'
import Link from 'next/link'
import { AppShell } from '@/components/app-shell'
import { cn } from '@/lib/utils'
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [participants, setParticipants] = useState('')
const [dragOver, setDragOver] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const pickFile = (f: File | null | undefined) => {
if (f) setFile(f)
}
const humanSize = (bytes: number) => {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
return (
<AppShell>
<div className="mx-auto max-w-2xl px-5 py-8 md:px-10 md:py-10">
<header className="mb-8">
<h1 className="text-[26px] font-semibold tracking-tight"></h1>
<p className="mt-1 text-[14px] text-muted-foreground">
</p>
</header>
{/* Dropzone */}
{!file ? (
<div
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
pickFile(e.dataTransfer.files?.[0])
}}
onClick={() => inputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-16 transition-all cursor-pointer',
dragOver
? 'border-primary bg-primary-subtle'
: 'border-border bg-surface-elevated hover:border-primary/50 hover:bg-primary-subtle/50',
)}
>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-subtle text-primary">
<UploadCloud className="h-7 w-7" />
</div>
<h3 className="mt-5 text-[17px] font-semibold"></h3>
<p className="mt-1 text-[13px] text-muted-foreground"></p>
<p className="mt-4 text-[12px] text-muted-foreground">
m4a · mp3 · wav · mp4 · opus · 500 MB
</p>
<input
ref={inputRef}
type="file"
accept="audio/*,video/mp4"
className="hidden"
onChange={(e) => pickFile(e.target.files?.[0])}
/>
</div>
) : (
<div className="rounded-2xl border border-border bg-surface-elevated p-5">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-subtle text-primary shrink-0">
<FileAudio className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="text-[15px] font-semibold truncate">{file.name}</h3>
<button
onClick={() => setFile(null)}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-surface-muted"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="mt-1 text-[13px] text-muted-foreground">{humanSize(file.size)}</p>
</div>
</div>
</div>
)}
{/* Form */}
<div className="mt-6 space-y-5">
<div>
<label className="block text-[13px] font-medium mb-2"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="例:与客户对齐定价"
className="w-full rounded-xl border border-input bg-surface-elevated px-4 py-3 text-[14px] placeholder:text-muted-foreground/60 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="block text-[13px] font-medium mb-2">
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
</span>
</label>
<input
type="text"
value={participants}
onChange={(e) => setParticipants(e.target.value)}
placeholder="张三, 李四, 客户王总"
className="w-full rounded-xl border border-input bg-surface-elevated px-4 py-3 text-[14px] placeholder:text-muted-foreground/60 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
</div>
{/* Actions */}
<div className="mt-8 flex items-center justify-end gap-3">
<Link
href="/"
className="rounded-full px-5 py-2.5 text-[14px] font-medium text-muted-foreground hover:bg-surface-muted"
>
</Link>
<button
disabled={!file}
className="rounded-full bg-primary px-6 py-2.5 text-[14px] font-medium text-primary-foreground transition-colors hover:bg-primary-light disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
<p className="mt-8 text-center text-[12px] text-muted-foreground">
Groq Whisper · Claude / /
</p>
</div>
</AppShell>
)
}