auto-save 2026-04-13 18:59 (+1)
This commit is contained in:
135
web/app/globals.css
Normal file
135
web/app/globals.css
Normal 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
34
web/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
256
web/app/meetings/[id]/page.tsx
Normal file
256
web/app/meetings/[id]/page.tsx
Normal 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
51
web/app/page.tsx
Normal 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
146
web/app/upload/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user