@@ -0,0 +1,123 @@
|
|||||||
|
name: knowledge-base
|
||||||
|
run-name: "${{ gitea.actor }} 正在部署知识库"
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: knowledge-base
|
||||||
|
BASE_IMAGE: knowledge-base-base
|
||||||
|
HOST_PORT: 31106
|
||||||
|
DOCKER_BUILDKIT: "0"
|
||||||
|
|
||||||
|
on:
|
||||||
|
- push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on:
|
||||||
|
- vps_jp
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 环境准备
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "📦 安装 docker cli..."
|
||||||
|
apk add docker-cli
|
||||||
|
fi
|
||||||
|
if ! command -v git &> /dev/null; then
|
||||||
|
echo "📦 安装 git..."
|
||||||
|
apk add git
|
||||||
|
fi
|
||||||
|
docker --version
|
||||||
|
echo "🧱 BuildKit: ${DOCKER_BUILDKIT}"
|
||||||
|
echo "✅ 环境准备耗时: $(( $(date +%s) - STEP_START ))s"
|
||||||
|
|
||||||
|
- name: 克隆仓库代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 构建基础镜像(首次或 Dockerfile.base 变更时)
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
|
||||||
|
BASE_HASH=$(sha256sum Dockerfile.base | cut -c1-12)
|
||||||
|
CURRENT_HASH=$(docker inspect $BASE_IMAGE:latest --format '{{index .Config.Labels "base.hash"}}' 2>/dev/null || echo "none")
|
||||||
|
|
||||||
|
if [ "$BASE_HASH" != "$CURRENT_HASH" ]; then
|
||||||
|
echo "📦 基础镜像需要构建 (hash: $BASE_HASH)"
|
||||||
|
docker build \
|
||||||
|
--label "base.hash=$BASE_HASH" \
|
||||||
|
-f Dockerfile.base \
|
||||||
|
-t $BASE_IMAGE:latest .
|
||||||
|
echo "✅ 基础镜像构建完成"
|
||||||
|
else
|
||||||
|
echo "✅ 基础镜像已是最新,跳过 (hash: $BASE_HASH)"
|
||||||
|
fi
|
||||||
|
echo "✅ 基础镜像步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||||
|
|
||||||
|
- name: 构建应用镜像
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
|
||||||
|
echo "📦 开始构建应用镜像..."
|
||||||
|
docker build \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} \
|
||||||
|
-t $IMAGE_NAME:latest .
|
||||||
|
echo "✅ 应用镜像构建完成"
|
||||||
|
docker images $IMAGE_NAME:latest --format " 大小: {{.Size}}"
|
||||||
|
echo "✅ 应用镜像步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||||
|
|
||||||
|
- name: 部署容器
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
|
||||||
|
echo "🔄 停止旧容器..."
|
||||||
|
if docker ps -a --filter "name=^${IMAGE_NAME}$" -q | grep -q .; then
|
||||||
|
docker stop --timeout 15 $IMAGE_NAME || true
|
||||||
|
docker rm $IMAGE_NAME || true
|
||||||
|
else
|
||||||
|
echo " 无旧容器需要清理"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 启动新容器..."
|
||||||
|
docker run -d \
|
||||||
|
--name $IMAGE_NAME \
|
||||||
|
-p 127.0.0.1:$HOST_PORT:80 \
|
||||||
|
-e NGINX_USER=${{ secrets.NGINX_USER }} \
|
||||||
|
-e NGINX_PASSWORD=${{ secrets.NGINX_PASSWORD }} \
|
||||||
|
--restart unless-stopped \
|
||||||
|
$IMAGE_NAME:latest
|
||||||
|
|
||||||
|
echo "⏳ 等待容器启动/健康检查..."
|
||||||
|
MAX_WAIT=60
|
||||||
|
WAITED=0
|
||||||
|
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||||
|
RUNNING_ID=$(docker ps --filter "name=^${IMAGE_NAME}$" --filter "status=running" -q)
|
||||||
|
if [ -n "$RUNNING_ID" ]; then
|
||||||
|
HEALTH=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $IMAGE_NAME 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$HEALTH" = "healthy" ] || [ "$HEALTH" = "none" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
WAITED=$((WAITED + 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
if docker ps --filter "name=$IMAGE_NAME" --filter "status=running" -q | grep -q .; then
|
||||||
|
echo "✅ 容器运行中"
|
||||||
|
docker ps --filter "name=$IMAGE_NAME" --format " 状态: {{.Status}}"
|
||||||
|
echo " 启动等待: ${WAITED}s"
|
||||||
|
else
|
||||||
|
echo "❌ 容器启动失败"
|
||||||
|
docker inspect $IMAGE_NAME --format ' ExitCode={{.State.ExitCode}} Error={{.State.Error}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' 2>/dev/null || true
|
||||||
|
docker logs --tail 30 $IMAGE_NAME
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ 部署步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||||
|
|
||||||
|
- name: 查看启动日志
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
echo "📋 容器最近日志:"
|
||||||
|
docker logs --tail 15 $IMAGE_NAME
|
||||||
|
echo "✅ 日志步骤耗时: $(( $(date +%s) - STEP_START ))s"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# v0 sandbox internal files
|
||||||
|
__v0_runtime_loader.js
|
||||||
|
__v0_devtools.tsx
|
||||||
|
__v0_jsx-dev-runtime.ts
|
||||||
|
.snowflake/
|
||||||
|
.v0-trash/
|
||||||
|
.vercel/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# Common ignores
|
||||||
|
node_modules
|
||||||
|
.next/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
FROM knowledge-base-base:latest
|
||||||
|
|
||||||
|
# 安装 nginx 和 htpasswd 工具
|
||||||
|
RUN apk add --no-cache nginx apache2-utils
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制包管理文件,利用 Docker 层缓存
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 复制源码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_* 变量在构建时嵌入,通过 build-arg 传入
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# 配置 nginx
|
||||||
|
COPY nginx.conf /etc/nginx/http.d/default.conf
|
||||||
|
|
||||||
|
# 入口脚本:生成 htpasswd -> 启动 Next.js -> 启动 nginx
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@latest
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-sans: var(--font-geist-sans), "Geist Fallback";
|
||||||
|
--font-mono: var(--font-geist-mono), "Geist Mono Fallback";
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--background: oklch(0.16 0.012 240);
|
||||||
|
--foreground: oklch(0.94 0.005 240);
|
||||||
|
--card: oklch(0.2 0.014 240);
|
||||||
|
--card-foreground: oklch(0.94 0.005 240);
|
||||||
|
--popover: oklch(0.19 0.014 240);
|
||||||
|
--popover-foreground: oklch(0.94 0.005 240);
|
||||||
|
--primary: oklch(0.78 0.16 175);
|
||||||
|
--primary-foreground: oklch(0.16 0.02 200);
|
||||||
|
--secondary: oklch(0.26 0.015 240);
|
||||||
|
--secondary-foreground: oklch(0.92 0.005 240);
|
||||||
|
--muted: oklch(0.24 0.013 240);
|
||||||
|
--muted-foreground: oklch(0.65 0.012 240);
|
||||||
|
--accent: oklch(0.28 0.02 200);
|
||||||
|
--accent-foreground: oklch(0.94 0.005 240);
|
||||||
|
--destructive: oklch(0.62 0.21 18);
|
||||||
|
--border: oklch(0.3 0.014 240);
|
||||||
|
--input: oklch(0.26 0.014 240);
|
||||||
|
--ring: oklch(0.78 0.16 175);
|
||||||
|
--chart-1: oklch(0.78 0.16 175);
|
||||||
|
--chart-2: oklch(0.7 0.13 220);
|
||||||
|
--chart-3: oklch(0.78 0.15 90);
|
||||||
|
--chart-4: oklch(0.65 0.2 18);
|
||||||
|
--chart-5: oklch(0.6 0.02 240);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.18 0.013 240);
|
||||||
|
--sidebar-foreground: oklch(0.94 0.005 240);
|
||||||
|
--sidebar-primary: oklch(0.78 0.16 175);
|
||||||
|
--sidebar-primary-foreground: oklch(0.16 0.02 200);
|
||||||
|
--sidebar-accent: oklch(0.26 0.015 240);
|
||||||
|
--sidebar-accent-foreground: oklch(0.94 0.005 240);
|
||||||
|
--sidebar-border: oklch(0.3 0.014 240);
|
||||||
|
--sidebar-ring: oklch(0.78 0.16 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.tech-grid-bg {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
oklch(0.3 0.014 240 / 0.4) 1px,
|
||||||
|
transparent 1px
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
oklch(0.3 0.014 240 / 0.4) 1px,
|
||||||
|
transparent 1px
|
||||||
|
);
|
||||||
|
background-size: 44px 44px;
|
||||||
|
}
|
||||||
|
.glow-ring {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px oklch(0.78 0.16 175 / 0.25),
|
||||||
|
0 0 24px -6px oklch(0.78 0.16 175 / 0.35);
|
||||||
|
}
|
||||||
|
/* glassmorphism surface for AI dialogs */
|
||||||
|
.glass-panel {
|
||||||
|
background: oklch(0.2 0.014 240 / 0.7);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
/* gradient AI accent (cyan -> magenta) used only as a subtle accent on buttons */
|
||||||
|
.ai-gradient {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
oklch(0.78 0.16 175),
|
||||||
|
oklch(0.62 0.21 330)
|
||||||
|
);
|
||||||
|
color: oklch(0.16 0.02 200);
|
||||||
|
}
|
||||||
|
.ai-gradient:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
.neon-error {
|
||||||
|
color: oklch(0.72 0.2 18);
|
||||||
|
border-color: oklch(0.62 0.21 18 / 0.4);
|
||||||
|
background: oklch(0.62 0.21 18 / 0.08);
|
||||||
|
box-shadow: 0 0 16px -8px oklch(0.62 0.21 18 / 0.6);
|
||||||
|
}
|
||||||
|
.neon-success {
|
||||||
|
color: oklch(0.82 0.16 160);
|
||||||
|
border-color: oklch(0.78 0.16 160 / 0.4);
|
||||||
|
background: oklch(0.78 0.16 160 / 0.08);
|
||||||
|
box-shadow: 0 0 16px -8px oklch(0.78 0.16 160 / 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scrollbar styles */
|
||||||
|
@supports selector(::-webkit-scrollbar) {
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: oklch(0.18 0.013 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.78 0.16 175 / 0.6);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid oklch(0.18 0.013 240);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.78 0.16 175 / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:active {
|
||||||
|
background: oklch(0.78 0.16 175);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-color: oklch(0.78 0.16 175 / 0.6) oklch(0.18 0.013 240);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Analytics } from '@vercel/analytics/next'
|
||||||
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import { AISettingsProvider } from '@/components/ai/ai-settings-provider'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
subsets: ['latin'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '前端知识库 · DevVault',
|
||||||
|
description: '收集、整理与复习前端核心知识点的个人知识库',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
colorScheme: 'dark',
|
||||||
|
themeColor: '#0c0f14',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh" className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
|
<body className="font-sans antialiased bg-background">
|
||||||
|
<AISettingsProvider>{children}</AISettingsProvider>
|
||||||
|
<Toaster position="top-center" richColors />
|
||||||
|
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createClient } from "@/lib/supabase/server"
|
||||||
|
import type { KnowledgeItem } from "@/lib/types"
|
||||||
|
import { KnowledgeDashboard } from "@/components/knowledge-dashboard"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("knowledge_items")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
return <KnowledgeDashboard initialItems={(data ?? []) as KnowledgeItem[]} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Loader2, Sparkles, X, Database, AlertTriangle } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useAISettings } from "./ai-settings-provider"
|
||||||
|
import { PromptEditor } from "./prompt-editor"
|
||||||
|
import { AIResultPreview, type DraftRow } from "./ai-result-preview"
|
||||||
|
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||||
|
import { validateForRequest } from "@/lib/ai/settings"
|
||||||
|
import { BATCH_IMPORT_PROMPT } from "@/lib/ai/prompts"
|
||||||
|
import { safeParseAIJson } from "@/lib/ai/json"
|
||||||
|
import type { KnowledgeItem, KnowledgeItemInput } from "@/lib/types"
|
||||||
|
|
||||||
|
export function AIBatchImportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onInserted,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
onInserted: (items: KnowledgeItem[]) => void
|
||||||
|
}) {
|
||||||
|
const { settings } = useAISettings()
|
||||||
|
const [rawInput, setRawInput] = useState("")
|
||||||
|
const [customPrompt, setCustomPrompt] = useState(BATCH_IMPORT_PROMPT)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [inserting, setInserting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [rawResponse, setRawResponse] = useState<string | null>(null)
|
||||||
|
const [rows, setRows] = useState<DraftRow[]>([])
|
||||||
|
const [controller, setController] = useState<AbortController | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setError(null)
|
||||||
|
setRawResponse(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const selectedCount = useMemo(() => rows.filter((r) => r._selected).length, [rows])
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
const err = validateForRequest(settings)
|
||||||
|
if (err) {
|
||||||
|
toast.error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!rawInput.trim()) {
|
||||||
|
toast.error("请先粘贴题目内容")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setRawResponse(null)
|
||||||
|
setRows([])
|
||||||
|
const ac = new AbortController()
|
||||||
|
setController(ac)
|
||||||
|
try {
|
||||||
|
const content = await requestOpenAICompatible({
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
model: settings.model,
|
||||||
|
systemPrompt: settings.systemPrompt,
|
||||||
|
customPrompt,
|
||||||
|
messages: [{ role: "user", content: rawInput }],
|
||||||
|
temperature: settings.temperature,
|
||||||
|
maxTokens: settings.maxTokens,
|
||||||
|
signal: ac.signal,
|
||||||
|
})
|
||||||
|
const parsed = safeParseAIJson(content)
|
||||||
|
if (!parsed.ok) {
|
||||||
|
setError(parsed.error)
|
||||||
|
setRawResponse(parsed.raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRows(
|
||||||
|
parsed.items.map((it, i) => ({
|
||||||
|
...it,
|
||||||
|
_id: `${Date.now()}-${i}`,
|
||||||
|
_selected: true,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
toast.success(`已生成 ${parsed.items.length} 条`)
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === "AbortError") {
|
||||||
|
toast.message("已取消生成")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setController(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
controller?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInsert() {
|
||||||
|
const selected = rows.filter((r) => r._selected)
|
||||||
|
if (selected.length === 0) {
|
||||||
|
toast.error("请至少勾选一条")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setInserting(true)
|
||||||
|
try {
|
||||||
|
const { createItems } = await import("@/lib/knowledge")
|
||||||
|
const inputs: KnowledgeItemInput[] = selected.map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
summary: r.summary,
|
||||||
|
content: r.content,
|
||||||
|
code_snippet: r.code_snippet,
|
||||||
|
tags: r.tags,
|
||||||
|
category: r.category,
|
||||||
|
difficulty: r.difficulty,
|
||||||
|
mastery: r.mastery,
|
||||||
|
is_favorite: false,
|
||||||
|
source_url: r.source_url,
|
||||||
|
notes: r.notes,
|
||||||
|
}))
|
||||||
|
const created = await createItems(inputs)
|
||||||
|
onInserted(created)
|
||||||
|
toast.success(`成功插入 ${created.length} 条知识点`)
|
||||||
|
// remove inserted rows from preview
|
||||||
|
setRows((prev) => prev.filter((r) => !r._selected))
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`插入失败:${(e as Error).message}`)
|
||||||
|
console.log("[v0] batch insert error:", e)
|
||||||
|
} finally {
|
||||||
|
setInserting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => (loading ? null : onOpenChange(o))}>
|
||||||
|
<DialogContent className="glass-panel flex max-h-[92vh] flex-col gap-0 overflow-hidden sm:max-w-3xl">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="size-5 text-primary" />
|
||||||
|
AI 批量整理
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
粘贴多个题目 / 面试题 / 知识点,AI 会整理成结构化数据。插入前可预览、编辑并勾选。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||||
|
<span className="text-primary">model</span> {settings.model || "—"}
|
||||||
|
<span className="mx-1 opacity-40">|</span>
|
||||||
|
<span className="text-primary">base</span>
|
||||||
|
<span className="truncate">{settings.baseUrl || "—"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="rawInput">题目内容</Label>
|
||||||
|
<Textarea
|
||||||
|
id="rawInput"
|
||||||
|
value={rawInput}
|
||||||
|
onChange={(e) => setRawInput(e.target.value)}
|
||||||
|
placeholder={"1. 什么是闭包?\n2. Vue2 和 Vue3 的区别?\n3. Promise.all 和 Promise.race 的区别?\n4. 实现防抖函数"}
|
||||||
|
className="min-h-28 bg-input/60 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PromptEditor value={customPrompt} onChange={setCustomPrompt} rows={5} />
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{loading ? (
|
||||||
|
<Button variant="outline" onClick={handleCancel} className="gap-1.5">
|
||||||
|
<X className="size-4" />
|
||||||
|
取消生成
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleGenerate} disabled={!rawInput.trim()} className="ai-gradient gap-1.5">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
AI 整理中...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="neon-error grid gap-2 rounded-lg border px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{rawResponse && (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-xs opacity-80">原始返回内容(可复制修复):</span>
|
||||||
|
<Textarea readOnly value={rawResponse} className="min-h-24 bg-background/40 font-mono text-xs" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && <AIResultPreview rows={rows} onChange={setRows} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border pt-4">
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">所有内容确认后才会写入数据库</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading || inserting}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleInsert}
|
||||||
|
disabled={selectedCount === 0 || inserting}
|
||||||
|
className={cn("gap-1.5")}
|
||||||
|
>
|
||||||
|
{inserting ? <Loader2 className="size-4 animate-spin" /> : <Database className="size-4" />}
|
||||||
|
插入选中 ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Send, Loader2, Copy, RefreshCw, Trash2, Plus, MessageSquare, X, Sparkles } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useAISettings } from "./ai-settings-provider"
|
||||||
|
import { Markdown } from "./markdown"
|
||||||
|
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||||
|
import { validateForRequest } from "@/lib/ai/settings"
|
||||||
|
import { buildChatSystemPrompt } from "@/lib/ai/prompts"
|
||||||
|
import {
|
||||||
|
addMessage,
|
||||||
|
clearMessages,
|
||||||
|
createConversation,
|
||||||
|
deleteLastAssistantMessage,
|
||||||
|
fetchMessages,
|
||||||
|
getLatestConversation,
|
||||||
|
type AIConversation,
|
||||||
|
} from "@/lib/ai/conversations"
|
||||||
|
import type { ChatMessage } from "@/lib/ai/types"
|
||||||
|
import type { KnowledgeItem } from "@/lib/types"
|
||||||
|
|
||||||
|
type UIMessage = { role: "user" | "assistant"; content: string }
|
||||||
|
|
||||||
|
export function AIChatPanel({ item }: { item: KnowledgeItem }) {
|
||||||
|
const { settings } = useAISettings()
|
||||||
|
const [conversation, setConversation] = useState<AIConversation | null>(null)
|
||||||
|
const [messages, setMessages] = useState<UIMessage[]>([])
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [booting, setBooting] = useState(true)
|
||||||
|
const controllerRef = useRef<AbortController | null>(null)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// load latest conversation + messages on mount / item change
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setBooting(true)
|
||||||
|
setMessages([])
|
||||||
|
setConversation(null)
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const conv = await getLatestConversation(item.id)
|
||||||
|
if (!active) return
|
||||||
|
if (conv) {
|
||||||
|
setConversation(conv)
|
||||||
|
const rows = await fetchMessages(conv.id)
|
||||||
|
if (!active) return
|
||||||
|
setMessages(rows.filter((r) => r.role !== "system").map((r) => ({ role: r.role as "user" | "assistant", content: r.content })))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[v0] load conversation error:", e)
|
||||||
|
} finally {
|
||||||
|
if (active) setBooting(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
controllerRef.current?.abort()
|
||||||
|
}
|
||||||
|
}, [item.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
||||||
|
}, [messages, loading])
|
||||||
|
|
||||||
|
async function ensureConversation(): Promise<AIConversation> {
|
||||||
|
if (conversation) return conversation
|
||||||
|
const conv = await createConversation(item.id, item.title)
|
||||||
|
setConversation(conv)
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCompletion(history: UIMessage[]): Promise<string> {
|
||||||
|
const chatMessages: ChatMessage[] = history.map((m) => ({ role: m.role, content: m.content }))
|
||||||
|
const ac = new AbortController()
|
||||||
|
controllerRef.current = ac
|
||||||
|
return requestOpenAICompatible({
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
model: settings.model,
|
||||||
|
systemPrompt: buildChatSystemPrompt(item),
|
||||||
|
messages: chatMessages,
|
||||||
|
temperature: settings.temperature,
|
||||||
|
maxTokens: settings.maxTokens,
|
||||||
|
signal: ac.signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const err = validateForRequest(settings)
|
||||||
|
if (err) {
|
||||||
|
toast.error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || loading) return
|
||||||
|
|
||||||
|
const userMsg: UIMessage = { role: "user", content: text }
|
||||||
|
const next = [...messages, userMsg]
|
||||||
|
setMessages(next)
|
||||||
|
setInput("")
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const conv = await ensureConversation()
|
||||||
|
await addMessage(conv.id, "user", text)
|
||||||
|
const reply = await runCompletion(next)
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
|
||||||
|
await addMessage(conv.id, "assistant", reply)
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === "AbortError") {
|
||||||
|
toast.message("已取消回复")
|
||||||
|
setMessages((prev) => prev.slice(0, -1)) // remove the user msg we optimistically added? keep it instead
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error((e as Error).message)
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: `[出错] ${(e as Error).message}` }])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
controllerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
if (loading) return
|
||||||
|
// find last user message
|
||||||
|
const lastUserIdx = [...messages].reverse().findIndex((m) => m.role === "user")
|
||||||
|
if (lastUserIdx === -1) return
|
||||||
|
const historyEnd = messages.length - lastUserIdx // index just after last user message
|
||||||
|
const history = messages.slice(0, historyEnd)
|
||||||
|
setMessages(history)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const conv = await ensureConversation()
|
||||||
|
await deleteLastAssistantMessage(conv.id)
|
||||||
|
const reply = await runCompletion(history)
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
|
||||||
|
await addMessage(conv.id, "assistant", reply)
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === "AbortError") return
|
||||||
|
toast.error((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
controllerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewConversation() {
|
||||||
|
controllerRef.current?.abort()
|
||||||
|
try {
|
||||||
|
const conv = await createConversation(item.id, item.title)
|
||||||
|
setConversation(conv)
|
||||||
|
setMessages([])
|
||||||
|
toast.success("已新建会话")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("新建会话失败")
|
||||||
|
console.log("[v0] new conversation error:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClear() {
|
||||||
|
if (!conversation) {
|
||||||
|
setMessages([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await clearMessages(conversation.id)
|
||||||
|
setMessages([])
|
||||||
|
toast.success("已清空当前会话")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("清空失败")
|
||||||
|
console.log("[v0] clear error:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyMessage(content: string) {
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
toast.success("已复制")
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAssistant = messages.some((m) => m.role === "assistant")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/40 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="flex items-center gap-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Sparkles className="size-3.5 text-primary" />
|
||||||
|
AI 追问
|
||||||
|
</h4>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleNewConversation} className="h-7 gap-1 px-2 text-xs">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
新建
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={messages.length === 0}
|
||||||
|
className="h-7 gap-1 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="flex max-h-72 flex-col gap-3 overflow-y-auto">
|
||||||
|
{booting ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
加载会话...
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
<MessageSquare className="size-6" />
|
||||||
|
针对「{item.title}」向 AI 提问,开始复习对话
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((m, i) => (
|
||||||
|
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
|
||||||
|
m.role === "user"
|
||||||
|
? "bg-primary/15 text-foreground"
|
||||||
|
: "border border-border bg-secondary/50 text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.role === "assistant" ? (
|
||||||
|
<Markdown content={m.content} />
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap break-words">{m.content}</p>
|
||||||
|
)}
|
||||||
|
{m.role === "assistant" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyMessage(m.content)}
|
||||||
|
aria-label="复制回复"
|
||||||
|
className="absolute -right-2 -top-2 hidden rounded-md border border-border bg-background p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
||||||
|
>
|
||||||
|
<Copy className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/50 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
AI 正在思考...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasAssistant && !loading && (
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleRegenerate} className="h-7 gap-1 px-2 text-xs">
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
重新生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="输入你的问题... (Ctrl/Cmd + Enter 发送)"
|
||||||
|
className="min-h-10 flex-1 bg-input/60 text-sm"
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<Button variant="outline" size="icon" onClick={() => controllerRef.current?.abort()} aria-label="取消">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="icon" onClick={handleSend} disabled={!input.trim()} aria-label="发送">
|
||||||
|
<Send className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ChevronDown, ChevronRight, Copy, Download } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DIFFICULTIES, DIFFICULTY_META } from "@/lib/types"
|
||||||
|
import { AI_CATEGORY_ENUM, type AIDraftItem } from "@/lib/ai/types"
|
||||||
|
|
||||||
|
export type DraftRow = AIDraftItem & { _id: string; _selected: boolean }
|
||||||
|
|
||||||
|
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
|
||||||
|
const CATEGORY_OPTIONS = AI_CATEGORY_ENUM.map((c) => ({ value: c, label: c }))
|
||||||
|
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
|
||||||
|
|
||||||
|
export function AIResultPreview({
|
||||||
|
rows,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
rows: DraftRow[]
|
||||||
|
onChange: (rows: DraftRow[]) => void
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
function patch(id: string, patch: Partial<DraftRow>) {
|
||||||
|
onChange(rows.map((r) => (r._id === id ? { ...r, ...patch } : r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(value: boolean) {
|
||||||
|
onChange(rows.map((r) => ({ ...r, _selected: value })))
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyJson() {
|
||||||
|
const items = rows.map(({ _id, _selected, ...rest }) => rest)
|
||||||
|
navigator.clipboard.writeText(JSON.stringify({ items }, null, 2))
|
||||||
|
toast.success("已复制 JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportJson() {
|
||||||
|
const items = rows.map(({ _id, _selected, ...rest }) => rest)
|
||||||
|
const blob = new Blob([JSON.stringify({ items }, null, 2)], { type: "application/json" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `knowledge-items-${Date.now()}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected = rows.length > 0 && rows.every((r) => r._selected)
|
||||||
|
const selectedCount = rows.filter((r) => r._selected).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} />
|
||||||
|
全选 · 已选 {selectedCount}/{rows.length}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={copyJson} className="gap-1.5">
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
复制 JSON
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={exportJson} className="gap-1.5">
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const open = expanded[row._id]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row._id}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card/60 transition-colors",
|
||||||
|
row._selected ? "border-primary/40" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<Checkbox checked={row._selected} onCheckedChange={(v) => patch(row._id, { _selected: !!v })} />
|
||||||
|
<Input
|
||||||
|
value={row.title}
|
||||||
|
onChange={(e) => patch(row._id, { title: e.target.value })}
|
||||||
|
className="h-8 flex-1 bg-input/60 text-sm font-medium"
|
||||||
|
/>
|
||||||
|
<Badge variant="outline" className="hidden font-mono text-[10px] sm:inline-flex">
|
||||||
|
{row.category}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((e) => ({ ...e, [row._id]: !e[row._id] }))}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={open ? "收起" : "展开"}
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="grid gap-3 border-t border-border p-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">摘要</span>
|
||||||
|
<Input
|
||||||
|
value={row.summary}
|
||||||
|
onChange={(e) => patch(row._id, { summary: e.target.value })}
|
||||||
|
className="bg-input/60 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">正文</span>
|
||||||
|
<Textarea
|
||||||
|
value={row.content}
|
||||||
|
onChange={(e) => patch(row._id, { content: e.target.value })}
|
||||||
|
className="min-h-20 bg-input/60 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{row.code_snippet && (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">代码片段</span>
|
||||||
|
<Textarea
|
||||||
|
value={row.code_snippet}
|
||||||
|
onChange={(e) => patch(row._id, { code_snippet: e.target.value })}
|
||||||
|
className="min-h-20 bg-input/60 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">分类</span>
|
||||||
|
<Select
|
||||||
|
value={row.category}
|
||||||
|
onValueChange={(v) => patch(row._id, { category: v })}
|
||||||
|
items={CATEGORY_OPTIONS}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 bg-input/60 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">难度</span>
|
||||||
|
<Select
|
||||||
|
value={row.difficulty}
|
||||||
|
onValueChange={(v) => patch(row._id, { difficulty: v as AIDraftItem["difficulty"] })}
|
||||||
|
items={DIFFICULTY_OPTIONS}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 bg-input/60 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{row.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{row.tags.map((t) => (
|
||||||
|
<Badge key={t} variant="secondary" className="font-mono text-xs">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Eye, EyeOff, Loader2, RotateCcw, Trash2, Zap, CheckCircle2, XCircle } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useAISettings } from "./ai-settings-provider"
|
||||||
|
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
|
||||||
|
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||||
|
import { validateForRequest } from "@/lib/ai/settings"
|
||||||
|
|
||||||
|
type TestState = { status: "idle" | "loading" | "ok" | "error"; message: string }
|
||||||
|
|
||||||
|
export function AISettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||||
|
const { settings, setSettings } = useAISettings()
|
||||||
|
const [draft, setDraft] = useState<AISettings>(settings)
|
||||||
|
const [showKey, setShowKey] = useState(false)
|
||||||
|
const [test, setTest] = useState<TestState>({ status: "idle", message: "" })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDraft(settings)
|
||||||
|
setTest({ status: "idle", message: "" })
|
||||||
|
}
|
||||||
|
}, [open, settings])
|
||||||
|
|
||||||
|
function update<K extends keyof AISettings>(key: K, value: AISettings[K]) {
|
||||||
|
setDraft((d) => ({ ...d, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
setSettings(draft)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setDraft({ ...DEFAULT_AI_SETTINGS })
|
||||||
|
setTest({ status: "idle", message: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearKey() {
|
||||||
|
update("apiKey", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
const err = validateForRequest(draft)
|
||||||
|
if (err) {
|
||||||
|
setTest({ status: "error", message: err })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTest({ status: "loading", message: "正在测试连接..." })
|
||||||
|
try {
|
||||||
|
const reply = await requestOpenAICompatible({
|
||||||
|
baseUrl: draft.baseUrl,
|
||||||
|
apiKey: draft.apiKey,
|
||||||
|
model: draft.model,
|
||||||
|
messages: [{ role: "user", content: "ping,请只回复 ok" }],
|
||||||
|
temperature: 0,
|
||||||
|
maxTokens: 16,
|
||||||
|
})
|
||||||
|
setTest({ status: "ok", message: `连接成功 · 模型回复:${reply.slice(0, 40)}` })
|
||||||
|
} catch (e) {
|
||||||
|
setTest({ status: "error", message: (e as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="glass-panel flex max-h-[90vh] flex-col gap-0 overflow-hidden sm:max-w-2xl">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Zap className="size-5 text-primary" />
|
||||||
|
AI 设置
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>配置 OpenAI-compatible 接口。API Key 仅保存在本地浏览器,不会写入数据库。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||||
|
<Input
|
||||||
|
id="baseUrl"
|
||||||
|
value={draft.baseUrl}
|
||||||
|
onChange={(e) => update("baseUrl", e.target.value)}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
className="bg-input/60 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="apiKey">API Key</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="apiKey"
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
value={draft.apiKey}
|
||||||
|
onChange={(e) => update("apiKey", e.target.value)}
|
||||||
|
placeholder="sk-..."
|
||||||
|
className="bg-input/60 pr-10 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
aria-label={showKey ? "隐藏" : "显示"}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClearKey} className="gap-1.5">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="model">Model</Label>
|
||||||
|
<Input
|
||||||
|
id="model"
|
||||||
|
value={draft.model}
|
||||||
|
onChange={(e) => update("model", e.target.value)}
|
||||||
|
placeholder="gpt-4o-mini / deepseek-chat / qwen-plus"
|
||||||
|
className="bg-input/60 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="temp">Temperature</Label>
|
||||||
|
<Input
|
||||||
|
id="temp"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
value={draft.temperature}
|
||||||
|
onChange={(e) => update("temperature", Number(e.target.value))}
|
||||||
|
className="bg-input/60 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="maxTokens">Max Tokens</Label>
|
||||||
|
<Input
|
||||||
|
id="maxTokens"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={draft.maxTokens}
|
||||||
|
onChange={(e) => update("maxTokens", Number(e.target.value))}
|
||||||
|
className="bg-input/60 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="systemPrompt">System Prompt</Label>
|
||||||
|
<Textarea
|
||||||
|
id="systemPrompt"
|
||||||
|
value={draft.systemPrompt}
|
||||||
|
onChange={(e) => update("systemPrompt", e.target.value)}
|
||||||
|
className="min-h-24 bg-input/60 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="stream" className="cursor-pointer">
|
||||||
|
启用流式输出 (stream)
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">部分接口需要关闭以兼容</p>
|
||||||
|
</div>
|
||||||
|
<Switch id="stream" checked={draft.stream} onCheckedChange={(v) => update("stream", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{test.status !== "idle" && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
test.status === "ok" && "neon-success",
|
||||||
|
test.status === "error" && "neon-error",
|
||||||
|
test.status === "loading" && "border-border bg-muted/30 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{test.status === "loading" && <Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />}
|
||||||
|
{test.status === "ok" && <CheckCircle2 className="mt-0.5 size-4 shrink-0" />}
|
||||||
|
{test.status === "error" && <XCircle className="mt-0.5 size-4 shrink-0" />}
|
||||||
|
<span className="break-words">{test.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 flex-col gap-2 sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleTest} disabled={test.status === "loading"} className="gap-1.5">
|
||||||
|
{test.status === "loading" ? <Loader2 className="size-4 animate-spin" /> : <Zap className="size-4" />}
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave}>保存设置</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
||||||
|
import { loadAISettings, saveAISettings } from "@/lib/ai/settings"
|
||||||
|
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
settings: AISettings
|
||||||
|
setSettings: (next: AISettings) => void
|
||||||
|
loaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AISettingsContext = createContext<Ctx | null>(null)
|
||||||
|
|
||||||
|
export function AISettingsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [settings, setSettingsState] = useState<AISettings>(DEFAULT_AI_SETTINGS)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettingsState(loadAISettings())
|
||||||
|
setLoaded(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSettings = useCallback((next: AISettings) => {
|
||||||
|
setSettingsState(next)
|
||||||
|
saveAISettings(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AISettingsContext.Provider value={{ settings, setSettings, loaded }}>{children}</AISettingsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAISettings() {
|
||||||
|
const ctx = useContext(AISettingsContext)
|
||||||
|
if (!ctx) throw new Error("useAISettings must be used within AISettingsProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的 AI Markdown 渲染组件,适配深色科技风主题。
|
||||||
|
* 用于面试回答版与 AI 追问的回复内容。
|
||||||
|
*/
|
||||||
|
export function Markdown({ content, className }: { content: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-sm leading-relaxed break-words",
|
||||||
|
// 段落与标题
|
||||||
|
"[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
|
||||||
|
"[&_h1]:mt-3 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold",
|
||||||
|
"[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold",
|
||||||
|
"[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold",
|
||||||
|
// 列表
|
||||||
|
"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
|
||||||
|
"[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
|
||||||
|
"[&_li]:my-0.5 [&_li]:marker:text-muted-foreground",
|
||||||
|
// 强调
|
||||||
|
"[&_strong]:font-semibold [&_strong]:text-foreground",
|
||||||
|
"[&_em]:italic",
|
||||||
|
// 链接
|
||||||
|
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
|
||||||
|
// 行内代码
|
||||||
|
"[&_code]:rounded [&_code]:bg-secondary/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
|
||||||
|
// 代码块
|
||||||
|
"[&_pre]:my-2 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-secondary/40 [&_pre]:p-3",
|
||||||
|
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground",
|
||||||
|
// 引用
|
||||||
|
"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
|
||||||
|
// 表格
|
||||||
|
"[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs",
|
||||||
|
"[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
|
||||||
|
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
|
||||||
|
// 分隔线
|
||||||
|
"[&_hr]:my-3 [&_hr]:border-border",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Copy } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
export function PromptEditor({
|
||||||
|
label = "自定义提示词 (customPrompt)",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
rows = 6,
|
||||||
|
}: {
|
||||||
|
label?: string
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
rows?: number
|
||||||
|
}) {
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard.writeText(value)
|
||||||
|
toast.success("已复制 Prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copy}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{ minHeight: `${rows * 1.6}rem` }}
|
||||||
|
className="bg-input/60 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Star, Pencil, Trash2, Repeat, ExternalLink, Maximize2, Minimize2, Mic, Loader2, Copy } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { KnowledgeItem } from "@/lib/types"
|
||||||
|
import { DIFFICULTY_META, MASTERY_META } from "@/lib/types"
|
||||||
|
import { AIChatPanel } from "@/components/ai/ai-chat-panel"
|
||||||
|
import { Markdown } from "@/components/ai/markdown"
|
||||||
|
import { useAISettings } from "@/components/ai/ai-settings-provider"
|
||||||
|
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||||
|
import { validateForRequest } from "@/lib/ai/settings"
|
||||||
|
import { buildInterviewPrompt } from "@/lib/ai/prompts"
|
||||||
|
import { saveInterviewAnswer } from "@/lib/knowledge"
|
||||||
|
|
||||||
|
function formatDate(value: string | null) {
|
||||||
|
if (!value) return "—"
|
||||||
|
return new Date(value).toLocaleDateString("zh-CN", { month: "short", day: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailDialog({
|
||||||
|
item,
|
||||||
|
onOpenChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onReview,
|
||||||
|
onToggleFav,
|
||||||
|
onItemUpdated,
|
||||||
|
}: {
|
||||||
|
item: KnowledgeItem | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onEdit: (item: KnowledgeItem) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onReview: (item: KnowledgeItem) => void
|
||||||
|
onToggleFav: (item: KnowledgeItem) => void
|
||||||
|
onItemUpdated?: (item: KnowledgeItem) => void
|
||||||
|
}) {
|
||||||
|
const { settings } = useAISettings()
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
const [interview, setInterview] = useState<string | null>(null)
|
||||||
|
const [interviewLoading, setInterviewLoading] = useState(false)
|
||||||
|
|
||||||
|
// 切换知识点时加载该卡片已保存的面试回答,避免不同卡片间串味
|
||||||
|
useEffect(() => {
|
||||||
|
setInterview(item?.interview_answer ?? null)
|
||||||
|
setInterviewLoading(false)
|
||||||
|
}, [item?.id, item?.interview_answer])
|
||||||
|
|
||||||
|
async function handleInterview(current: KnowledgeItem) {
|
||||||
|
const err = validateForRequest(settings)
|
||||||
|
if (err) return toast.error(err)
|
||||||
|
setInterviewLoading(true)
|
||||||
|
try {
|
||||||
|
const reply = await requestOpenAICompatible({
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
model: settings.model,
|
||||||
|
customPrompt: buildInterviewPrompt(current),
|
||||||
|
messages: [],
|
||||||
|
temperature: settings.temperature,
|
||||||
|
maxTokens: settings.maxTokens,
|
||||||
|
})
|
||||||
|
const answer = reply.trim()
|
||||||
|
setInterview(answer)
|
||||||
|
// 持久化到数据库,新回答覆盖旧回答
|
||||||
|
await saveInterviewAnswer(current.id, answer)
|
||||||
|
onItemUpdated?.({ ...current, interview_answer: answer })
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setInterviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) return null
|
||||||
|
const diff = DIFFICULTY_META[item.difficulty]
|
||||||
|
const mastery = MASTERY_META[item.mastery]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!item} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0 overflow-hidden",
|
||||||
|
fullscreen
|
||||||
|
? "left-0 top-0 h-screen max-h-screen w-screen max-w-none translate-x-0 translate-y-0 rounded-none sm:max-w-none"
|
||||||
|
: "max-h-[90vh] sm:max-w-3xl",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFullscreen((v) => !v)}
|
||||||
|
aria-label={fullscreen ? "退出全屏" : "全屏"}
|
||||||
|
className="absolute right-12 top-2.5 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
||||||
|
</button>
|
||||||
|
<DialogHeader className="shrink-0 pr-16">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-[10px] uppercase">
|
||||||
|
{item.category}
|
||||||
|
</Badge>
|
||||||
|
<span className={cn("inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px]", mastery.color)}>
|
||||||
|
<span className={cn("size-1.5 rounded-full", mastery.dot)} />
|
||||||
|
{mastery.label}
|
||||||
|
</span>
|
||||||
|
<span className={cn("rounded-md border px-2 py-0.5 text-[10px]", diff.color)}>{diff.label}</span>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-pretty text-xl">{item.title}</DialogTitle>
|
||||||
|
{item.summary && <p className="text-sm leading-relaxed text-muted-foreground">{item.summary}</p>}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 flex-1 gap-5 overflow-y-auto py-2">
|
||||||
|
{item.content && (
|
||||||
|
<section className="min-w-0">
|
||||||
|
<h4 className="mb-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">详细内容</h4>
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{item.content}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.code_snippet && (
|
||||||
|
<section className="min-w-0">
|
||||||
|
<h4 className="mb-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">代码片段</h4>
|
||||||
|
<pre className="max-w-full overflow-x-auto rounded-lg border border-border bg-secondary/40 p-3 text-sm">
|
||||||
|
<code className="font-mono text-foreground">{item.code_snippet}</code>
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.notes && (
|
||||||
|
<section className="min-w-0">
|
||||||
|
<h4 className="mb-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">个人笔记</h4>
|
||||||
|
<p className="whitespace-pre-wrap break-words rounded-lg border border-border bg-muted/30 p-3 text-sm leading-relaxed">
|
||||||
|
{item.notes}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{item.tags.map((t) => (
|
||||||
|
<Badge key={t} variant="secondary" className="font-mono text-xs">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 rounded-lg border border-border bg-card/50 p-3 text-center font-mono">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-primary">{item.review_count}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">复习次数</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{formatDate(item.last_reviewed_at)}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">上次复习</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{formatDate(item.next_review_at)}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">下次复习</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.source_url && (
|
||||||
|
<a
|
||||||
|
href={item.source_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
查看来源
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 面试回答版 */}
|
||||||
|
<section className="min-w-0">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<h4 className="font-mono text-xs uppercase tracking-wide text-muted-foreground">面试回答版</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleInterview(item)}
|
||||||
|
disabled={interviewLoading}
|
||||||
|
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
{interviewLoading ? <Loader2 className="size-3.5 animate-spin" /> : <Mic className="size-3.5" />}
|
||||||
|
{interview ? "重新生成" : "AI 生成面试回答"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{interview && (
|
||||||
|
<div className="group relative rounded-lg border border-border bg-secondary/40 p-3">
|
||||||
|
<Markdown content={interview} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(interview)
|
||||||
|
toast.success("已复制")
|
||||||
|
}}
|
||||||
|
aria-label="复制面试回答"
|
||||||
|
className="absolute right-2 top-2 hidden rounded-md border border-border bg-background p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
||||||
|
>
|
||||||
|
<Copy className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* AI 追问聊天 */}
|
||||||
|
<AIChatPanel key={item.id} item={item} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => onReview(item)} className="gap-1.5">
|
||||||
|
<Repeat className="size-4" />
|
||||||
|
完成复习
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onToggleFav(item)} className="gap-1.5">
|
||||||
|
<Star className={cn("size-4", item.is_favorite && "fill-amber-400 text-amber-400")} />
|
||||||
|
{item.is_favorite ? "已收藏" : "收藏"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => onEdit(item)} aria-label="编辑">
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
aria-label="删除"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { X, Maximize2, Minimize2, Sparkles, Code2, Loader2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import type { Difficulty, KnowledgeItem, KnowledgeItemInput, Mastery } from "@/lib/types"
|
||||||
|
import {
|
||||||
|
CATEGORIES,
|
||||||
|
DIFFICULTIES,
|
||||||
|
DIFFICULTY_META,
|
||||||
|
MASTERY_LEVELS,
|
||||||
|
MASTERY_META,
|
||||||
|
emptyItemInput,
|
||||||
|
} from "@/lib/types"
|
||||||
|
import { useAISettings } from "@/components/ai/ai-settings-provider"
|
||||||
|
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||||
|
import { validateForRequest } from "@/lib/ai/settings"
|
||||||
|
import { buildCodeGenPrompt, buildOptimizePrompt } from "@/lib/ai/prompts"
|
||||||
|
|
||||||
|
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
|
||||||
|
const CATEGORY_OPTIONS = CATEGORIES.map((c) => ({ value: c, label: c }))
|
||||||
|
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
|
||||||
|
const MASTERY_OPTIONS = MASTERY_LEVELS.map((m) => ({ value: m, label: MASTERY_META[m].label }))
|
||||||
|
|
||||||
|
export function ItemDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
initial: KnowledgeItem | null
|
||||||
|
onSubmit: (input: KnowledgeItemInput) => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const { settings } = useAISettings()
|
||||||
|
const [form, setForm] = useState<KnowledgeItemInput>(emptyItemInput())
|
||||||
|
const [tagInput, setTagInput] = useState("")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
|
const [genCode, setGenCode] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (initial) {
|
||||||
|
setForm({
|
||||||
|
title: initial.title,
|
||||||
|
summary: initial.summary ?? "",
|
||||||
|
content: initial.content ?? "",
|
||||||
|
code_snippet: initial.code_snippet ?? "",
|
||||||
|
tags: initial.tags ?? [],
|
||||||
|
category: initial.category,
|
||||||
|
difficulty: initial.difficulty,
|
||||||
|
mastery: initial.mastery,
|
||||||
|
is_favorite: initial.is_favorite,
|
||||||
|
source_url: initial.source_url ?? "",
|
||||||
|
notes: initial.notes ?? "",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setForm(emptyItemInput())
|
||||||
|
}
|
||||||
|
setTagInput("")
|
||||||
|
}, [open, initial])
|
||||||
|
|
||||||
|
function update<K extends keyof KnowledgeItemInput>(key: K, value: KnowledgeItemInput[K]) {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag() {
|
||||||
|
const t = tagInput.trim().replace(/^#/, "")
|
||||||
|
if (t && !form.tags.includes(t)) update("tags", [...form.tags, t])
|
||||||
|
setTagInput("")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!form.title.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
await onSubmit({ ...form, title: form.title.trim() })
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOptimize() {
|
||||||
|
const err = validateForRequest(settings)
|
||||||
|
if (err) return toast.error(err)
|
||||||
|
if (!form.title.trim()) return toast.error("请先填写标题")
|
||||||
|
setOptimizing(true)
|
||||||
|
try {
|
||||||
|
const content = await requestOpenAICompatible({
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
model: settings.model,
|
||||||
|
customPrompt: buildOptimizePrompt({ title: form.title, summary: form.summary, content: form.content }),
|
||||||
|
messages: [],
|
||||||
|
temperature: settings.temperature,
|
||||||
|
maxTokens: settings.maxTokens,
|
||||||
|
})
|
||||||
|
const cleaned = content.replace(/```(?:json)?/gi, "").trim()
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cleaned)
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
summary: typeof parsed.summary === "string" ? parsed.summary : f.summary,
|
||||||
|
content: typeof parsed.content === "string" ? parsed.content : f.content,
|
||||||
|
}))
|
||||||
|
toast.success("已用 AI 优化内容")
|
||||||
|
} catch {
|
||||||
|
// not JSON: treat whole thing as content
|
||||||
|
setForm((f) => ({ ...f, content: cleaned }))
|
||||||
|
toast.success("已用 AI 优化内容")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setOptimizing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateCode() {
|
||||||
|
const err = validateForRequest(settings)
|
||||||
|
if (err) return toast.error(err)
|
||||||
|
if (!form.title.trim()) return toast.error("请先填写标题")
|
||||||
|
setGenCode(true)
|
||||||
|
try {
|
||||||
|
const content = await requestOpenAICompatible({
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
model: settings.model,
|
||||||
|
customPrompt: buildCodeGenPrompt({ title: form.title, summary: form.summary, content: form.content }),
|
||||||
|
messages: [],
|
||||||
|
temperature: settings.temperature,
|
||||||
|
maxTokens: settings.maxTokens,
|
||||||
|
})
|
||||||
|
const code = content.replace(/```[a-z]*\n?/gi, "").replace(/```$/g, "").trim()
|
||||||
|
setForm((f) => ({ ...f, code_snippet: code }))
|
||||||
|
toast.success("已生成代码示例")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setGenCode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0 overflow-hidden",
|
||||||
|
fullscreen
|
||||||
|
? "left-0 top-0 h-screen max-h-screen w-screen max-w-none translate-x-0 translate-y-0 rounded-none sm:max-w-none"
|
||||||
|
: "max-h-[90vh] sm:max-w-3xl",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFullscreen((v) => !v)}
|
||||||
|
aria-label={fullscreen ? "退出全屏" : "全屏"}
|
||||||
|
className="absolute right-12 top-2.5 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
||||||
|
</button>
|
||||||
|
<DialogHeader className="shrink-0 pr-16 pl-2 pb-2">
|
||||||
|
<DialogTitle>{initial ? "编辑知识点" : "新增知识点"}</DialogTitle>
|
||||||
|
<DialogDescription>记录前端核心概念、代码片段与个人笔记。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="title">标题 *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => update("title", e.target.value)}
|
||||||
|
placeholder="例如:事件循环 Event Loop"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="summary">一句话摘要</Label>
|
||||||
|
<Input
|
||||||
|
id="summary"
|
||||||
|
value={form.summary}
|
||||||
|
onChange={(e) => update("summary", e.target.value)}
|
||||||
|
placeholder="简短描述这个知识点"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>分类</Label>
|
||||||
|
<Select value={form.category} onValueChange={(v) => update("category", v)} items={CATEGORY_OPTIONS}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>难度</Label>
|
||||||
|
<Select
|
||||||
|
value={form.difficulty}
|
||||||
|
onValueChange={(v) => update("difficulty", v as Difficulty)}
|
||||||
|
items={DIFFICULTY_OPTIONS}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>掌握度</Label>
|
||||||
|
<Select
|
||||||
|
value={form.mastery}
|
||||||
|
onValueChange={(v) => update("mastery", v as Mastery)}
|
||||||
|
items={MASTERY_OPTIONS}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MASTERY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="content">详细内容</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOptimize}
|
||||||
|
disabled={optimizing}
|
||||||
|
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
{optimizing ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||||
|
AI 优化内容
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) => update("content", e.target.value)}
|
||||||
|
placeholder="详细说明、原理、注意事项..."
|
||||||
|
className="min-h-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="code">代码片段</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateCode}
|
||||||
|
disabled={genCode}
|
||||||
|
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
{genCode ? <Loader2 className="size-3.5 animate-spin" /> : <Code2 className="size-3.5" />}
|
||||||
|
AI 生成代码示例
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="code"
|
||||||
|
value={form.code_snippet}
|
||||||
|
onChange={(e) => update("code_snippet", e.target.value)}
|
||||||
|
placeholder="// 相关示例代码"
|
||||||
|
className="min-h-24 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="tags">标签</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="tags"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
addTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="输入标签后回车"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={addTag}>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{form.tags.map((t) => (
|
||||||
|
<Badge key={t} variant="secondary" className="gap-1 font-mono text-xs">
|
||||||
|
#{t}
|
||||||
|
<button onClick={() => update("tags", form.tags.filter((x) => x !== t))}>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="source">来源链接</Label>
|
||||||
|
<Input
|
||||||
|
id="source"
|
||||||
|
value={form.source_url}
|
||||||
|
onChange={(e) => update("source_url", e.target.value)}
|
||||||
|
placeholder="https://"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="notes">个人笔记</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => update("notes", e.target.value)}
|
||||||
|
placeholder="自己的理解、踩坑记录..."
|
||||||
|
className="min-h-16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||||
|
<Label htmlFor="fav" className="cursor-pointer">
|
||||||
|
加入收藏
|
||||||
|
</Label>
|
||||||
|
<Switch id="fav" checked={form.is_favorite} onCheckedChange={(v) => update("is_favorite", v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0">
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={!form.title.trim() || submitting}>
|
||||||
|
{submitting ? "保存中..." : initial ? "保存修改" : "添加"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Star, Code2, Repeat } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { KnowledgeItem } from "@/lib/types"
|
||||||
|
import { DIFFICULTY_META, MASTERY_META } from "@/lib/types"
|
||||||
|
|
||||||
|
export function KnowledgeCard({
|
||||||
|
item,
|
||||||
|
onOpen,
|
||||||
|
onToggleFav,
|
||||||
|
onTagClick,
|
||||||
|
}: {
|
||||||
|
item: KnowledgeItem
|
||||||
|
onOpen: () => void
|
||||||
|
onToggleFav: () => void
|
||||||
|
onTagClick: (tag: string) => void
|
||||||
|
}) {
|
||||||
|
const diff = DIFFICULTY_META[item.difficulty]
|
||||||
|
const mastery = MASTERY_META[item.mastery]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
onClick={onOpen}
|
||||||
|
className="group flex cursor-pointer flex-col rounded-xl border border-border bg-card/70 p-4 backdrop-blur-sm transition-colors hover:border-primary/40 hover:bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-[10px] uppercase tracking-wide">
|
||||||
|
{item.category}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
className={cn("inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px]", mastery.color)}
|
||||||
|
>
|
||||||
|
<span className={cn("size-1.5 rounded-full", mastery.dot)} />
|
||||||
|
{mastery.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleFav()
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground transition-colors hover:text-amber-400"
|
||||||
|
aria-label={item.is_favorite ? "取消收藏" : "收藏"}
|
||||||
|
>
|
||||||
|
<Star className={cn("size-4", item.is_favorite && "fill-amber-400 text-amber-400")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mt-3 text-pretty font-semibold leading-snug group-hover:text-primary">{item.title}</h3>
|
||||||
|
{item.summary && (
|
||||||
|
<p className="flex-1 mt-1.5 line-clamp-2 text-sm leading-relaxed text-muted-foreground">{item.summary}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{item.tags.slice(0, 4).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onTagClick(t)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||||
|
<span className={cn("rounded-md border px-1.5 py-0.5", diff.color)}>{diff.label}</span>
|
||||||
|
<div className="flex items-center gap-3 font-mono">
|
||||||
|
{item.code_snippet && <Code2 className="size-3.5" />}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Repeat className="size-3.5" />
|
||||||
|
{item.review_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Plus, Search, Boxes, X, Settings, Sparkles } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import type { KnowledgeItem, KnowledgeItemInput } from "@/lib/types"
|
||||||
|
import { CATEGORIES, MASTERY_LEVELS, MASTERY_META } from "@/lib/types"
|
||||||
|
import {
|
||||||
|
createItem,
|
||||||
|
deleteItem,
|
||||||
|
reviewItem,
|
||||||
|
toggleFavorite,
|
||||||
|
updateItem,
|
||||||
|
} from "@/lib/knowledge"
|
||||||
|
import { StatsBar } from "@/components/stats-bar"
|
||||||
|
import { KnowledgeCard } from "@/components/knowledge-card"
|
||||||
|
import { ItemDialog } from "@/components/item-dialog"
|
||||||
|
import { DetailDialog } from "@/components/detail-dialog"
|
||||||
|
import { AISettingsDialog } from "@/components/ai/ai-settings-dialog"
|
||||||
|
import { AIBatchImportDialog } from "@/components/ai/ai-batch-import-dialog"
|
||||||
|
|
||||||
|
type SortKey = "recent" | "review" | "title"
|
||||||
|
|
||||||
|
type SelectOption<T extends string = string> = { value: T; label: string }
|
||||||
|
|
||||||
|
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
|
||||||
|
const CATEGORY_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: "all", label: "全部分类" },
|
||||||
|
...CATEGORIES.map((c) => ({ value: c, label: c })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const MASTERY_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: "all", label: "全部状态" },
|
||||||
|
...MASTERY_LEVELS.map((m) => ({ value: m, label: MASTERY_META[m].label })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const SORT_OPTIONS: SelectOption<SortKey>[] = [
|
||||||
|
{ value: "recent", label: "最近添加" },
|
||||||
|
{ value: "review", label: "待复习" },
|
||||||
|
{ value: "title", label: "按标题" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function KnowledgeDashboard({ initialItems }: { initialItems: KnowledgeItem[] }) {
|
||||||
|
const [items, setItems] = useState<KnowledgeItem[]>(initialItems)
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [category, setCategory] = useState<string>("all")
|
||||||
|
const [mastery, setMastery] = useState<string>("all")
|
||||||
|
const [favoritesOnly, setFavoritesOnly] = useState(false)
|
||||||
|
const [sort, setSort] = useState<SortKey>("recent")
|
||||||
|
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [formOpen, setFormOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<KnowledgeItem | null>(null)
|
||||||
|
const [detail, setDetail] = useState<KnowledgeItem | null>(null)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [batchOpen, setBatchOpen] = useState(false)
|
||||||
|
|
||||||
|
const allTags = useMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const it of items) for (const t of it.tags) map.set(t, (map.get(t) ?? 0) + 1)
|
||||||
|
return [...map.entries()].sort((a, b) => b[1] - a[1])
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = items.filter((it) => {
|
||||||
|
if (category !== "all" && it.category !== category) return false
|
||||||
|
if (mastery !== "all" && it.mastery !== mastery) return false
|
||||||
|
if (favoritesOnly && !it.is_favorite) return false
|
||||||
|
if (activeTag && !it.tags.includes(activeTag)) return false
|
||||||
|
if (query) {
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const hay = [it.title, it.summary, it.content, it.notes, it.tags.join(" ")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
if (!hay.includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
if (sort === "title") return a.title.localeCompare(b.title, "zh")
|
||||||
|
if (sort === "review") {
|
||||||
|
const av = a.next_review_at ? new Date(a.next_review_at).getTime() : Infinity
|
||||||
|
const bv = b.next_review_at ? new Date(b.next_review_at).getTime() : Infinity
|
||||||
|
return av - bv
|
||||||
|
}
|
||||||
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}, [items, category, mastery, favoritesOnly, activeTag, query, sort])
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null)
|
||||||
|
setFormOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: KnowledgeItem) {
|
||||||
|
setEditing(item)
|
||||||
|
setDetail(null)
|
||||||
|
setFormOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(input: KnowledgeItemInput) {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const updated = await updateItem(editing.id, input)
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||||
|
toast.success("知识点已更新")
|
||||||
|
} else {
|
||||||
|
const created = await createItem(input)
|
||||||
|
setItems((prev) => [created, ...prev])
|
||||||
|
toast.success("知识点已添加")
|
||||||
|
}
|
||||||
|
setFormOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("操作失败,请重试")
|
||||||
|
console.log("[v0] submit error:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchInserted(created: KnowledgeItem[]) {
|
||||||
|
setItems((prev) => [...created, ...prev])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteItem(id)
|
||||||
|
setItems((prev) => prev.filter((it) => it.id !== id))
|
||||||
|
setDetail(null)
|
||||||
|
toast.success("已删除")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("删除失败")
|
||||||
|
console.log("[v0] delete error:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleFav(item: KnowledgeItem) {
|
||||||
|
const value = !item.is_favorite
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, is_favorite: value } : it)))
|
||||||
|
try {
|
||||||
|
await toggleFavorite(item.id, value)
|
||||||
|
} catch (e) {
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, is_favorite: !value } : it)))
|
||||||
|
toast.error("收藏失败")
|
||||||
|
console.log("[v0] fav error:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReview(item: KnowledgeItem) {
|
||||||
|
try {
|
||||||
|
const updated = await reviewItem(item.id, item.review_count, item.mastery)
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||||
|
setDetail((d) => (d && d.id === updated.id ? updated : d))
|
||||||
|
toast.success(`已复习 · 累计 ${updated.review_count} 次`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("记录复习失败")
|
||||||
|
console.log("[v0] review error:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = category !== "all" || mastery !== "all" || favoritesOnly || activeTag || query
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-30 border-b border-border/60 bg-background/80 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg border border-primary/30 bg-primary/10 text-primary glow-ring">
|
||||||
|
<Boxes className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-balance text-lg font-semibold tracking-tight">前端知识库</h1>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">DevVault · 收集 · 整理 · 复习</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBatchOpen(true)}
|
||||||
|
className="gap-2 border-primary/30 text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
<span className="hidden sm:inline">AI 批量整理</span>
|
||||||
|
<span className="sm:hidden">AI</span>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreate} className="gap-2">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
<span className="hidden sm:inline">新增知识点</span>
|
||||||
|
<span className="sm:hidden">新增</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setSettingsOpen(true)} aria-label="设置">
|
||||||
|
<Settings className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="tech-grid-bg flex-1">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
|
||||||
|
<StatsBar items={items} />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索标题、内容、标签..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Select value={category} onValueChange={setCategory} items={CATEGORY_OPTIONS}>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue placeholder="分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={mastery} onValueChange={setMastery} items={MASTERY_OPTIONS}>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue placeholder="掌握度" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MASTERY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={sort} onValueChange={(v) => setSort(v as SortKey)} items={SORT_OPTIONS}>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue placeholder="排序" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SORT_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant={favoritesOnly ? "default" : "outline"}
|
||||||
|
onClick={() => setFavoritesOnly((v) => !v)}
|
||||||
|
>
|
||||||
|
收藏
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag cloud */}
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{allTags.slice(0, 16).map(([tag, count]) => (
|
||||||
|
<button key={tag} onClick={() => setActiveTag((t) => (t === tag ? null : tag))}>
|
||||||
|
<Badge
|
||||||
|
variant={activeTag === tag ? "default" : "secondary"}
|
||||||
|
className="cursor-pointer font-mono text-xs"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
<span className="ml-1 opacity-60">{count}</span>
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("")
|
||||||
|
setCategory("all")
|
||||||
|
setMastery("all")
|
||||||
|
setFavoritesOnly(false)
|
||||||
|
setActiveTag(null)
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3" /> 清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="mb-3 font-mono text-xs text-muted-foreground">
|
||||||
|
{filtered.length} / {items.length} 条记录
|
||||||
|
</p>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||||
|
<Boxes className="mb-3 size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{items.length === 0 ? "还没有知识点,点击「新增」开始收集吧" : "没有匹配的结果"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<KnowledgeCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onOpen={() => setDetail(item)}
|
||||||
|
onToggleFav={() => handleToggleFav(item)}
|
||||||
|
onTagClick={(t) => setActiveTag((cur) => (cur === t ? null : t))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ItemDialog
|
||||||
|
open={formOpen}
|
||||||
|
onOpenChange={setFormOpen}
|
||||||
|
initial={editing}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<DetailDialog
|
||||||
|
item={detail}
|
||||||
|
onOpenChange={(o) => !o && setDetail(null)}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onReview={handleReview}
|
||||||
|
onToggleFav={handleToggleFav}
|
||||||
|
onItemUpdated={(updated) => {
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||||
|
setDetail((d) => (d && d.id === updated.id ? updated : d))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AISettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||||
|
<AIBatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} onInserted={handleBatchInserted} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Library, GraduationCap, Star, Repeat } from "lucide-react"
|
||||||
|
import type { KnowledgeItem } from "@/lib/types"
|
||||||
|
|
||||||
|
export function StatsBar({ items }: { items: KnowledgeItem[] }) {
|
||||||
|
const total = items.length
|
||||||
|
const mastered = items.filter((i) => i.mastery === "mastered").length
|
||||||
|
const favorites = items.filter((i) => i.is_favorite).length
|
||||||
|
const now = Date.now()
|
||||||
|
const due = items.filter((i) => i.next_review_at && new Date(i.next_review_at).getTime() <= now).length
|
||||||
|
const masteredPct = total ? Math.round((mastered / total) * 100) : 0
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: "知识点总数", value: total, icon: Library, hint: "全部收录" },
|
||||||
|
{ label: "已掌握", value: mastered, icon: GraduationCap, hint: `${masteredPct}% 掌握率` },
|
||||||
|
{ label: "已收藏", value: favorites, icon: Star, hint: "重点关注" },
|
||||||
|
{ label: "待复习", value: due, icon: Repeat, hint: "今日到期" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className="rounded-xl border border-border bg-card/60 p-4 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||||
|
<s.icon className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-mono text-2xl font-semibold tracking-tight">{s.value}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{s.hint}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from '@base-ui/react/button'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
|
outline:
|
||||||
|
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
icon: 'size-8',
|
||||||
|
'icon-xs':
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
'icon-sm':
|
||||||
|
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||||
|
'icon-lg': 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 生成 nginx htpasswd
|
||||||
|
if [ -z "$NGINX_USER" ] || [ -z "$NGINX_PASSWORD" ]; then
|
||||||
|
echo "❌ 未设置 NGINX_USER 或 NGINX_PASSWORD,拒绝启动"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "🔐 生成访问密码..."
|
||||||
|
htpasswd -bc /etc/nginx/.htpasswd "$NGINX_USER" "$NGINX_PASSWORD"
|
||||||
|
|
||||||
|
# 后台启动 Next.js
|
||||||
|
echo "🚀 启动 Next.js..."
|
||||||
|
pnpm start &
|
||||||
|
|
||||||
|
# 等待 Next.js 就绪
|
||||||
|
echo "⏳ 等待 Next.js 就绪..."
|
||||||
|
MAX_WAIT=60
|
||||||
|
WAITED=0
|
||||||
|
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||||
|
if wget -q --spider http://localhost:3000 2>/dev/null; then
|
||||||
|
echo "✅ Next.js 已就绪 (${WAITED}s)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
WAITED=$((WAITED + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# 前台启动 nginx
|
||||||
|
echo "🌐 启动 nginx..."
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ChatMessage, RequestAIParams } from "./types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic, reusable OpenAI-compatible chat completion request.
|
||||||
|
* Not coupled to any specific business logic.
|
||||||
|
*/
|
||||||
|
export async function requestOpenAICompatible(params: RequestAIParams): Promise<string> {
|
||||||
|
const {
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
systemPrompt,
|
||||||
|
customPrompt,
|
||||||
|
messages,
|
||||||
|
temperature = 0.3,
|
||||||
|
maxTokens = 4000,
|
||||||
|
stream = false,
|
||||||
|
signal,
|
||||||
|
} = params
|
||||||
|
|
||||||
|
const finalMessages: ChatMessage[] = []
|
||||||
|
|
||||||
|
if (systemPrompt) {
|
||||||
|
finalMessages.push({ role: "system", content: systemPrompt })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customPrompt) {
|
||||||
|
finalMessages.push({ role: "user", content: customPrompt })
|
||||||
|
}
|
||||||
|
|
||||||
|
finalMessages.push(...messages)
|
||||||
|
|
||||||
|
const url = `${baseUrl.replace(/\/$/, "")}/chat/completions`
|
||||||
|
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: finalMessages,
|
||||||
|
temperature,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
stream,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") throw err
|
||||||
|
throw new Error(`无法连接到 AI 服务:${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => "")
|
||||||
|
throw new Error(`AI 请求失败:${response.status} ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const content = data?.choices?.[0]?.message?.content
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw new Error("AI 没有返回有效内容")
|
||||||
|
}
|
||||||
|
|
||||||
|
return content as string
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createClient } from "@/lib/supabase/client"
|
||||||
|
|
||||||
|
export type AIConversation = {
|
||||||
|
id: string
|
||||||
|
knowledge_item_id: string | null
|
||||||
|
title: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AIMessageRow = {
|
||||||
|
id: string
|
||||||
|
conversation_id: string
|
||||||
|
role: "user" | "assistant" | "system"
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the latest conversation for a knowledge item, if any. */
|
||||||
|
export async function getLatestConversation(knowledgeItemId: string): Promise<AIConversation | null> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("ai_conversations")
|
||||||
|
.select("*")
|
||||||
|
.eq("knowledge_item_id", knowledgeItemId)
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
if (error) throw error
|
||||||
|
return (data as AIConversation) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConversation(knowledgeItemId: string, title: string): Promise<AIConversation> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("ai_conversations")
|
||||||
|
.insert({ knowledge_item_id: knowledgeItemId, title })
|
||||||
|
.select("*")
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return data as AIConversation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMessages(conversationId: string): Promise<AIMessageRow[]> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("ai_messages")
|
||||||
|
.select("*")
|
||||||
|
.eq("conversation_id", conversationId)
|
||||||
|
.order("created_at", { ascending: true })
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []) as AIMessageRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMessage(
|
||||||
|
conversationId: string,
|
||||||
|
role: "user" | "assistant",
|
||||||
|
content: string,
|
||||||
|
): Promise<AIMessageRow> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("ai_messages")
|
||||||
|
.insert({ conversation_id: conversationId, role, content })
|
||||||
|
.select("*")
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
// touch conversation updated_at
|
||||||
|
await supabase.from("ai_conversations").update({ updated_at: new Date().toISOString() }).eq("id", conversationId)
|
||||||
|
return data as AIMessageRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearMessages(conversationId: string): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase.from("ai_messages").delete().eq("conversation_id", conversationId)
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLastAssistantMessage(conversationId: string): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("ai_messages")
|
||||||
|
.select("id, role")
|
||||||
|
.eq("conversation_id", conversationId)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
if (error) throw error
|
||||||
|
if (data && (data as { role: string }).role === "assistant") {
|
||||||
|
await supabase.from("ai_messages").delete().eq("id", (data as { id: string }).id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
AI_CATEGORY_ENUM,
|
||||||
|
AI_DIFFICULTY_ENUM,
|
||||||
|
AI_MASTERY_ENUM,
|
||||||
|
AI_TAG_ENUM,
|
||||||
|
type AIDraftItem,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
export type ParseResult =
|
||||||
|
| { ok: true; items: AIDraftItem[] }
|
||||||
|
| { ok: false; error: string; raw: string }
|
||||||
|
|
||||||
|
/** Strip markdown code fences and locate the JSON payload. */
|
||||||
|
function stripFences(input: string): string {
|
||||||
|
let text = input.trim()
|
||||||
|
// remove ```json ... ``` or ``` ... ``` wrappers
|
||||||
|
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
||||||
|
if (fenceMatch) {
|
||||||
|
text = fenceMatch[1].trim()
|
||||||
|
}
|
||||||
|
// fall back: slice from first { to last }
|
||||||
|
if (!text.startsWith("{")) {
|
||||||
|
const first = text.indexOf("{")
|
||||||
|
const last = text.lastIndexOf("}")
|
||||||
|
if (first !== -1 && last !== -1 && last > first) {
|
||||||
|
text = text.slice(first, last + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStr(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
if (value == null) return ""
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTags(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const allowed = new Set<string>(AI_TAG_ENUM)
|
||||||
|
const out: string[] = []
|
||||||
|
for (const t of value) {
|
||||||
|
const tag = toStr(t).trim()
|
||||||
|
if (allowed.has(tag) && !out.includes(tag)) out.push(tag)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeItem(raw: Record<string, unknown>): AIDraftItem {
|
||||||
|
const category = toStr(raw.category).trim()
|
||||||
|
const difficulty = toStr(raw.difficulty).trim()
|
||||||
|
const mastery = toStr(raw.mastery).trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: toStr(raw.title).trim(),
|
||||||
|
summary: toStr(raw.summary).trim(),
|
||||||
|
content: toStr(raw.content).trim(),
|
||||||
|
code_snippet: toStr(raw.code_snippet),
|
||||||
|
tags: sanitizeTags(raw.tags),
|
||||||
|
category: (AI_CATEGORY_ENUM as readonly string[]).includes(category) ? category : "其他",
|
||||||
|
difficulty: (AI_DIFFICULTY_ENUM as readonly string[]).includes(difficulty)
|
||||||
|
? (difficulty as AIDraftItem["difficulty"])
|
||||||
|
: "medium",
|
||||||
|
mastery: (AI_MASTERY_ENUM as readonly string[]).includes(mastery)
|
||||||
|
? (mastery as AIDraftItem["mastery"])
|
||||||
|
: "new",
|
||||||
|
source_url: toStr(raw.source_url).trim(),
|
||||||
|
notes: toStr(raw.notes).trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse AI output into a list of draft knowledge items.
|
||||||
|
* - tolerant of markdown fences
|
||||||
|
* - validates items is an array
|
||||||
|
* - filters/normalizes enum fields with sensible fallbacks
|
||||||
|
* - drops items without a title
|
||||||
|
*/
|
||||||
|
export function safeParseAIJson(input: string): ParseResult {
|
||||||
|
const cleaned = stripFences(input)
|
||||||
|
let data: unknown
|
||||||
|
try {
|
||||||
|
data = JSON.parse(cleaned)
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "无法解析 AI 返回的 JSON", raw: input }
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsRaw = (data as { items?: unknown })?.items
|
||||||
|
if (!Array.isArray(itemsRaw)) {
|
||||||
|
return { ok: false, error: 'JSON 顶层缺少数组字段 "items"', raw: input }
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = itemsRaw
|
||||||
|
.filter((it): it is Record<string, unknown> => !!it && typeof it === "object")
|
||||||
|
.map(sanitizeItem)
|
||||||
|
.filter((it) => it.title.length > 0)
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return { ok: false, error: "未能从返回内容中提取到有效条目", raw: input }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, items }
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { KnowledgeItem } from "@/lib/types"
|
||||||
|
import { CATEGORIES, DIFFICULTIES, MASTERY_LEVELS, TAGS } from "@/lib/types"
|
||||||
|
|
||||||
|
const ITEM_FIELDS = [
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"content",
|
||||||
|
"code_snippet",
|
||||||
|
"tags",
|
||||||
|
"category",
|
||||||
|
"difficulty",
|
||||||
|
"mastery",
|
||||||
|
"source_url",
|
||||||
|
"notes",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export interface BatchImportPromptOptions {
|
||||||
|
/** tags 可选枚举,默认取 lib/types 中的 TAGS */
|
||||||
|
tags?: readonly string[]
|
||||||
|
/** category 可选枚举,默认取 lib/types 中的 CATEGORIES */
|
||||||
|
categories?: readonly string[]
|
||||||
|
/** difficulty 可选枚举,默认取 lib/types 中的 DIFFICULTIES */
|
||||||
|
difficulties?: readonly string[]
|
||||||
|
/** mastery 默认值,默认取 MASTERY_LEVELS 的第一项 */
|
||||||
|
defaultMastery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据类型定义生成批量导入提示词。
|
||||||
|
* 枚举字段全部从 lib/types 的常量派生,修改类型后提示词会自动同步。
|
||||||
|
*/
|
||||||
|
export function buildBatchImportPrompt(options: BatchImportPromptOptions = {}): string {
|
||||||
|
const {
|
||||||
|
tags = TAGS,
|
||||||
|
categories = CATEGORIES,
|
||||||
|
difficulties = DIFFICULTIES,
|
||||||
|
defaultMastery = MASTERY_LEVELS[0],
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const fieldsList = ITEM_FIELDS.map((f) => ` - ${f}`).join("\n")
|
||||||
|
|
||||||
|
return `请把用户输入的多个前端题目整理成知识库 JSON。
|
||||||
|
要求:
|
||||||
|
1. 只返回严格 JSON。
|
||||||
|
2. JSON 顶层格式必须是:{ "items": [] }
|
||||||
|
3. 每个 item 必须包含:
|
||||||
|
${fieldsList}
|
||||||
|
4. tags 只能从这些枚举中选择:
|
||||||
|
${tags.join("、")}
|
||||||
|
5. category 只能从这些枚举中选择:
|
||||||
|
${categories.join("、")}
|
||||||
|
6. difficulty 只能是:
|
||||||
|
${difficulties.join("、")}
|
||||||
|
7. mastery 默认是 ${defaultMastery}
|
||||||
|
8. 如果题目适合写代码示例,请放入 code_snippet
|
||||||
|
9. content 要适合后续复习,结构清晰
|
||||||
|
10. 不要返回 Markdown,不要返回解释`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认批量导入提示词(使用 lib/types 中的枚举常量) */
|
||||||
|
export const BATCH_IMPORT_PROMPT = buildBatchImportPrompt()
|
||||||
|
|
||||||
|
export function buildItemContext(item: KnowledgeItem): string {
|
||||||
|
return `当前知识点:
|
||||||
|
标题:${item.title}
|
||||||
|
摘要:${item.summary ?? ""}
|
||||||
|
正文:${item.content ?? ""}
|
||||||
|
代码:${item.code_snippet ?? ""}
|
||||||
|
标签:${item.tags.join("、")}
|
||||||
|
难度:${item.difficulty}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChatSystemPrompt(item: KnowledgeItem): string {
|
||||||
|
return `你正在帮助用户复习一个前端知识点。
|
||||||
|
|
||||||
|
${buildItemContext(item)}
|
||||||
|
|
||||||
|
请基于这个知识点回答用户问题。
|
||||||
|
回答要求:
|
||||||
|
- 解释清晰
|
||||||
|
- 尽量结合面试场景
|
||||||
|
- 必要时给代码示例
|
||||||
|
- 如果用户回答错误,要指出问题并给出正确理解
|
||||||
|
- 不要编造不存在的上下文`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOptimizePrompt(item: { title: string; summary: string; content: string }): string {
|
||||||
|
return `请优化下面这个前端知识点,使其更适合复习记忆。
|
||||||
|
只返回严格 JSON,格式为:{ "summary": "...", "content": "..." }
|
||||||
|
不要返回 Markdown,不要返回解释。
|
||||||
|
|
||||||
|
标题:${item.title}
|
||||||
|
当前摘要:${item.summary}
|
||||||
|
当前正文:${item.content}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- summary 为一句话精炼摘要
|
||||||
|
- content 结构清晰、分点、突出重点与易错点,适合反复复习`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCodeGenPrompt(item: { title: string; summary: string; content: string }): string {
|
||||||
|
return `请为下面这个前端知识点生成一段简洁、可运行、有代表性的示例代码。
|
||||||
|
只返回代码本身,不要返回 Markdown 代码块标记,不要返回解释。
|
||||||
|
|
||||||
|
标题:${item.title}
|
||||||
|
摘要:${item.summary}
|
||||||
|
正文:${item.content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInterviewPrompt(item: KnowledgeItem): string {
|
||||||
|
return `请把下面这个前端知识点整理成"面试回答版"。
|
||||||
|
要求:
|
||||||
|
- 模拟面试场景下口语化但专业的回答
|
||||||
|
- 先给结论,再展开原理,最后补充延伸/注意点
|
||||||
|
- 必要时给简短代码示例
|
||||||
|
- 不要返回 Markdown 代码块以外的多余解释
|
||||||
|
|
||||||
|
${buildItemContext(item)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { DEFAULT_AI_SETTINGS, type AISettings } from "./types"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "devvault.ai-settings"
|
||||||
|
|
||||||
|
export function loadAISettings(): AISettings {
|
||||||
|
if (typeof window === "undefined") return { ...DEFAULT_AI_SETTINGS }
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return { ...DEFAULT_AI_SETTINGS }
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AISettings>
|
||||||
|
return { ...DEFAULT_AI_SETTINGS, ...parsed }
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_AI_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAISettings(settings: AISettings): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearApiKey(): AISettings {
|
||||||
|
const current = loadAISettings()
|
||||||
|
const next = { ...current, apiKey: "" }
|
||||||
|
saveAISettings(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAISettings(): AISettings {
|
||||||
|
const next = { ...DEFAULT_AI_SETTINGS }
|
||||||
|
saveAISettings(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an error message if the settings are not usable for a request, otherwise null. */
|
||||||
|
export function validateForRequest(settings: AISettings): string | null {
|
||||||
|
if (!settings.baseUrl.trim()) return "请先在设置中填写 API Base URL"
|
||||||
|
if (!settings.model.trim()) return "请先在设置中填写 Model"
|
||||||
|
if (!settings.apiKey.trim()) return "请先在设置中填写 API Key"
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export type AISettings = {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
temperature: number
|
||||||
|
maxTokens: number
|
||||||
|
systemPrompt: string
|
||||||
|
stream: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: "system" | "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestAIParams = {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
systemPrompt?: string
|
||||||
|
customPrompt?: string
|
||||||
|
messages: ChatMessage[]
|
||||||
|
temperature?: number
|
||||||
|
maxTokens?: number
|
||||||
|
stream?: boolean
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_AI_SETTINGS: AISettings = {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "",
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
temperature: 0.3,
|
||||||
|
maxTokens: 4000,
|
||||||
|
stream: false,
|
||||||
|
systemPrompt: `你是一个前端学习助手,擅长把零散的前端题目、知识点、面试题整理成结构化知识库数据。
|
||||||
|
你必须返回严格 JSON,不要返回 Markdown,不要返回解释。`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed enums for AI-generated knowledge items (per spec)
|
||||||
|
export const AI_TAG_ENUM = [
|
||||||
|
"八股",
|
||||||
|
"JavaScript",
|
||||||
|
"TypeScript",
|
||||||
|
"Vue",
|
||||||
|
"React",
|
||||||
|
"Next.js",
|
||||||
|
"CSS",
|
||||||
|
"HTML",
|
||||||
|
"浏览器",
|
||||||
|
"工程化",
|
||||||
|
"性能优化",
|
||||||
|
"算法",
|
||||||
|
"网络",
|
||||||
|
"Node.js",
|
||||||
|
"面试",
|
||||||
|
"项目经验",
|
||||||
|
"其他",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const AI_CATEGORY_ENUM = ["基础", "框架", "工程化", "算法", "面试", "项目", "其他"] as const
|
||||||
|
|
||||||
|
export const AI_DIFFICULTY_ENUM = ["easy", "medium", "hard"] as const
|
||||||
|
|
||||||
|
export const AI_MASTERY_ENUM = ["new", "learning", "mastered"] as const
|
||||||
|
|
||||||
|
// Shape of a single item produced by the batch-import flow.
|
||||||
|
export type AIDraftItem = {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
content: string
|
||||||
|
code_snippet: string
|
||||||
|
tags: string[]
|
||||||
|
category: string
|
||||||
|
difficulty: (typeof AI_DIFFICULTY_ENUM)[number]
|
||||||
|
mastery: (typeof AI_MASTERY_ENUM)[number]
|
||||||
|
source_url: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createClient } from "@/lib/supabase/client"
|
||||||
|
import type { KnowledgeItem, KnowledgeItemInput, Mastery } from "@/lib/types"
|
||||||
|
|
||||||
|
const TABLE = "knowledge_items"
|
||||||
|
|
||||||
|
export async function fetchItems(): Promise<KnowledgeItem[]> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase.from(TABLE).select("*").order("created_at", { ascending: false })
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []) as KnowledgeItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createItem(input: KnowledgeItemInput): Promise<KnowledgeItem> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(TABLE)
|
||||||
|
.insert({ ...input })
|
||||||
|
.select("*")
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return data as KnowledgeItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createItems(inputs: KnowledgeItemInput[]): Promise<KnowledgeItem[]> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(TABLE)
|
||||||
|
.insert(inputs.map((i) => ({ ...i })))
|
||||||
|
.select("*")
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []) as KnowledgeItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItem(id: string, input: Partial<KnowledgeItemInput>): Promise<KnowledgeItem> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(TABLE)
|
||||||
|
.update({ ...input, updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", id)
|
||||||
|
.select("*")
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return data as KnowledgeItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveInterviewAnswer(id: string, answer: string): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from(TABLE)
|
||||||
|
.update({ interview_answer: answer, updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", id)
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItem(id: string): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase.from(TABLE).delete().eq("id", id)
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleFavorite(id: string, value: boolean): Promise<void> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase.from(TABLE).update({ is_favorite: value }).eq("id", id)
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reviewItem(id: string, currentCount: number, mastery: Mastery): Promise<KnowledgeItem> {
|
||||||
|
const supabase = createClient()
|
||||||
|
const now = new Date()
|
||||||
|
// simple spaced-repetition: schedule next review based on mastery
|
||||||
|
const daysAhead = mastery === "mastered" ? 14 : mastery === "learning" ? 4 : 1
|
||||||
|
const next = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000)
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(TABLE)
|
||||||
|
.update({
|
||||||
|
review_count: currentCount + 1,
|
||||||
|
last_reviewed_at: now.toISOString(),
|
||||||
|
next_review_at: next.toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", id)
|
||||||
|
.select("*")
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return data as KnowledgeItem
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Especially important if using Fluid compute: Don't put this client in a
|
||||||
|
* global variable. Always create a new client within each function when using
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// The "setAll" method was called from a Server Component.
|
||||||
|
// This can be ignored if you have proxy refreshing
|
||||||
|
// user sessions.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
||||||
|
export type Difficulty = (typeof DIFFICULTIES)[number];
|
||||||
|
|
||||||
|
export const MASTERY_LEVELS = ["new", "learning", "mastered"] as const;
|
||||||
|
export type Mastery = (typeof MASTERY_LEVELS)[number];
|
||||||
|
|
||||||
|
export interface KnowledgeItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
content: string | null;
|
||||||
|
code_snippet: string | null;
|
||||||
|
tags: string[];
|
||||||
|
category: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
mastery: Mastery;
|
||||||
|
is_favorite: boolean;
|
||||||
|
source_url: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
interview_answer: string | null;
|
||||||
|
review_count: number;
|
||||||
|
last_reviewed_at: string | null;
|
||||||
|
next_review_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KnowledgeItemInput = {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
code_snippet: string;
|
||||||
|
tags: string[];
|
||||||
|
category: string;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
mastery: Mastery;
|
||||||
|
is_favorite: boolean;
|
||||||
|
source_url: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORIES = [
|
||||||
|
"JavaScript",
|
||||||
|
"TypeScript",
|
||||||
|
"CSS",
|
||||||
|
"HTML",
|
||||||
|
"React",
|
||||||
|
"Vue",
|
||||||
|
"性能优化",
|
||||||
|
"网络",
|
||||||
|
"工程化",
|
||||||
|
"算法",
|
||||||
|
"其他"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const TAGS = [
|
||||||
|
"八股",
|
||||||
|
"JavaScript",
|
||||||
|
"TypeScript",
|
||||||
|
"Vue",
|
||||||
|
"React",
|
||||||
|
"Next.js",
|
||||||
|
"CSS",
|
||||||
|
"HTML",
|
||||||
|
"浏览器",
|
||||||
|
"工程化",
|
||||||
|
"性能优化",
|
||||||
|
"算法",
|
||||||
|
"网络",
|
||||||
|
"Node.js",
|
||||||
|
"面试",
|
||||||
|
"项目经验",
|
||||||
|
"其他"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DIFFICULTY_META: Record<
|
||||||
|
Difficulty,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
easy: {
|
||||||
|
label: "简单",
|
||||||
|
color: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10"
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
label: "中等",
|
||||||
|
color: "text-amber-400 border-amber-500/30 bg-amber-500/10"
|
||||||
|
},
|
||||||
|
hard: {
|
||||||
|
label: "困难",
|
||||||
|
color: "text-rose-400 border-rose-500/30 bg-rose-500/10"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MASTERY_META: Record<
|
||||||
|
Mastery,
|
||||||
|
{ label: string; color: string; dot: string }
|
||||||
|
> = {
|
||||||
|
new: {
|
||||||
|
label: "待学习",
|
||||||
|
color: "text-muted-foreground border-border bg-muted/40",
|
||||||
|
dot: "bg-muted-foreground"
|
||||||
|
},
|
||||||
|
learning: {
|
||||||
|
label: "学习中",
|
||||||
|
color: "text-sky-400 border-sky-500/30 bg-sky-500/10",
|
||||||
|
dot: "bg-sky-400"
|
||||||
|
},
|
||||||
|
mastered: {
|
||||||
|
label: "已掌握",
|
||||||
|
color: "text-primary border-primary/40 bg-primary/10",
|
||||||
|
dot: "bg-primary"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function emptyItemInput(): KnowledgeItemInput {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
summary: "",
|
||||||
|
content: "",
|
||||||
|
code_snippet: "",
|
||||||
|
tags: [],
|
||||||
|
category: "JavaScript",
|
||||||
|
difficulty: "easy",
|
||||||
|
mastery: "new",
|
||||||
|
is_favorite: false,
|
||||||
|
source_url: "",
|
||||||
|
notes: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# 密码保护
|
||||||
|
auth_basic "请输入访问密码";
|
||||||
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "my-project",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@supabase/ssr": "^0.12.0",
|
||||||
|
"@supabase/supabase-js": "^2.108.2",
|
||||||
|
"@vercel/analytics": "1.6.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"next": "16.2.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"shadcn": "^4.8.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
|
"@types/node": "^24",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"postcss": "^8.5",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
|
"typescript": "5.7.3"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"hono": "4.12.25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 585 B |
|
After Width: | Height: | Size: 566 B |
@@ -0,0 +1,26 @@
|
|||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.background { fill: black; }
|
||||||
|
.foreground { fill: white; }
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.background { fill: white; }
|
||||||
|
.foreground { fill: black; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<g clip-path="url(#clip0_7960_43945)">
|
||||||
|
<rect class="background" width="180" height="180" rx="37" />
|
||||||
|
<g style="transform: scale(95%); transform-origin: center">
|
||||||
|
<path class="foreground"
|
||||||
|
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||||
|
<path class="foreground"
|
||||||
|
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7960_43945">
|
||||||
|
<rect width="180" height="180" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"target": "ES6",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||