commit 73b9c9fb7adf419b8dd08794d78e640dd065eafa Author: Marcus <1922576605@qq.com> Date: Tue Jun 23 19:09:11 2026 +0800 feat: 初版 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..96ccd22 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97eb8c0 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91a6298 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..8a2ea17 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,3 @@ +FROM node:22-alpine + +RUN npm install -g pnpm@latest diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..f022f45 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9d94317 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + {process.env.NODE_ENV === 'production' && } + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..6d2ebf1 --- /dev/null +++ b/app/page.tsx @@ -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 +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..45921ec --- /dev/null +++ b/components.json @@ -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" +} diff --git a/components/ai/ai-batch-import-dialog.tsx b/components/ai/ai-batch-import-dialog.tsx new file mode 100644 index 0000000..9f8cd3c --- /dev/null +++ b/components/ai/ai-batch-import-dialog.tsx @@ -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(null) + const [rawResponse, setRawResponse] = useState(null) + const [rows, setRows] = useState([]) + const [controller, setController] = useState(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 ( + (loading ? null : onOpenChange(o))}> + + + + + AI 批量整理 + + + 粘贴多个题目 / 面试题 / 知识点,AI 会整理成结构化数据。插入前可预览、编辑并勾选。 + + + +
+
+ model {settings.model || "—"} + | + base + {settings.baseUrl || "—"} +
+ +
+ +