phase 5 part 1: typescript, vue, react, ssr(Next.js 16) topics with lessons, scenarios, interviews, verified examples
This commit is contained in:
1 parent
7a32d2c9a6
commit
7b5cae7d24
39 files changed
+2482
-48
No files matched your search
@@ -0,0 +1,35 @@
|
||||
// 不自动运行(Next.js 路由处理器,npm run dev 后 POST /api/chat)
|
||||
// 服务端调用大模型并以流的形式返回;模型密钥只在服务端,浏览器拿不到。
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function* fakeLLM(question: string, signal: AbortSignal): AsyncGenerator<string> {
|
||||
// 真实项目:调用模型 SDK 的流式接口,并把 signal 传进去
|
||||
const words = `关于“${question}”:签收 7 天内可以无理由退货。`.split("");
|
||||
for (const w of words) {
|
||||
if (signal.aborted) return; // 用户停止或断开:不再继续生成(节省费用)
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
yield w;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const { question } = (await request.json()) as { question?: unknown };
|
||||
if (typeof question !== "string" || question.length === 0 || question.length > 500) {
|
||||
return Response.json({ error: "question 必须是 1~500 字的字符串" }, { status: 400 });
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const token of fakeLLM(question, request.signal)) {
|
||||
controller.enqueue(encoder.encode(token));
|
||||
}
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
// 不自动运行
|
||||
// 客户端组件:有状态和事件处理。读取路由处理器返回的流,逐段显示。
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function Chat() {
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctrl = useRef<AbortController | null>(null);
|
||||
|
||||
async function ask(formData: FormData) {
|
||||
ctrl.current?.abort();
|
||||
ctrl.current = new AbortController();
|
||||
setAnswer("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const resp = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question: formData.get("q") }),
|
||||
signal: ctrl.current.signal,
|
||||
});
|
||||
if (!resp.body) throw new Error("没有响应体");
|
||||
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
setAnswer((a) => a + value);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") setAnswer("出错了,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={ask}>
|
||||
<input name="q" placeholder="问点什么" required />
|
||||
<button disabled={busy}>发送</button>
|
||||
{busy && <button type="button" onClick={() => ctrl.current?.abort()}>停止</button>}
|
||||
<p aria-live="polite">{answer}</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 不自动运行
|
||||
export const metadata = { title: "帮助中心", description: "常见问题和智能客服" };
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 不自动运行
|
||||
// 服务端组件(默认):在服务器上取数和渲染,HTML 直接包含常见问题,对 SEO 友好;不会把这段代码发到浏览器。
|
||||
import { Chat } from "./chat";
|
||||
|
||||
interface Faq {
|
||||
id: number;
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
async function getFaqs(): Promise<Faq[]> {
|
||||
// 真实项目:await fetch("https://cms.example.com/faqs", { next: { revalidate: 300 } })(缓存策略以官方文档为准)
|
||||
return [
|
||||
{ id: 1, q: "多久内可以退货?", a: "签收 7 天内可以无理由退货。" },
|
||||
{ id: 2, q: "怎么开发票?", a: "在订单详情页申请电子发票。" },
|
||||
];
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const faqs = await getFaqs();
|
||||
return (
|
||||
<main>
|
||||
<h1>帮助中心</h1>
|
||||
<section>
|
||||
{faqs.map((f) => (
|
||||
<details key={f.id}>
|
||||
<summary>{f.q}</summary>
|
||||
<p>{f.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</section>
|
||||
{/* 交互部分是客户端组件,只有它的 JS 会发到浏览器 */}
|
||||
<Chat />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 不自动运行(Next.js 项目配置,npm install 后 npm run build)
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "next-ai-chat",
|
||||
"private": true,
|
||||
"scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
|
||||
"dependencies": { "next": "^16.4.0", "react": "^19.2.0", "react-dom": "^19.2.0" },
|
||||
"devDependencies": { "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5.9" }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user