Compare commits

..

23 Commits

Author SHA1 Message Date
胡旭 58c27f96bf fix: validation schema 添加 coverImage 字段(修复封面图无法保存) 2026-06-25 09:37:08 +08:00
胡旭 5ac3617a9e chore: 添加 .npmrc 允许 Prisma build scripts 2026-06-25 09:10:03 +08:00
胡旭 a4c265d503 chore: 移除 Docker 相关配置 2026-06-25 09:00:04 +08:00
胡旭 159ec69d3d fix: runner 阶段安装 openssl(Prisma 检测需要) 2026-06-25 08:57:40 +08:00
胡旭 df9dee453f fix: runner 阶段重新 prisma generate(匹配 OpenSSL 版本) 2026-06-25 08:51:27 +08:00
胡旭 30823e9926 fix: AiAssistant selectedText 可选链修复 2026-06-25 08:36:27 +08:00
胡旭 5e129fda86 fix: 改用 node:22-slim(Debian),去掉 Alpine openssl 依赖
Alpine 包索引 TLS 错误导致 openssl 安装失败。
Debian slim 镜像自带 OpenSSL,无需额外安装。
同步修改 addgroup/adduser 为 Debian 语法。
2026-06-25 08:31:16 +08:00
胡旭 0268048c62 fix: 去掉 start.sh,CMD 内联启动逻辑(彻底解决权限问题) 2026-06-25 08:21:32 +08:00
huxu 707d065edb feat: AI 写作助手重构 — 右侧面板、选中文本、撤销、生成摘要/标题
- AiAssistant 改为右侧粘性面板,按文本处理/翻译/智能生成分组
- 支持选中文本局部处理(未选中时处理全文)
- 替换/追加内容后支持撤销恢复
- 新增「生成标题」「生成摘要」按钮,结果可一键填入表单
- 编辑器最小高度从 300px 提高到 500px
- admin layout 去除 max-w-5xl 限制,充分利用宽屏空间
- 添加 .env.example 模板,.gitignore 放行
- package.json 添加 @tailwindcss/oxide-win32-x64-msvc optionalDependencies(兼容 Windows)
2026-06-24 20:06:49 +08:00
胡旭 bf76975000 fix: start.sh 在 chown 之前 COPY(确保 nextjs 用户可读) 2026-06-24 17:29:08 +08:00
胡旭 57bbf1fece fix: 去掉 chmod(sh 执行不需要 +x) 2026-06-24 17:24:42 +08:00
胡旭 38f2a9823b fix: sitemap 加 force-dynamic + try-catch(构建时不查数据库) 2026-06-24 17:20:01 +08:00
胡旭 89f8d6e223 fix: 所有数据页面加 force-dynamic(解决构建时预渲染空数据缓存)
构建时数据库为空,Next.js 缓存了空页面。
加 force-dynamic 强制每次请求时从数据库读取。
2026-06-24 17:14:26 +08:00
胡旭 92d190a081 fix: prisma db push 移到启动时执行(保留用户数据)
构建时执行会覆盖用户拷贝的数据库文件,
改为容器启动时执行,已有数据则跳过。
2026-06-24 16:48:07 +08:00
胡旭 e6839da566 fix: Docker 构建前 prisma db push 创建表 2026-06-24 16:38:46 +08:00
胡旭 faa17f0ccd fix: Docker 安装 openssl(Prisma 引擎需要 libssl.so.1.1) 2026-06-24 16:30:16 +08:00
胡旭 814729df02 fix: Docker runner 拷贝完整 node_modules(pnpm .prisma 在 .pnpm 内) 2026-06-24 16:22:37 +08:00
胡旭 7fbfaa9572 fix: Docker 改用 pnpm 9,绕过 pnpm 11 build scripts 限制
pnpm 11 的 onlyBuiltDependencies 配置在 Docker 中始终不生效,
改用 npm 全局安装 pnpm 9 彻底解决。
2026-06-24 16:10:16 +08:00
胡旭 3bebf669bd fix: pnpm 11 onlyBuiltDependencies 移到 .npmrc
pnpm 11 不再读取 package.json 中的 pnpm 字段,
需要在 .npmrc 中配置 onlyBuiltDependencies。
2026-06-24 16:01:17 +08:00
胡旭 5e77c0fa61 fix: Dockerfile 移除多余的 .npmrc 拷贝 2026-06-24 15:56:00 +08:00
胡旭 56cd507e81 fix: pnpm 11 build scripts 允许 Prisma/sharp 等包执行
pnpm 11 默认阻止 build scripts,导致 Prisma Client 生成失败。
在 package.json 中添加 pnpm.onlyBuiltDependencies 白名单。
2026-06-24 15:55:40 +08:00
胡旭 6de0f6ad3d fix: Docker 基础镜像改为 node:22-alpine(pnpm 11 需要 node:sqlite) 2026-06-24 15:45:05 +08:00
胡旭 9cc923e868 chore: 容器端口改为 8090 2026-06-24 15:40:31 +08:00
20 changed files with 473 additions and 236 deletions
-8
View File
@@ -1,8 +0,0 @@
node_modules
.next
.git
*.md
.env
.env.local
prisma/dev.db
prisma/dev.db-journal
+17
View File
@@ -0,0 +1,17 @@
# ── 数据库(SQLite 本地路径)──
DATABASE_URL="file:./prisma/dev.db"
# ── 认证 ──
# 会话签名密钥,请改为随机字符串(生产环境务必修改)
SESSION_SECRET="change-me-to-a-random-string"
# 后台登录密码
ADMIN_PASSWORD="asui2026"
# ── AI 写作助手(可选)──
# 不配置 AI_API_KEY 时,AI 功能不可用,但博客其他功能正常
AI_BASE_URL="https://api.openai.com/v1"
AI_API_KEY=""
AI_MODEL="gpt-4o-mini"
# ── 站点 URL(用于 SEO metadata、OG 标签等)──
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
+1
View File
@@ -38,6 +38,7 @@ prisma/dev.db-shm
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel
+5
View File
@@ -0,0 +1,5 @@
onlyBuiltDependencies[]=prisma
onlyBuiltDependencies[]=@prisma/client
onlyBuiltDependencies[]=@prisma/engines
onlyBuiltDependencies[]=sharp
onlyBuiltDependencies[]=unrs-resolver
-50
View File
@@ -1,50 +0,0 @@
FROM node:20-alpine AS base
# ── 依赖安装层 ──
FROM base AS deps
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
RUN pnpm install --frozen-lockfile
RUN pnpm exec prisma generate
# ── 构建层 ──
FROM base AS builder
WORKDIR /app
RUN corepack enable pnpm
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ── 运行层 ──
FROM base AS runner
WORKDIR /app
RUN corepack enable pnpm
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 拷贝构建产物
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# 拷贝 prisma 和数据库
COPY --from=builder /app/prisma ./prisma
COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma
# 数据库持久化目录
RUN mkdir -p /app/prisma && chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
-19
View File
@@ -1,19 +0,0 @@
services:
blog:
build: .
container_name: sui-blog
restart: unless-stopped
ports:
- "3000:3000"
environment:
- DATABASE_URL=file:/app/prisma/dev.db
- SESSION_SECRET=${SESSION_SECRET:-change-me-to-a-random-string}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-asui2026}
- AI_BASE_URL=${AI_BASE_URL:-https://api.openai.com/v1}
- AI_API_KEY=${AI_API_KEY:-}
- AI_MODEL=${AI_MODEL:-gpt-4o-mini}
volumes:
- blog-data:/app/prisma
volumes:
blog-data:
+3
View File
@@ -36,6 +36,9 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"optionalDependencies": {
"@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
+4
View File
@@ -86,6 +86,10 @@ importers:
zod: zod:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.3
optionalDependencies:
'@tailwindcss/oxide-win32-x64-msvc':
specifier: 4.3.1
version: 4.3.1
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
+1 -1
View File
@@ -135,7 +135,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Main */} {/* Main */}
<div className="flex-1 overflow-auto md:pt-0 pt-12"> <div className="flex-1 overflow-auto md:pt-0 pt-12">
<div className="p-6 md:p-8 max-w-5xl"> <div className="p-6 md:p-8">
{children} {children}
</div> </div>
</div> </div>
+2
View File
@@ -2,6 +2,8 @@ import { Suspense } from "react";
import { getPublishedPosts } from "@/lib/store"; import { getPublishedPosts } from "@/lib/store";
import BlogList from "@/components/BlogList"; import BlogList from "@/components/BlogList";
export const dynamic = "force-dynamic";
export const metadata = { export const metadata = {
title: "文章", title: "文章",
description: "胡旭的博客文章 — 技术、随笔、旅行、阅读", description: "胡旭的博客文章 — 技术、随笔、旅行、阅读",
+2
View File
@@ -2,6 +2,8 @@ import Link from "next/link";
import { getPublicCategories, getPostsByCategory } from "@/lib/store"; import { getPublicCategories, getPostsByCategory } from "@/lib/store";
import GsapReveal from "@/components/GsapReveal"; import GsapReveal from "@/components/GsapReveal";
export const dynamic = "force-dynamic";
export const metadata = { export const metadata = {
title: "分类", title: "分类",
description: "按分类浏览博客文章", description: "按分类浏览博客文章",
+2
View File
@@ -2,6 +2,8 @@ import { getPublishedPosts } from "@/lib/store";
import HeroSection from "@/components/HeroSection"; import HeroSection from "@/components/HeroSection";
import { FeaturedGrid, RecentList } from "@/components/PostSections"; import { FeaturedGrid, RecentList } from "@/components/PostSections";
export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const featuredPosts = posts.filter((p) => p.featured); const featuredPosts = posts.filter((p) => p.featured);
+1 -4
View File
@@ -5,10 +5,7 @@ import PostContent from "@/components/PostContent";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
export async function generateStaticParams() { export const dynamic = "force-dynamic";
const posts = await getPublishedPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ export async function generateMetadata({
params, params,
+29 -23
View File
@@ -1,6 +1,8 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { getPublishedPosts, getPublicCategories, getAllTags } from "@/lib/store"; import { getPublishedPosts, getPublicCategories, getAllTags } from "@/lib/store";
export const dynamic = "force-dynamic";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
@@ -12,32 +14,36 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ url: `${SITE_URL}/about`, changeFrequency: "yearly", priority: 0.5 }, { url: `${SITE_URL}/about`, changeFrequency: "yearly", priority: 0.5 },
]; ];
const [publishedPosts, tags, categories] = await Promise.all([ try {
getPublishedPosts(), const [publishedPosts, tags, categories] = await Promise.all([
getAllTags(), getPublishedPosts(),
getPublicCategories(), getAllTags(),
]); getPublicCategories(),
]);
const postRoutes: MetadataRoute.Sitemap = publishedPosts.map((post) => ({ const postRoutes: MetadataRoute.Sitemap = publishedPosts.map((post) => ({
url: `${SITE_URL}/posts/${post.slug}`, url: `${SITE_URL}/posts/${post.slug}`,
lastModified: new Date(post.updatedAt || post.date), lastModified: new Date(post.updatedAt || post.date),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.8, priority: 0.8,
})); }));
const tagRoutes: MetadataRoute.Sitemap = tags.map((tag) => ({ const tagRoutes: MetadataRoute.Sitemap = tags.map((tag) => ({
url: `${SITE_URL}/blog?tag=${encodeURIComponent(tag.name)}`, url: `${SITE_URL}/blog?tag=${encodeURIComponent(tag.name)}`,
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.4,
}));
const categoryRoutes: MetadataRoute.Sitemap = categories
.filter((c) => c.count > 0)
.map((cat) => ({
url: `${SITE_URL}/blog?category=${encodeURIComponent(cat.name)}`,
changeFrequency: "monthly" as const,
priority: 0.4, priority: 0.4,
})); }));
return [...staticRoutes, ...postRoutes, ...tagRoutes, ...categoryRoutes]; const categoryRoutes: MetadataRoute.Sitemap = categories
.filter((c) => c.count > 0)
.map((cat) => ({
url: `${SITE_URL}/blog?category=${encodeURIComponent(cat.name)}`,
changeFrequency: "monthly" as const,
priority: 0.4,
}));
return [...staticRoutes, ...postRoutes, ...tagRoutes, ...categoryRoutes];
} catch {
return staticRoutes;
}
} }
+2
View File
@@ -1,6 +1,8 @@
import { getAllTags } from "@/lib/store"; import { getAllTags } from "@/lib/store";
import GsapReveal from "@/components/GsapReveal"; import GsapReveal from "@/components/GsapReveal";
export const dynamic = "force-dynamic";
export const metadata = { export const metadata = {
title: "标签", title: "标签",
description: "按标签浏览博客文章", description: "按标签浏览博客文章",
+311 -109
View File
@@ -11,36 +11,35 @@ import {
Languages, Languages,
FileText, FileText,
Bug, Bug,
X,
Loader2, Loader2,
Copy, Copy,
Check, Check,
Heading,
Send,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
interface AiAssistantProps { interface AiAssistantProps {
/** 当前编辑器的纯文本内容 */
content: string; content: string;
/** 将 AI 结果插入/替换到编辑器 */ selectedText?: string;
onInsert: (text: string, mode: "replace" | "append") => void; onInsert: (text: string, mode: "replace" | "append") => void;
onGenerateExcerpt?: (text: string) => void;
onGenerateTitle?: (text: string) => void;
} }
type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar"; type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar";
const ACTIONS: { key: Action; label: string; icon: React.ReactNode; needSelection?: boolean }[] = [ export default function AiAssistant({
{ key: "polish", label: "润色", icon: <Wand2 className="w-3.5 h-3.5" /> }, content,
{ key: "expand", label: "扩写", icon: <Maximize2 className="w-3.5 h-3.5" /> }, selectedText,
{ key: "shorten", label: "精简", icon: <Minimize2 className="w-3.5 h-3.5" /> }, onInsert,
{ key: "continue", label: "续写", icon: <ArrowRight className="w-3.5 h-3.5" /> }, onGenerateExcerpt,
{ key: "fix_grammar", label: "纠错", icon: <Bug className="w-3.5 h-3.5" /> }, onGenerateTitle,
{ key: "translate_en", label: "中→英", icon: <Languages className="w-3.5 h-3.5" /> }, }: AiAssistantProps) {
{ key: "translate_zh", label: "英→中", icon: <Languages className="w-3.5 h-3.5" /> },
{ key: "summarize", label: "摘要", icon: <FileText className="w-3.5 h-3.5" /> },
];
export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [result, setResult] = useState(""); const [result, setResult] = useState("");
const [resultLabel, setResultLabel] = useState("");
const [generateTarget, setGenerateTarget] = useState<"title" | "excerpt" | null>(null);
const [customPrompt, setCustomPrompt] = useState(""); const [customPrompt, setCustomPrompt] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -49,19 +48,28 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = null; abortRef.current = null;
setLoading(false); setLoading(false);
setGenerateTarget(null);
}, []); }, []);
async function runAction(action?: Action) { function getEffectiveText(): string {
const text = content.replace(/<[^>]+>/g, "").trim(); if (selectedText?.trim()) return selectedText.trim();
return content.replace(/<[^>]+>/g, "").trim();
}
const hasSelection = !!(selectedText?.trim());
async function runAction(action?: Action, label?: string) {
const text = getEffectiveText();
if (!text && action !== "summarize") { if (!text && action !== "summarize") {
setResult("请先输入文章内容"); setResult("请先输入文章内容或选中文字");
setOpen(true); setResultLabel("");
return; return;
} }
setLoading(true); setLoading(true);
setResult(""); setResult("");
setOpen(true); setResultLabel(label || "");
setGenerateTarget(null);
abortRef.current = new AbortController(); abortRef.current = new AbortController();
try { try {
@@ -83,7 +91,6 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
return; return;
} }
// 读取 SSE 流
const reader = res.body?.getReader(); const reader = res.body?.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let accumulated = ""; let accumulated = "";
@@ -92,9 +99,7 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value, { stream: true }); const chunk = decoder.decode(value, { stream: true });
// 解析 SSE 数据
for (const line of chunk.split("\n")) { for (const line of chunk.split("\n")) {
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
const data = line.slice(6); const data = line.slice(6);
@@ -106,9 +111,7 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
accumulated += delta; accumulated += delta;
setResult(accumulated); setResult(accumulated);
} }
} catch { } catch { /* ignore */ }
// 忽略解析错误
}
} }
} }
} }
@@ -122,6 +125,93 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
} finally { } finally {
setLoading(false); setLoading(false);
abortRef.current = null; abortRef.current = null;
setGenerateTarget(null);
}
}
async function runGenerate(target: "title" | "excerpt") {
const text = content.replace(/<[^>]+>/g, "").trim();
if (!text) {
setResult("请先输入文章内容");
setResultLabel("");
return;
}
setLoading(true);
setResult("");
setResultLabel(target === "title" ? "生成标题" : "生成摘要");
setGenerateTarget(target);
abortRef.current = new AbortController();
const promptText = target === "title"
? `请为以下文章生成一个简洁的中文标题(不超过30字),直接输出标题,不要引号:\n\n${text}`
: `请为以下文章写一段摘要(2-3句话,不超过200字),直接输出摘要:\n\n${text}`;
try {
const res = await fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: promptText, selectedText: text }),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
setResult(`错误:${err.error || "请求失败"}`);
setLoading(false);
setGenerateTarget(null);
return;
}
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n")) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
accumulated += delta;
setResult(accumulated);
}
} catch { /* ignore */ }
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
setResult((prev) => prev + "\n\n[已停止]");
} else {
setResult(`错误:${err instanceof Error ? err.message : "未知错误"}`);
}
} finally {
setLoading(false);
abortRef.current = null;
setGenerateTarget(null);
}
}
function applyGenerated() {
if (generateTarget === "title" && onGenerateTitle && result) {
onGenerateTitle(result);
setResult("");
setResultLabel("");
setGenerateTarget(null);
} else if (generateTarget === "excerpt" && onGenerateExcerpt && result) {
onGenerateExcerpt(result);
setResult("");
setResultLabel("");
setGenerateTarget(null);
} }
} }
@@ -134,109 +224,221 @@ export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
function handleInsert(mode: "replace" | "append") { function handleInsert(mode: "replace" | "append") {
onInsert(result, mode); onInsert(result, mode);
setResult(""); setResult("");
setOpen(false); setResultLabel("");
} }
return ( return (
<div className="space-y-2"> <div className="sticky top-6 space-y-5">
{/* 触发按钮 */} {/* ── 标题栏 ── */}
<button <div className="flex items-center gap-2">
type="button" <div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
onClick={() => setOpen((v) => !v)} <Sparkles className="w-3.5 h-3.5 text-primary" />
className="flex items-center gap-1.5 font-sans text-xs text-muted-foreground hover:text-primary transition-colors" </div>
> <span className="font-sans text-sm font-semibold text-foreground">AI </span>
<Sparkles className="w-3.5 h-3.5" /> </div>
AI
</button>
{open && ( {/* ── 选中提示 ── */}
<div className="rounded-xl border border-border bg-card p-4 space-y-3"> {hasSelection && (
{/* 关闭按钮 */} <div className="rounded-lg bg-primary/5 border border-primary/20 px-3 py-2">
<div className="flex items-center justify-between"> <p className="font-sans text-xs text-primary/70">
<span className="font-sans text-xs text-muted-foreground"></span> 📌 <span className="font-medium">{selectedText?.length}</span> AI
<button type="button" onClick={() => { setOpen(false); stopGeneration(); }} className="text-muted-foreground hover:text-foreground"> </p>
<X className="w-4 h-4" /> </div>
</button> )}
</div>
{/* 操作按钮组 */} {/* ── 文本处理 ── */}
<div className="flex flex-wrap gap-1.5"> <div className="space-y-2">
{ACTIONS.map((a) => ( <p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50"></p>
<button <div className="grid grid-cols-2 gap-1.5">
key={a.key} {[
type="button" { key: "polish" as Action, label: "润色", icon: <Wand2 className="w-3 h-3" /> },
disabled={loading} { key: "expand" as Action, label: "扩写", icon: <Maximize2 className="w-3 h-3" /> },
onClick={() => runAction(a.key)} { key: "shorten" as Action, label: "精简", icon: <Minimize2 className="w-3 h-3" /> },
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 disabled:opacity-50 transition-colors" { key: "continue" as Action, label: "续写", icon: <ArrowRight className="w-3 h-3" /> },
> { key: "fix_grammar" as Action, label: "纠错", icon: <Bug className="w-3 h-3" /> },
{a.icon} { key: "summarize" as Action, label: "摘要", icon: <FileText className="w-3 h-3" /> },
{a.label} ].map((a) => (
</button> <button
))} key={a.key}
</div> type="button"
{/* 自定义指令 */}
<div className="flex gap-2">
<input
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(); } }}
placeholder="或输入自定义指令..."
className="flex-1 h-8 px-3 rounded-lg border border-border bg-transparent font-sans text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
disabled={loading} disabled={loading}
/> onClick={() => runAction(a.key, a.label)}
<Button size="sm" variant="outline" disabled={loading || !customPrompt.trim()} onClick={() => runAction()} className="h-8 px-3 text-xs"> className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />} >
</Button> {a.icon}
{a.label}
</button>
))}
</div>
</div>
{/* ── 翻译 ── */}
<div className="space-y-2">
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50"></p>
<div className="grid grid-cols-2 gap-1.5">
{[
{ key: "translate_en" as Action, label: "中 → 英", icon: <Languages className="w-3 h-3" /> },
{ key: "translate_zh" as Action, label: "英 → 中", icon: <Languages className="w-3 h-3" /> },
].map((a) => (
<button
key={a.key}
type="button"
disabled={loading}
onClick={() => runAction(a.key, a.label)}
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
>
{a.icon}
{a.label}
</button>
))}
</div>
</div>
{/* ── 智能生成 ── */}
<div className="space-y-2">
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50"></p>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
disabled={loading}
onClick={() => runGenerate("title")}
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
>
<Heading className="w-3 h-3" />
</button>
<button
type="button"
disabled={loading}
onClick={() => runGenerate("excerpt")}
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
>
<FileText className="w-3 h-3" />
</button>
</div>
</div>
{/* ── 自定义指令 ── */}
<div className="space-y-2">
<div className="flex gap-1.5">
<input
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(undefined, "自定义指令"); }
}}
placeholder="输入自定义指令..."
className="flex-1 h-8 px-3 rounded-lg border border-border bg-transparent font-sans text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
disabled={loading}
/>
<Button
size="sm"
variant="outline"
disabled={loading || !customPrompt.trim()}
onClick={() => runAction(undefined, "自定义指令")}
className="h-8 w-8 p-0 shrink-0"
>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
{/* ── 结果区 ── */}
{(result || loading) && (
<div className="space-y-3">
{/* 分割线 */}
<div className="flex items-center gap-2">
<div className="flex-1 h-px bg-border" />
<span className="font-sans text-[10px] text-muted-foreground/50 shrink-0">
{loading ? "生成中..." : "结果"}
</span>
<div className="flex-1 h-px bg-border" />
</div> </div>
{/* 结果 */} {/* 结果标签 */}
{result && ( {resultLabel && !loading && result && (
<div className="space-y-2"> <p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">{resultLabel}</p>
<div className="relative rounded-lg border border-border bg-muted/30 p-3 max-h-60 overflow-auto"> )}
<pre className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">{result}</pre>
{loading && (
<button
type="button"
onClick={stopGeneration}
className="absolute top-2 right-2 px-2 py-0.5 rounded text-xs bg-red-600 text-white hover:bg-red-700 transition-colors"
>
</button>
)}
</div>
{/* 操作按钮 */} {/* 结果内容 */}
{!loading && result && ( <div className="relative rounded-lg border border-border bg-muted/20 p-3 max-h-80 overflow-auto">
<div className="flex gap-2"> {loading && !result && (
<button <div className="flex items-center gap-2 py-2">
type="button" <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
onClick={handleCopy} <span className="font-sans text-sm text-muted-foreground">AI ...</span>
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors" </div>
> )}
{copied ? <Check className="w-3.5 h-3.5 text-green-600" /> : <Copy className="w-3.5 h-3.5" />} {result && (
{copied ? "已复制" : "复制"} <div className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">
</button> {result}
{loading && <span className="inline-block w-1.5 h-4 bg-primary ml-0.5 animate-pulse align-middle" />}
</div>
)}
{loading && result && (
<button
type="button"
onClick={stopGeneration}
className="absolute top-2 right-2 px-2 py-0.5 rounded text-[10px] font-medium bg-red-600 text-white hover:bg-red-700 transition-colors"
>
</button>
)}
</div>
{/* 操作按钮 */}
{!loading && result && (
<div className="space-y-2">
{generateTarget ? (
<button
type="button"
onClick={applyGenerated}
className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
>
<Send className="w-3 h-3" />
{generateTarget === "title" ? "标题" : "摘要"}
</button>
) : (
<div className="space-y-1.5">
<button <button
type="button" type="button"
onClick={() => handleInsert("replace")} onClick={() => handleInsert("replace")}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-primary text-primary-foreground font-sans text-xs hover:bg-primary/90 transition-colors" className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
> >
<RefreshCw className="w-3 h-3" />
</button> </button>
<button <div className="grid grid-cols-2 gap-1.5">
type="button" <button
onClick={() => handleInsert("append")} type="button"
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors" onClick={() => handleInsert("append")}
> className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
>
</button>
</button>
<button
type="button"
onClick={handleCopy}
className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
>
{copied ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
{copied ? "已复制" : "复制"}
</button>
</div>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
)} )}
{/* ── 空状态 ── */}
{!result && !loading && (
<p className="font-sans text-xs text-muted-foreground/40 leading-relaxed text-center py-4">
<br />
</p>
)}
</div> </div>
); );
} }
+9
View File
@@ -7,6 +7,8 @@ interface MarkdownEditorProps {
value: string; // HTML from parent value: string; // HTML from parent
onChange: (html: string) => void; onChange: (html: string) => void;
placeholder?: string; placeholder?: string;
/** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */
onSelectionChange?: (selectedText: string) => void;
} }
/** 简易 HTML → Markdown 转换(仅处理常见标签) */ /** 简易 HTML → Markdown 转换(仅处理常见标签) */
@@ -46,6 +48,7 @@ export default function MarkdownEditor({
value, value,
onChange, onChange,
placeholder = "使用 Markdown 语法写作...", placeholder = "使用 Markdown 语法写作...",
onSelectionChange,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value)); const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value));
@@ -87,6 +90,12 @@ export default function MarkdownEditor({
<textarea <textarea
value={markdown} value={markdown}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onSelect={(e) => {
if (!onSelectionChange) return;
const ta = e.currentTarget;
const selected = ta.value.substring(ta.selectionStart, ta.selectionEnd);
onSelectionChange(selected);
}}
placeholder={placeholder} placeholder={placeholder}
className="flex-1 min-h-0 px-4 py-3 bg-transparent font-mono text-sm text-foreground resize-none focus:outline-none leading-relaxed" className="flex-1 min-h-0 px-4 py-3 bg-transparent font-mono text-sm text-foreground resize-none focus:outline-none leading-relaxed"
spellCheck={false} spellCheck={false}
+68 -21
View File
@@ -71,6 +71,13 @@ export default function PostForm({
const [isMarkdown, setIsMarkdown] = useState(false); const [isMarkdown, setIsMarkdown] = useState(false);
const fullscreenRef = useRef<HTMLDivElement>(null); const fullscreenRef = useRef<HTMLDivElement>(null);
// 编辑器选中的文本
const [selectedText, setSelectedText] = useState("");
// 撤销机制
const [showUndo, setShowUndo] = useState(false);
const previousContentRef = useRef("");
// 自动保存 // 自动保存
const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`; const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`;
const [lastSaved, setLastSaved] = useState<Date | null>(null); const [lastSaved, setLastSaved] = useState<Date | null>(null);
@@ -339,30 +346,70 @@ export default function PostForm({
)} )}
</div> </div>
{/* 内容 */} {/* 内容 + AI 助手(水平对齐) */}
<div className="space-y-1.5"> <div className="flex gap-8">
<div className="flex items-center justify-between"> <div className="flex-1 min-w-0 space-y-1.5">
<Label></Label> <Label></Label>
<AiAssistant <RichEditor
content={form.content} value={form.content}
onInsert={(text, mode) => { onChange={(html) => update("content", html)}
if (mode === "replace") { isFullscreen={false}
update("content", text); isMarkdown={isMarkdown}
} else { onToggleFullscreen={enterFullscreen}
update("content", form.content + "\n\n" + text); onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
} onSelectionChange={setSelectedText}
}}
/> />
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
{showUndo && (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg border border-primary/30 bg-primary/5">
<span className="font-sans text-xs text-primary/80"></span>
<button
type="button"
onClick={() => {
update("content", previousContentRef.current);
setShowUndo(false);
}}
className="font-sans text-xs text-primary font-medium hover:underline"
>
</button>
<button
type="button"
onClick={() => setShowUndo(false)}
className="font-sans text-xs text-muted-foreground hover:text-foreground"
>
</button>
</div>
)}
</div> </div>
<RichEditor
value={form.content} {/* AI 助手面板 — 与编辑器水平对齐 */}
onChange={(html) => update("content", html)} <aside className="hidden lg:block w-72 shrink-0">
isFullscreen={false} <div className="pt-6">
isMarkdown={isMarkdown} <AiAssistant
onToggleFullscreen={enterFullscreen} content={form.content}
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)} selectedText={selectedText}
/> onInsert={(text, mode) => {
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>} if (mode === "replace") {
previousContentRef.current = form.content;
setShowUndo(true);
update("content", text);
} else {
previousContentRef.current = form.content;
setShowUndo(true);
update("content", form.content + "\n\n" + text);
}
}}
onGenerateExcerpt={(text) => {
update("excerpt", text);
}}
onGenerateTitle={(text) => {
update("title", text);
}}
/>
</div>
</aside>
</div> </div>
{/* 分类 + 阅读时间 */} {/* 分类 + 阅读时间 */}
+15 -1
View File
@@ -43,6 +43,8 @@ interface RichEditorProps {
isMarkdown?: boolean; isMarkdown?: boolean;
onToggleFullscreen?: () => void; onToggleFullscreen?: () => void;
onSwitchToMarkdown?: () => void; onSwitchToMarkdown?: () => void;
/** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */
onSelectionChange?: (selectedText: string) => void;
} }
export default function RichEditor({ export default function RichEditor({
@@ -53,6 +55,7 @@ export default function RichEditor({
isMarkdown, isMarkdown,
onToggleFullscreen, onToggleFullscreen,
onSwitchToMarkdown, onSwitchToMarkdown,
onSelectionChange,
}: RichEditorProps) { }: RichEditorProps) {
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -75,9 +78,19 @@ export default function RichEditor({
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onChange(editor.getHTML()); onChange(editor.getHTML());
}, },
onSelectionUpdate: ({ editor }) => {
if (!onSelectionChange) return;
const { from, to } = editor.state.selection;
if (from !== to) {
const text = editor.state.doc.textBetween(from, to);
onSelectionChange(text);
} else {
onSelectionChange("");
}
},
editorProps: { editorProps: {
attributes: { attributes: {
class: "prose-literary min-h-[300px] px-4 py-3 focus:outline-none", class: "prose-literary min-h-[500px] px-4 py-3 focus:outline-none",
}, },
}, },
}); });
@@ -286,6 +299,7 @@ export default function RichEditor({
<MarkdownEditor <MarkdownEditor
value={value} value={value}
onChange={onChange} onChange={onChange}
onSelectionChange={onSelectionChange}
/> />
) : ( ) : (
<EditorContent editor={editor} /> <EditorContent editor={editor} />
+1
View File
@@ -10,6 +10,7 @@ export const createPostSchema = z.object({
slug: z.string().min(1).max(200).regex(/^[a-zA-Z0-9\u4e00-\u9fff_-]+$/), slug: z.string().min(1).max(200).regex(/^[a-zA-Z0-9\u4e00-\u9fff_-]+$/),
excerpt: z.string().max(500).optional().default(""), excerpt: z.string().max(500).optional().default(""),
content: z.string().min(1), content: z.string().min(1),
coverImage: z.string().max(500).optional().default(""),
date: z.string().min(1), date: z.string().min(1),
category: z.string().min(1).max(50), category: z.string().min(1).max(50),
tags: z.array(z.string().max(50)).max(20).default([]), tags: z.array(z.string().max(50)).max(20).default([]),