Просмотр исходного кода

feat : 注册登录,AI聊天亮暗色模式

ice-static 4 месяцев назад
Родитель
Сommit
6e29c103fe
34 измененных файлов с 2307 добавлено и 115 удалено
  1. 86 0
      .trae/documents/qa_ai_logic_plan.md
  2. 15 3
      frontend/angular.json
  3. 3 1
      frontend/package.json
  4. 757 25
      frontend/pnpm-lock.yaml
  5. 7 0
      frontend/proxy.conf.json
  6. 4 2
      frontend/src/app/app.config.ts
  7. 1 0
      frontend/src/app/app.html
  8. 10 5
      frontend/src/app/app.routes.ts
  9. 2 1
      frontend/src/app/app.ts
  10. 42 0
      frontend/src/app/core/guards/auth.guard.ts
  11. 9 0
      frontend/src/app/core/models/qa.model.ts
  12. 111 0
      frontend/src/app/core/services/ai-chat.service.ts
  13. 49 0
      frontend/src/app/core/services/auth.service.ts
  14. 5 0
      frontend/src/app/core/services/data.service.ts
  15. 76 0
      frontend/src/app/core/services/qa-storage.service.ts
  16. 29 0
      frontend/src/app/core/services/session.service.ts
  17. 9 4
      frontend/src/app/core/services/theme.service.ts
  18. 21 2
      frontend/src/app/core/services/toast.service.ts
  19. 78 33
      frontend/src/app/features/qa/qa/qa.html
  20. 168 0
      frontend/src/app/features/qa/qa/qa.scss
  21. 388 38
      frontend/src/app/features/qa/qa/qa.ts
  22. 69 0
      frontend/src/app/pages/login/login.html
  23. 3 0
      frontend/src/app/pages/login/login.scss
  24. 92 0
      frontend/src/app/pages/login/login.ts
  25. 87 0
      frontend/src/app/pages/register/register.html
  26. 3 0
      frontend/src/app/pages/register/register.scss
  27. 101 0
      frontend/src/app/pages/register/register.ts
  28. 8 0
      frontend/src/app/parse.ts
  29. 20 0
      frontend/src/app/shared/components/toast/toast.html
  30. 12 0
      frontend/src/app/shared/components/toast/toast.scss
  31. 18 0
      frontend/src/app/shared/components/toast/toast.ts
  32. 7 0
      frontend/src/environments/environment.development.ts
  33. 7 0
      frontend/src/environments/environment.ts
  34. 10 1
      frontend/src/index.html

+ 86 - 0
.trae/documents/qa_ai_logic_plan.md

@@ -0,0 +1,86 @@
+# QA 页面接入 AI 逻辑 + Markdown 渲染 + 图片处理计划
+
+## 现状分析
+
+| 模块 | 状态 | 说明 |
+|------|------|------|
+| 后端 `/api/chat` | ✅ 已实现 | 完整的 DashScope SSE 代理 |
+| 前端 `qa.ts` sendMessage | ❌ Mock | setTimeout 模拟 |
+| Markdown 渲染 | ❌ 无 | 当前纯文本,没有 Markdown |
+| 图片处理 | ❌ 无 | demo 中有 MCP 图片提取逻辑 |
+| `marked` 库 | ✅ 已安装 | 但未在 QA 页面使用 |
+
+## 实现方案
+
+### 第一步:Markdown 渲染
+使用 `ngx-markdown` 库(npm install ngx-markdown),通过 `<markdown>` 组件或 `| markdown` pipe 渲染。
+- `provideMarkdown()` 在 app.config.ts 全局配置
+- QA 页面的 AI 消息用 `[data]` 绑定渲染 Markdown
+- 高亮代码使用 `highlight.js`(已安装)
+
+### 第二步:图片处理
+从 demo 迁移 MCP 图片提取逻辑:
+- 解析 `output.thoughts` 中的 `action_type === 'mcp'` 节点
+- 从 `observation.content[].text` 中提取 image URLs(`results[]` 数组)
+- **不直接渲染图片**,而是在 Markdown 渲染后将 `![alt](url)` 替换为 `<img>` 标签,统一添加样式类、懒加载、点击放大等
+
+### 第三步:AI 流式逻辑
+从 demo 迁移到 Angular:
+- `ai-chat.service.ts` — SSE 流式请求 + 图片提取
+- `qa.ts` — 替换 sendMessage,接入真实流式逻辑
+- `QAMessage` 模型扩展 — 添加 `thoughts`、`images` 字段
+
+### 第四步:后端 .env
+基于 `.env.example` 创建 `.env` 文件
+
+---
+
+## 需要修改的文件
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `frontend/package.json` | 添加依赖 | `ngx-markdown` |
+| `frontend/src/app/app.config.ts` | 更新 | 添加 `provideMarkdown()` |
+| `frontend/src/app/core/services/ai-chat.service.ts` | **新建** | SSE 流 + 图片提取 |
+| `frontend/src/app/core/models/qa.model.ts` | 更新 | 扩展 QAMessage 接口 |
+| `frontend/src/app/features/qa/qa/qa.ts` | 重写 | 接入 AI 流式 + Markdown |
+| `frontend/src/app/features/qa/qa/qa.html` | 更新 | 使用 `<markdown>` 组件 |
+| `backend/.env` | 新建 | 基于 .env.example |
+
+## 图片处理策略
+
+demo 中的图片提取逻辑:
+```javascript
+// thought.action_type === 'mcp'
+// → observation.content[].type === 'text'
+// → JSON.parse(item.text).results[] → 图片 URL 数组
+```
+
+Angular 中:
+1. 在 `ai-chat.service.ts` 中提取 imageUrls
+2. API 返回 `{ text, thoughts, images }` 给组件
+3. 组件将 images 拼接到 Markdown 内容末尾(用 `![图片](url)` 格式)
+4. `ngx-markdown` 自动渲染为 `<img>`,配合 CSS 控制样式
+
+## 数据流
+
+```
+用户输入 → qa.ts sendMessage()
+  → aiChatService.stream(prompt, sessionId)
+    → fetch('/api/chat')
+      → SSE 逐帧解析
+        → output.text → 追加到 Markdown 内容
+        → output.thoughts[].thought → 思考过程
+        → output.thoughts[].mcp → 提取图片 URLs
+        → output.session_id → 保存会话
+          → qa.ts 实时更新 messages signal
+            → <markdown> 组件渲染
+            → 图片自动生成 <img> 标签
+```
+
+## 关键实现
+
+1. **ngx-markdown 配置**:需要 `provideHttpClient()`(已提供)+ `provideMarkdown()`
+2. **安全渲染**:使用 `[data]` 绑定而非 `[src]` 加载外部文件
+3. **图片样式**:CSS 统一控制 `.qa-markdown img { max-width: 100%; border-radius: 8px; }`
+4. **流式更新**:发送时插入空 AI 消息,收到 chunk 后更新 content 字段,ngx-markdown 自动重新渲染

+ 15 - 3
frontend/angular.json

@@ -56,6 +56,9 @@
             ],
             "styles": [
               "src/styles.scss"
+            ],
+            "allowedCommonJsDependencies": [
+              "parse"
             ]
           },
           "configurations": {
@@ -63,8 +66,8 @@
               "budgets": [
                 {
                   "type": "initial",
-                  "maximumWarning": "500kB",
-                  "maximumError": "1MB"
+                  "maximumWarning": "1MB",
+                  "maximumError": "2MB"
                 },
                 {
                   "type": "anyComponentStyle",
@@ -77,13 +80,22 @@
             "development": {
               "optimization": false,
               "extractLicenses": false,
-              "sourceMap": true
+              "sourceMap": true,
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.development.ts"
+                }
+              ]
             }
           },
           "defaultConfiguration": "production"
         },
         "serve": {
           "builder": "@angular/build:dev-server",
+          "options": {
+            "proxyConfig": "proxy.conf.json"
+          },
           "configurations": {
             "production": {
               "buildTarget": "frontend:build:production"

+ 3 - 1
frontend/package.json

@@ -3,7 +3,7 @@
   "version": "0.0.0",
   "scripts": {
     "ng": "ng",
-    "start": "ng serve",
+    "start": "ng serve -o",
     "build": "ng build",
     "watch": "ng build --watch --configuration development",
     "test": "ng test"
@@ -32,9 +32,11 @@
     "@fortawesome/fontawesome-svg-core": "^7.2.0",
     "@fortawesome/free-regular-svg-icons": "^7.2.0",
     "@fortawesome/free-solid-svg-icons": "^7.2.0",
+    "fmode-ng": "^0.0.246",
     "highlight.js": "^11.9.0",
     "marked": "^12.0.2",
     "marked-highlight": "^2.1.0",
+    "ngx-markdown": "^20.1.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0"

Разница между файлами не показана из-за своего большого размера
+ 757 - 25
frontend/pnpm-lock.yaml


+ 7 - 0
frontend/proxy.conf.json

@@ -0,0 +1,7 @@
+{
+  "/api": {
+    "target": "http://localhost:3000",
+    "secure": false,
+    "changeOrigin": true
+  }
+}

+ 4 - 2
frontend/src/app/app.config.ts

@@ -1,6 +1,7 @@
 import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
 import { provideRouter } from '@angular/router';
 import { provideHttpClient } from '@angular/common/http';
+import { provideMarkdown } from 'ngx-markdown';
 
 import { routes } from './app.routes';
 
@@ -16,7 +17,7 @@ import {
   faAward, faLock, faStar, faHistory, faRepeat, faBullseye,
   faCheckCircle, faLightbulb, faExclamationTriangle, faExclamationCircle,
   faQrcode, faFileInvoice, faFlag, faRocket, faUserPlus, faComments,
-  faUsers, faChevronDown, faCirclePlay,
+  faUsers, faChevronDown, faCirclePlay, faStop,
   } from '@fortawesome/free-solid-svg-icons';
 
 import { importProvidersFrom } from '@angular/core';
@@ -34,7 +35,7 @@ export function initializeIcons() {
     faAward, faLock, faStar, faHistory, faRepeat, faBullseye,
     faCheckCircle, faLightbulb, faExclamationTriangle, faExclamationCircle,
     faQrcode, faFileInvoice, faFlag, faRocket, faUserPlus, faComments,
-    faUsers, faChevronDown, faCirclePlay,
+    faUsers, faChevronDown, faCirclePlay, faStop,
   );
 }
 
@@ -44,6 +45,7 @@ export const appConfig: ApplicationConfig = {
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(routes),
     provideHttpClient(),
+    provideMarkdown(),
     importProvidersFrom(FontAwesomeModule),
   ]
 };

+ 1 - 0
frontend/src/app/app.html

@@ -1 +1,2 @@
 <router-outlet></router-outlet>
+<app-toast></app-toast>

+ 10 - 5
frontend/src/app/app.routes.ts

@@ -1,10 +1,15 @@
 import { Routes } from '@angular/router';
+import { AuthGuard } from './core/guards/auth.guard';
 
 export const routes: Routes = [
+  { path: '', redirectTo: 'home', pathMatch: 'full' },
+  { path: 'login', canActivate: [AuthGuard], loadComponent: () => import('./pages/login/login').then(m => m.Login) },
+  { path: 'register', canActivate: [AuthGuard], loadComponent: () => import('./pages/register/register').then(m => m.Register) },
   {
     path: '',
     loadComponent: () => import('./layouts/mobile-layout/mobile-layout')
       .then(m => m.MobileLayout),
+    canActivate: [AuthGuard],
     children: [
       { path: '', redirectTo: 'home', pathMatch: 'full' },
       { path: 'home', loadComponent: () => import('./features/home/home/home').then(m => m.Home) },
@@ -15,10 +20,10 @@ export const routes: Routes = [
       { path: 'profile', loadComponent: () => import('./features/profile/profile/profile').then(m => m.Profile) },
     ]
   },
-  { path: 'course-detail/:id', loadComponent: () => import('./pages/course-detail/course-detail').then(m => m.CourseDetail) },
-  { path: 'video/:id', loadComponent: () => import('./pages/video-player/video-player').then(m => m.VideoPlayer) },
-  { path: 'quiz/:id', loadComponent: () => import('./pages/quiz/quiz').then(m => m.Quiz) },
-  { path: 'quiz-result', loadComponent: () => import('./pages/quiz-result/quiz-result').then(m => m.QuizResult) },
-  { path: 'score-report', loadComponent: () => import('./pages/score-report/score-report').then(m => m.ScoreReport) },
+  { path: 'course-detail/:id', canActivate: [AuthGuard], loadComponent: () => import('./pages/course-detail/course-detail').then(m => m.CourseDetail) },
+  { path: 'video/:id', canActivate: [AuthGuard], loadComponent: () => import('./pages/video-player/video-player').then(m => m.VideoPlayer) },
+  { path: 'quiz/:id', canActivate: [AuthGuard], loadComponent: () => import('./pages/quiz/quiz').then(m => m.Quiz) },
+  { path: 'quiz-result', canActivate: [AuthGuard], loadComponent: () => import('./pages/quiz-result/quiz-result').then(m => m.QuizResult) },
+  { path: 'score-report', canActivate: [AuthGuard], loadComponent: () => import('./pages/score-report/score-report').then(m => m.ScoreReport) },
   { path: '**', redirectTo: 'home' },
 ];

+ 2 - 1
frontend/src/app/app.ts

@@ -1,9 +1,10 @@
 import { Component } from '@angular/core';
 import { RouterOutlet } from '@angular/router';
+import { Toast } from './shared/components/toast/toast';
 
 @Component({
   selector: 'app-root',
-  imports: [RouterOutlet],
+  imports: [RouterOutlet, Toast],
   templateUrl: './app.html',
   styleUrl: './app.scss'
 })

+ 42 - 0
frontend/src/app/core/guards/auth.guard.ts

@@ -0,0 +1,42 @@
+import { Injectable, inject } from '@angular/core';
+import { CanActivate, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
+import { MSQParse } from '../../parse';
+import { SessionService } from '../services/session.service';
+
+@Injectable({ providedIn: 'root' })
+export class AuthGuard implements CanActivate {
+
+  private readonly router = inject(Router);
+  private readonly sessionService = inject(SessionService);
+
+  async canActivate(route: ActivatedRouteSnapshot): Promise<boolean | UrlTree> {
+    const isLoginPage = route.routeConfig?.path === 'login';
+
+    // 先检查 session 是否过期
+    if (this.sessionService.checkExpired()) {
+      this.sessionService.clear();
+      if (isLoginPage) {
+        return true;
+      }
+      return this.router.createUrlTree(['/login']);
+    }
+
+    // 检查 Parse 本地登录态
+    try {
+      const user = MSQParse.User.current();
+      if (user) {
+        if (isLoginPage) {
+          return this.router.createUrlTree(['/home']);
+        }
+        return true;
+      }
+    } catch {
+      // 检查失败,视为未登录
+    }
+
+    if (isLoginPage) {
+      return true;
+    }
+    return this.router.createUrlTree(['/login']);
+  }
+}

+ 9 - 0
frontend/src/app/core/models/qa.model.ts

@@ -6,7 +6,16 @@ export interface QAConversation {
 }
 
 export interface QAMessage {
+  id: string;
   type: 'ai' | 'user';
   content: string;
   timestamp: number;
+  thoughts?: string;
+  images?: string[];
+}
+
+export interface ConversationItem {
+  objectId: string;
+  title: string;
+  updatedAt: string;
 }

+ 111 - 0
frontend/src/app/core/services/ai-chat.service.ts

@@ -0,0 +1,111 @@
+import { inject, Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+export interface ChatStreamChunk {
+  text: string;
+  thoughts: string;
+  sessionId: string | null;
+  images: string[];
+  done: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class AiChatService {
+  private readonly http = inject(HttpClient);
+
+  async *stream(apiUrl: string, body: Record<string, unknown>, signal?: AbortSignal): AsyncIterable<ChatStreamChunk> {
+    const response = await fetch(apiUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+      signal,
+    });
+
+    if (!response.ok) {
+      const err = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
+      throw new Error(err.message || `请求失败: ${response.status}`);
+    }
+
+    const reader = response.body?.getReader();
+    if (!reader) throw new Error('无法读取响应流');
+
+    const decoder = new TextDecoder();
+    let buffer = '';
+    let fullContent = '';
+    let fullThoughts = '';
+    let sessionId: string | null = null;
+    let hasThought = false;
+    const imageUrls: string[] = [];
+
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split('\n');
+      buffer = lines.pop() || '';
+
+      for (const line of lines) {
+        if (!line.startsWith('data:')) continue;
+        const data = line.slice(5).trim();
+        if (!data) continue;
+
+        try {
+          const json = JSON.parse(data);
+
+          if (json.output?.session_id) {
+            sessionId = json.output.session_id;
+          }
+
+          if (json.output?.thoughts?.length > 0) {
+            for (const thought of json.output.thoughts) {
+              if (thought.thought) {
+                fullThoughts += thought.thought;
+                hasThought = true;
+              }
+              if (thought.action_type === 'mcp' && thought.observation) {
+                const obs = typeof thought.observation === 'string'
+                  ? JSON.parse(thought.observation)
+                  : thought.observation;
+                const contents = obs?.content ?? [];
+                for (const item of contents) {
+                  if (item.type === 'text') {
+                    try {
+                      const textData = JSON.parse(item.text);
+                      if (textData.results?.length > 0) {
+                        for (const imgUrl of textData.results) {
+                          if (!imageUrls.includes(imgUrl)) {
+                            imageUrls.push(imgUrl);
+                          }
+                        }
+                      }
+                    } catch {
+                      // text is not JSON, ignore
+                    }
+                  }
+                }
+              }
+            }
+            yield { text: fullContent, thoughts: fullThoughts, sessionId, images: [...imageUrls], done: false };
+          }
+
+          if (json.output?.text) {
+            fullContent += json.output.text;
+            yield { text: fullContent, thoughts: hasThought ? fullThoughts : '', sessionId, images: [...imageUrls], done: false };
+          }
+
+          if (json.error) {
+            throw new Error(json.error.message || 'AI 响应错误');
+          }
+        } catch (e) {
+          if ((e as Error).message?.includes('AI 响应错误') || (e as Error).message?.includes('请求失败')) {
+            throw e;
+          }
+          // JSON parse errors from incomplete chunks are expected, ignore
+        }
+      }
+    }
+
+    yield { text: fullContent, thoughts: hasThought ? fullThoughts : '', sessionId, images: imageUrls, done: true };
+  }
+}

+ 49 - 0
frontend/src/app/core/services/auth.service.ts

@@ -0,0 +1,49 @@
+import { Injectable, inject } from '@angular/core';
+import { MSQParse } from '../../parse';
+import { SessionService } from './session.service';
+
+export interface LoginResult {
+  user: ReturnType<typeof MSQParse.User.current>;
+  action: 'login' | 'registered';
+}
+
+@Injectable({
+  providedIn: 'root'
+})
+export class AuthService {
+  private readonly sessionService = inject(SessionService);
+
+  isLoggedIn(): boolean {
+    return !!MSQParse.User.current();
+  }
+
+  getCurrentUser() {
+    return MSQParse.User.current();
+  }
+
+  async login(phone: string, password: string): Promise<LoginResult> {
+    try {
+      const user = await MSQParse.User.logIn(phone, password);
+      this.sessionService.setSession(phone);
+      return { user, action: 'login' };
+    } catch (err: any) {
+      if (err?.code === 101) {
+        const user = await MSQParse.User.signUp(phone, password, { username: phone });
+        this.sessionService.setSession(phone);
+        return { user, action: 'registered' };
+      }
+      throw err;
+    }
+  }
+
+  async signUp(phone: string, password: string) {
+    const user = await MSQParse.User.signUp(phone, password, { username: phone });
+    this.sessionService.setSession(phone);
+    return user;
+  }
+
+  async logout(): Promise<void> {
+    this.sessionService.clear();
+    await MSQParse.User.logOut();
+  }
+}

+ 5 - 0
frontend/src/app/core/services/data.service.ts

@@ -5,6 +5,10 @@ import { ScoreDimension, LearningStats, LearningHistory, CapabilityRadar } from
 import { PracticeScene, PracticeRecord, CategoryFilter, ChatMessage } from '../models/practice.model';
 import { QAConversation, QAMessage } from '../models/qa.model';
 import { QuizQuestion, QuizOption } from '../models/quiz.model';
+
+function generateId(): string {
+  return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
 import { QuickEntry } from '../models/statistics.model';
 
 @Injectable({
@@ -348,6 +352,7 @@ export class DataService {
       title: '新对话',
       messages: [
         {
+          id: generateId(),
           type: 'ai',
           content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~',
           timestamp: Date.now(),

+ 76 - 0
frontend/src/app/core/services/qa-storage.service.ts

@@ -0,0 +1,76 @@
+import { Injectable } from '@angular/core';
+import { MSQParse } from '../../parse';
+import { QAMessage, ConversationItem } from '../models/qa.model';
+
+const CLASS_NAME = 'QAConversation';
+const DEFAULT_TITLE = '新对话';
+
+@Injectable({ providedIn: 'root' })
+export class QaStorageService {
+
+  private getCurrentUser() {
+    return MSQParse.User.current();
+  }
+
+  async listConversations(): Promise<ConversationItem[]> {
+    const user = this.getCurrentUser();
+    if (!user) return [];
+
+    const query = new MSQParse.Query(CLASS_NAME);
+    query.equalTo('user', user);
+    query.notEqualTo('isDeleted', true);
+    query.descending('updatedAt');
+    query.select('title', 'updatedAt');
+
+    const results = await query.find();
+    return results.map((r: any) => ({
+      objectId: r.id || '',
+      title: r.get('title') || DEFAULT_TITLE,
+      updatedAt: r.get('updatedAt'),
+    }));
+  }
+
+  async getConversation(objectId: string): Promise<{ messages: QAMessage[]; title: string } | null> {
+    const query = new MSQParse.Query(CLASS_NAME);
+    try {
+      const obj = await query.get(objectId) as any;
+      return {
+        messages: (obj.get('messages') || []) as QAMessage[],
+        title: obj.get('title') || DEFAULT_TITLE,
+      };
+    } catch {
+      return null;
+    }
+  }
+
+  async createConversation(
+    title: string,
+    messages: QAMessage[],
+  ): Promise<string> {
+    const user = this.getCurrentUser();
+    const obj = new MSQParse.Object(CLASS_NAME);
+    obj.set('user', user);
+    obj.set('title', title);
+    obj.set('messages', messages);
+    obj.set('isDeleted', false);
+    const saved = await obj.save();
+    return saved.id || '';
+  }
+
+  async updateConversation(objectId: string, messages: QAMessage[], title?: string): Promise<void> {
+    const query = new MSQParse.Query(CLASS_NAME);
+    const obj = await query.get(objectId) as any;
+    obj.set('messages', messages);
+    if (title) {
+      obj.set('title', title);
+    }
+    await obj.save();
+  }
+
+  async deleteConversation(objectId: string): Promise<void> {
+    const query = new MSQParse.Query(CLASS_NAME);
+    const obj = await query.get(objectId) as any;
+    obj.set('isDeleted', true);
+    await obj.save();
+  }
+}

+ 29 - 0
frontend/src/app/core/services/session.service.ts

@@ -0,0 +1,29 @@
+import { Injectable } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+export class SessionService {
+  private readonly EXPIRES_KEY = 'app_session_expires_at';
+  private readonly PHONE_KEY = 'app_session_phone';
+  private readonly DURATION_MS = 30 * 24 * 60 * 60 * 1000;
+
+  setSession(phone: string): void {
+    const expiresAt = Date.now() + this.DURATION_MS;
+    localStorage.setItem(this.EXPIRES_KEY, String(expiresAt));
+    localStorage.setItem(this.PHONE_KEY, phone);
+  }
+
+  getPhone(): string | null {
+    return localStorage.getItem(this.PHONE_KEY);
+  }
+
+  checkExpired(): boolean {
+    const expiresAt = localStorage.getItem(this.EXPIRES_KEY);
+    if (!expiresAt) return true;
+    return Date.now() > Number(expiresAt);
+  }
+
+  clear(): void {
+    localStorage.removeItem(this.EXPIRES_KEY);
+    localStorage.removeItem(this.PHONE_KEY);
+  }
+}

+ 9 - 4
frontend/src/app/core/services/theme.service.ts

@@ -10,8 +10,10 @@ export class ThemeService {
 
   constructor() {
     const stored = localStorage.getItem('theme');
-    if (stored === 'dark') {
-      this.setDark(true);
+    if (stored) {
+      this.isDark.set(stored === 'dark');
+    } else {
+      this.isDark.set(this.getSystemPrefersDark());
     }
   }
 
@@ -25,11 +27,14 @@ export class ThemeService {
     if (dark) {
       html.classList.add('dark');
       html.classList.remove('light');
-      localStorage.setItem('theme', 'dark');
     } else {
       html.classList.remove('dark');
       html.classList.add('light');
-      localStorage.setItem('theme', 'light');
     }
+    localStorage.setItem('theme', dark ? 'dark' : 'light');
+  }
+
+  private getSystemPrefersDark(): boolean {
+    return window.matchMedia('(prefers-color-scheme: dark)').matches;
   }
 }

+ 21 - 2
frontend/src/app/core/services/toast.service.ts

@@ -1,17 +1,36 @@
 import { Injectable, signal } from '@angular/core';
 
+export type ToastType = 'success' | 'error' | 'info';
+
 @Injectable({
   providedIn: 'root'
 })
 export class ToastService {
   readonly message = signal('');
   readonly visible = signal(false);
+  readonly toastType = signal<ToastType>('info');
+
+  private timer: ReturnType<typeof setTimeout> | null = null;
 
-  show(msg: string, duration = 2000): void {
+  show(msg: string, type: ToastType = 'info', duration = 2000): void {
+    if (this.timer) clearTimeout(this.timer);
     this.message.set(msg);
+    this.toastType.set(type);
     this.visible.set(true);
-    setTimeout(() => {
+    this.timer = setTimeout(() => {
       this.visible.set(false);
     }, duration);
   }
+
+  success(msg: string): void {
+    this.show(msg, 'success');
+  }
+
+  error(msg: string): void {
+    this.show(msg, 'error', 3000);
+  }
+
+  info(msg: string): void {
+    this.show(msg, 'info');
+  }
 }

+ 78 - 33
frontend/src/app/features/qa/qa/qa.html

@@ -1,60 +1,91 @@
 <div class="fixed inset-0 bg-gray-50 dark:bg-[#0f1419] z-[60]">
-  <!-- 侧边栏遮罩 -->
   <div id="sidebar-overlay" class="absolute inset-0 bg-black/70 z-[75] hidden" [class.hidden]="!showSidebar()" (click)="closeSidebar()"></div>
-  
-  <!-- 侧边栏历史记录 -->
-  <div id="qa-sidebar" class="absolute left-0 top-0 bottom-0 w-[75vw] bg-white dark:bg-[#1a1f2e] z-[90] transform -translate-x-full transition-transform duration-300 shadow-2xl" [class.-translate-x-full]="!showSidebar()" [class.translate-x-0]="showSidebar()">
+
+  <div id="qa-sidebar" class="absolute left-0 top-0 bottom-0 w-[75vw] bg-gray-100 dark:bg-[#0f1419] z-[90] transform -translate-x-full transition-transform duration-300 shadow-2xl" [class.-translate-x-full]="!showSidebar()" [class.translate-x-0]="showSidebar()">
     <div class="h-full flex flex-col">
       <div class="px-5 py-6 border-b border-gray-200 dark:border-gray-700/50 flex items-center justify-between" style="padding-top: calc(env(safe-area-inset-top) + 16px);">
         <h3 class="font-semibold text-lg text-gray-900 dark:text-white">历史记录</h3>
-        <button (click)="closeSidebar()" class="w-10 h-10 flex items-center justify-center text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-white/10">
+        <button (click)="closeSidebar()" class="w-10 h-10 flex items-center justify-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors rounded-full hover:bg-gray-200 dark:hover:bg-white/10">
           <fa-icon [icon]="faTimes" class="text-lg text-gray-600 dark:text-gray-400"></fa-icon>
         </button>
       </div>
       <div class="flex-1 overflow-y-auto">
-        <div class="px-5 py-3 space-y-2">
-          <p class="text-sm text-gray-400 py-8 text-center">暂无历史记录</p>
+        <div class="px-4 py-3 space-y-2">
+          @if (conversations().length === 0) {
+            <p class="text-sm text-gray-400 py-8 text-center">暂无历史记录</p>
+          } @else {
+            @for (conv of conversations(); track conv.objectId) {
+              <button
+                (click)="selectConversation(conv.objectId)"
+                class="w-full text-left px-4 py-3 rounded-xl transition-colors bg-white dark:bg-[#1a1f2e] shadow-sm border border-gray-100 dark:border-white/5"
+                [class.qa-selected]="conv.objectId === currentConversationId()"
+              >
+                <p class="text-sm truncate text-gray-600 dark:text-gray-300">{{ conv.title }}</p>
+                <p class="text-xs mt-0.5 text-gray-400 dark:text-gray-500">{{ conv.updatedAt }}</p>
+              </button>
+            }
+          }
         </div>
       </div>
     </div>
   </div>
 
-  <!-- 主内容容器 -->
   <div class="h-full flex flex-col">
-    <!-- 头部 -->
     <div class="flex-shrink-0 bg-white dark:bg-[#1a1f2e] px-4 flex items-center justify-between border-b border-gray-200 dark:border-gray-700/50" style="padding-top: calc(env(safe-area-inset-top) + 12px); padding-bottom: 12px;">
       <button (click)="toggleSidebar()" class="w-11 h-11 flex items-center justify-center text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors rounded-xl hover:bg-gray-100 dark:hover:bg-white/10">
         <fa-icon [icon]="faBars" class="text-lg text-gray-600 dark:text-gray-400"></fa-icon>
       </button>
       <div class="text-center flex-1">
         <h2 class="font-semibold text-lg text-gray-900 dark:text-white">AI知识问答</h2>
-        <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">随时随地获取销售知识支持</p>
+        <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ currentConversationId() ? '会话进行中' : '随时随地获取销售知识支持' }}</p>
       </div>
       <button (click)="goBack()" class="w-11 h-11 flex items-center justify-center text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors rounded-xl hover:bg-gray-100 dark:hover:bg-white/10">
         <fa-icon [icon]="faTimes" class="text-lg text-gray-600 dark:text-gray-400"></fa-icon>
       </button>
     </div>
 
-    <!-- 消息区域 -->
-    <div id="qa-messages" class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-4">
-      @for (msg of messages(); track msg.timestamp) {
+    <div #messagesContainer id="qa-messages" class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-4">
+      @for (msg of messages(); track msg.id; let last = $last) {
         @if (msg.type === 'ai') {
           <div class="flex justify-start">
-            <div class="bg-gray-100 dark:bg-[#252d3d] rounded-2xl rounded-tl-sm px-5 py-3.5 max-w-[80%]">
-              <p class="text-sm text-gray-900 dark:text-gray-100 leading-relaxed">{{ msg.content }}</p>
+            <div class="px-2 py-2 w-full qa-markdown-wrapper"
+                 (touchstart)="startLongPress($event, msg.id, msg.content)"
+                 (touchmove)="trackTouchMove($event)"
+                 (touchend)="cancelLongPress()">
+              @if (msg.thoughts) {
+                <div class="mb-3 border-t border-gray-200 dark:border-gray-700 pt-2">
+                  <button (click)="toggleThought(msg.id)" class="w-full px-1 flex items-center justify-between text-xs font-medium text-gray-500 dark:text-gray-400">
+                    <span>💭 思考过程</span>
+                    <fa-icon [icon]="faChevronRight" class="text-[10px] transition-transform" [class.rotate-90]="isThoughtExpanded(msg.id)"></fa-icon>
+                  </button>
+                  @if (isThoughtExpanded(msg.id)) {
+                    <div class="px-1 pt-2 text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap font-mono">{{ msg.thoughts }}</div>
+                  }
+                </div>
+              }
+              @if (msg.content) {
+                <markdown [data]="msg.content" class="text-sm text-gray-900 dark:text-gray-100 leading-relaxed"></markdown>
+              }
+              @if (msg.images?.length) {
+                <div class="mt-3 grid gap-2">
+                  @for (img of msg.images; track img) {
+                    <img [src]="img" class="w-full rounded-lg" loading="lazy" />
+                  }
+                </div>
+              }
             </div>
           </div>
         } @else {
           <div class="flex justify-end">
-            <div class="bg-gradient-to-br from-[#00d68f] to-[#00b377] rounded-2xl rounded-tr-sm px-5 py-3.5 max-w-[80%]">
-              <p class="text-sm text-white leading-relaxed">{{ msg.content }}</p>
+            <div class="bg-gradient-to-br from-[#00d68f] to-[#00b377] rounded-2xl rounded-tr-sm px-5 py-3.5 max-w-[85%] ml-auto">
+              <p class="text-sm text-white leading-relaxed whitespace-pre-wrap">{{ msg.content }}</p>
             </div>
           </div>
         }
       }
       @if (showThinking()) {
         <div class="flex justify-start">
-          <div class="bg-gray-100 dark:bg-[#252d3d] rounded-2xl rounded-tl-sm px-5 py-3.5 flex items-center gap-1.5">
+          <div class="px-2 py-2 w-full flex items-center gap-1.5">
             <span class="inline-block w-2 h-2 bg-gray-400 rounded-full animate-bounce"></span>
             <span class="inline-block w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s;"></span>
             <span class="inline-block w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.4s;"></span>
@@ -63,7 +94,6 @@
       }
     </div>
 
-    <!-- 底部输入区 -->
     <div class="flex-shrink-0 bg-white dark:bg-[#1a1f2e] border-t border-gray-200 dark:border-gray-700/50" style="padding-bottom: env(safe-area-inset-bottom);">
       <div class="px-4 py-3 flex items-center gap-3">
         <button (click)="newConversation()" class="flex items-center gap-2 px-4 py-2.5 bg-green-50 dark:bg-green-500/15 text-green-600 dark:text-green-400 rounded-full text-sm whitespace-nowrap hover:bg-green-100 dark:hover:bg-green-500/25 transition-colors">
@@ -80,16 +110,21 @@
           <fa-icon [icon]="faMicrophone" class="text-lg text-gray-600 dark:text-gray-400"></fa-icon>
         </button>
         <div class="flex-1 bg-gray-100 dark:bg-[#252d3d] rounded-full px-5 py-3">
-          <input #qaInput type="text" placeholder="发消息或按住说话" class="w-full bg-transparent outline-none text-base text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-0" (keydown.enter)="sendMessage(qaInput.value); qaInput.value = ''" />
+          <input #qaInput type="text" placeholder="发消息或按住说话" class="w-full bg-transparent outline-none text-base text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-0" (keydown.enter)="handleEnter(qaInput)" />
         </div>
-        <button (click)="sendMessage(qaInput.value); qaInput.value = ''" class="w-11 h-11 bg-gradient-to-br from-[#00d68f] to-[#00b377] rounded-full flex items-center justify-center text-white">
-          <fa-icon [icon]="faPaperPlane" class="text-sm text-white"></fa-icon>
-        </button>
+        @if (isGenerating()) {
+          <button (click)="stopGeneration()" class="w-11 h-11 bg-gradient-to-br from-orange-400 to-orange-600 rounded-full flex items-center justify-center text-white">
+            <fa-icon [icon]="faStop" class="text-sm text-white"></fa-icon>
+          </button>
+        } @else {
+          <button (click)="handleSend(qaInput)" class="w-11 h-11 bg-gradient-to-br from-[#00d68f] to-[#00b377] rounded-full flex items-center justify-center text-white">
+            <fa-icon [icon]="faPaperPlane" class="text-sm text-white"></fa-icon>
+          </button>
+        }
       </div>
     </div>
   </div>
 
-  <!-- 清除对话确认模态框 -->
   <div id="clear-conversation-modal" class="fixed inset-0 z-[100]" [class.hidden]="!showClearModal()">
     <div class="absolute inset-0 bg-black/50" (click)="closeClearModal()"></div>
     <div class="absolute inset-0 flex items-center justify-center">
@@ -99,19 +134,29 @@
             <fa-icon [icon]="faExclamationTriangle" class="text-3xl text-red-500"></fa-icon>
           </div>
           <h3 class="text-lg font-semibold text-center mb-2">确认清除当前对话</h3>
-          <p class="text-sm text-gray-500 dark:text-gray-400 text-center leading-relaxed">
-            清除后将无法找回当前对话记录,确定要清除吗?
-          </p>
+          <p class="text-sm text-gray-500 dark:text-gray-400 text-center leading-relaxed">清除后将无法找回当前对话记录,确定要清除吗?</p>
         </div>
         <div class="flex border-t border-gray-200 dark:border-gray-700">
-          <button (click)="closeClearModal()" class="flex-1 py-4 text-gray-600 dark:text-gray-400 font-medium border-r border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
-            取消
-          </button>
-          <button (click)="clearConversation()" class="flex-1 py-4 text-red-500 font-medium hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
-            确定清除
-          </button>
+          <button (click)="closeClearModal()" class="flex-1 py-4 text-gray-600 dark:text-gray-400 font-medium border-r border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">取消</button>
+          <button (click)="clearConversation()" class="flex-1 py-4 text-red-500 font-medium hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">确定清除</button>
         </div>
       </div>
     </div>
   </div>
+
+  @if (contextMenu(); as menu) {
+    <div class="fixed inset-0 z-[110]" (click)="closeContextMenu()">
+      <div class="absolute bg-white dark:bg-gray-800 rounded-2xl shadow-2xl py-1 overflow-hidden border border-gray-200 dark:border-gray-700 min-w-[140px]"
+           [style.left.px]="menu.x" [style.top.px]="menu.y">
+        <button (click)="copyMessageContent(menu.content)" class="w-full text-left px-4 py-3 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-white/5 flex items-center gap-3 transition-colors">
+          <fa-icon [icon]="faCopy" class="text-gray-400 dark:text-gray-500"></fa-icon>
+          复制全文
+        </button>
+        <button (click)="enableTextSelection()" class="w-full text-left px-4 py-3 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-white/5 flex items-center gap-3 transition-colors border-t border-gray-100 dark:border-gray-700">
+          <fa-icon [icon]="faFont" class="text-gray-400 dark:text-gray-500"></fa-icon>
+          选取复制
+        </button>
+      </div>
+    </div>
+  }
 </div>

+ 168 - 0
frontend/src/app/features/qa/qa/qa.scss

@@ -0,0 +1,168 @@
+// ── 侧边栏选中态 ──
+.qa-selected {
+  background: #f0fdf4 !important;
+}
+
+p {
+  .qa-selected & {
+    color: #15803d !important;
+  }
+}
+
+:host-context(.dark) .qa-selected {
+  background: rgba(22, 163, 74, 0.15) !important;
+}
+
+:host-context(.dark) .qa-selected p {
+  color: #4ade80 !important;
+}
+
+.qa-markdown-wrapper {
+}
+
+::ng-deep .qa-markdown-wrapper markdown {
+  word-break: break-word;
+}
+
+::ng-deep .qa-markdown-wrapper markdown h1 {
+  font-size: 1.25rem;
+  font-weight: 700;
+  margin: 1rem 0 0.5rem;
+}
+
+::ng-deep .qa-markdown-wrapper markdown h2 {
+  font-size: 1.125rem;
+  font-weight: 700;
+  margin: 0.75rem 0 0.5rem;
+}
+
+::ng-deep .qa-markdown-wrapper markdown h3 {
+  font-size: 1.0625rem;
+  font-weight: 600;
+  margin: 0.625rem 0 0.375rem;
+}
+
+::ng-deep .qa-markdown-wrapper markdown h4,
+::ng-deep .qa-markdown-wrapper markdown h5,
+::ng-deep .qa-markdown-wrapper markdown h6 {
+  font-size: 1rem;
+  font-weight: 600;
+  margin: 0.5rem 0 0.25rem;
+}
+
+::ng-deep .qa-markdown-wrapper markdown p {
+  margin-bottom: 0.5rem;
+}
+
+::ng-deep .qa-markdown-wrapper markdown p:last-child {
+  margin-bottom: 0;
+}
+
+::ng-deep .qa-markdown-wrapper markdown strong {
+  font-weight: 700;
+}
+
+::ng-deep .qa-markdown-wrapper markdown ul,
+::ng-deep .qa-markdown-wrapper markdown ol {
+  margin: 0.5rem 0;
+  padding-left: 1.25rem;
+}
+
+::ng-deep .qa-markdown-wrapper markdown li {
+  margin-bottom: 0.25rem;
+}
+
+::ng-deep .qa-markdown-wrapper markdown blockquote {
+  border-left: 3px solid #cbd5e1;
+  padding: 0.25rem 0 0.25rem 0.75rem;
+  margin: 0.5rem 0;
+  color: #64748b;
+}
+
+::ng-deep .qa-markdown-wrapper markdown code {
+  background: rgba(0, 0, 0, 0.08);
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 0.85em;
+  font-family: 'Fira Code', 'Consolas', monospace;
+}
+
+::ng-deep .qa-markdown-wrapper markdown pre {
+  display: block;
+  overflow: auto;
+  margin: 0.5rem 0;
+  padding: 1rem;
+  background: #1e293b;
+  border-radius: 10px;
+}
+
+::ng-deep .qa-markdown-wrapper markdown pre code {
+  background: transparent;
+  padding: 0;
+  border-radius: 0;
+  font-size: 0.8rem;
+  line-height: 1.65;
+  color: #e2e8f0;
+}
+
+// ── 表格:全边框 + 水平滚动 ──
+::ng-deep .qa-markdown-wrapper markdown table {
+  display: block;
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+  border-collapse: collapse;
+  font-size: 0.8125rem;
+  margin: 0.75rem 0;
+}
+
+::ng-deep .qa-markdown-wrapper markdown th,
+::ng-deep .qa-markdown-wrapper markdown td {
+  padding: 0.625rem 0.75rem;
+  text-align: left;
+  border: 1px solid #cbd5e1;
+  vertical-align: top;
+  white-space: nowrap;
+}
+
+::ng-deep .qa-markdown-wrapper markdown th {
+  font-weight: 700;
+  font-size: 0.75rem;
+  color: #475569;
+  background: #f8fafc;
+}
+
+::ng-deep .qa-markdown-wrapper markdown td {
+  color: #334155;
+}
+
+::ng-deep .qa-markdown-wrapper markdown hr {
+  border: none;
+  border-top: 1px solid #e2e8f0;
+  margin: 1rem 0;
+}
+
+::ng-deep .qa-markdown-wrapper markdown img {
+  max-width: 100%;
+  border-radius: 8px;
+  margin: 0.5rem 0;
+}
+
+// ═══════════════════════
+//  深色模式
+// ═══════════════════════
+
+
+:host-context(.dark) ::ng-deep .qa-markdown-wrapper markdown th {
+  background: #1e293b;
+  color: #94a3b8;
+  border-color: #334155;
+}
+
+:host-context(.dark) ::ng-deep .qa-markdown-wrapper markdown td {
+  border-color: #334155;
+  color: #cbd5e1;
+}
+
+:host-context(.dark) ::ng-deep .qa-markdown-wrapper markdown hr {
+  border-color: #334155;
+}

+ 388 - 38
frontend/src/app/features/qa/qa/qa.ts

@@ -1,34 +1,133 @@
-import { Component, signal } from '@angular/core';
+import { Component, signal, inject, OnDestroy, effect, ElementRef, viewChild } from '@angular/core';
 import { Router } from '@angular/router';
 import { FaIconComponent } from '@fortawesome/angular-fontawesome';
-import { 
-  faBars, 
-  faTimes, 
-  faPlus, 
-  faTrash, 
-  faMicrophone, 
+import { MarkdownComponent } from 'ngx-markdown';
+import {
+  faBars,
+  faTimes,
+  faPlus,
+  faTrash,
+  faMicrophone,
   faPaperPlane,
-  faExclamationTriangle 
+  faExclamationTriangle,
+  faStop,
+  faChevronRight,
+  faCopy,
+  faFont,
 } from '@fortawesome/free-solid-svg-icons';
-import { QAMessage } from '../../../core/models/qa.model';
+import { QAMessage, ConversationItem } from '../../../core/models/qa.model';
+import { AiChatService } from '../../../core/services/ai-chat.service';
+import { QaStorageService } from '../../../core/services/qa-storage.service';
+import { ToastService } from '../../../core/services/toast.service';
+
+function generateId(): string {
+  return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
+
+const INITIAL_MESSAGES: QAMessage[] = [
+  {
+    id: generateId(),
+    type: 'ai',
+    content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~',
+    timestamp: Date.now(),
+  },
+];
+
+function makeTitle(content: string): string {
+  return content.trim().slice(0, 20) || '新对话';
+}
 
 @Component({
   selector: 'app-qa',
-  imports: [FaIconComponent],
+  imports: [FaIconComponent, MarkdownComponent],
   templateUrl: './qa.html',
-  styleUrl: './qa.scss'
+  styleUrl: './qa.scss',
 })
-export class Qa {
+export class Qa implements OnDestroy {
+  private readonly router = inject(Router);
+  private readonly aiChat = inject(AiChatService);
+  private readonly storage = inject(QaStorageService);
+  private readonly toast = inject(ToastService);
+
   readonly showSidebar = signal(false);
   readonly showClearModal = signal(false);
+  readonly showThinking = signal(false);
+  readonly isGenerating = signal(false);
 
-  readonly messages = signal<QAMessage[]>([
-    { type: 'ai', content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~', timestamp: Date.now() },
-  ]);
+  readonly messages = signal<QAMessage[]>([...INITIAL_MESSAGES]);
+  readonly conversations = signal<ConversationItem[]>([]);
+  readonly currentConversationId = signal<string | null>(null);
+
+  readonly streamingContent = signal('');
+  readonly streamingThoughts = signal('');
+  readonly expandedThoughts = signal<Set<string>>(new Set());
+  readonly contextMenu = signal<{ msgId: string; content: string; x: number; y: number } | null>(null);
+
+  private longPressTimer: ReturnType<typeof setTimeout> | null = null;
+  private touchStartPos = { x: 0, y: 0 };
+
+  startLongPress(event: TouchEvent, msgId: string, content: string): void {
+    this.touchStartPos = { x: event.touches[0].clientX, y: event.touches[0].clientY };
+    this.longPressTimer = setTimeout(() => {
+      const el = document.elementFromPoint(this.touchStartPos.x, this.touchStartPos.y);
+      if (el?.closest('.qa-markdown-wrapper')) {
+        this.contextMenu.set({
+          msgId,
+          content,
+          x: Math.min(this.touchStartPos.x, window.innerWidth - 160),
+          y: Math.max(this.touchStartPos.y - 80, 10),
+        });
+      }
+    }, 500);
+  }
+
+  cancelLongPress(): void {
+    if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; }
+  }
+
+  trackTouchMove(event: TouchEvent): void {
+    const dx = Math.abs(event.touches[0].clientX - this.touchStartPos.x);
+    const dy = Math.abs(event.touches[0].clientY - this.touchStartPos.y);
+    if (dx > 10 || dy > 10) this.cancelLongPress();
+  }
+
+  async copyMessageContent(content: string): Promise<void> {
+    this.closeContextMenu();
+    try {
+      await navigator.clipboard.writeText(content);
+      this.toast.success('已复制');
+    } catch {
+      this.toast.error('复制失败');
+    }
+  }
+
+  enableTextSelection(): void {
+    this.closeContextMenu();
+  }
+
+  closeContextMenu(): void {
+    this.cancelLongPress();
+    this.contextMenu.set(null);
+  }
+
+  toggleThought(msgId: string): void {
+    this.expandedThoughts.update((set) => {
+      const next = new Set(set);
+      if (next.has(msgId)) {
+        next.delete(msgId);
+      } else {
+        next.add(msgId);
+      }
+      return next;
+    });
+  }
+
+  isThoughtExpanded(msgId: string): boolean {
+    return this.expandedThoughts().has(msgId);
+  }
+
+  private abortController: AbortController | null = null;
 
-  readonly showThinking = signal(false);
-  readonly conversationId = signal('default');
-  
   readonly faBars = faBars;
   readonly faTimes = faTimes;
   readonly faPlus = faPlus;
@@ -36,19 +135,83 @@ export class Qa {
   readonly faMicrophone = faMicrophone;
   readonly faPaperPlane = faPaperPlane;
   readonly faExclamationTriangle = faExclamationTriangle;
+  readonly faStop = faStop;
+  readonly faChevronRight = faChevronRight;
+  readonly faCopy = faCopy;
+  readonly faFont = faFont;
+
+  readonly messagesContainer = viewChild<ElementRef<HTMLElement>>('messagesContainer');
 
-  constructor(private readonly router: Router) {}
+  private scrollEffect = effect(() => {
+    if (this.streamingContent() || this.messages().length) {
+      requestAnimationFrame(() => this.scrollToBottom());
+    }
+  });
+
+  constructor() {
+    this.loadConversations();
+  }
+
+  ngOnDestroy(): void {
+    this.abortController?.abort();
+  }
+
+  // ─── 侧边栏 ────────────────────────────────────────
 
   toggleSidebar(): void {
-    this.showSidebar.update(v => !v);
+    if (!this.showSidebar()) {
+      this.loadConversations();
+    }
+    this.showSidebar.update((v) => !v);
   }
 
   closeSidebar(): void {
     this.showSidebar.set(false);
   }
 
-  goBack(): void {
-    this.router.navigateByUrl('/');
+  private async loadConversations(): Promise<void> {
+    try {
+      const list = await this.storage.listConversations();
+      this.conversations.set(list);
+    } catch {
+      // 静默失败
+    }
+  }
+
+  // ─── 会话操作 ──────────────────────────────────────
+
+  async selectConversation(objectId: string): Promise<void> {
+    if (this.isGenerating()) return;
+    if (objectId === this.currentConversationId()) {
+      this.closeSidebar();
+      return;
+    }
+
+    const data = await this.storage.getConversation(objectId);
+    if (!data) {
+      this.toast.error('会话加载失败');
+      return;
+    }
+
+    this.abortController?.abort();
+    // 为加载出的历史消息补充 id 字段(兼容性处理)
+    const messagesWithIds: QAMessage[] = data.messages.map((msg) => ({
+      ...msg,
+      id: (msg as any).id || generateId(),
+    }));
+    this.messages.set(messagesWithIds.length > 0 ? messagesWithIds : [...INITIAL_MESSAGES]);
+    this.currentConversationId.set(objectId);
+    this.isGenerating.set(false);
+    this.showThinking.set(false);
+    this.closeSidebar();
+  }
+
+  newConversation(): void {
+    this.abortController?.abort();
+    this.messages.set([...INITIAL_MESSAGES]);
+    this.currentConversationId.set(null);
+    this.isGenerating.set(false);
+    this.showThinking.set(false);
   }
 
   openClearModal(): void {
@@ -59,28 +222,215 @@ export class Qa {
     this.showClearModal.set(false);
   }
 
-  clearConversation(): void {
-    this.messages.set([
-      { type: 'ai', content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~', timestamp: Date.now() },
-    ]);
+  async clearConversation(): Promise<void> {
     this.showClearModal.set(false);
+    const id = this.currentConversationId();
+    if (id) {
+      try {
+        await this.storage.deleteConversation(id);
+      } catch {
+        this.toast.error('删除失败');
+      }
+    }
+    this.abortController?.abort();
+    this.messages.set([...INITIAL_MESSAGES]);
+    this.currentConversationId.set(null);
+    this.isGenerating.set(false);
+    this.showThinking.set(false);
+    this.loadConversations();
   }
 
-  newConversation(): void {
-    this.messages.set([
-      { type: 'ai', content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~', timestamp: Date.now() },
-    ]);
+  goBack(): void {
+    this.router.navigateByUrl('/home');
   }
 
-  sendMessage(content: string): void {
-    if (!content.trim()) return;
-    this.messages.update(msgs => [...msgs, { type: 'user', content: content.trim(), timestamp: Date.now() }]);
+  // ─── 发送消息 ──────────────────────────────────────
+
+  async sendMessage(content: string): Promise<void> {
+    if (!content.trim() || this.isGenerating()) return;
+
+    const trimmed = content.trim();
+    let conversationId = this.currentConversationId();
+
+    // 新会话:先创建数据库记录
+    if (!conversationId) {
+      try {
+        const title = makeTitle(trimmed);
+        const initialMsgs: QAMessage[] = [
+          ...INITIAL_MESSAGES,
+          { id: generateId(), type: 'user', content: trimmed, timestamp: Date.now() },
+        ];
+        conversationId = await this.storage.createConversation(title, initialMsgs);
+        this.currentConversationId.set(conversationId);
+        this.loadConversations();
+      } catch {
+        this.toast.error('创建会话失败');
+        return;
+      }
+    } else {
+      // 已有会话:添加用户消息
+      this.messages.update((msgs) => [
+        ...msgs,
+        { id: generateId(), type: 'user', content: trimmed, timestamp: Date.now() },
+      ]);
+    }
+
     this.showThinking.set(true);
-    setTimeout(() => {
-      const replies = ['好的,关于这个问题,我来详细为你解答...', '这是一个常见的销售场景,让我分享一些应对策略...', '很高兴你问到这个问题。从专业角度看...'];
-      const reply = replies[Math.floor(Math.random() * replies.length)];
-      this.messages.update(msgs => [...msgs, { type: 'ai', content: reply, timestamp: Date.now() }]);
+    this.isGenerating.set(true);
+    this.streamingContent.set('');
+    this.streamingThoughts.set('');
+
+    this.abortController = new AbortController();
+    let finalContent = '';
+    let finalThoughts = '';
+    let finalImages: string[] = [];
+    let aiMessageInserted = false;
+    let aiMessageId = '';
+    let thoughtsAutoCollapsed = false;
+    let lastContentLen = 0;
+
+    const body: Record<string, unknown> = {
+      prompt: trimmed,
+      parameters: { incremental_output: true, has_thoughts: true },
+      debug: {},
+    };
+
+    try {
+      for await (const chunk of this.aiChat.stream('/api/chat', body, this.abortController.signal)) {
+        finalContent = chunk.text;
+        finalThoughts = chunk.thoughts;
+        finalImages = chunk.images;
+
+        this.streamingContent.set(finalContent);
+        this.streamingThoughts.set(finalThoughts);
+
+        // 首次有内容时插入 AI 消息
+        if (!aiMessageInserted && (finalContent || finalThoughts)) {
+          aiMessageId = generateId();
+          this.expandedThoughts.update((set) => new Set(set).add(aiMessageId));
+          const aiMsg: QAMessage = {
+            id: aiMessageId,
+            type: 'ai',
+            content: finalContent,
+            thoughts: finalThoughts || undefined,
+            images: finalImages.length > 0 ? [...finalImages] : undefined,
+            timestamp: Date.now(),
+          };
+          this.messages.update((msgs) => [...msgs, aiMsg]);
+          aiMessageInserted = true;
+          this.showThinking.set(false);
+        } else if (aiMessageInserted) {
+          const lastIdx = this.messages().length - 1;
+          this.messages.update((msgs) => {
+            const updated = [...msgs];
+            updated[lastIdx] = {
+              ...updated[lastIdx],
+              content: finalContent,
+              thoughts: finalThoughts || undefined,
+              images: finalImages.length > 0 ? [...finalImages] : undefined,
+            };
+            return updated;
+          });
+        }
+
+        // 检测到内容开始输出时,自动折叠思考(仅一次)
+        if (!thoughtsAutoCollapsed && finalContent.length > lastContentLen && finalContent.trim().length > 0) {
+          this.expandedThoughts.update((set) => {
+            const next = new Set(set);
+            next.delete(aiMessageId);
+            return next;
+          });
+          thoughtsAutoCollapsed = true;
+        }
+        lastContentLen = finalContent.length;
+      }
+    } catch (err) {
+      if ((err as Error).name === 'AbortError') {
+        if (finalContent || finalThoughts) {
+          if (!aiMessageInserted) {
+            const aiMsg: QAMessage = {
+              id: generateId(),
+              type: 'ai',
+              content: finalContent || '已停止生成',
+              thoughts: finalThoughts || undefined,
+              images: finalImages.length > 0 ? [...finalImages] : undefined,
+              timestamp: Date.now(),
+            };
+            this.messages.update((msgs) => [...msgs, aiMsg]);
+          } else {
+            const lastIdx = this.messages().length - 1;
+            this.messages.update((msgs) => {
+              const updated = [...msgs];
+              updated[lastIdx] = {
+                ...updated[lastIdx],
+                content: finalContent || '已停止生成',
+                thoughts: finalThoughts || undefined,
+                images: finalImages.length > 0 ? [...finalImages] : undefined,
+              };
+              return updated;
+            });
+          }
+          this.showThinking.set(false);
+        }
+      } else {
+        const errorContent = `❌ 错误: ${(err as Error).message}`;
+        if (!aiMessageInserted) {
+          const aiMsg: QAMessage = {
+            id: generateId(),
+            type: 'ai',
+            content: errorContent,
+            timestamp: Date.now(),
+          };
+          this.messages.update((msgs) => [...msgs, aiMsg]);
+        } else {
+          const lastIdx = this.messages().length - 1;
+          this.messages.update((msgs) => {
+            const updated = [...msgs];
+            updated[lastIdx] = { ...updated[lastIdx], content: errorContent };
+            return updated;
+          });
+        }
+        this.showThinking.set(false);
+        this.toast.error((err as Error).message || '请求失败');
+      }
+    } finally {
+      this.abortController = null;
+      this.isGenerating.set(false);
       this.showThinking.set(false);
-    }, 1500);
+    }
+
+    // 保存到数据库
+    if (conversationId) {
+      try {
+        await this.storage.updateConversation(conversationId, this.messages());
+      } catch {
+        // 静默失败,下次发消息时会再次尝试保存
+      }
+    }
+  }
+
+  stopGeneration(): void {
+    this.abortController?.abort();
+  }
+
+  handleEnter(input: HTMLInputElement): void {
+    if (this.isGenerating()) {
+      this.stopGeneration();
+    } else {
+      this.handleSend(input);
+    }
+  }
+
+  handleSend(input: HTMLInputElement): void {
+    const val = input.value;
+    input.value = '';
+    this.sendMessage(val);
+  }
+
+  private scrollToBottom(): void {
+    const el = this.messagesContainer()?.nativeElement;
+    if (el) {
+      el.scrollTop = el.scrollHeight;
+    }
   }
 }

+ 69 - 0
frontend/src/app/pages/login/login.html

@@ -0,0 +1,69 @@
+<div class="fixed inset-0 bg-gray-50 dark:bg-[#0f1419] z-[60] flex flex-col">
+  <div class="flex-1 flex flex-col items-center justify-center px-6">
+    <div class="w-full max-w-sm">
+      <div class="text-center mb-10">
+        <div class="w-20 h-20 mx-auto mb-4 bg-gradient-to-br from-[#00d68f] to-[#00b377] rounded-2xl flex items-center justify-center shadow-lg shadow-green-500/25">
+          <span class="text-white text-3xl font-bold">AI</span>
+        </div>
+        <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">AI销售培训系统</h1>
+        <p class="text-sm text-gray-500 dark:text-gray-400">使用手机号登录</p>
+      </div>
+
+      <div class="space-y-4">
+        <div class="bg-white dark:bg-gray-800 rounded-xl px-4 py-3.5 flex items-center gap-3 border border-gray-200 dark:border-gray-700 focus-within:border-green-500 dark:focus-within:border-green-400 transition-colors">
+          <fa-icon [icon]="faMobileAlt" class="text-gray-400 dark:text-gray-500 text-lg w-5 text-center flex-shrink-0"></fa-icon>
+          <input
+            [(ngModel)]="phone"
+            type="tel"
+            placeholder="请输入手机号"
+            maxlength="11"
+            class="flex-1 bg-transparent outline-none text-base text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
+            autocomplete="tel"
+          />
+        </div>
+
+        <div class="bg-white dark:bg-gray-800 rounded-xl px-4 py-3.5 flex items-center gap-3 border border-gray-200 dark:border-gray-700 focus-within:border-green-500 dark:focus-within:border-green-400 transition-colors">
+          <fa-icon [icon]="faLock" class="text-gray-400 dark:text-gray-500 text-lg w-5 text-center flex-shrink-0"></fa-icon>
+          <input
+            [(ngModel)]="password"
+            [type]="showPassword() ? 'text' : 'password'"
+            placeholder="请输入密码"
+            class="flex-1 bg-transparent outline-none text-base text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
+            autocomplete="current-password"
+            (keydown.enter)="login()"
+          />
+          <button
+            (click)="togglePassword()"
+            class="w-8 h-8 flex items-center justify-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors flex-shrink-0"
+            tabindex="-1"
+          >
+            <fa-icon [icon]="showPassword() ? faEye : faEyeSlash" class="text-base"></fa-icon>
+          </button>
+        </div>
+
+        @if (errorMsg()) {
+          <p class="text-sm text-red-500 px-1 animate-fadeIn">{{ errorMsg() }}</p>
+        }
+
+        <button
+          (click)="login()"
+          [disabled]="isLoading()"
+          class="w-full py-3.5 bg-gradient-to-r from-[#00d68f] to-[#00b377] text-white font-semibold text-base rounded-xl shadow-lg shadow-green-500/25 hover:shadow-green-500/40 active:scale-[0.98] transition-all duration-200 disabled:opacity-60 disabled:active:scale-100 flex items-center justify-center gap-2"
+        >
+          @if (isLoading()) {
+            <span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
+            <span>登录中...</span>
+          } @else {
+            <span>登 录</span>
+          }
+        </button>
+      </div>
+    </div>
+  </div>
+
+  <div class="py-6 text-center">
+    <button (click)="goRegister()" class="text-sm text-gray-500 dark:text-gray-400">
+      还没有账号?<span class="text-green-600 dark:text-green-400">立即注册</span>
+    </button>
+  </div>
+</div>

+ 3 - 0
frontend/src/app/pages/login/login.scss

@@ -0,0 +1,3 @@
+:host {
+  display: block;
+}

+ 92 - 0
frontend/src/app/pages/login/login.ts

@@ -0,0 +1,92 @@
+import { Component, inject, signal, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router, ActivatedRoute } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { faMobileAlt, faLock, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons';
+import { AuthService } from '../../core/services/auth.service';
+import { ToastService } from '../../core/services/toast.service';
+
+const PHONE_REGEX = /^1[3-9]\d{9}$/;
+
+@Component({
+  selector: 'app-login',
+  imports: [FormsModule, FaIconComponent],
+  templateUrl: './login.html',
+  styleUrl: './login.scss'
+})
+export class Login implements OnInit {
+  private router = inject(Router);
+  private route = inject(ActivatedRoute);
+  private authService = inject(AuthService);
+  private toast = inject(ToastService);
+
+  readonly phone = signal('');
+  readonly password = signal('');
+  readonly showPassword = signal(false);
+  readonly isLoading = signal(false);
+  readonly errorMsg = signal('');
+
+  readonly faMobileAlt = faMobileAlt;
+  readonly faLock = faLock;
+  readonly faEye = faEye;
+  readonly faEyeSlash = faEyeSlash;
+
+  ngOnInit(): void {
+    const params = this.route.snapshot.queryParams;
+    if (params['phone']) {
+      this.phone.set(params['phone']);
+    }
+  }
+
+  togglePassword(): void {
+    this.showPassword.update(v => !v);
+  }
+
+  goRegister(): void {
+    this.router.navigateByUrl('/register');
+  }
+
+  async login(): Promise<void> {
+    const p = this.phone().trim();
+    const pw = this.password();
+
+    if (!p) {
+      this.errorMsg.set('请输入手机号');
+      return;
+    }
+    if (!PHONE_REGEX.test(p)) {
+      this.errorMsg.set('手机号格式不正确');
+      return;
+    }
+    if (!pw) {
+      this.errorMsg.set('请输入密码');
+      return;
+    }
+    if (pw.length < 6) {
+      this.errorMsg.set('密码不能少于6位');
+      return;
+    }
+
+    this.errorMsg.set('');
+    this.isLoading.set(true);
+
+    try {
+      const result = await this.authService.login(p, pw);
+      if (result.action === 'registered') {
+        this.toast.success('手机号未注册,已自动为您注册并登录');
+      } else {
+        this.toast.success('登录成功');
+      }
+      this.router.navigateByUrl('/home');
+    } catch (err: any) {
+      const code = err?.code;
+      if (code === 100) {
+        this.toast.error('无法连接服务器,请检查网络');
+      } else {
+        this.toast.error(err?.message || '登录失败,请重试');
+      }
+    } finally {
+      this.isLoading.set(false);
+    }
+  }
+}

+ 87 - 0
frontend/src/app/pages/register/register.html

@@ -0,0 +1,87 @@
+<div class="fixed inset-0 bg-gray-50 dark:bg-[#0f1419] z-[60] flex flex-col">
+  <div class="flex-1 flex flex-col items-center justify-center px-6">
+    <div class="w-full max-w-sm">
+      <div class="text-center mb-8">
+        <div class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-[#00d68f] to-[#00b377] rounded-2xl flex items-center justify-center shadow-lg shadow-green-500/25">
+          <span class="text-white text-2xl font-bold">+</span>
+        </div>
+        <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">创建账号</h1>
+        <p class="text-sm text-gray-500 dark:text-gray-400">注册后即可开始学习</p>
+      </div>
+
+      <div class="space-y-4">
+        <div class="bg-white dark:bg-gray-800 rounded-xl px-4 py-3.5 flex items-center gap-3 border border-gray-200 dark:border-gray-700 focus-within:border-green-500 dark:focus-within:border-green-400 transition-colors">
+          <fa-icon [icon]="faMobileAlt" class="text-gray-400 dark:text-gray-500 text-lg w-5 text-center flex-shrink-0"></fa-icon>
+          <input
+            [(ngModel)]="phone"
+            type="tel"
+            placeholder="请输入手机号"
+            maxlength="11"
+            class="flex-1 bg-transparent outline-none text-base text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
+            autocomplete="tel"
+          />
+        </div>
+
+        <div class="bg-white dark:bg-gray-800 rounded-xl px-4 py-3.5 flex items-center gap-3 border border-gray-200 dark:border-gray-700 focus-within:border-green-500 dark:focus-within:border-green-400 transition-colors">
+          <fa-icon [icon]="faLock" class="text-gray-400 dark:text-gray-500 text-lg w-5 text-center flex-shrink-0"></fa-icon>
+          <input
+            [(ngModel)]="password"
+            [type]="showPassword() ? 'text' : 'password'"
+            placeholder="请输入密码(至少6位)"
+            class="flex-1 bg-transparent outline-none text-base text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
+            autocomplete="new-password"
+          />
+          <button
+            (click)="togglePassword()"
+            class="w-8 h-8 flex items-center justify-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors flex-shrink-0"
+            tabindex="-1"
+          >
+            <fa-icon [icon]="showPassword() ? faEye : faEyeSlash" class="text-base"></fa-icon>
+          </button>
+        </div>
+
+        <div class="bg-white dark:bg-gray-800 rounded-xl px-4 py-3.5 flex items-center gap-3 border border-gray-200 dark:border-gray-700 focus-within:border-green-500 dark:focus-within:border-green-400 transition-colors">
+          <fa-icon [icon]="faLock" class="text-gray-400 dark:text-gray-500 text-lg w-5 text-center flex-shrink-0"></fa-icon>
+          <input
+            [(ngModel)]="confirmPassword"
+            [type]="showConfirmPassword() ? 'text' : 'password'"
+            placeholder="请再次输入密码"
+            class="flex-1 bg-transparent outline-none text-base text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
+            autocomplete="new-password"
+            (keydown.enter)="register()"
+          />
+          <button
+            (click)="toggleConfirmPassword()"
+            class="w-8 h-8 flex items-center justify-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors flex-shrink-0"
+            tabindex="-1"
+          >
+            <fa-icon [icon]="showConfirmPassword() ? faEye : faEyeSlash" class="text-base"></fa-icon>
+          </button>
+        </div>
+
+        @if (errorMsg()) {
+          <p class="text-sm text-red-500 px-1 animate-fadeIn">{{ errorMsg() }}</p>
+        }
+
+        <button
+          (click)="register()"
+          [disabled]="isLoading()"
+          class="w-full py-3.5 bg-gradient-to-r from-[#00d68f] to-[#00b377] text-white font-semibold text-base rounded-xl shadow-lg shadow-green-500/25 hover:shadow-green-500/40 active:scale-[0.98] transition-all duration-200 disabled:opacity-60 disabled:active:scale-100 flex items-center justify-center gap-2"
+        >
+          @if (isLoading()) {
+            <span class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
+            <span>注册中...</span>
+          } @else {
+            <span>注 册</span>
+          }
+        </button>
+      </div>
+    </div>
+  </div>
+
+  <div class="py-6 text-center">
+    <button (click)="goLogin()" class="text-sm text-gray-500 dark:text-gray-400">
+      已有账号?<span class="text-green-600 dark:text-green-400">返回登录</span>
+    </button>
+  </div>
+</div>

+ 3 - 0
frontend/src/app/pages/register/register.scss

@@ -0,0 +1,3 @@
+:host {
+  display: block;
+}

+ 101 - 0
frontend/src/app/pages/register/register.ts

@@ -0,0 +1,101 @@
+import { Component, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import {
+  faMobileAlt,
+  faLock,
+  faEye,
+  faEyeSlash,
+  faArrowLeft,
+} from '@fortawesome/free-solid-svg-icons';
+import { AuthService } from '../../core/services/auth.service';
+import { ToastService } from '../../core/services/toast.service';
+
+const PHONE_REGEX = /^1[3-9]\d{9}$/;
+
+@Component({
+  selector: 'app-register',
+  imports: [FormsModule, FaIconComponent],
+  templateUrl: './register.html',
+  styleUrl: './register.scss',
+})
+export class Register {
+  private router = inject(Router);
+  private authService = inject(AuthService);
+  private toast = inject(ToastService);
+
+  readonly phone = signal('');
+  readonly password = signal('');
+  readonly confirmPassword = signal('');
+  readonly showPassword = signal(false);
+  readonly showConfirmPassword = signal(false);
+  readonly isLoading = signal(false);
+  readonly errorMsg = signal('');
+
+  readonly faMobileAlt = faMobileAlt;
+  readonly faLock = faLock;
+  readonly faEye = faEye;
+  readonly faEyeSlash = faEyeSlash;
+  readonly faArrowLeft = faArrowLeft;
+
+  togglePassword(): void {
+    this.showPassword.update((v) => !v);
+  }
+
+  toggleConfirmPassword(): void {
+    this.showConfirmPassword.update((v) => !v);
+  }
+
+  goLogin(): void {
+    this.router.navigateByUrl('/login');
+  }
+
+  async register(): Promise<void> {
+    const p = this.phone().trim();
+    const pw = this.password();
+    const cp = this.confirmPassword();
+
+    if (!p) {
+      this.errorMsg.set('请输入手机号');
+      return;
+    }
+    if (!PHONE_REGEX.test(p)) {
+      this.errorMsg.set('手机号格式不正确');
+      return;
+    }
+    if (!pw) {
+      this.errorMsg.set('请输入密码');
+      return;
+    }
+    if (pw.length < 6) {
+      this.errorMsg.set('密码不能少于6位');
+      return;
+    }
+    if (pw !== cp) {
+      this.errorMsg.set('两次密码输入不一致');
+      return;
+    }
+
+    this.errorMsg.set('');
+    this.isLoading.set(true);
+
+    try {
+      await this.authService.signUp(p, pw);
+      this.toast.success('注册成功,请登录');
+      this.router.navigate(['/login'], {
+        queryParams: { phone: p },
+      });
+    } catch (err: any) {
+      if (err?.code === 202) {
+        this.errorMsg.set('该手机号已被注册');
+      } else if (err?.code === 100) {
+        this.toast.error('无法连接服务器,请检查网络');
+      } else {
+        this.toast.error(err?.message || '注册失败,请重试');
+      }
+    } finally {
+      this.isLoading.set(false);
+    }
+  }
+}

+ 8 - 0
frontend/src/app/parse.ts

@@ -0,0 +1,8 @@
+import { FmodeParse } from 'fmode-ng/core';
+import { environment } from '../environments/environment';
+
+export const MSQParse = FmodeParse.initialize({
+  appId: environment.parse.appId,
+  serverURL: environment.parse.serverURL,
+  config: 'lami-ai'
+});

+ 20 - 0
frontend/src/app/shared/components/toast/toast.html

@@ -0,0 +1,20 @@
+@if (toastService.visible()) {
+  <div
+    class="fixed top-4 left-1/2 -translate-x-1/2 z-[9999] px-4 py-2.5 rounded-xl shadow-lg flex items-center gap-2 text-sm font-medium toast-enter max-w-[94vw]"
+    [class]="toastService.toastType() === 'success'
+      ? 'bg-green-500 text-white'
+      : toastService.toastType() === 'error'
+        ? 'bg-red-500 text-white'
+        : 'bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900'"
+  >
+    <fa-icon
+      [icon]="toastService.toastType() === 'success'
+        ? faCheckCircle
+        : toastService.toastType() === 'error'
+          ? faExclamationCircle
+          : faInfoCircle"
+      class="text-base flex-shrink-0"
+    ></fa-icon>
+    <span class="leading-snug">{{ toastService.message() }}</span>
+  </div>
+}

+ 12 - 0
frontend/src/app/shared/components/toast/toast.scss

@@ -0,0 +1,12 @@
+:host {
+  display: block;
+}
+
+@keyframes toastFadeIn {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+.toast-enter {
+  animation: toastFadeIn 0.35s ease-out forwards;
+}

+ 18 - 0
frontend/src/app/shared/components/toast/toast.ts

@@ -0,0 +1,18 @@
+import { Component, inject } from '@angular/core';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { faCheckCircle, faExclamationCircle, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
+import { ToastService } from '../../../core/services/toast.service';
+
+@Component({
+  selector: 'app-toast',
+  imports: [FaIconComponent],
+  templateUrl: './toast.html',
+  styleUrl: './toast.scss'
+})
+export class Toast {
+  readonly toastService = inject(ToastService);
+
+  readonly faCheckCircle = faCheckCircle;
+  readonly faExclamationCircle = faExclamationCircle;
+  readonly faInfoCircle = faInfoCircle;
+}

+ 7 - 0
frontend/src/environments/environment.development.ts

@@ -0,0 +1,7 @@
+export const environment = {
+  production: true,
+  parse: {
+    appId: 'lami-ai',
+    serverURL: 'http://47.116.38.72/parse',
+  },
+};

+ 7 - 0
frontend/src/environments/environment.ts

@@ -0,0 +1,7 @@
+export const environment = {
+  production: false,
+  parse: {
+    appId: 'lami-ai',
+    serverURL: 'http://47.116.38.72/parse',
+  },
+};

+ 10 - 1
frontend/src/index.html

@@ -1,11 +1,20 @@
 <!doctype html>
-<html lang="zh-CN" class="light">
+<html lang="zh-CN">
 <head>
   <meta charset="utf-8">
   <title>AI销售培训系统</title>
   <base href="/">
   <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
+  <script>
+    (function() {
+      var theme = localStorage.getItem('theme');
+      if (!theme) {
+        theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+      }
+      document.documentElement.classList.add(theme);
+    })();
+  </script>
 </head>
 <body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
   <app-root></app-root>

Некоторые файлы не были показаны из-за большого количества измененных файлов