Forráskód Böngészése

fix: real API login for AuthModal + admin login gate

- AuthModal 登录/注册/发码对接真实 API(失败显示服务端消息,成功跳转控制台)
- /admin 增加登录门(role=admin 校验,localStorage 会话,退出按钮)
- demo 库 admin@dotouch.ai 创建 + demo.user 角色修正为 user
- NEXT_PUBLIC_API_URL 环境变量支持(默认 localhost:8081)
DoTouch Dev 2 hete
szülő
commit
d831707ac1

+ 2 - 2
.omo/run-continuation/ses_0229f653bffedHZYFR3ILxjlpg.json

@@ -1,10 +1,10 @@
 {
   "sessionID": "ses_0229f653bffedHZYFR3ILxjlpg",
-  "updatedAt": "2026-08-08T17:29:33.769Z",
+  "updatedAt": "2026-08-08T17:35:27.903Z",
   "sources": {
     "background-task": {
       "state": "idle",
-      "updatedAt": "2026-08-08T17:29:33.769Z"
+      "updatedAt": "2026-08-08T17:35:27.903Z"
     }
   }
 }

+ 26 - 0
Dockerfile.web

@@ -0,0 +1,26 @@
+# DoTouch.AI Web 容器镜像(Next.js standalone 多阶段构建)
+FROM node:22-alpine AS base
+RUN npm i -g pnpm@10
+WORKDIR /app
+
+# 1. 依赖层
+FROM base AS deps
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./
+COPY apps/web/package.json apps/web/package.json
+COPY packages/shared/package.json packages/shared/package.json
+RUN pnpm install --frozen-lockfile --filter @dotouch/web... --filter @dotouch/shared...
+
+# 2. 构建层
+FROM deps AS build
+COPY . .
+RUN pnpm --filter @dotouch/shared build && pnpm --filter @dotouch/web build
+
+# 3. 运行层(standalone 最小产物)
+FROM node:22-alpine AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+COPY --from=build /app/apps/web/.next/standalone ./
+COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
+EXPOSE 3000
+ENV PORT=3000 HOSTNAME=0.0.0.0
+CMD ["node", "apps/web/server.js"]

+ 1 - 0
apps/web/next.config.js

@@ -7,6 +7,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   reactStrictMode: true,
+  output: 'standalone',
 };
 
 module.exports = withNextIntl(nextConfig);

+ 3 - 3
apps/web/src/app/admin/layout.tsx

@@ -1,8 +1,8 @@
 /**
- * 管理后台根布局 - 独立 html/body(zh-CN,不走 next-intl),包裹 AdminShell 侧边栏外壳
+ * 管理后台根布局 - 独立 html/body(zh-CN,不走 next-intl),登录门包裹 AdminShell 侧边栏外壳
  */
 import type { Metadata } from 'next';
-import { AdminShell } from '@/components/admin/AdminShell';
+import { AdminGate } from '@/components/admin/AdminLoginGate';
 import '../globals.css';
 
 export const metadata: Metadata = {
@@ -14,7 +14,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
   return (
     <html lang="zh-CN">
       <body className="bg-brand-bg text-brand-text antialiased">
-        <AdminShell>{children}</AdminShell>
+        <AdminGate>{children}</AdminGate>
       </body>
     </html>
   );

+ 88 - 13
apps/web/src/components/AuthModal.tsx

@@ -1,18 +1,20 @@
 /**
- * 认证弹窗 - 登录 / 注册 / 重置密码三视图(前端交互占位,无后端
+ * 认证弹窗 - 登录 / 注册 / 重置密码三视图,对接真实 API(演示环境默认 localhost:8081
  * 通过 ?auth=login|register|reset 查询参数打开;含 8 格验证码输入与 60 秒重发倒计时
  */
 'use client';
 
 import { useEffect, useRef, useState } from 'react';
 import { usePathname, useRouter, useSearchParams } from 'next/navigation';
-import { useTranslations } from 'next-intl';
+import { useLocale, useTranslations } from 'next-intl';
 
 type View = 'login' | 'register' | 'reset';
-type ErrKind = 'email' | 'pwd' | 'code';
+type ErrKind = 'email' | 'pwd' | 'code' | 'server';
 
 const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 const CODE_RE = /^[A-Z0-9]{8}$/;
+/** 演示环境 API 地址(生产替换为真实网关域名) */
+const API_BASE = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:8081/v1';
 
 /** 8 格验证码输入:自动跳格 + 转大写 + 仅 A-Z0-9 + 粘贴分发 */
 function CodeInput({
@@ -77,6 +79,7 @@ function CodeInput({
 
 export function AuthModal() {
   const t = useTranslations('auth');
+  const locale = useLocale();
   const searchParams = useSearchParams();
   const router = useRouter();
   const pathname = usePathname();
@@ -92,6 +95,8 @@ export function AuthModal() {
   const [confirmNew, setConfirmNew] = useState('');
   const [code, setCode] = useState('');
   const [error, setError] = useState<ErrKind | null>(null);
+  const [serverMsg, setServerMsg] = useState('');
+  const [submitting, setSubmitting] = useState(false);
   const [sentEmail, setSentEmail] = useState<string | null>(null);
   const [countdown, setCountdown] = useState(0);
 
@@ -126,33 +131,103 @@ export function AuthModal() {
 
   const close = () => router.replace(pathname, { scroll: false });
 
-  const sendCode = () => {
+  const sendCode = async () => {
     if (!EMAIL_RE.test(email)) {
       setError('email');
       return;
     }
     setError(null);
-    setSentEmail(email);
-    setCountdown(60);
+    setSubmitting(true);
+    try {
+      const resp = await fetch(`${API_BASE}/auth/send-code`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ email, purpose: 'register' }),
+      });
+      const data = await resp.json().catch(() => ({}));
+      if (resp.ok) {
+        setSentEmail(email);
+        setCountdown(60);
+      } else {
+        setServerMsg(data.message || '验证码发送失败');
+        setError('server');
+      }
+    } catch {
+      setServerMsg('无法连接 API,请确认演示服务已启动(localhost:8081)');
+      setError('server');
+    } finally {
+      setSubmitting(false);
+    }
   };
 
-  const submitLogin = () => {
-    if (!EMAIL_RE.test(email)) setError('email');
-    else setError(null);
+  const submitLogin = async () => {
+    if (!EMAIL_RE.test(email)) return setError('email');
+    setSubmitting(true);
+    try {
+      const resp = await fetch(`${API_BASE}/auth/login`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ email, password }),
+      });
+      const data = await resp.json().catch(() => ({}));
+      if (resp.ok && data.accessToken) {
+        try {
+          localStorage.setItem('dotouch_token', data.accessToken);
+          localStorage.setItem('dotouch_user', email);
+        } catch { /* 存储失败不影响跳转 */ }
+        setError(null);
+        close();
+        router.push(`/${locale}/console`);
+      } else {
+        setServerMsg(data.message || '登录失败');
+        setError('server');
+      }
+    } catch {
+      setServerMsg('无法连接 API,请确认演示服务已启动(localhost:8081)');
+      setError('server');
+    } finally {
+      setSubmitting(false);
+    }
   };
 
-  const submitRegister = () => {
+  const submitRegister = async () => {
     if (!EMAIL_RE.test(email)) return setError('email');
     if (password !== confirm) return setError('pwd');
     if (!CODE_RE.test(code)) return setError('code');
-    setError(null);
+    setSubmitting(true);
+    try {
+      const resp = await fetch(`${API_BASE}/auth/register`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ email, password, code }),
+      });
+      const data = await resp.json().catch(() => ({}));
+      if (resp.ok) {
+        setError(null);
+        setView('login');
+        setSentEmail(email);
+        setEmail('');
+        setPassword('');
+        setConfirm('');
+        setCode('');
+      } else {
+        setServerMsg(data.message || '注册失败');
+        setError('server');
+      }
+    } catch {
+      setServerMsg('无法连接 API,请确认演示服务已启动(localhost:8081)');
+      setError('server');
+    } finally {
+      setSubmitting(false);
+    }
   };
 
   const submitReset = () => {
     if (!EMAIL_RE.test(email)) return setError('email');
     if (!CODE_RE.test(code)) return setError('code');
     if (newPwd !== confirmNew) return setError('pwd');
-    setError(null);
+    setServerMsg('演示环境暂未开放自助重置,请联系管理员');
+    setError('server');
   };
 
   const fieldClass =
@@ -181,7 +256,7 @@ export function AuthModal() {
 
   const errorBox = error ? (
     <div className="mb-3 rounded-[9px] border border-[rgba(255,107,107,.35)] bg-[rgba(255,107,107,.1)] px-3 py-[9px] text-[12.5px] text-[#FF8A8A]">
-      {t(`err_${error}`)}
+      {error === 'server' ? serverMsg : t(`err_${error}`)}
     </div>
   ) : null;
 

+ 137 - 0
apps/web/src/components/admin/AdminLoginGate.tsx

@@ -0,0 +1,137 @@
+/**
+ * 管理后台登录门 - 对接真实 API(演示环境默认 localhost:8081)
+ * 已登录(localStorage 持有 admin token)渲染管理界面;否则显示管理员登录表单
+ */
+'use client';
+
+import { useState, type ReactNode } from 'react';
+import { AdminShell } from '@/components/admin/AdminShell';
+
+/** 演示环境 API 地址(生产替换为真实网关域名) */
+const API_BASE = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:8081/v1';
+
+export function AdminGate({ children }: { children: ReactNode }) {
+  const [token, setToken] = useState<string | null>(() => {
+    try {
+      return localStorage.getItem('dotouch_admin_token');
+    } catch {
+      return null;
+    }
+  });
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [msg, setMsg] = useState<string | null>(null);
+  const [msgType, setMsgType] = useState<'err' | 'ok'>('err');
+  const [loading, setLoading] = useState(false);
+
+  const logout = () => {
+    try {
+      localStorage.removeItem('dotouch_admin_token');
+      localStorage.removeItem('dotouch_admin_user');
+    } catch { /* 忽略 */ }
+    setToken(null);
+  };
+
+  const login = async () => {
+    if (!email || !password) {
+      setMsg('请输入账号和密码');
+      setMsgType('err');
+      return;
+    }
+    setLoading(true);
+    setMsg(null);
+    try {
+      const resp = await fetch(`${API_BASE}/auth/login`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ email, password }),
+      });
+      const data = await resp.json().catch(() => ({}));
+      if (resp.ok && data.accessToken && data.user?.role === 'admin') {
+        try {
+          localStorage.setItem('dotouch_admin_token', data.accessToken);
+          localStorage.setItem('dotouch_admin_user', email);
+        } catch { /* 忽略 */ }
+        setToken(data.accessToken);
+      } else if (resp.ok && data.accessToken) {
+        setMsg('该账号无管理员权限(需要 role=admin)');
+        setMsgType('err');
+      } else {
+        setMsg(data.message || '登录失败,请检查账号密码');
+        setMsgType('err');
+      }
+    } catch {
+      setMsg('无法连接 API,请确认演示服务已启动(localhost:8081)');
+      setMsgType('err');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  if (token) {
+    return (
+      <AdminShell>
+        <button
+          type="button"
+          onClick={logout}
+          className="fixed right-4 top-2 z-[500] rounded-lg border border-white/[0.08] bg-brand-card px-3 py-1 text-xs text-brand-muted transition-colors hover:border-[rgba(255,107,107,.4)] hover:text-[#FF8A8A]"
+        >
+          退出登录
+        </button>
+        {children}
+      </AdminShell>
+    );
+  }
+
+  return (
+    <div className="flex min-h-screen items-center justify-center bg-brand-bg px-5">
+      <div className="w-[380px] max-w-full rounded-[20px] border border-[rgba(124,92,255,.4)] bg-brand-card px-[30px] py-[34px] shadow-[0_24px_80px_rgba(0,0,0,.6)]">
+        <div className="mb-6 text-center">
+          <div className="text-xl font-extrabold tracking-wide">
+            DoTouch<span className="text-brand-purple-l">.AI</span>
+          </div>
+          <div className="mt-1 text-[12.5px] text-brand-muted">平台管理后台 · 管理员登录</div>
+        </div>
+        {msg && (
+          <div
+            className={`mb-3 rounded-[9px] border px-3 py-[9px] text-[12.5px] ${
+              msgType === 'err'
+                ? 'border-[rgba(255,107,107,.35)] bg-[rgba(255,107,107,.1)] text-[#FF8A8A]'
+                : 'border-[rgba(0,229,160,.3)] bg-[rgba(0,229,160,.1)] text-brand-green-l'
+            }`}
+          >
+            {msg}
+          </div>
+        )}
+        <label className="mb-1.5 block text-[12.5px] text-brand-muted">账号(邮箱)</label>
+        <input
+          type="email"
+          value={email}
+          onChange={(e) => setEmail(e.target.value)}
+          placeholder="admin@dotouch.ai"
+          className="mb-4 w-full rounded-[10px] border border-white/[0.08] bg-brand-card2 px-[14px] py-[11px] text-sm text-brand-text outline-none focus:border-brand-purple"
+        />
+        <label className="mb-1.5 block text-[12.5px] text-brand-muted">密码</label>
+        <input
+          type="password"
+          value={password}
+          onChange={(e) => setPassword(e.target.value)}
+          onKeyDown={(e) => e.key === 'Enter' && login()}
+          placeholder="••••••••"
+          className="mb-6 w-full rounded-[10px] border border-white/[0.08] bg-brand-card2 px-[14px] py-[11px] text-sm text-brand-text outline-none focus:border-brand-purple"
+        />
+        <button
+          type="button"
+          onClick={login}
+          disabled={loading}
+          className="w-full justify-center rounded-[10px] bg-gradient-to-br from-brand-purple to-[#5C3DF0] px-[18px] py-[11px] text-sm font-semibold text-white shadow-[0_4px_20px_rgba(124,92,255,.35)] transition-all hover:-translate-y-px hover:shadow-[0_6px_28px_rgba(124,92,255,.5)] disabled:cursor-not-allowed disabled:opacity-60"
+        >
+          {loading ? '登录中…' : '登 录'}
+        </button>
+        <div className="mt-5 text-center text-[11.5px] text-brand-muted">
+          演示账号:admin@dotouch.ai / Admin2026!
+        </div>
+      </div>
+    </div>
+  );
+}

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
apps/web/tsconfig.tsbuildinfo


+ 11 - 0
docker-compose.demo.yml

@@ -68,5 +68,16 @@ services:
     ports:
       - "8081:8080"
 
+  demo-web:
+    build:
+      context: .
+      dockerfile: Dockerfile.web
+    container_name: demo-dotouch-web
+    restart: unless-stopped
+    depends_on:
+      - demo-api
+    ports:
+      - "3000:3000"
+
 volumes:
   demo-pgdata:

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott