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

Merge branch 'master' of http://git.fmode.cn:3000/nkkj/yss-project

Future 2 месяцев назад
Родитель
Сommit
83b4f9bcc6
100 измененных файлов с 8130 добавлено и 1108 удалено
  1. 19 9
      angular.json
  2. 505 0
      docs/dynamic-data-integration.md
  3. 372 0
      docs/phase3-implementation-progress.md
  4. 27 0
      docs/task/20251019-project-detail.md
  5. 487 496
      package-lock.json
  6. 24 19
      package.json
  7. 0 0
      public/assets/images/case-1-cover.svg
  8. 0 0
      public/assets/images/case-2-cover.svg
  9. 0 0
      public/assets/images/case-3-cover.svg
  10. 0 0
      public/assets/images/case-4-cover.svg
  11. 4 12
      public/assets/images/default-avatar.svg
  12. 0 10
      public/assets/images/hr-icon.svg
  13. 0 11
      public/assets/images/hr-logo.svg
  14. 11 10
      public/assets/images/portfolio-1.svg
  15. 12 10
      public/assets/images/portfolio-2.svg
  16. 12 10
      public/assets/images/portfolio-3.svg
  17. 12 10
      public/assets/images/portfolio-4.svg
  18. 0 0
      public/assets/logo.jpg
  19. 0 0
      public/assets/presets/living_room_modern_1.jpg
  20. 0 0
      public/assets/presets/living_room_modern_2.jpg
  21. 0 0
      public/assets/presets/living_room_modern_3.jpg
  22. 3 0
      rules/schemas.md
  23. 93 0
      rules/storage.md
  24. 0 247
      rules/wxwork/auth-user-confirm.md
  25. 6 2
      rules/wxwork/auth.md
  26. 448 0
      scripts/migration/create-schema.js
  27. 1 9
      src/app/app.html
  28. 7 1
      src/app/app.routes.ts
  29. 25 11
      src/app/app.ts
  30. 16 0
      src/app/pages/admin/admin-layout/admin-layout.ts
  31. 2 2
      src/app/pages/admin/api-integrations/api-dialog/api-dialog.html
  32. 244 10
      src/app/pages/admin/dashboard/dashboard.ts
  33. 1 1
      src/app/pages/admin/departments/departments.html
  34. 18 2
      src/app/pages/admin/employees/employees.html
  35. 2 2
      src/app/pages/admin/employees/employees.ts
  36. 1 1
      src/app/pages/admin/groupchats/groupchats.html
  37. 10 2
      src/app/pages/admin/services/department.service.ts
  38. 1 1
      src/app/pages/admin/services/project.service.ts
  39. 1 0
      src/app/pages/admin/system-management/system-management.ts
  40. 3 3
      src/app/pages/auth/login/login.html
  41. 25 6
      src/app/pages/auth/login/login.scss
  42. 6 4
      src/app/pages/customer-service/customer-service-layout/customer-service-layout.html
  43. 17 2
      src/app/pages/customer-service/customer-service-layout/customer-service-layout.ts
  44. 6 6
      src/app/pages/customer-service/dashboard/dashboard.html
  45. 109 7
      src/app/pages/customer-service/dashboard/dashboard.ts
  46. 63 2
      src/app/pages/designer/dashboard/dashboard.ts
  47. 1 0
      src/app/pages/designer/dashboard/skill-radar/skill-radar.component.ts
  48. 1 0
      src/app/pages/designer/material-share/material-share.ts
  49. 1 0
      src/app/pages/designer/personal-board/personal-board.ts
  50. 2 2
      src/app/pages/designer/project-detail/components/order-creation/order-creation.component.html
  51. 1 0
      src/app/pages/finance/dashboard/dashboard.ts
  52. 1 0
      src/app/pages/finance/project-records/project-records.ts
  53. 1 0
      src/app/pages/finance/reconciliation/reconciliation.ts
  54. 1 1
      src/app/pages/hr/recruitment-performance/recruitment-performance.html
  55. 1 0
      src/app/pages/team-leader/dashboard/dashboard.ts
  56. 5 6
      src/app/pages/team-leader/workload-calendar/workload-calendar.html
  57. 2 2
      src/app/shared/components/auto-settlement-config/auto-settlement-config.html
  58. 1 1
      src/app/shared/components/complaint-card/complaint-card.html
  59. 7 0
      src/app/shared/components/complaint-card/complaint-card.ts
  60. 1 1
      src/app/shared/components/consultation-order-panel/consultation-order-panel.component.scss
  61. 17 17
      src/app/shared/components/requirements-confirm-card/requirements-confirm-card.html
  62. 1 1
      src/app/shared/components/settlement-card/settlement-card.html
  63. 7 0
      src/app/shared/components/settlement-card/settlement-card.ts
  64. 138 0
      src/app/shared/components/upload-component/upload.component.html
  65. 344 0
      src/app/shared/components/upload-component/upload.component.scss
  66. 332 0
      src/app/shared/components/upload-component/upload.component.ts
  67. 125 0
      src/app/shared/components/upload-example/upload-example.component.html
  68. 380 0
      src/app/shared/components/upload-example/upload-example.component.scss
  69. 178 0
      src/app/shared/components/upload-example/upload-example.component.ts
  70. 1 1
      src/app/shared/components/upload-success-modal/upload-success-modal.component.html
  71. 1 0
      src/app/shared/filter/filter.ts
  72. 1 0
      src/app/shared/project-card/project-card.ts
  73. 1 0
      src/app/shared/status-label/status-label.ts
  74. 0 5
      src/assets/images/default-avatar.svg
  75. 0 15
      src/assets/images/portfolio-1.svg
  76. 0 16
      src/assets/images/portfolio-2.svg
  77. 0 16
      src/assets/images/portfolio-3.svg
  78. 0 16
      src/assets/images/portfolio-4.svg
  79. 70 0
      src/modules/project/components/project-bottom-card/project-bottom-card.component.html
  80. 264 0
      src/modules/project/components/project-bottom-card/project-bottom-card.component.scss
  81. 55 0
      src/modules/project/components/project-bottom-card/project-bottom-card.component.ts
  82. 280 0
      src/modules/project/components/project-files-modal/project-files-modal.component.html
  83. 853 0
      src/modules/project/components/project-files-modal/project-files-modal.component.scss
  84. 254 0
      src/modules/project/components/project-files-modal/project-files-modal.component.ts
  85. 209 0
      src/modules/project/components/project-members-modal/project-members-modal.component.html
  86. 650 0
      src/modules/project/components/project-members-modal/project-members-modal.component.scss
  87. 337 0
      src/modules/project/components/project-members-modal/project-members-modal.component.ts
  88. 3 3
      src/modules/project/pages/contact/contact.component.ts
  89. 56 46
      src/modules/project/pages/project-detail/project-detail.component.html
  90. 6 11
      src/modules/project/pages/project-detail/project-detail.component.scss
  91. 36 1
      src/modules/project/pages/project-detail/project-detail.component.ts
  92. 2 2
      src/modules/project/pages/project-detail/stages/stage-aftercare.component.ts
  93. 2 2
      src/modules/project/pages/project-detail/stages/stage-delivery.component.ts
  94. 154 1
      src/modules/project/pages/project-detail/stages/stage-order.component.html
  95. 374 0
      src/modules/project/pages/project-detail/stages/stage-order.component.scss
  96. 357 3
      src/modules/project/pages/project-detail/stages/stage-order.component.ts
  97. 2 2
      src/modules/project/pages/project-loader/project-loader.component.ts
  98. 1 1
      src/modules/project/services/upload.service.ts
  99. 3 3
      src/modules/project/services/wxwork-sdk.service.ts
  100. 17 15
      src/styles.scss

+ 19 - 9
angular.json

@@ -15,9 +15,11 @@
       "prefix": "app",
       "architect": {
         "build": {
-          "builder": "@angular/build:application",
+          "builder": "@angular-devkit/build-angular:browser",
           "options": {
-            "browser": "src/main.ts",
+            "outputPath": "dist/yss-project",
+            "index": "src/index.html",
+            "main": "src/main.ts",
             "polyfills": [
               "zone.js"
             ],
@@ -26,12 +28,13 @@
             "assets": [
               {
                 "glob": "**/*",
-                "input": "public"
+                "input": "public",
+                "output": "/"
               },
               {
                 "glob": "**/*.svg",
                 "input": "node_modules/ionicons/dist/ionicons/svg",
-                "output": "./svg"
+                "output": "/svg"
               }
             ],
             "styles": [
@@ -97,7 +100,13 @@
               "jsqr",
               "dhtmlx-gantt",
               "@ctrl/ngx-codemirror",
-              "node_modules/wangeditor/release/wangEditor.js"
+              "node_modules/wangeditor/release/wangEditor.js",
+              "camelcase",
+              "decamelize",
+              "p-retry",
+              "crypto-js",
+              "qiniu-js",
+              "semver"
             ]
           },
           "configurations": {
@@ -131,7 +140,7 @@
           "defaultConfiguration": "production"
         },
         "serve": {
-          "builder": "@angular/build:dev-server",
+          "builder": "@angular-devkit/build-angular:dev-server",
           "configurations": {
             "production": {
               "buildTarget": "yss-project:build:production"
@@ -143,10 +152,10 @@
           "defaultConfiguration": "development"
         },
         "extract-i18n": {
-          "builder": "@angular/build:extract-i18n"
+          "builder": "@angular-devkit/build-angular:extract-i18n"
         },
         "test": {
-          "builder": "@angular/build:karma",
+          "builder": "@angular-devkit/build-angular:karma",
           "options": {
             "polyfills": [
               "zone.js",
@@ -157,7 +166,8 @@
             "assets": [
               {
                 "glob": "**/*",
-                "input": "public"
+                "input": "public",
+                "output": "/"
               }
             ],
             "styles": [

+ 505 - 0
docs/dynamic-data-integration.md

@@ -0,0 +1,505 @@
+# 动态数据对接和企业微信认证集成文档
+
+## 概述
+
+本文档说明了如何在 yss-project 中集成企业微信认证 (WxworkAuthGuard) 和动态数据服务 (FmodeParse)。
+
+## 已完成的功能
+
+### 1. 企业微信认证集成
+
+#### 路由守卫配置
+已在 `src/app/app.routes.ts` 中为所有主要路由添加了 `WxworkAuthGuard`:
+
+```typescript
+import { WxworkAuthGuard } from 'fmode-ng/social';
+
+// 客服路由
+{
+  path: 'customer-service',
+  canActivate: [WxworkAuthGuard],
+  children: [...]
+}
+
+// 设计师路由
+{
+  path: 'designer',
+  canActivate: [WxworkAuthGuard],
+  children: [...]
+}
+
+// 组长路由
+{
+  path: 'team-leader',
+  canActivate: [WxworkAuthGuard],
+  children: [...]
+}
+
+// 财务路由
+{
+  path: 'finance',
+  canActivate: [WxworkAuthGuard],
+  children: [...]
+}
+
+// 人事路由
+{
+  path: 'hr',
+  canActivate: [WxworkAuthGuard],
+  children: [...]
+}
+
+// 管理员路由
+{
+  path: 'admin',
+  canActivate: [WxworkAuthGuard],
+  children: [...]
+}
+```
+
+#### 组件级认证
+已在主要页面组件中集成了 `WxworkAuth` 类:
+
+**管理员仪表板** (`src/app/pages/admin/dashboard/dashboard.ts`)
+```typescript
+import { WxworkAuth, FmodeQuery, FmodeObject, FmodeUser } from 'fmode-ng/core';
+
+private initAuth(): void {
+  this.wxAuth = new WxworkAuth({
+    cid: 'cDL6R1hgSi'  // 公司帐套ID
+  });
+}
+
+private async authenticateAndLoadData(): Promise<void> {
+  const { user } = await this.wxAuth.authenticateAndLogin();
+  if (user) {
+    console.log('✅ 管理员登录成功:', user.get('username'));
+    await this.loadDashboardData();
+  }
+}
+```
+
+**客服仪表板** (`src/app/pages/customer-service/dashboard/dashboard.ts`)
+```typescript
+// 同样的认证模式,加载咨询统计数据
+private async loadConsultationStats(): Promise<void> {
+  const consultationQuery = new FmodeQuery('Consultation');
+  consultationQuery.equalTo('status', 'new');
+  const newConsultations = await consultationQuery.count();
+  this.stats.newConsultations.set(newConsultations);
+}
+```
+
+**设计师仪表板** (`src/app/pages/designer/dashboard/dashboard.ts`)
+```typescript
+// 同样的认证模式,加载任务数据
+```
+
+### 2. FmodeParse 初始化
+
+已在 `src/app/app.ts` 中初始化了 FmodeParse:
+
+```typescript
+import { FmodeParse } from 'fmode-ng/core';
+
+private initParse(): void {
+  try {
+    const Parse = FmodeParse.with("nova");
+    console.log('✅ FmodeParse 初始化成功');
+  } catch (error) {
+    console.error('❌ FmodeParse 初始化失败:', error);
+  }
+}
+```
+
+### 3. 动态数据对接
+
+#### 管理员仪表板数据源
+
+**项目统计**
+```typescript
+private async loadProjectStats(): Promise<void> {
+  const projectQuery = new FmodeQuery('Project');
+
+  // 总项目数
+  const totalProjects = await projectQuery.count();
+  this.stats.totalProjects.set(totalProjects);
+
+  // 进行中项目数
+  projectQuery.equalTo('status', '进行中');
+  const activeProjects = await projectQuery.count();
+  this.stats.activeProjects.set(activeProjects);
+}
+```
+
+**用户统计**
+```typescript
+private async loadUserStats(): Promise<void> {
+  // 设计师统计
+  const designerQuery = new FmodeQuery('Profile');
+  designerQuery.equalTo('role', 'designer');
+  const designers = await designerQuery.count();
+  this.stats.totalDesigners.set(designers);
+}
+```
+
+**收入统计**
+```typescript
+private async loadRevenueStats(): Promise<void> {
+  const orderQuery = new FmodeQuery('Order');
+  orderQuery.equalTo('status', 'paid');
+  const orders = await orderQuery.find();
+
+  let totalRevenue = 0;
+  for (const order of orders) {
+    const amount = order.get('amount') || 0;
+    totalRevenue += amount;
+  }
+  this.stats.totalRevenue.set(totalRevenue);
+}
+```
+
+#### 客服仪表板数据源
+
+**咨询统计**
+```typescript
+private async loadConsultationStats(): Promise<void> {
+  // 新咨询数
+  const consultationQuery = new FmodeQuery('Consultation');
+  consultationQuery.equalTo('status', 'new');
+  consultationQuery.greaterThanOrEqualTo('createdAt', new Date(new Date().setHours(0,0,0,0)));
+  const newConsultations = await consultationQuery.count();
+  this.stats.newConsultations.set(newConsultations);
+}
+```
+
+### 4. NovaStorage 上传组件
+
+#### 上传组件 (`src/app/shared/components/upload-component/`)
+
+直接使用 `NovaStorage.withCid(cid).upload()` 方法,无需额外服务层。
+
+**功能特性:**
+- 拖拽上传支持
+- 多文件上传
+- 文件类型和大小验证
+- 上传进度显示
+- 预览功能
+- 错误处理
+- 自动文件路径生成
+
+**核心实现:**
+```typescript
+import { NovaStorage, NovaFile } from 'fmode-ng/core';
+
+// 初始化存储服务
+private async initStorage(): Promise<void> {
+  const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
+  this.storage = await NovaStorage.withCid(cid);
+}
+
+// 上传文件
+const uploaded: NovaFile = await this.storage.upload(file, {
+  prefixKey: 'project/pid/', // 可选的路径前缀
+  onProgress: (p) => console.log('进度:', p.total.percent),
+});
+```
+
+**使用示例:**
+```typescript
+import { UploadComponent, UploadResult } from './shared/components/upload-component/upload.component';
+
+@Component({
+  standalone: true,
+  imports: [UploadComponent]
+})
+export class MyComponent {
+  onUploadComplete(results: UploadResult[]) {
+    results.forEach(result => {
+      if (result.success) {
+        console.log('上传成功:', result.file?.url);
+        // 保存文件信息到数据库
+        this.saveFileInfo(result.file);
+      }
+    });
+  }
+
+  private async saveFileInfo(file: NovaFile) {
+    // 保存 key, url, name, type, size, metadata, md5 等信息
+    console.log('文件信息:', {
+      key: file.key,
+      url: file.url,
+      name: file.name,
+      size: file.size
+    });
+  }
+}
+```
+
+```html
+<app-upload-component
+  [accept]="image/*"
+  [multiple]="true"
+  [maxSize]="10"
+  [prefixKey]="'demo/images/'"
+  [showPreview]="true"
+  (uploadComplete)="onUploadComplete($event)">
+</app-upload-component>
+```
+
+**示例组件:** `src/app/shared/components/upload-example/` 提供了完整的使用示例和配置选项演示。
+
+### 5. Stage-order 项目文件管理
+
+#### ProjectFile 表集成
+
+在订单分配阶段 (`src/modules/project/pages/project-detail/stages/stage-order.component`) 中集成了项目文件管理功能:
+
+**核心功能:**
+- ✅ 使用 `NovaStorage.withCid(cid).upload()` 上传文件
+- ✅ 文件信息存储到 `ProjectFile` 表
+- ✅ 支持企业微信拖拽文件上传
+- ✅ 文件路径:`projects/{projectId}/` 前缀
+- ✅ 支持多文件上传和进度显示
+
+**实现代码:**
+```typescript
+// 初始化 NovaStorage
+private async initStorage(): Promise<void> {
+  const cid = localStorage.getItem('company') || this.cid || 'cDL6R1hgSi';
+  this.storage = await NovaStorage.withCid(cid);
+}
+
+// 上传文件到项目目录
+const uploaded: NovaFile = await this.storage.upload(file, {
+  prefixKey: `projects/${this.projectId}/`,
+  onProgress: (p) => { /* 进度回调 */ }
+});
+
+// 保存到 ProjectFile 表
+const projectFile = new Parse.Object('ProjectFile');
+projectFile.set('project', this.project.toPointer());
+projectFile.set('name', file.name);
+projectFile.set('url', uploaded.url);
+projectFile.set('key', uploaded.key);
+projectFile.set('uploadedBy', this.currentUser.toPointer());
+projectFile.set('source', source); // 标记来源
+await projectFile.save();
+```
+
+**企业微信拖拽支持:**
+```typescript
+// 检测企业微信环境
+if (typeof window !== 'undefined' && (window as any).wx) {
+  this.wxFileDropSupported = true;
+  this.initWxWorkFileDrop();
+}
+
+// 监听拖拽事件
+document.addEventListener('drop', (e) => {
+  if (this.isWxWorkFileDrop(e)) {
+    this.handleWxWorkFileDrop(e);
+  }
+});
+```
+
+## 数据表结构
+
+### 新增数据表
+
+**ProjectFile** - 项目文件表
+- project: 项目关联 (Pointer to Project)
+- name: 文件名称
+- originalName: 原始文件名
+- url: 文件访问URL
+- key: 存储键值
+- type: 文件类型
+- size: 文件大小
+- uploadedBy: 上传者 (Pointer to Profile)
+- uploadedAt: 上传时间
+- source: 来源 (企业微信拖拽/手动选择/其他)
+- md5: 文件MD5值
+- metadata: 文件元数据
+
+### 主要数据表
+
+1. **Project** - 项目表
+   - name: 项目名称
+   - status: 项目状态 (进行中, 已完成, 异常)
+   - owner: 负责人 (Pointer to Profile)
+   - startDate: 开始日期
+   - endDate: 结束日期
+
+2. **Profile** - 用户档案表
+   - name: 姓名
+   - role: 角色 (designer, customer, admin)
+   - level: 级别 (junior, mid, senior)
+   - completedProjects: 完成项目数
+   - activeProjects: 进行中项目数
+
+3. **Consultation** - 咨询表
+   - status: 状态 (new, pending_assignment, processing)
+   - customer: 客户 (Pointer to Profile)
+   - createdAt: 创建时间
+
+4. **Order** - 订单表
+   - status: 订单状态
+   - amount: 金额
+   - customer: 客户 (Pointer to Profile)
+   - invoiceNo: 发票号
+
+5. **AfterSales** - 售后表
+   - status: 状态
+   - project: 项目 (Pointer to Project)
+   - createdAt: 创建时间
+
+6. **Images** - 图片表
+   - originalName: 原始文件名
+   - url: 图片URL
+   - thumbnailUrl: 缩略图URL
+   - size: 文件大小
+
+## 使用指南
+
+### 1. 新页面添加认证
+
+对于新页面,按以下步骤添加认证:
+
+```typescript
+import { WxworkAuth, FmodeQuery, FmodeObject, FmodeUser } from 'fmode-ng/core';
+
+@Component({...})
+export class NewPageComponent {
+  private wxAuth: WxworkAuth;
+  private currentUser: FmodeUser | null = null;
+
+  constructor() {
+    this.initAuth();
+  }
+
+  private initAuth(): void {
+    this.wxAuth = new WxworkAuth({
+      cid: 'cDL6R1hgSi'
+    });
+  }
+
+  async ngOnInit(): Promise<void> {
+    await this.authenticateAndLoadData();
+  }
+
+  private async authenticateAndLoadData(): Promise<void> {
+    const { user } = await this.wxAuth.authenticateAndLogin();
+    if (user) {
+      this.currentUser = user;
+      await this.loadData();
+    }
+  }
+}
+```
+
+### 2. 数据查询示例
+
+```typescript
+// 基本查询
+const query = new FmodeQuery('Project');
+const projects = await query.find();
+
+// 条件查询
+query.equalTo('status', '进行中');
+query.greaterThan('createdAt', new Date('2025-01-01'));
+
+// 排序
+query.descending('createdAt');
+
+// 分页
+query.limit(20);
+query.skip(0);
+
+// 计数
+const count = await query.count();
+
+// 关联查询
+const userQuery = new FmodeQuery('Profile');
+userQuery.include('projects');
+```
+
+### 3. 数据保存示例
+
+```typescript
+// 创建新对象
+const project = new FmodeObject('Project');
+project.set('name', '新项目');
+project.set('status', '进行中');
+const savedProject = await project.save();
+
+// 更新对象
+savedProject.set('status', '已完成');
+await savedProject.save();
+
+// 删除对象
+await savedProject.destroy();
+```
+
+### 4. NovaStorage 上传组件使用
+
+```typescript
+import { UploadComponent, UploadResult } from '../shared/components/upload-component/upload.component';
+
+@Component({
+  standalone: true,
+  imports: [UploadComponent]
+})
+export class MyComponent {
+  async onUploadComplete(results: UploadResult[]) {
+    // 处理上传结果
+    for (const result of results) {
+      if (result.success && result.file) {
+        console.log('上传成功:', result.file.url);
+        // 保存文件信息到数据库
+        await this.saveFileInfo(result.file);
+      }
+    }
+  }
+
+  async onUploadError(error: string) {
+    console.error('上传失败:', error);
+  }
+
+  private async saveFileInfo(file: NovaFile) {
+    // 保存文件信息到 Attachment 表或其他相关表
+    console.log('保存文件:', file.key, file.url);
+  }
+}
+```
+
+## 错误处理
+
+所有数据操作都包含错误处理,在API调用失败时会自动降级到模拟数据:
+
+```typescript
+try {
+  await this.loadDashboardData();
+} catch (error) {
+  console.error('❌ 数据加载失败:', error);
+  // 降级到模拟数据
+  this.loadMockData();
+}
+```
+
+## 注意事项
+
+1. **认证配置**: 确保 `cid: 'cDL6R1hgSi'` 在 `WxworkSDK.companyMap` 中有对应配置
+2. **数据表名**: 确保后端数据表名与代码中使用的表名一致
+3. **权限设置**: 确保企业微信用户有相应的数据访问权限
+4. **NovaStorage**: 使用 `NovaStorage.withCid(cid)` 自动初始化,无需手动配置 Provider
+5. **文件路径**: 自动生成格式 `storage/company/<cid>/<prefixKey>/<YYYYMMDD>/<HHmmss-rand>-<name>`
+6. **文件大小**: 默认上传文件大小限制为 10MB,可在组件中自定义
+7. **prefixKey**: 通过 `prefixKey` 参数指定文件存储路径前缀,如 `'project/pid/'`
+
+## 后续优化建议
+
+1. **缓存机制**: 为频繁查询的数据添加缓存
+2. **实时更新**: 使用 WebSocket 实现数据实时更新
+3. **离线支持**: 添加离线数据存储和同步功能
+4. **批量操作**: 优化批量数据操作的性能
+5. **权限细化**: 根据用户角色实现更细粒度的数据访问控制

+ 372 - 0
docs/phase3-implementation-progress.md

@@ -0,0 +1,372 @@
+# 阶段3:协作验证重构实施进度
+
+## 📊 总体进度
+
+| 子阶段 | 任务 | 状态 | 完成度 | 备注 |
+|--------|------|------|--------|------|
+| 3.1 | 五维验证模块 | 🚧 进行中 | 40% | 数据模型和基础组件已完成 |
+| 3.2 | 素材关联系统 | ⏸️ 待开始 | 0% | |
+| 3.3 | 标注工具集成 | ⏸️ 待开始 | 0% | |
+| 3.4 | 区域细分管理 | ⏸️ 待开始 | 0% | |
+| 3.5 | 分工和人员系统 | ⏸️ 待开始 | 0% | |
+
+---
+
+## ✅ 已完成的工作
+
+### 1. 数据模型接口定义 (100%)
+
+#### 1.1 协作验证接口
+**文件**: `src/app/models/collaboration-verification.interface.ts`
+
+**完成内容**:
+- ✅ `DimensionVerification` - 维度验证主接口
+- ✅ `LinkedMaterial` - 关联素材接口
+- ✅ `VerificationItem` - 验证项接口
+- ✅ `Annotation` - 标注接口
+- ✅ `Comment` - 评论接口
+- ✅ `DimensionConfig` - 维度配置接口
+- ✅ `CollaborationUser` - 协作用户接口
+
+**核心特性**:
+```typescript
+export interface DimensionVerification {
+  id: string;
+  dimension: 'color' | 'form' | 'texture' | 'pattern' | 'lighting';
+  status: 'pending' | 'in-review' | 'confirmed' | 'rejected';
+  linkedMaterials: LinkedMaterial[];
+  verificationItems: VerificationItem[];
+  annotations: Annotation[];
+  assignedTo?: string;
+  comments: Comment[];
+  areaId?: string;
+}
+```
+
+#### 1.2 区域管理接口
+**文件**: `src/app/models/area-management.interface.ts`
+
+**完成内容**:
+- ✅ `Area` - 区域主接口
+- ✅ `AreaPreset` - 区域预设接口
+- ✅ `AreaStats` - 区域统计接口
+- ✅ `AREA_PRESETS` - 8个预定义区域预设(客厅、婴儿房、主卧、厨房、卫生间、书房、餐厅、自定义)
+
+**区域预设示例**:
+- 🛋️ 客厅 - 现代简约,明亮开阔
+- 👶 婴儿房 - 色彩丰富,童趣温馨
+- 🛏️ 主卧 - 宁静优雅,私密舒适
+- 🍳 厨房 - 实用高效,易于清洁
+- 🚿 卫生间 - 清新明亮,干湿分离
+- 📚 书房 - 安静专注,书香氛围
+- 🍽️ 餐厅 - 温馨舒适,聚餐氛围
+- ✏️ 自定义 - 完全自定义
+
+#### 1.3 审批流程接口
+**文件**: `src/app/models/approval-workflow.interface.ts`
+
+**完成内容**:
+- ✅ `ApprovalStep` - 审批步骤接口
+- ✅ `ApprovalWorkflow` - 审批工作流接口
+- ✅ `ApprovalHistory` - 审批历史记录接口
+- ✅ `DEFAULT_APPROVAL_STEPS` - 4步默认审批流程(五维验证→客服审核→组长审批→客户确认)
+
+### 2. 维度验证组件 (80%)
+
+#### 2.1 组件文件
+**目录**: `src/app/shared/components/dimension-verification/`
+
+**完成的文件**:
+- ✅ `dimension-verification.component.ts` - 组件逻辑
+- ✅ `dimension-verification.component.html` - 组件模板
+- ✅ `dimension-verification.component.scss` - 组件样式
+
+#### 2.2 核心功能实现
+
+**已实现功能**:
+1. ✅ 维度显示(色彩、形体、质感、纹理、灯光)
+2. ✅ 状态管理(待验证、验证中、已确认、已拒绝)
+3. ✅ 素材关联(下拉选择、相关度计算)
+4. ✅ 验证项管理(添加、编辑、删除、状态更新)
+5. ✅ 优先级设置(高、中、低)
+6. ✅ 响应式布局
+
+**组件特性**:
+```typescript
+@Input() dimension: 'color' | 'form' | 'texture' | 'pattern' | 'lighting';
+@Input() materials: MaterialFile[];
+@Input() verificationData?: DimensionVerification;
+
+@Output() materialLinked: EventEmitter<LinkedMaterial>;
+@Output() annotationAdded: EventEmitter<Annotation>;
+@Output() statusChanged: EventEmitter<{dimension: string; status: string}>;
+@Output() verificationItemAdded: EventEmitter<VerificationItem>;
+```
+
+**相关度计算算法**:
+- 色彩维度:有增强色彩分析 90%,否则 50%
+- 形体维度:有形体分析 85%,否则 40%
+- 质感维度:有质感分析 88%,否则 45%
+- 纹理维度:有纹理分析 82%,否则 35%
+- 灯光维度:有灯光分析 92%,否则 50%
+
+#### 2.3 UI设计要点
+
+**维度头部**:
+- 维度图标(emoji)+ 维度名称 + 描述
+- 状态徽章(待验证/验证中/已确认/已拒绝)
+- 操作按钮(开始验证/确认/拒绝)
+
+**素材关联区域**:
+- 已关联素材网格展示(缩略图 + 名称 + 相关度)
+- 下拉选择器快速关联
+- 取消关联按钮
+
+**验证项列表**:
+- 验证项卡片(标题 + 描述 + 优先级 + 状态)
+- 添加验证项表单
+- 验证项状态操作(确认/拒绝/删除)
+
+---
+
+## 🚧 进行中的工作
+
+### 子阶段 3.1: 五维验证模块(40% 完成)
+
+**已完成**:
+- ✅ 数据模型设计
+- ✅ 基础组件创建
+- ✅ 素材关联逻辑
+- ✅ 验证项管理
+- ✅ UI布局和样式
+
+**待完成**:
+- ⏳ 集成到协作验证标签页
+- ⏳ 维度切换逻辑
+- ⏳ 数据持久化
+- ⏳ 与父组件通信
+
+---
+
+## ⏸️ 待开始的工作
+
+### 子阶段 3.2: 素材关联系统 (0%)
+**预估时间**: 2-3天
+
+**计划任务**:
+- [ ] 安装和配置 Angular CDK Drag&Drop
+- [ ] 实现拖拽关联功能
+- [ ] 实现素材高亮显示
+- [ ] 实现关联历史记录
+- [ ] 优化相关度计算算法
+
+### 子阶段 3.3: 标注工具集成 (0%)
+**预估时间**: 3-4天
+
+**计划任务**:
+- [ ] 创建 annotation-tool 组件
+- [ ] 实现 SVG 标注层
+- [ ] 支持文字标注
+- [ ] 支持箭头标注
+- [ ] 支持高亮标注
+- [ ] 支持圆圈标注
+- [ ] 支持矩形标注
+- [ ] 实现标注保存和加载
+- [ ] 实现标注列表管理
+- [ ] 实现标注导出
+
+### 子阶段 3.4: 区域细分管理 (0%)
+**预估时间**: 2-3天
+
+**计划任务**:
+- [ ] 创建 area-management 组件
+- [ ] 实现区域列表展示
+- [ ] 实现区域添加对话框
+- [ ] 实现区域编辑功能
+- [ ] 实现区域删除功能
+- [ ] 应用区域预设
+- [ ] 实现区域切换
+- [ ] 实现区域对比视图
+- [ ] 区域专属素材管理
+
+### 子阶段 3.5: 分工和人员系统 (0%)
+**预估时间**: 2-3天
+
+**计划任务**:
+- [ ] 定义用户角色和权限
+- [ ] 实现分工管理面板
+- [ ] 实现协作状态跟踪
+- [ ] 实现审批流程
+- [ ] 实现审批通过/驳回逻辑
+- [ ] 实现通知机制
+- [ ] 实现权限控制
+
+---
+
+## 🔄 下一步行动计划
+
+### 第一步:完成五维验证模块集成 (1天)
+
+**任务清单**:
+1. 在 `requirements-confirm-card.ts` 中导入 `DimensionVerificationComponent`
+2. 修改协作验证标签页布局,添加维度选择器
+3. 实现维度切换逻辑
+4. 为每个维度创建 `DimensionVerification` 数据结构
+5. 实现数据双向绑定
+6. 添加保存功能
+
+### 第二步:实现拖拽素材关联 (2天)
+
+**任务清单**:
+1. 安装 `@angular/cdk/drag-drop`
+2. 修改素材关联区域,支持拖拽
+3. 实现拖拽事件处理
+4. 添加拖拽视觉反馈
+5. 测试拖拽功能
+
+### 第三步:开发标注工具 (3天)
+
+**任务清单**:
+1. 创建基础组件结构
+2. 实现 SVG 画布
+3. 实现绘图工具
+4. 实现标注编辑
+5. 集成到维度验证组件
+6. 测试标注功能
+
+---
+
+## 📋 技术实施细节
+
+### 架构设计
+
+```
+requirements-confirm-card (父组件)
+├── collaboration-section (协作验证标签页)
+│   ├── dimension-selector (维度选择器)
+│   │   ├── 色彩验证
+│   │   ├── 形体验证
+│   │   ├── 质感验证
+│   │   ├── 纹理验证
+│   │   └── 灯光验证
+│   │
+│   └── dimension-verification (当前维度验证组件)
+│       ├── material-linking (素材关联)
+│       ├── verification-items (验证项列表)
+│       └── annotation-tool (标注工具)
+│
+├── area-management (区域管理)
+│   ├── area-list (区域列表)
+│   ├── area-dialog (区域添加/编辑)
+│   └── area-comparison (区域对比)
+│
+└── assignment-panel (分工管理)
+    ├── dimension-assignments (维度分工)
+    ├── area-assignments (区域分工)
+    └── approval-workflow (审批流程)
+```
+
+### 数据流
+
+```
+用户操作 (UI)
+    ↓
+维度验证组件 (DimensionVerificationComponent)
+    ↓
+事件发射 (materialLinked, statusChanged, etc.)
+    ↓
+父组件接收 (RequirementsConfirmCardComponent)
+    ↓
+数据持久化 (保存到服务器/本地)
+    ↓
+更新状态显示
+```
+
+---
+
+## 🎯 验收标准
+
+### 功能验收
+
+**五维验证模块**:
+- [ ] 可以独立验证5个维度
+- [ ] 素材能正确关联到各维度
+- [ ] 验证项能正常添加、编辑、删除
+- [ ] 状态能正确更新和显示
+- [ ] 数据能正确保存和加载
+
+**素材关联**:
+- [ ] 下拉选择关联功能正常
+- [ ] 拖拽关联流畅无卡顿
+- [ ] 相关度计算准确
+- [ ] 关联状态显示清晰
+
+**标注工具**:
+- [ ] 支持5种标注类型
+- [ ] 绘制实时反馈
+- [ ] 标注能保存和加载
+- [ ] 标注列表管理完整
+
+**区域管理**:
+- [ ] 区域能正常添加、编辑、删除
+- [ ] 区域预设能正确应用
+- [ ] 区域切换流畅
+- [ ] 区域对比视图正常
+
+**分工系统**:
+- [ ] 权限控制正确
+- [ ] 分工分配正常
+- [ ] 审批流程完整
+- [ ] 通知机制有效
+
+### 性能验收
+
+- [ ] 维度切换响应时间 < 200ms
+- [ ] 拖拽操作流畅无卡顿
+- [ ] 标注绘制实时反馈
+- [ ] 区域切换 < 200ms
+- [ ] 审批操作 < 300ms
+
+### UI/UX验收
+
+- [ ] UI风格与整体系统一致
+- [ ] 操作步骤明确,不超过3步
+- [ ] 提供操作提示
+- [ ] 错误提示友好明确
+- [ ] 响应式设计适配各种分辨率
+
+---
+
+## 📝 开发日志
+
+### 2025-10-15
+
+**完成工作**:
+1. ✅ 创建了3个核心接口文件
+2. ✅ 实现了维度验证组件的基础功能
+3. ✅ 完成了组件样式设计
+4. ✅ 验证了构建无错误
+
+**遇到的问题**:
+- 无
+
+**下一步计划**:
+1. 集成维度验证组件到协作验证标签页
+2. 实现维度选择器和切换逻辑
+3. 开始实现拖拽素材关联功能
+
+---
+
+## 📚 参考资源
+
+- [requirements-optimization.md](./requirements-optimization.md) - 完整优化方案文档
+- [Angular CDK Drag and Drop](https://material.angular.io/cdk/drag-drop/overview)
+- [Fabric.js Documentation](http://fabricjs.com/docs/) - 标注工具可选库
+
+---
+
+**文档版本**: v1.0  
+**创建日期**: 2025-10-15  
+**最后更新**: 2025-10-15  
+**当前阶段**: 子阶段3.1(进行中)
+

+ 27 - 0
docs/task/20251019-project-detail.md

@@ -0,0 +1,27 @@
+
+# 任务:完善开发项目详情页面
+
+# 项目开发需求
+## 项目卡片固定底部
+请您分析src/modules/project/pages/project-detail/project-detail.component.ts页面,将项目标题放到固定底部,显示项目卡片,左侧是标题,右侧是按钮:文件\成员.分别再开发两个组件:项目文件\项目成员.
+
+## 项目文件组件
+其中项目文件点击后弹出来ProjectFile中所有文件,每个文件都有卡片(图片的需要可预览),其他图片格式后缀要明显标记,同时文件还需要有说明,方便项目组成员标记。尽可能让文件卡片和点击后预览丰富美观。
+- 请参考storage.md Attachment结构说明
+
+## 项目成员组件
+项目成员页面,需要展示GoupChat.member_list中所有员工帐号,需要匹配ProjectTeam中对应信息,特别是如果ProjectTeam有组员,但是member_list没有的,需要显示加入群聊按钮调用:
+ww.updateEnterpriseChat({
+                chatId: 'CHATID',
+                userIdsToAdd: [
+                    'zhangsan',
+                    'lisi'
+                ]
+    })
+根据ProjectTeam.get("profile")?.get("userid")的值,发起添加员工进群聊。如果非企业微信环境,可不显示该按钮。
+
+# 任务执行
+- 请您根据上面需求分析相关项目文档
+- 请您合理设计更加丰富美观的项目文件组件、项目成员组件,但数据范式要严格按照已有的schemas.md规范描述
+- 根据您设计的具体内容进行开发,完成项目详情,以及新增两个组件的开发,并在项目详情点击按钮是能弹出对应组件,确保功能正常
+- 通过ng b 验证,并修复所有问题

Разница между файлами не показана из-за своего большого размера
+ 487 - 496
package-lock.json


+ 24 - 19
package.json

@@ -22,15 +22,15 @@
   "private": true,
   "dependencies": {
     "@amap/amap-jsapi-loader": "^1.0.1",
-    "@angular/animations": "20.1.0",
-    "@angular/cdk": "20.2.2",
-    "@angular/common": "^20.1.0",
-    "@angular/compiler": "^20.1.0",
-    "@angular/core": "^20.1.0",
-    "@angular/forms": "^20.1.0",
-    "@angular/material": "^20.2.2",
-    "@angular/platform-browser": "^20.1.0",
-    "@angular/router": "^20.1.0",
+    "@angular/animations": "^17.3.0",
+    "@angular/cdk": "^17.3.0",
+    "@angular/common": "^17.3.0",
+    "@angular/compiler": "^17.3.0",
+    "@angular/core": "^17.3.0",
+    "@angular/forms": "^17.3.0",
+    "@angular/material": "^17.3.0",
+    "@angular/platform-browser": "^17.3.0",
+    "@angular/router": "^17.3.0",
     "@awesome-cordova-plugins/core": "^8.1.0",
     "@awesome-cordova-plugins/diagnostic": "^8.1.0",
     "@awesome-cordova-plugins/media-capture": "^8.1.0",
@@ -68,7 +68,7 @@
     "echarts": "^6.0.0",
     "esdk-obs-browserjs": "^3.25.6",
     "eventemitter3": "^5.0.1",
-    "fmode-ng": "^0.0.219",
+    "fmode-ng": "^0.0.222",
     "highlight.js": "^11.11.1",
     "jquery": "^3.7.1",
     "markdown-it": "^14.1.0",
@@ -96,25 +96,30 @@
     "rxjs": "~7.8.0",
     "spark-md5": "^3.0.2",
     "swiper": "^11.2.10",
-    "tslib": "^2.3.0",
+    "tslib": "^2.8.1",
     "uuid": "^11.1.0",
-    "zone.js": "~0.15.0"
+    "zone.js": "~0.14.0"
   },
   "devDependencies": {
-    "@angular/build": "^20.1.5",
-    "@angular/cli": "^20.1.5",
-    "@angular/compiler-cli": "^20.1.0",
+    "@angular-devkit/build-angular": "^17.3.0",
+    "@angular-eslint/builder": "^17.5.3",
+    "@angular-eslint/eslint-plugin": "^17.5.3",
+    "@angular-eslint/eslint-plugin-template": "^17.5.3",
+    "@angular-eslint/schematics": "^17.5.3",
+    "@angular-eslint/template-parser": "^17.5.3",
+    "@angular/cli": "^17.3.0",
+    "@angular/compiler-cli": "^17.3.0",
     "@types/jasmine": "~5.1.0",
     "@types/qrcode": "^1.5.5",
-    "angular-eslint": "20.2.0",
-    "eslint": "^9.33.0",
+    "@typescript-eslint/eslint-plugin": "^7.18.0",
+    "@typescript-eslint/parser": "^7.18.0",
+    "eslint": "^8.57.0",
     "jasmine-core": "~5.8.0",
     "karma": "~6.4.0",
     "karma-chrome-launcher": "~3.2.0",
     "karma-coverage": "~2.2.0",
     "karma-jasmine": "~5.1.0",
     "karma-jasmine-html-reporter": "~2.1.0",
-    "typescript": "~5.8.2",
-    "typescript-eslint": "8.40.0"
+    "typescript": "~5.4.2"
   }
 }

+ 0 - 0
src/assets/images/case-1-cover.svg → public/assets/images/case-1-cover.svg


+ 0 - 0
src/assets/images/case-2-cover.svg → public/assets/images/case-2-cover.svg


+ 0 - 0
src/assets/images/case-3-cover.svg → public/assets/images/case-3-cover.svg


+ 0 - 0
src/assets/images/case-4-cover.svg → public/assets/images/case-4-cover.svg


+ 4 - 12
public/assets/images/default-avatar.svg

@@ -1,13 +1,5 @@
-<svg width="160" height="160" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="默认头像">
-  <defs>
-    <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0%" stop-color="#165DFF"/>
-      <stop offset="100%" stop-color="#7c3aed"/>
-    </linearGradient>
-  </defs>
-  <rect width="160" height="160" rx="24" fill="url(#g)"/>
-  <g transform="translate(80,78)" fill="#fff">
-    <circle cx="0" cy="-28" r="22" opacity="0.95"/>
-    <path d="M-46,40 a46,46 0 1,1 92,0 v8 h-92 z" opacity="0.95"/>
-  </g>
+<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <circle cx="32" cy="32" r="32" fill="#E5E7EB"/>
+  <circle cx="32" cy="24" r="10" fill="#9CA3AF"/>
+  <path d="M12 52C12 44.268 18.268 38 26 38H38C45.732 38 52 44.268 52 52V56C52 58.209 50.209 60 48 60H16C13.791 60 12 58.209 12 56V52Z" fill="#9CA3AF"/>
 </svg>

+ 0 - 10
public/assets/images/hr-icon.svg

@@ -1,10 +0,0 @@
-<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="HR Icon">
-  <defs>
-    <linearGradient id="ig" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0%" stop-color="#165DFF"/>
-      <stop offset="100%" stop-color="#7c3aed"/>
-    </linearGradient>
-  </defs>
-  <rect width="64" height="64" rx="14" fill="url(#ig)"/>
-  <text x="32" y="40" font-size="28" font-family="Segoe UI, Roboto, Arial" text-anchor="middle" fill="#fff" font-weight="700">HR</text>
-</svg>

+ 0 - 11
public/assets/images/hr-logo.svg

@@ -1,11 +0,0 @@
-<svg width="200" height="60" viewBox="0 0 200 60" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="HR Logo">
-  <defs>
-    <linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0%" stop-color="#165DFF"/>
-      <stop offset="100%" stop-color="#7c3aed"/>
-    </linearGradient>
-  </defs>
-  <rect width="200" height="60" rx="12" fill="#fff"/>
-  <rect x="6" y="6" width="188" height="48" rx="10" fill="url(#lg)"/>
-  <text x="100" y="38" font-size="28" font-family="Segoe UI, Roboto, Arial" text-anchor="middle" fill="#fff" font-weight="700">HR Center</text>
-</svg>

+ 11 - 10
public/assets/images/portfolio-1.svg

@@ -1,14 +1,15 @@
-<svg width="320" height="200" viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="作品集占位1">
+<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
   <defs>
-    <linearGradient id="p1" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0%" stop-color="#e6f7ff"/>
-      <stop offset="100%" stop-color="#f3e8ff"/>
+    <linearGradient id="bg1" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
     </linearGradient>
   </defs>
-  <rect width="320" height="200" rx="12" fill="url(#p1)"/>
-  <g fill="#1a3a6e" opacity="0.8">
-    <rect x="28" y="48" width="120" height="16" rx="8"/>
-    <rect x="28" y="72" width="180" height="12" rx="6"/>
-    <rect x="28" y="92" width="140" height="12" rx="6"/>
-  </g>
+  <rect width="400" height="300" fill="url(#bg1)"/>
+  <rect x="50" y="50" width="300" height="200" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
+  <circle cx="120" cy="120" r="30" fill="rgba(255,255,255,0.2)"/>
+  <rect x="170" y="100" width="120" height="40" fill="rgba(255,255,255,0.2)" rx="4"/>
+  <rect x="170" y="160" width="80" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <rect x="170" y="190" width="100" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">现代简约风格</text>
 </svg>

+ 12 - 10
public/assets/images/portfolio-2.svg

@@ -1,14 +1,16 @@
-<svg width="320" height="200" viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="作品集占位2">
+<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
   <defs>
-    <linearGradient id="p2" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0%" stop-color="#fff7e6"/>
-      <stop offset="100%" stop-color="#e6fffb"/>
+    <linearGradient id="bg2" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#f093fb;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#f5576c;stop-opacity:1" />
     </linearGradient>
   </defs>
-  <rect width="320" height="200" rx="12" fill="url(#p2)"/>
-  <g fill="#1a3a6e" opacity="0.8">
-    <rect x="28" y="48" width="120" height="16" rx="8"/>
-    <rect x="28" y="72" width="180" height="12" rx="6"/>
-    <rect x="28" y="92" width="140" height="12" rx="6"/>
-  </g>
+  <rect width="400" height="300" fill="url(#bg2)"/>
+  <rect x="60" y="60" width="280" height="180" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
+  <rect x="80" y="80" width="60" height="60" fill="rgba(255,255,255,0.2)" rx="4"/>
+  <rect x="160" y="80" width="60" height="60" fill="rgba(255,255,255,0.2)" rx="4"/>
+  <rect x="240" y="80" width="60" height="60" fill="rgba(255,255,255,0.2)" rx="4"/>
+  <rect x="80" y="160" width="220" height="30" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <rect x="80" y="200" width="160" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">北欧风格</text>
 </svg>

+ 12 - 10
public/assets/images/portfolio-3.svg

@@ -1,14 +1,16 @@
-<svg width="320" height="200" viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="作品集占位3">
+<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
   <defs>
-    <linearGradient id="p3" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0%" stop-color="#f0f5ff"/>
-      <stop offset="100%" stop-color="#f9f0ff"/>
+    <linearGradient id="bg3" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#4facfe;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#00f2fe;stop-opacity:1" />
     </linearGradient>
   </defs>
-  <rect width="320" height="200" rx="12" fill="url(#p3)"/>
-  <g fill="#1a3a6e" opacity="0.8">
-    <rect x="28" y="48" width="120" height="16" rx="8"/>
-    <rect x="28" y="72" width="180" height="12" rx="6"/>
-    <rect x="28" y="92" width="140" height="12" rx="6"/>
-  </g>
+  <rect width="400" height="300" fill="url(#bg3)"/>
+  <rect x="40" y="40" width="320" height="220" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
+  <rect x="60" y="60" width="120" height="80" fill="rgba(255,255,255,0.2)" rx="4"/>
+  <rect x="200" y="60" width="140" height="40" fill="rgba(255,255,255,0.2)" rx="4"/>
+  <rect x="200" y="120" width="140" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <rect x="60" y="160" width="280" height="40" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <rect x="60" y="220" width="200" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">新中式风格</text>
 </svg>

+ 12 - 10
public/assets/images/portfolio-4.svg

@@ -1,14 +1,16 @@
-<svg width="320" height="200" viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="作品集占位4">
+<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
   <defs>
-    <linearGradient id="p4" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0%" stop-color="#e6fffb"/>
-      <stop offset="100%" stop-color="#fff0f6"/>
+    <linearGradient id="bg4" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#43e97b;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#38f9d7;stop-opacity:1" />
     </linearGradient>
   </defs>
-  <rect width="320" height="200" rx="12" fill="url(#p4)"/>
-  <g fill="#1a3a6e" opacity="0.8">
-    <rect x="28" y="48" width="120" height="16" rx="8"/>
-    <rect x="28" y="72" width="180" height="12" rx="6"/>
-    <rect x="28" y="92" width="140" height="12" rx="6"/>
-  </g>
+  <rect width="400" height="300" fill="url(#bg4)"/>
+  <rect x="50" y="50" width="300" height="200" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
+  <circle cx="150" cy="130" r="25" fill="rgba(255,255,255,0.2)"/>
+  <circle cx="200" cy="130" r="25" fill="rgba(255,255,255,0.2)"/>
+  <circle cx="250" cy="130" r="25" fill="rgba(255,255,255,0.2)"/>
+  <rect x="100" y="180" width="200" height="30" fill="rgba(255,255,255,0.2)" rx="4"/>
+  <rect x="120" y="220" width="160" height="15" fill="rgba(255,255,255,0.15)" rx="2"/>
+  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">工业风格</text>
 </svg>

+ 0 - 0
src/assets/logo.jpg → public/assets/logo.jpg


+ 0 - 0
src/assets/presets/living_room_modern_1.jpg → public/assets/presets/living_room_modern_1.jpg


+ 0 - 0
src/assets/presets/living_room_modern_2.jpg → public/assets/presets/living_room_modern_2.jpg


+ 0 - 0
src/assets/presets/living_room_modern_3.jpg → public/assets/presets/living_room_modern_3.jpg


+ 3 - 0
rules/schemas.md

@@ -144,9 +144,11 @@ TABLE(Product, "Product\n产品即交付物表") {
     FIELD(isDeleted, Boolean)
 }
 
+' NovaFile.id为Attachment.objectId
 TABLE(ProjectFile, "ProjectFile\n项目文件表") {
     FIELD(objectId, String)
     FIELD(project, Pointer→Project)
+    FIELD(attach, Attachment) 
     FIELD(uploadedBy, Pointer→Profile)
     FIELD(fileType, String)
     FIELD(fileUrl, String)
@@ -933,6 +935,7 @@ Product.quotation 产品报价字段
 |--------|------|------|------|--------|
 | objectId | String | 是 | 主键ID | "file001" |
 | project | Pointer | 是 | 所属项目 | → Project |
+| attach | Pointer | 是 | 所属项目 | → Attachment | NovaFile.id为Attachment.objectId
 | uploadedBy | Pointer | 是 | 上传人 | → Profile |
 | fileType | String | 是 | 文件类型 | "cad" / "reference" / "document" |
 | fileUrl | String | 是 | 文件URL | "https://..." |

+ 93 - 0
rules/storage.md

@@ -0,0 +1,93 @@
+# NovaStorage 使用规则(cc 编程工具)
+
+面向 cc 的编程规则,帮助你在任意页面仅通过 `cid` 即可加载与使用存储工具,无需关注 Provider 细节。
+
+## 快速约定
+- 仅需 `cid`:使用 `NovaStorage.withCid(cid)` 自动选择企业账套 Provider。
+- 统一返回:上传结果为 `NovaFile`,含 `key`, `url`, `name`, `type`, `size`, `metadata`, `md5` 等。
+- 生成 Key:默认格式 `storage/company/<cid>/<prefixKey?>/<YYYYMMDD>/<HHmmss-rand>-<name>`。
+- 可选前缀:通过 `prefixKey` 在公司段后、日期目录前插入自定义目录(自动去除首尾斜杠)。
+- 查重与保存:封装 `getAttachmentByMd5` 与 `saveAttachment`,避免重复上传并统一写入 `Attachment`。
+
+## 依赖导入
+```ts
+import { NovaStorage, NovaFile } from 'fmode-ng/core';
+```
+
+## 初始化(仅凭 cid)
+```ts
+const cid = localStorage.getItem('company')!; // 或服务获取
+const storage = await NovaStorage.withCid(cid);
+```
+
+## 上传文件(含进度与 prefixKey)
+```ts
+const file = (event.target as HTMLInputElement).files![0];
+const uploaded: NovaFile = await storage.upload(file, {
+  prefixKey: 'project/pid/', // 项目页需要加项目前缀
+  onProgress: (p) => console.log('progress %', p.total.percent),
+});
+console.log(uploaded.key, uploaded.url);
+```
+
+# Attachment 数据范式说明
+# Parse 服务 `Attachment` 表结构分析
+`Attachment` 表是 Parse 服务中用于**存储文件附件核心信息**的业务表,主要关联文件元数据、上传用户、所属公司/分类等维度,支撑附件的管理、查询与关联业务(如文档附件、媒体文件关联等)。
+
+
+## 一、表核心定位
+用于记录附件的基础属性(名称、大小、URL)、校验信息(MD5)、关联主体(用户、公司、分类)及文件元数据(分辨率、时长等),是附件与业务模块(如内容、用户系统)的关联枢纽。
+
+
+## 二、字段详情
+### 1. 核心业务字段(自定义字段)
+以下字段为代码中明确通过 `attachment.set(...)` 定义的业务字段,涵盖附件的核心属性与关联关系:
+
+| 字段名       | 数据类型                  | 赋值逻辑                                                                 | 字段含义                                                                 | 可选性 |
+|--------------|---------------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------|--------|
+| `size`       | Number(整数)            | 取自 `file.size`(`NovaFile` 或传入对象的 `size` 字段,单位:字节)       | 附件文件的实际大小,用于前端展示、容量统计等                             | 必选   |
+| `url`        | String                    | 1. 优先取 `file.url`;<br>2. 若 `url` 非 HTTP 开头,拼接 `domain` 前缀;<br>3. 清除 `undefined/` 无效前缀 | 附件的**可访问地址**(绝对 URL),用于前端下载、预览附件                 | 必选   |
+| `name`       | String                    | 取自 `file.name`(`NovaFile` 或传入对象的文件名,含扩展名)               | 附件的原始文件名(如 `test.jpg`),用于前端展示、文件识别                 | 必选   |
+| `mime`       | String                    | 取自 `file.type`(MIME 类型,如 `image/jpeg`、`audio/mp3`、`video/mp4`)  | 附件的文件类型,用于判断文件类别(图片/音视频/文档)、前端渲染方式        | 必选   |
+| `md5`        | String                    | 1. 优先取 `file.md5`;<br>2. 若有 `srcFile`(原始 `File/Blob`),通过 `calcFileMd5` 计算 | 附件文件的 MD5 哈希值,用于**文件完整性校验**(避免传输损坏)、去重判断   | 可选   |
+| `metadata`   | Object(嵌套结构)        | 取自 `file.metadata`(若传入则直接存储,无则不主动生成)                 | 附件的**文件元数据**,随文件类型不同包含不同子字段(见下方补充)         | 可选   |
+| `company`    | Parse Pointer(关联 `Company` 表) | 取值优先级:`categoryId` > `this.cid` > localStorage 的 `company` 值       | 关联附件所属的公司,用于数据隔离(不同公司只能查看自己的附件)           | 可选   |
+| `category`   | Parse Pointer(关联 `Category` 表) | 取自传入的 `categoryId` 参数                                             | 关联附件所属的分类(如“产品图片”“合同附件”),用于附件分类管理           | 可选   |
+| `user`       | Parse Pointer(关联 `_User` 表) | 取自 `Parse.User.current()`(当前登录用户),生成指向 `_User` 表的指针    | 记录附件的**上传用户**,用于追溯上传人、权限控制(如仅上传者可编辑)     | 可选(无当前用户时不设置) |
+
+
+#### 补充:`metadata` 字段的常见子字段
+`metadata` 存储文件的个性化元数据,具体子字段由文件类型决定(依赖 `getFileMetadata` 函数提取):
+| 子字段         | 数据类型 | 适用文件类型       | 含义                                                                 |
+|----------------|----------|--------------------|----------------------------------------------------------------------|
+| `width`        | Number   | 图片、视频         | 分辨率宽度(如图片 1920px,视频 1280px)                             |
+| `height`       | Number   | 图片、视频         | 分辨率高度(如图片 1080px,视频 720px)                             |
+| `duration`     | Number   | 音频、视频         | 媒体时长(单位:秒,如音频 120s,视频 300s)                         |
+| `lastModified` | Number   | 所有文件           | 文件最后修改时间戳(取自 `file.lastModified` 或当前时间)             |
+| `bitrate`      | Number   | 音频、视频(预留) | 媒体码率(代码类型定义中存在,暂未在 `extractMediaMetadata` 中实现) |
+| `codec`        | String   | 音频、视频(预留) | 媒体编码格式(如 H.264、MP3,暂未实现)                              |
+| `colorDepth`   | Number   | 图片(预留)       | 图片色深(如 24 位真彩色,暂未实现)                                 |
+
+
+### 2. Parse 系统默认字段
+所有 Parse Object(包括 `Attachment`)都会自动生成以下系统字段,无需手动赋值:
+
+| 字段名         | 数据类型   | 含义                                                                 |
+|----------------|------------|----------------------------------------------------------------------|
+| `objectId`     | String     | 表的**主键**(唯一标识),由 Parse 自动生成(如 `X7y8Z9a0B1c2D3e4`) |
+| `createdAt`    | Date       | 附件记录的**创建时间**(上传时间),自动生成                          |
+| `updatedAt`    | Date       | 附件记录的**最后更新时间**(如修改分类、更新 URL),自动更新          |
+| `ACL`          | Parse ACL  | 附件的权限控制列表(默认继承 Parse 全局配置,可自定义读写权限)       |
+
+
+## 三、关键关联关系
+`Attachment` 表通过 **Pointer 类型**与其他核心表关联,形成业务数据链路:
+1. **与 `_User` 表**:通过 `user` 字段关联,追溯附件的上传用户,支持“个人附件列表”“按用户筛选附件”等场景。
+2. **与 `Company` 表**:通过 `company` 字段关联,实现“公司级数据隔离”,确保不同公司用户仅能访问本公司的附件。
+3. **与 `Category` 表**:通过 `category` 字段关联,支持附件的分类管理(如“产品素材”“财务文档”),便于筛选与归类。
+
+
+## 四、特殊处理逻辑
+1. **URL 兼容性处理**:若传入的 `file.url` 是相对路径(非 HTTP 开头),自动拼接 `domain` 生成绝对 URL,避免前端无法访问。
+2. **MD5 动态计算**:若未传入 `file.md5`,但提供了原始文件 `srcFile`,则通过 `calcFileMd5` 函数动态计算 MD5,保证校验信息的可用性。
+3. **公司 ID 多来源 fallback**:`company` 字段优先取显式传入的 `companyId`,其次取当前实例的 `cid`、localStorage 缓存的 `company` 值,确保关联关系尽可能完整。

+ 0 - 247
rules/wxwork/auth-user-confirm.md

@@ -1,247 +0,0 @@
-# 用户身份确认页面
-
-## 页面概述
-
-用户身份确认页面 (`page-user-confirm`) 是一个受企微路由守卫保护的单页面应用,用于展示当前企业微信用户的详细身份信息,并允许用户确认其身份。
-
-``` ts
-import { WxworkSDK } from 'fmode-ng/core';
-import { FmodeParse } from 'fmode-ng/parse';
-import { WxworkAuthGuard } from 'fmode-ng';
-```
-
-## 访问路径
-
-- **路径1**: `/:cid/auth/user-confirm`
-- **路径2**: `/:cid/auth/:appId/user-confirm`
-
-**示例**:
-- 脑控科技身份确认 https://app.fmode.cn/dev/crm/auth/E4KpGvTEto/user-confirm
-- 映三色身份确认 https://app.fmode.cn/dev/crm/auth/cDL6R1hgSi/user-confirm
-
-## 路由守卫
-
-使用 `WxworkAuthGuard` 企微路由守卫,确保:
-
-1. **获取路由参数**:
-   - `cid`: 公司帐套ID (Company.objectId)
-   - `appId`: 应用ID (可选)
-
-2. **识别用户身份**:
-   - 企业员工: 通过 `UserId` 识别,关联到 `Profile` 表
-   - 外部用户: 通过 `external_userid` 或 `OpenId` 识别,关联到 `UserSocial` 表
-
-3. **数据持久化**:
-   - localStorage 存储位置: `{{cid}}/USERINFO`
-   - 获取函数: `wxsdk.getUserinfo()`
-
-## 功能模块
-
-### 1. 用户信息加载
-
-#### Profile (企业员工)
-
-从 Parse Server 的 `Profile` 表查询用户信息,查询条件:
-- `userId`: 企业员工的微信UserId
-- `company`: Pointer 指向当前 Company (cid)
-
-**展示字段**:
-- `name`: 姓名
-- `avatar`: 头像
-- `mobile`: 手机号
-- `email`: 邮箱
-- `department`: 部门
-- `position`: 职位
-- `corpName`: 企业名称
-- `isVerified`: 身份确认状态
-- `userId`: 员工ID
-
-#### UserSocial (外部用户)
-
-从 Parse Server 的 `UserSocial` 表查询用户信息,查询条件:
-- `externalUserId`: 外部用户的微信external_userid
-- `company`: Pointer 指向当前 Company (cid)
-
-**展示字段**:
-- `name`: 姓名
-- `avatar`: 头像
-- `mobile`: 手机号
-- `externalName`: 外部名称
-- `externalType`: 外部用户类型
-- `corpName`: 企业名称
-- `isVerified`: 身份确认状态
-- `externalUserId`: 外部用户ID
-
-### 2. 用户信息展示
-
-页面采用卡片式布局,包含以下区域:
-
-#### 头像区域
-- 居中展示用户头像(圆形,100px)
-- 无头像时显示默认占位图标
-- 显示用户姓名
-- 显示用户类型标签(企业员工/外部用户)
-
-#### 身份状态徽章
-- **已确认**: 绿色徽章,显示"已确认身份"和对勾图标
-- **待确认**: 黄色徽章,显示"待确认"
-
-#### 详细信息列表
-根据用户类型展示不同的字段信息,每项包含:
-- 图标标识
-- 字段标题
-- 字段值
-
-### 3. 身份确认功能
-
-#### 确认按钮状态
-- **可点击**: 用户未确认 && 用户记录存在 && 未加载中
-- **禁用**: 用户已确认 || 用户记录不存在 || 加载中
-
-#### 确认流程
-1. 点击"确认身份"按钮
-2. 更新对应表记录的 `isVerified` 字段为 `true`
-3. 显示成功提示
-4. 更新页面状态为"已确认身份"
-
-#### 异常处理
-- **用户记录不存在**: 提示"系统中未找到您的用户记录,请联系管理员"
-- **确认失败**: 显示错误提示信息
-
-## 数据模型
-
-### Profile 表
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| userId | String | 企业员工微信UserId |
-| company | Pointer<Company> | 所属企业 |
-| name | String | 姓名 |
-| avatar | String | 头像URL |
-| mobile | String | 手机号 |
-| email | String | 邮箱 |
-| department | String | 部门 |
-| position | String | 职位 |
-| corpName | String | 企业名称 |
-| isVerified | Boolean | 身份确认状态 |
-
-### UserSocial 表
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| externalUserId | String | 外部用户微信external_userid |
-| company | Pointer<Company> | 所属企业 |
-| name | String | 姓名 |
-| avatar | String | 头像URL |
-| mobile | String | 手机号 |
-| externalName | String | 外部名称 |
-| type | String | 外部用户类型 |
-| corpName | String | 企业名称 |
-| isVerified | Boolean | 身份确认状态 |
-
-## UI/UX 设计
-
-### 布局特点
-- 响应式设计,适配移动端和桌面端
-- 居中布局,最大宽度600px
-- 卡片式信息展示,清晰分层
-- 图标辅助,增强信息可读性
-
-### 交互状态
-1. **加载状态**: 显示加载动画和提示文字
-2. **信息展示**: 完整展示用户信息
-3. **确认成功**: 按钮变为禁用状态,徽章变为绿色
-4. **错误状态**: 显示友好的错误提示
-
-### 视觉元素
-- **主色调**: Ionic 默认主题色 (#3880ff)
-- **成功色**: 绿色 (success)
-- **警告色**: 黄色 (warning)
-- **字体大小**:
-  - 标题: 20px
-  - 用户名: 24px
-  - 正文: 16px
-  - 辅助文本: 14px
-
-## 技术实现
-
-### 核心依赖
-- **Angular**: 独立组件架构
-- **Ionic**: UI 组件库
-- **fmode-ng**:
-  - `WxworkSDK`: 企微SDK
-  - `FmodeParse`: Parse Server 数据服务
-
-### 主要方法
-
-#### `ngOnInit()`
-- 获取路由参数 (cid, appId)
-- 调用 `loadUserInfo()` 加载用户信息
-
-#### `loadUserInfo()`
-- 初始化 WxworkSDK
-- 从 localStorage 获取缓存的用户信息
-- 根据用户类型调用相应的加载方法
-
-#### `loadProfileInfo(userId: string)`
-- 查询 Profile 表
-- 构建 UserInfo 对象
-
-#### `loadUserSocialInfo(cachedInfo: any)`
-- 查询 UserSocial 表
-- 构建 UserInfo 对象
-
-#### `confirmIdentity()`
-- 更新 isVerified 字段为 true
-- 刷新页面状态
-- 显示成功提示
-
-## 使用场景
-
-### 场景1: 首次登录确认
-企业员工首次通过企业微信进入系统时,需要确认其身份信息,系统记录确认状态。
-
-### 场景2: 外部用户验证
-外部客户通过企业微信接入时,展示其在系统中的信息,确认身份后才能使用完整功能。
-
-### 场景3: 信息核对
-管理员可以引导用户访问此页面,核对和更新用户信息。
-
-## 扩展性
-
-### 未来可能的增强功能
-1. 允许用户编辑部分信息(如手机号、邮箱)
-2. 添加人脸识别或其他二次验证
-3. 记录确认时间和IP地址
-4. 支持批量身份确认
-5. 增加身份过期和重新确认机制
-
-## 注意事项
-
-1. **路由守卫**: 必须配置 `WxworkAuthGuard`,否则无法获取用户信息
-2. **数据隐私**: 敏感信息需要适当脱敏显示
-3. **错误处理**: 网络异常、Parse查询失败等都需要友好提示
-4. **状态同步**: isVerified 状态更新后需要同步到其他使用该字段的模块
-5. **权限控制**: 未确认身份的用户可能需要限制部分功能访问
-
-## 文件结构
-
-```
-src/modules/auth/page-user-confirm/
-├── page-user-confirm.component.ts       # 组件逻辑
-├── page-user-confirm.component.html     # 模板
-├── page-user-confirm.component.scss     # 样式
-└── page-user-confirm.component.spec.ts  # 单元测试
-```
-
-## 路由配置
-
-```typescript
-// app.routes.ts
-{
-    path:"auth/:cid/user-confirm",
-    canActivate:[WxworkAuthGuard],
-    loadComponent:()=>import("../modules/auth/page-user-confirm/page-user-confirm.component")
-        .then((m)=>m.PageUserConfirmComponent)
-}
-```

+ 6 - 2
rules/wxwork/auth.md

@@ -53,12 +53,16 @@ export class MyPageComponent {
     this.wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
 
     // 页面加载后执行用户认证
-    this.initAuth();
+    await this.initAuth();
+
+    // 验证后随时获取当前员工/外部联系人
+    console.log("current Profile:FmodeObject",await wwauth.currentProfile())
+    console.log("current ContactInfo:FmodeObject",await wwauth.currentContact())
   }
 
   async initAuth() {
     try {
-      // 方案1: 一站式认证和登录
+      // 场景: 一站式认证和登录
       const { userInfo, profile, user } = await this.wxAuth.authenticateAndLogin();
       console.log('用户信息:', userInfo);
       console.log('Profile ID:', profile.id);

+ 448 - 0
scripts/migration/create-schema.js

@@ -0,0 +1,448 @@
+const Parse = require('parse/node');
+
+// 加载环境变量(建议使用.env文件管理敏感信息)
+
+// 1. 初始化Parse连接
+Parse.initialize(
+  process.env.PARSE_APP_ID || 'ncloudmaster', // 替换为你的Parse App ID
+  process.env.PARSE_MASTER_KEY || "SnkK12*&sunq2#@20!" // 替换为你的Parse Master Key
+);
+Parse.serverURL = process.env.PARSE_SERVER_URL || 'https://server.fmode.cn/parse'; // 替换为你的Parse Server地址
+Parse.masterKey = process.env.PARSE_MASTER_KEY || "SnkK12*&sunq2#@20!"; // Master Key用于Schema操作
+
+// 2. 定义所有数据表结构配置(与文档范式完全对应)
+const tableConfigs = [
+  // 核心租户表:Company
+  {
+    className: 'Company',
+    fields: [
+      { name: 'name', type: 'String', required: true },
+      { name: 'corpId', type: 'String', required: false },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'corpId_index', fields: { corpId: 1 } },
+      { name: 'name_index', fields: { name: 1 } }
+    ]
+  },
+
+  // 部门表:Department
+  {
+    className: 'Department',
+    fields: [
+      { name: 'name', type: 'String', required: true },
+      { name: 'type', type: 'String', required: true, defaultValue: 'project' },
+      { name: 'leader', type: 'Pointer', required: false, targetClass: 'Profile' },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'company_isDeleted', fields: { company: 1, isDeleted: 1 } },
+      { name: 'leader_index', fields: { leader: 1 } }
+    ]
+  },
+
+  // 员工档案表:Profile
+  {
+    className: 'Profile',
+    fields: [
+      { name: 'name', type: 'String', required: true },
+      { name: 'mobile', type: 'String', required: false },
+      { name: 'department', type: 'Pointer', required: true, targetClass: 'Department' },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'userId', type: 'String', required: false },
+      { name: 'roleName', type: 'String', required: true },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'company_isDeleted', fields: { company: 1, isDeleted: 1 } },
+      { name: 'userId_company', fields: { userId: 1, company: 1 }, unique: true },
+      { name: 'roleName_company', fields: { roleName: 1, company: 1 } },
+      { name: 'mobile_company', fields: { mobile: 1, company: 1 } }
+    ]
+  },
+
+  // 客户信息表:ContactInfo
+  {
+    className: 'ContactInfo',
+    fields: [
+      { name: 'name', type: 'String', required: true },
+      { name: 'mobile', type: 'String', required: false },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'external_userid', type: 'String', required: false },
+      { name: 'source', type: 'String', required: false },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'company_isDeleted', fields: { company: 1, isDeleted: 1 } },
+      { name: 'external_userid_company', fields: { external_userid: 1, company: 1 }, unique: true },
+      { name: 'mobile_company', fields: { mobile: 1, company: 1 } },
+      { name: 'source_company', fields: { source: 1, company: 1 } }
+    ]
+  },
+
+  // 企微群聊表:GroupChat
+  {
+    className: 'GroupChat',
+    fields: [
+      { name: 'chat_id', type: 'String', required: true },
+      { name: 'name', type: 'String', required: true },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'project', type: 'Pointer', required: false, targetClass: 'Project' },
+      { name: 'member_list', type: 'Array', required: false, defaultValue: [] },
+      { name: 'joinUrl', type: 'String', required: false },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'chat_id_company', fields: { chat_id: 1, company: 1 }, unique: true },
+      { name: 'project_isDeleted', fields: { project: 1, isDeleted: 1 } },
+      { name: 'company_isDeleted', fields: { company: 1, isDeleted: 1 } }
+    ]
+  },
+
+  // 项目群组关联表:ProjectGroup
+  {
+    className: 'ProjectGroup',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'groupChat', type: 'Pointer', required: true, targetClass: 'GroupChat' },
+      { name: 'isPrimary', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_groupChat', fields: { project: 1, groupChat: 1 }, unique: true },
+      { name: 'groupChat_index', fields: { groupChat: 1 } },
+      { name: 'isPrimary_index', fields: { isPrimary: 1 } }
+    ]
+  },
+
+  // 项目表:Project
+  {
+    className: 'Project',
+    fields: [
+      { name: 'title', type: 'String', required: true },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'customer', type: 'Pointer', required: true, targetClass: 'ContactInfo' },
+      { name: 'assignee', type: 'Pointer', required: false, targetClass: 'Profile' },
+      { name: 'status', type: 'String', required: true, defaultValue: '待分配' },
+      { name: 'currentStage', type: 'String', required: true, defaultValue: '订单分配' },
+      { name: 'deadline', type: 'Date', required: false },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'company_isDeleted', fields: { company: 1, isDeleted: 1 } },
+      { name: 'assignee_status', fields: { assignee: 1, status: 1 } },
+      { name: 'customer_isDeleted', fields: { customer: 1, isDeleted: 1 } },
+      { name: 'currentStage_status', fields: { currentStage: 1, status: 1 } },
+      { name: 'deadline_index', fields: { deadline: 1 } },
+      { name: 'updatedAt_desc', fields: { updatedAt: -1 } }
+    ]
+  },
+
+  // 需求信息表:ProjectRequirement
+  {
+    className: 'ProjectRequirement',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'spaces', type: 'Array', required: false, defaultValue: [] },
+      { name: 'designRequirements', type: 'Object', required: false, defaultValue: {} },
+      { name: 'materialAnalysis', type: 'Object', required: false, defaultValue: {} },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_unique', fields: { project: 1 }, unique: true },
+      { name: 'company_isDeleted', fields: { company: 1, isDeleted: 1 } }
+    ]
+  },
+
+  // 项目团队表:ProjectTeam
+  {
+    className: 'ProjectTeam',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'profile', type: 'Pointer', required: true, targetClass: 'Profile' },
+      { name: 'role', type: 'String', required: true },
+      { name: 'workload', type: 'Number', required: false, defaultValue: 0 },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_isDeleted', fields: { project: 1, isDeleted: 1 } },
+      { name: 'profile_project', fields: { profile: 1, project: 1 }, unique: true }
+    ]
+  },
+
+  // 交付物表:Product
+  {
+    className: 'Product',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'stage', type: 'String', required: true },
+      { name: 'processType', type: 'String', required: false },
+      { name: 'space', type: 'String', required: false },
+      { name: 'fileUrl', type: 'String', required: true },
+      { name: 'reviewStatus', type: 'String', required: true, defaultValue: 'pending' },
+      { name: 'quotation', type: 'Object', required: false, defaultValue: {} },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_stage_isDeleted', fields: { project: 1, stage: 1, isDeleted: 1 } },
+      { name: 'project_space', fields: { project: 1, space: 1 } },
+      { name: 'reviewStatus_project', fields: { reviewStatus: 1, project: 1 } }
+    ]
+  },
+
+  // 项目文件表:ProjectFile
+  {
+    className: 'ProjectFile',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'uploadedBy', type: 'Pointer', required: true, targetClass: 'Profile' },
+      { name: 'fileType', type: 'String', required: true },
+      { name: 'fileUrl', type: 'String', required: true },
+      { name: 'fileName', type: 'String', required: true },
+      { name: 'fileSize', type: 'Number', required: false, defaultValue: 0 },
+      { name: 'stage', type: 'String', required: false },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_fileType_isDeleted', fields: { project: 1, fileType: 1, isDeleted: 1 } },
+      { name: 'uploadedBy_project', fields: { uploadedBy: 1, project: 1 } }
+    ]
+  },
+
+  // 结算记录表:ProjectSettlement
+  {
+    className: 'ProjectSettlement',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'company', type: 'Pointer', required: true, targetClass: 'Company' },
+      { name: 'stage', type: 'String', required: true },
+      { name: 'amount', type: 'Number', required: true },
+      { name: 'percentage', type: 'Number', required: false, defaultValue: 0 },
+      { name: 'status', type: 'String', required: true, defaultValue: '待结算' },
+      { name: 'dueDate', type: 'Date', required: false },
+      { name: 'settledAt', type: 'Date', required: false },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_stage', fields: { project: 1, stage: 1 }, unique: true },
+      { name: 'status_company', fields: { status: 1, company: 1 } },
+      { name: 'dueDate_index', fields: { dueDate: 1 } }
+    ]
+  },
+
+  // 付款凭证表:ProjectVoucher
+  {
+    className: 'ProjectVoucher',
+    fields: [
+      { name: 'settlement', type: 'Pointer', required: true, targetClass: 'ProjectSettlement' },
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'amount', type: 'Number', required: true },
+      { name: 'voucherUrl', type: 'String', required: true },
+      { name: 'recognizedInfo', type: 'Object', required: false, defaultValue: {} },
+      { name: 'verifiedBy', type: 'Pointer', required: false, targetClass: 'Profile' },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'settlement_project', fields: { settlement: 1, project: 1 } },
+      { name: 'project_isDeleted', fields: { project: 1, isDeleted: 1 } }
+    ]
+  },
+
+  // 客户反馈表:ProjectFeedback
+  {
+    className: 'ProjectFeedback',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'customer', type: 'Pointer', required: true, targetClass: 'ContactInfo' },
+      { name: 'stage', type: 'String', required: true },
+      { name: 'feedbackType', type: 'String', required: true },
+      { name: 'content', type: 'String', required: true },
+      { name: 'rating', type: 'Number', required: false, defaultValue: 0 },
+      { name: 'status', type: 'String', required: true, defaultValue: '待处理' },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_status_isDeleted', fields: { project: 1, status: 1, isDeleted: 1 } },
+      { name: 'customer_project', fields: { customer: 1, project: 1 } },
+      { name: 'feedbackType_stage', fields: { feedbackType: 1, stage: 1 } }
+    ]
+  },
+
+  // 产品质量检查表:ProductCheck
+  {
+    className: 'ProductCheck',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'checkType', type: 'String', required: true },
+      { name: 'checkedBy', type: 'Pointer', required: true, targetClass: 'Profile' },
+      { name: 'checkedAt', type: 'Date', required: true },
+      { name: 'isPassed', type: 'Boolean', required: true },
+      { name: 'items', type: 'Array', required: false, defaultValue: [] },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_checkType_isDeleted', fields: { project: 1, checkType: 1, isDeleted: 1 } },
+      { name: 'checkedBy_isPassed', fields: { checkedBy: 1, isPassed: 1 } }
+    ]
+  },
+
+  // 异常记录表:ProjectIssue
+  {
+    className: 'ProjectIssue',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'reportedBy', type: 'Pointer', required: true, targetClass: 'Profile' },
+      { name: 'exceptionType', type: 'String', required: true },
+      { name: 'severity', type: 'String', required: true },
+      { name: 'description', type: 'String', required: true },
+      { name: 'status', type: 'String', required: true, defaultValue: '待处理' },
+      { name: 'resolution', type: 'String', required: false },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_status_isDeleted', fields: { project: 1, status: 1, isDeleted: 1 } },
+      { name: 'exceptionType_severity', fields: { exceptionType: 1, severity: 1 } },
+      { name: 'reportedBy_index', fields: { reportedBy: 1 } }
+    ]
+  },
+
+  // 跟进记录表:ContactFollow
+  {
+    className: 'ContactFollow',
+    fields: [
+      { name: 'project', type: 'Pointer', required: true, targetClass: 'Project' },
+      { name: 'profile', type: 'Pointer', required: true, targetClass: 'Profile' },
+      { name: 'contact', type: 'Pointer', required: true, targetClass: 'ContactInfo' },
+      { name: 'content', type: 'String', required: true },
+      { name: 'type', type: 'String', required: true },
+      { name: 'stage', type: 'String', required: false },
+      { name: 'attachments', type: 'Array', required: false, defaultValue: [] },
+      { name: 'data', type: 'Object', required: false, defaultValue: {} },
+      { name: 'isDeleted', type: 'Boolean', required: false, defaultValue: false }
+    ],
+    indexes: [
+      { name: 'project_createdAt', fields: { project: 1, createdAt: -1 } },
+      { name: 'profile_project', fields: { profile: 1, project: 1 } },
+      { name: 'stage_index', fields: { stage: 1 } }
+    ]
+  }
+];
+
+// 3. 核心工具函数:创建/更新数据表结构
+async function setupTableSchema(tableConfig) {
+  const { className, fields, indexes } = tableConfig;
+  let schema;
+
+  try {
+    // 尝试获取已有Schema
+    schema = new Parse.Schema(className);
+    await schema.get();
+    console.log(`✅ 找到已有表 [${className}],开始补充缺失字段和索引`);
+  } catch (error) {
+    // 表不存在,创建新Schema
+    schema = new Parse.Schema(className);
+    console.log(`🆕 未找到表 [${className}],开始创建新表`);
+  }
+
+  // 3.1 添加/更新字段
+  for (const field of fields) {
+    try {
+      // 处理Pointer类型(需特殊配置targetClass)
+      if (field.type === 'Pointer') {
+        schema.addPointer(field.name, field.targetClass);
+      } 
+      // 处理普通类型
+      else {
+        schema.addField(field.name, field.type);
+      }
+
+      console.log(`  📝 配置字段 [${className}.${field.name}] (类型: ${field.type})`);
+    } catch (error) {
+      // 字段已存在时忽略错误
+      if (error.message.includes('Field already exists')) {
+        console.log(`  ⚠️  字段 [${className}.${field.name}] 已存在,跳过`);
+      } else {
+        console.error(`  ❌ 配置字段 [${className}.${field.name}] 失败:`, error.message);
+      }
+    }
+  }
+
+  // 3.2 添加/更新索引
+  for (const index of indexes) {
+    try {
+      schema.addIndex(index.name, index.fields, index.unique || false);
+      console.log(`  🔍 配置索引 [${className}.${index.name}] (字段: ${JSON.stringify(index.fields)})`);
+    } catch (error) {
+      // 索引已存在时忽略错误
+      if (error.message.includes('Index already exists')) {
+        console.log(`  ⚠️  索引 [${className}.${index.name}] 已存在,跳过`);
+      } else {
+        console.error(`  ❌ 配置索引 [${className}.${index.name}] 失败:`, error.message);
+      }
+    }
+  }
+
+  // 3.3 保存Schema变更
+  try {
+    await schema.save();
+    console.log(`🎉 表 [${className}] 配置完成\n`);
+  } catch (error) {
+    console.error(`❌ 保存表 [${className}] 配置失败:`, error.message, '\n');
+  }
+}
+
+// 4. 批量执行所有表的初始化
+async function initAllTables() {
+  console.log('=====================================================');
+  console.log('🚀 开始初始化YSS项目管理系统数据表结构');
+  console.log(`📌 Parse Server地址: ${Parse.serverURL}`);
+  console.log(`📌 应用ID: ${Parse.applicationId}`);
+  console.log('=====================================================\n');
+
+  // 按依赖顺序执行(先创建基础表,再创建依赖表)
+  const dependencyOrder = [
+    'Company', 'Department', 'Profile', 'ContactInfo', // 基础人员表
+    'Project', 'ProjectRequirement', 'ProjectTeam',   // 核心项目表
+    'GroupChat', 'ProjectGroup',                     // 企微关联表
+    'Product', 'ProjectFile',                        // 交付物/文件表
+    'ProjectSettlement', 'ProjectVoucher',           // 财务表
+    'ProjectFeedback', 'ProductCheck', 'ProjectIssue',// 质量反馈表
+    'ContactFollow'                                  // 跟进记录表
+  ];
+
+  // 按依赖顺序处理每个表
+  for (const className of dependencyOrder) {
+    const tableConfig = tableConfigs.find(config => config.className === className);
+    if (tableConfig) {
+      await setupTableSchema(tableConfig);
+    } else {
+      console.error(`❌ 未找到表 [${className}] 的配置,跳过\n`);
+    }
+  }
+
+  console.log('=====================================================');
+  console.log('🏁 所有数据表结构初始化完成!');
+  console.log('💡 提示:请检查控制台输出,确认是否有失败的配置项');
+  console.log('=====================================================');
+}
+
+// 5. 执行初始化
+initAllTables().catch(error => {
+  console.error('💥 初始化过程中发生致命错误:', error.message);
+  process.exit(1);
+});

+ 1 - 9
src/app/app.html

@@ -1,9 +1 @@
-<div class="app-container">
-  <!-- 主要内容区 -->
-  <main class="main-content">
-    <!-- 中间内容区 -->
-    <div class="content-wrapper">
-      <router-outlet />
-    </div>
-  </main>
-</div>
+<router-outlet />

+ 7 - 1
src/app/app.routes.ts

@@ -1,11 +1,12 @@
 import { Routes } from '@angular/router';
-// import { WxworkAuthGuard } from 'fmode-ng/social';
+import { WxworkAuthGuard } from 'fmode-ng/social';
 
 export const routes: Routes = [
   // 客服路由
   {
     path: 'customer-service',
     loadComponent: () => import('./pages/customer-service/customer-service-layout/customer-service-layout').then(m => m.CustomerServiceLayout),
+    canActivate: [WxworkAuthGuard],
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -60,6 +61,7 @@ export const routes: Routes = [
   // 设计师路由
   {
     path: 'designer',
+    canActivate: [WxworkAuthGuard],
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -83,6 +85,7 @@ export const routes: Routes = [
   // 组长路由
   {
     path: 'team-leader',
+    canActivate: [WxworkAuthGuard],
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -122,6 +125,7 @@ export const routes: Routes = [
   // 财务路由
   {
     path: 'finance',
+    canActivate: [WxworkAuthGuard],
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -156,6 +160,7 @@ export const routes: Routes = [
   {
     path: 'hr',
     loadComponent: () => import('./pages/hr/hr-layout/hr-layout').then(m => m.HrLayout),
+    canActivate: [WxworkAuthGuard],
     children: [
       {
         path: 'dashboard',
@@ -180,6 +185,7 @@ export const routes: Routes = [
   {
     path: 'admin',
     loadComponent: () => import('./pages/admin/admin-layout/admin-layout').then(m => m.AdminLayout),
+    canActivate: [WxworkAuthGuard],
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {

+ 25 - 11
src/app/app.ts

@@ -1,6 +1,6 @@
 import { Component, signal } from '@angular/core';
 import { Router, RouterModule, RouterOutlet } from '@angular/router';
-// import { AuthService } from 'fmode-ng/user';
+import { FmodeParse, WxworkAuth } from 'fmode-ng/core';
 
 @Component({
   selector: 'app-root',
@@ -12,18 +12,32 @@ import { Router, RouterModule, RouterOutlet } from '@angular/router';
 })
 export class App {
   protected readonly title = signal('yss-project');
+
   constructor(
-    private router:Router
-    // private authServ:AuthService
+    private router: Router
   ){
-    this.initAuthServ();
+    this.initParse();
+    this.initAuth();
+  }
+
+  // 初始化Parse配置
+  private initParse(): void {
+    try {
+      // 设置默认后端配置,替代原有Parse
+      const Parse = FmodeParse.with("nova");
+      console.log('✅ FmodeParse 初始化成功');
+    } catch (error) {
+      console.error('❌ FmodeParse 初始化失败:', error);
+    }
   }
-  initAuthServ(){
-    // this.authServ.LoginPage = "/pcuser/E4KpGvTEto/login" // 登录时默认为用户名增加飞码AI账套company前缀
-    // this.authServ.init({
-    //   company:"E4KpGvTEto", // 登录时默认为用户名增加飞码AI账套company
-    //   guardType: "modal", // 设置登录守卫方式
-    // })
-    // this.authServ.logoUrl = document.baseURI+"/assets/logo.jpg"
+
+  // 初始化企业微信认证
+  private initAuth(): void {
+    try {
+      // 可以在这里做一些全局的认证配置
+      console.log('✅ 企业微信认证模块初始化成功');
+    } catch (error) {
+      console.error('❌ 企业微信认证初始化失败:', error);
+    }
   }
 }

+ 16 - 0
src/app/pages/admin/admin-layout/admin-layout.ts

@@ -1,6 +1,7 @@
 import { Component } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { RouterModule, RouterOutlet } from '@angular/router';
+import { WxworkAuth } from "fmode-ng/core";
 
 @Component({
   selector: 'app-admin-layout',
@@ -11,9 +12,24 @@ import { RouterModule, RouterOutlet } from '@angular/router';
 }) 
 export class AdminLayout {
   sidebarOpen = true;
+
   currentUser = { name: '超级管理员', avatar: "data:image/svg+xml,%3Csvg width='40' height='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='%23CCFFCC'/%3E%3Ctext x='50%25' y='50%25' font-family='Arial' font-size='13.333333333333334' font-weight='bold' text-anchor='middle' fill='%23555555' dy='0.3em'%3EADMIN%3C/text%3E%3C/svg%3E" };
   currentDate = new Date();
 
+  constructor(){
+    this.loadProfile()
+  }
+  async loadProfile(){
+    let cid = localStorage.getItem("company");
+    if(cid){
+      let wwAuth = new WxworkAuth({cid:cid})
+      let profile = await wwAuth.currentProfile();
+      this.currentUser  = {
+        name: profile?.get("name") || profile?.get("mobile"),
+        avatar: profile?.get("avatar")
+      }
+    }
+  }
   toggleSidebar() {
     this.sidebarOpen = !this.sidebarOpen;
   }

+ 2 - 2
src/app/pages/admin/api-integrations/api-dialog/api-dialog.html

@@ -122,14 +122,14 @@
                 <mat-label>配置键</mat-label>
                 <input matInput type="text" [(ngModel)]="configKeys[i]" 
                        placeholder="例如:apiKey">
-                <mat-error *ngIf="errors[`config_key_${i}`]">{{ errors[`config_key_${i}`] }}</mat-error>
+                <mat-error *ngIf="errors['config_key_' + i]">{{ errors['config_key_' + i] }}</mat-error>
               </mat-form-field>
               
               <mat-form-field appearance="outline" class="value-field">
                 <mat-label>配置值</mat-label>
                 <textarea matInput [(ngModel)]="configValues[i]" 
                           placeholder="例如:abcdef123456" rows="2"></textarea>
-                <mat-error *ngIf="errors[`config_value_${i}`]">{{ errors[`config_value_${i}`] }}</mat-error>
+                <mat-error *ngIf="errors['config_value_' + i]">{{ errors['config_value_' + i] }}</mat-error>
               </mat-form-field>
             </div>
           </div>

+ 244 - 10
src/app/pages/admin/dashboard/dashboard.ts

@@ -3,6 +3,8 @@ import { RouterModule } from '@angular/router';
 import { Subscription } from 'rxjs';
 import { signal, Component, OnInit, AfterViewInit, OnDestroy, computed } from '@angular/core';
 import { AdminDashboardService } from './dashboard.service';
+import { FmodeQuery, FmodeObject, FmodeUser } from 'fmode-ng/core';
+import { WxworkAuth } from 'fmode-ng/core';
 import * as echarts from 'echarts';
 
 @Component({
@@ -121,11 +123,45 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
   private projectChart: any | null = null;
   private revenueChart: any | null = null;
   private detailChart: any | null = null;
+  private wxAuth: WxworkAuth | null = null;
+  private currentUser: FmodeUser | null = null;
 
-  constructor(private dashboardService: AdminDashboardService) {}
+  constructor(private dashboardService: AdminDashboardService) {
+    this.initAuth();
+  }
 
-  ngOnInit(): void {
-    this.loadDashboardData();
+  async ngOnInit(): Promise<void> {
+    await this.authenticateAndLoadData();
+  }
+
+  // 初始化企业微信认证
+  private initAuth(): void {
+    try {
+      this.wxAuth = new WxworkAuth({
+        cid: 'cDL6R1hgSi'  // 公司帐套ID
+      });
+      console.log('✅ 管理员仪表板企业微信认证初始化成功');
+    } catch (error) {
+      console.error('❌ 管理员仪表板企业微信认证初始化失败:', error);
+    }
+  }
+
+  // 认证并加载数据
+  private async authenticateAndLoadData(): Promise<void> {
+    try {
+      // 执行企业微信认证和登录
+      const { user } = await this.wxAuth!.authenticateAndLogin();
+      this.currentUser = user;
+
+      if (user) {
+        console.log('✅ 管理员登录成功:', user.get('username'));
+        this.loadDashboardData();
+      } else {
+        console.error('❌ 管理员登录失败');
+      }
+    } catch (error) {
+      console.error('❌ 管理员认证过程出错:', error);
+    }
   }
 
   ngAfterViewInit(): void {
@@ -145,8 +181,99 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
     if (this.detailChart) { this.detailChart.dispose(); this.detailChart = null; }
   }
 
-  loadDashboardData(): void {
-    // 加载统计数据
+  async loadDashboardData(): Promise<void> {
+    try {
+      // 加载项目统计数据
+      await this.loadProjectStats();
+
+      // 加载用户统计数据
+      await this.loadUserStats();
+
+      // 加载收入统计数据
+      await this.loadRevenueStats();
+
+      console.log('✅ 管理员仪表板数据加载完成');
+    } catch (error) {
+      console.error('❌ 管理员仪表板数据加载失败:', error);
+      // 降级到模拟数据
+      this.loadMockData();
+    }
+  }
+
+  // 加载项目统计数据
+  private async loadProjectStats(): Promise<void> {
+    try {
+      const projectQuery = new FmodeQuery('Project');
+
+      // 总项目数
+      const totalProjects = await projectQuery.count();
+      this.stats.totalProjects.set(totalProjects);
+
+      // 进行中项目数
+      projectQuery.equalTo('status', '进行中');
+      const activeProjects = await projectQuery.count();
+      this.stats.activeProjects.set(activeProjects);
+
+      // 已完成项目数
+      projectQuery.equalTo('status', '已完成');
+      const completedProjects = await projectQuery.count();
+      this.stats.completedProjects.set(completedProjects);
+
+      console.log(`✅ 项目统计: 总计${totalProjects}, 进行中${activeProjects}, 已完成${completedProjects}`);
+    } catch (error) {
+      console.error('❌ 项目统计加载失败:', error);
+      throw error;
+    }
+  }
+
+  // 加载用户统计数据
+  private async loadUserStats(): Promise<void> {
+    try {
+      // 设计师统计
+      const designerQuery = new FmodeQuery('Profile');
+      designerQuery.equalTo('role', 'designer');
+      const designers = await designerQuery.count();
+      this.stats.totalDesigners.set(designers);
+
+      // 客户统计
+      const customerQuery = new FmodeQuery('Profile');
+      customerQuery.equalTo('role', 'customer');
+      const customers = await customerQuery.count();
+      this.stats.totalCustomers.set(customers);
+
+      console.log(`✅ 用户统计: 设计师${designers}, 客户${customers}`);
+    } catch (error) {
+      console.error('❌ 用户统计加载失败:', error);
+      throw error;
+    }
+  }
+
+  // 加载收入统计数据
+  private async loadRevenueStats(): Promise<void> {
+    try {
+      // 从订单表计算总收入
+      const orderQuery = new FmodeQuery('Order');
+      orderQuery.equalTo('status', 'paid');
+
+      const orders = await orderQuery.find();
+      let totalRevenue = 0;
+
+      for (const order of orders) {
+        const amount = order.get('amount') || 0;
+        totalRevenue += amount;
+      }
+
+      this.stats.totalRevenue.set(totalRevenue);
+      console.log(`✅ 收入统计: 总收入 ¥${totalRevenue.toLocaleString()}`);
+    } catch (error) {
+      console.error('❌ 收入统计加载失败:', error);
+      throw error;
+    }
+  }
+
+  // 降级到模拟数据
+  private loadMockData(): void {
+    console.warn('⚠️ 使用模拟数据');
     this.subscriptions.add(
       this.dashboardService.getDashboardStats().subscribe(stats => {
         this.stats.totalProjects.set(stats.totalProjects);
@@ -250,7 +377,7 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
   }
 
   // ====== 详情面板 ======
-  showPanel(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
+  async showPanel(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
     this.detailType.set(type);
     // 重置筛选与分页
     this.keyword.set('');
@@ -260,7 +387,7 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
     this.pageIndex.set(1);
 
     // 加载本次类型的明细数据
-    this.loadDetailData(type);
+    await this.loadDetailData(type);
 
     // 打开抽屉并初始化图表
     this.detailOpen.set(true);
@@ -359,8 +486,114 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
   showFinanceDetails(): void { this.showPanel('revenue'); }
 
   // ====== 明细数据:加载、列配置、导出与分页 ======
-  private loadDetailData(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
-    // 构造模拟数据(足量便于分页演示)
+  private async loadDetailData(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue') {
+    try {
+      switch (type) {
+        case 'totalProjects':
+        case 'active':
+        case 'completed':
+          await this.loadProjectDetailData(type);
+          break;
+        case 'designers':
+          await this.loadDesignerDetailData();
+          break;
+        case 'customers':
+          await this.loadCustomerDetailData();
+          break;
+        case 'revenue':
+          await this.loadRevenueDetailData();
+          break;
+      }
+    } catch (error) {
+      console.error('❌ 详情数据加载失败:', error);
+      this.loadMockDetailData(type);
+    }
+  }
+
+  // 加载项目详情数据
+  private async loadProjectDetailData(type: 'totalProjects' | 'active' | 'completed'): Promise<void> {
+    const projectQuery = new FmodeQuery('Project');
+
+    if (type === 'active') {
+      projectQuery.equalTo('status', '进行中');
+    } else if (type === 'completed') {
+      projectQuery.equalTo('status', '已完成');
+    }
+
+    const projects = await projectQuery.descending('createdAt').find();
+
+    const detailItems = projects.map((project: FmodeObject) => ({
+      id: project.id,
+      name: project.get('name') || '未命名项目',
+      owner: project.get('owner')?.get('name') || '未分配',
+      status: project.get('status') || '未知',
+      startDate: project.get('startDate') ? new Date(project.get('startDate')).toISOString().slice(0,10) : '',
+      endDate: project.get('endDate') ? new Date(project.get('endDate')).toISOString().slice(0,10) : '',
+      date: project.get('createdAt') ? new Date(project.get('createdAt')).toISOString().slice(0,10) : ''
+    }));
+
+    this.detailData.set(detailItems);
+  }
+
+  // 加载设计师详情数据
+  private async loadDesignerDetailData(): Promise<void> {
+    const designerQuery = new FmodeQuery('Profile');
+    designerQuery.equalTo('role', 'designer');
+
+    const designers = await designerQuery.descending('createdAt').find();
+
+    const detailItems = designers.map((designer: FmodeObject) => ({
+      id: designer.id,
+      name: designer.get('name') || '未命名',
+      level: designer.get('level') || 'junior',
+      completed: designer.get('completedProjects') || 0,
+      inProgress: designer.get('activeProjects') || 0,
+      avgCycle: designer.get('avgCycle') || 7,
+      date: designer.get('createdAt') ? new Date(designer.get('createdAt')).toISOString().slice(0,10) : ''
+    }));
+
+    this.detailData.set(detailItems);
+  }
+
+  // 加载客户详情数据
+  private async loadCustomerDetailData(): Promise<void> {
+    const customerQuery = new FmodeQuery('Profile');
+    customerQuery.equalTo('role', 'customer');
+
+    const customers = await customerQuery.descending('createdAt').find();
+
+    const detailItems = customers.map((customer: FmodeObject) => ({
+      id: customer.id,
+      name: customer.get('name') || '未命名',
+      projects: customer.get('projectCount') || 0,
+      lastContact: customer.get('lastContactAt') ? new Date(customer.get('lastContactAt')).toISOString().slice(0,10) : '',
+      status: customer.get('status') || '潜在',
+      date: customer.get('createdAt') ? new Date(customer.get('createdAt')).toISOString().slice(0,10) : ''
+    }));
+
+    this.detailData.set(detailItems);
+  }
+
+  // 加载收入详情数据
+  private async loadRevenueDetailData(): Promise<void> {
+    const orderQuery = new FmodeQuery('Order');
+    orderQuery.equalTo('status', 'paid');
+
+    const orders = await orderQuery.descending('createdAt').find();
+
+    const detailItems = orders.map((order: FmodeObject) => ({
+      invoiceNo: order.get('invoiceNo') || `INV-${order.id}`,
+      customer: order.get('customer')?.get('name') || '未知客户',
+      amount: order.get('amount') || 0,
+      type: order.get('type') || 'service',
+      date: order.get('createdAt') ? new Date(order.get('createdAt')).toISOString().slice(0,10) : ''
+    }));
+
+    this.detailData.set(detailItems);
+  }
+
+  // 降级到模拟详情数据
+  private loadMockDetailData(type: 'totalProjects' | 'active' | 'completed' | 'designers' | 'customers' | 'revenue'): void {
     const now = new Date();
     const addDays = (base: Date, days: number) => new Date(base.getTime() + days * 86400000);
 
@@ -372,7 +605,8 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
         owner: ['张三','李四','王五','赵六'][i % 4],
         status: status || (i % 3 === 0 ? '进行中' : (i % 3 === 1 ? '已完成' : '待启动')),
         startDate: addDays(now, -60 + i).toISOString().slice(0,10),
-        endDate: addDays(now, -30 + i).toISOString().slice(0,10)
+        endDate: addDays(now, -30 + i).toISOString().slice(0,10),
+        date: addDays(now, -i).toISOString().slice(0,10)
       }));
       this.detailData.set(items);
       return;

+ 1 - 1
src/app/pages/admin/departments/departments.html

@@ -133,7 +133,7 @@
           </div>
           <div class="detail-item">
             <label>组长</label>
-            <div class="detail-value">{{ currentDepartment.leader?.get("name") }}</div>
+            <div class="detail-value">{{ currentDepartment.leader.get("name") }}</div>
           </div>
           <div class="detail-item">
             <label>成员数</label>

+ 18 - 2
src/app/pages/admin/employees/employees.html

@@ -68,7 +68,15 @@
           <td>{{ emp.mobile }}</td>
           <td>{{ emp.userid }}</td>
           <td><span class="badge">{{ emp.roleName }}</span></td>
-          <td>{{ emp.department }}</td>
+          <td>
+            @if(emp.roleName=="客服"){
+              客服部
+            }@else if(emp.roleName=="管理员"){
+              总部
+            }@else{
+              {{ emp.department }}
+            }
+           </td>
           <td><span [class]="'status ' + (emp.isDisabled ? 'disabled' : 'active')">{{ emp.isDisabled ? '已禁用' : '正常' }}</span></td>
           <td>
             <button class="btn-icon" (click)="viewEmployee(emp)" title="查看">👁</button>
@@ -99,7 +107,15 @@
           <div class="detail-item"><label>手机号</label><div>{{ currentEmployee.mobile }}</div></div>
           <div class="detail-item"><label>企微ID</label><div>{{ currentEmployee.userid }}</div></div>
           <div class="detail-item"><label>身份</label><div>{{ currentEmployee.roleName }}</div></div>
-          <div class="detail-item"><label>部门</label><div>{{ currentEmployee.department }}</div></div>
+          <div class="detail-item"><label>部门</label><div>
+          @if(currentEmployee.roleName=="客服"){
+            客服部
+          }@else if(currentEmployee.roleName=="管理员"){
+            总部
+          }@else{
+            {{ currentEmployee.department }}
+          }
+          </div></div>
           <div class="detail-item"><label>状态</label><div>{{ currentEmployee.isDisabled ? '已禁用' : '正常' }}</div></div>
         </div>
         <div *ngIf="panelMode === 'edit'" class="form-view">

+ 2 - 2
src/app/pages/admin/employees/employees.ts

@@ -80,8 +80,8 @@ export class Employees implements OnInit {
           mobile: json.mobile || '',
           userid: json.userid || '',
           roleName: json.roleName || '未分配',
-          department: json.departmentName || '未分配',
-          departmentId: json.departmentId,
+          department: e.get("department")?.get("name") || '未分配',
+          departmentId: e.get("department")?.id,
           isDisabled: json.isDisabled || false,
           createdAt: json.createdAt
         };

+ 1 - 1
src/app/pages/admin/groupchats/groupchats.html

@@ -44,7 +44,7 @@
           <td><code>{{ group.chat_id }}</code></td>
           <td>
             <span *ngIf="group.project" class="project-link" [routerLink]="['/admin/project-detail', group.project.id]" title="点击查看项目详情">
-              {{ group.project?.get("title") }}
+              {{ group.project.get("title") }}
             </span>
             <span *ngIf="!group.project" class="no-project">
               未关联

+ 10 - 2
src/app/pages/admin/services/department.service.ts

@@ -1,6 +1,7 @@
 import { Injectable } from '@angular/core';
 import { AdminDataService } from './admin-data.service';
-import { FmodeObject } from 'fmode-ng/core';
+import { FmodeObject, FmodeParse } from 'fmode-ng/core';
+const Parse = FmodeParse.with("nova")
 
 /**
  * 项目组(部门)管理数据服务
@@ -86,6 +87,12 @@ export class DepartmentService {
     return await this.adminData.save(dept);
   }
 
+  async saveProfileDepart(pid:string,did:string){
+    let query = new Parse.Query("Profile");
+    let profile = await query.get(pid);
+    profile.set("department",{__type:"Pointer",className:"Department",objectId:did});
+    profile.save();
+  }
   /**
    * 更新项目组
    */
@@ -107,12 +114,13 @@ export class DepartmentService {
       dept.set('name', updates.name);
     }
 
-    if (updates.leaderId !== undefined) {
+    if (updates.leaderId) {
       dept.set('leader', {
         __type: 'Pointer',
         className: 'Profile',
         objectId: updates.leaderId
       });
+      this.saveProfileDepart(updates.leaderId,objectId)
     }
 
     if (updates.type !== undefined) {

+ 1 - 1
src/app/pages/admin/services/project.service.ts

@@ -190,7 +190,7 @@ export class ProjectService {
       objectIds.map(id => this.getProject(id))
     );
 
-    const validProjects = projects.filter(p => p !== null) as FmodeObject[];
+    const validProjects = projects.filter((p): p is FmodeObject => p !== null);
     if (validProjects.length === 0) {
       return 0;
     }

+ 1 - 0
src/app/pages/admin/system-management/system-management.ts

@@ -2,6 +2,7 @@ import { Component } from '@angular/core';
 
 @Component({
   selector: 'app-system-management',
+  standalone: true,
   imports: [],
   templateUrl: './system-management.html',
   styleUrl: './system-management.scss'

+ 3 - 3
src/app/pages/auth/login/login.html

@@ -1,8 +1,8 @@
 <div class="login-container">
   <div class="login-card">
     <div class="brand">
-      <div class="logo"></div>
-      <h1>欢迎使用 YSS 平台</h1>
+      <div class="logo"><img src="/assets/logo.jpg" width="100px" alt="" srcset=""></div>
+      <h1>欢迎使用 映三色 智慧项目系统</h1>
       
     </div>
 
@@ -50,7 +50,7 @@
     </div>
 
     <footer class="footer">
-      <span>© {{ currentYear }} YSS</span>
+      <span>© {{ currentYear }} 映三色</span>
       <a href="https://app.fmode.cn/dev/yss/">隐私</a>
       <a href="https://app.fmode.cn/dev/yss/wxwork/cDL6R1hgSi/project-loader">条款</a>
     </footer>

+ 25 - 6
src/app/pages/auth/login/login.scss

@@ -9,16 +9,31 @@ $border: #e5e5ea;
 $shadow: 0 8px 30px rgba(0,0,0,0.06);
 
 .login-container {
-  min-height: 100vh;
+  height: 100vh;
+  width: 100vw;
   display: grid;
   place-items: center;
-  background: linear-gradient(180deg, #f5f7fb, #eef1f7);
   padding: 24px;
+  background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
+  background-size: 400% 400%;
+  animation: gradient 15s ease infinite;
+}
+
+@keyframes gradient {
+  0% {
+      background-position: 0% 50%;
+  }
+  50% {
+      background-position: 100% 50%;
+  }
+  100% {
+      background-position: 0% 50%;
+  }
 }
 
 .login-card {
   width: min(960px, 100%);
-  background: $card;
+  background: rgba(255,255,255,0.5);
   border-radius: 24px;
   border: 1px solid $border;
   box-shadow: $shadow;
@@ -29,10 +44,14 @@ $shadow: 0 8px 30px rgba(0,0,0,0.06);
 }
 
 .brand {
-  text-align: center;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
   .logo {
-    font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji';
-    font-size: 42px;
+    width:100px;
+    border-radius: 20px;
+    padding: 10px;
+    background: white;
     margin-bottom: 4px;
   }
   h1 { font-size: clamp(20px, 2vw, 26px); color: $text; margin: 0; }

+ 6 - 4
src/app/pages/customer-service/customer-service-layout/customer-service-layout.html

@@ -70,10 +70,12 @@
       </div>
     </div>
     }
-    <div class="user-profile">
-      <div style="width: 40px; height: 40px; background-color: #FFCCCC; color: #555555; display: flex; align-items: center; justify-content: center; font-size: 13.333333333333334px; font-weight: bold;" class="user-avatar" title="用户头像">CS</div>
-      <span class="user-name">客服小李</span>
-    </div>
+    @if(currentUser?.name){
+      <div class="user-profile">
+        <img [src]="currentUser.avatar" alt="用户头像" class="user-avatar">
+        <span class="user-name">{{ currentUser.name }}</span>
+      </div>
+    }
   </div>
 </header>
 

+ 17 - 2
src/app/pages/customer-service/customer-service-layout/customer-service-layout.ts

@@ -2,7 +2,7 @@ import { Component, signal, OnInit, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, RouterOutlet, RouterLinkActive, RouterLink } from '@angular/router';
 import { FormsModule } from '@angular/forms';
-
+import { WxworkAuth } from "fmode-ng/core";
 @Component({
   selector: 'app-customer-service-layout',
   standalone: true,
@@ -23,12 +23,27 @@ export class CustomerServiceLayout implements OnInit, OnDestroy {
   ]);
   showNotifications = signal(false);
 
-  constructor(private router: Router) {}
+  constructor(private router: Router) {
+    this.loadProfile();
+  }
 
   ngOnInit() {
     // 监听来自iframe的消息
     window.addEventListener('message', this.handleIframeMessage.bind(this));
   }
+  currentUser:any = {}
+  async loadProfile(){
+      let cid = localStorage.getItem("company");
+      if(cid){
+        let wwAuth = new WxworkAuth({cid:cid})
+        let profile = await wwAuth.currentProfile();
+        this.currentUser  = {
+          name: profile?.get("name") || profile?.get("mobile"),
+          avatar: profile?.get("avatar"),
+          roleName: profile?.get("roleName")
+        }
+      }
+    }
 
   ngOnDestroy() {
     // 清理事件监听器

+ 6 - 6
src/app/pages/customer-service/dashboard/dashboard.html

@@ -2,7 +2,7 @@
 <section class="welcome-section">
   <div class="welcome-header">
     <div>
-      <h2>您好,客服小李 👋</h2>
+      <h2>您好,{{currentUser?.name}} 👋</h2>
       <p>今天是 {{ currentDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }) }},祝您工作顺利!</p>
     </div>
     <button class="attendance-view-btn" (click)="viewAttendance()" title="查看人员考勤">
@@ -551,7 +551,7 @@
         <input 
           type="text" 
           [value]="searchTerm()"
-          (input)="searchTerm.set($event.target.value)"
+          (input)="searchTerm.set($any($event.target).value)"
           placeholder="搜索动态..." 
           class="search-input"
         />
@@ -578,18 +578,18 @@
           </svg>
         </div>
         <div class="update-content">
-          @if ('name' in update && update.name && 'status' in update && update.status) {
+          @if (isProjectUpdate(update) && update.name && update.status) {
           <div class="update-title">
             项目 <strong>{{ update.name }}</strong> 状态更新为 {{ update.status }}
           </div>
           }
-          @if ('content' in update) {
+          @if (hasContent(update)) {
           <div class="update-title">
             <strong>{{ getCustomerName(update) }}</strong> 提交了反馈
           </div>
           }
-          @if ('content' in update && update.content) {
-          <p class="update-text">{{ update.content }}</p>
+          @if (hasContent(update) && getUpdateContent(update)) {
+          <p class="update-text">{{ getUpdateContent(update) }}</p>
           }
           <div class="update-meta">
             <span class="update-time">{{ getFormattedDate(update) }}</span>

+ 109 - 7
src/app/pages/customer-service/dashboard/dashboard.ts

@@ -5,6 +5,8 @@ import { FormsModule } from '@angular/forms';
 import { RouterModule, Router, ActivatedRoute } from '@angular/router';
 import { ProjectService } from '../../../services/project.service';
 import { Project, Task, CustomerFeedback } from '../../../models/project.model';
+import { FmodeQuery } from 'fmode-ng/core';
+import { WxworkAuth } from 'fmode-ng/core';
 
 @Component({
   selector: 'app-dashboard',
@@ -156,22 +158,96 @@ export class Dashboard implements OnInit, OnDestroy {
     return date.toISOString().split('T')[0];
   }
 
+
   constructor(
     private projectService: ProjectService,
     private router: Router,
     private activatedRoute: ActivatedRoute
-  ) {}
+  ) {
+    this.loadProfile();
+  }
+
+  currentUser:any = {}
+  async loadProfile(){
+      let cid = localStorage.getItem("company");
+      if(cid){
+        let wwAuth = new WxworkAuth({cid:cid})
+        let profile = await wwAuth.currentProfile();
+        this.currentUser  = {
+          name: profile?.get("name") || profile?.get("mobile"),
+          avatar: profile?.get("avatar"),
+          roleName: profile?.get("roleName")
+        }
+      }
+    }
 
-  ngOnInit(): void {
-    this.loadUrgentTasks();
-    this.loadProjectUpdates();
-    this.loadCRMQueues(); // 新增:加载新客户触达与老客户回访队列
-    this.loadPendingFinalPaymentProjects(); // 新增:加载待跟进尾款项目
-    
+
+  async ngOnInit(): Promise<void> {
     // 添加滚动事件监听
     window.addEventListener('scroll', this.onScroll.bind(this));
   }
 
+
+  // 加载仪表板数据
+  private async loadDashboardData(): Promise<void> {
+    try {
+      await Promise.all([
+        this.loadConsultationStats(),
+        this.loadUrgentTasks(),
+        this.loadProjectUpdates(),
+        this.loadCRMQueues(),
+        this.loadPendingFinalPaymentProjects()
+      ]);
+      console.log('✅ 客服仪表板数据加载完成');
+    } catch (error) {
+      console.error('❌ 客服仪表板数据加载失败:', error);
+      throw error;
+    }
+  }
+
+  // 加载咨询统计数据
+  private async loadConsultationStats(): Promise<void> {
+    try {
+      // 新咨询数
+      const consultationQuery = new FmodeQuery('Consultation');
+      consultationQuery.equalTo('status', 'new');
+      consultationQuery.greaterThanOrEqualTo('createdAt', new Date(new Date().setHours(0,0,0,0)));
+      const newConsultations = await consultationQuery.count();
+      this.stats.newConsultations.set(newConsultations);
+
+      // 待派单数
+      consultationQuery.equalTo('status', 'pending_assignment');
+      const pendingAssignments = await consultationQuery.count();
+      this.stats.pendingAssignments.set(pendingAssignments);
+
+      // 异常项目数
+      const projectQuery = new FmodeQuery('Project');
+      projectQuery.equalTo('status', 'exception');
+      const exceptionProjects = await projectQuery.count();
+      this.stats.exceptionProjects.set(exceptionProjects);
+
+      // 售后服务数量
+      const afterSalesQuery = new FmodeQuery('AfterSales');
+      afterSalesQuery.equalTo('status', 'pending');
+      const afterSalesCount = await afterSalesQuery.count();
+      this.stats.afterSalesCount.set(afterSalesCount);
+
+      console.log(`✅ 咨询统计: 新咨询${newConsultations}, 待派单${pendingAssignments}, 异常${exceptionProjects}, 售后${afterSalesCount}`);
+    } catch (error) {
+      console.error('❌ 咨询统计加载失败:', error);
+      throw error;
+    }
+  }
+
+  // 降级到模拟数据
+  private loadMockData(): void {
+    console.warn('⚠️ 使用模拟数据');
+    this.loadUrgentTasks();
+    this.loadProjectUpdates();
+    this.loadCRMQueues();
+    this.loadPendingFinalPaymentProjects();
+  }
+
   // 添加滚动事件处理方法
   private onScroll(): void {
     this.showBackToTopSignal.set(window.scrollY > 300);
@@ -658,6 +734,32 @@ getUpdateStatus(update: Project | CustomerFeedback): string {
   return '已更新';
 }
 
+// 检查是否是项目更新
+isProjectUpdate(update: Project | CustomerFeedback): update is Project {
+  return 'name' in update && 'status' in update;
+}
+
+// 检查是否有内容字段
+hasContent(update: Project | CustomerFeedback): boolean {
+  return 'content' in update;
+}
+
+// 获取更新内容
+getUpdateContent(update: Project | CustomerFeedback): string {
+  if ('content' in update) {
+    return (update as CustomerFeedback).content;
+  }
+  return '';
+}
+
+// 处理搜索输入事件
+onSearchInput(event: Event): void {
+  const target = event.target as HTMLInputElement;
+  if (target) {
+    this.searchTerm.set(target.value);
+  }
+}
+
   // 添加getTaskStatus方法的正确实现
   getTaskStatus(task: Task): string {
     if (!task) return '未知状态';

+ 63 - 2
src/app/pages/designer/dashboard/dashboard.ts

@@ -5,6 +5,8 @@ import { ProjectService } from '../../../services/project.service';
 import { Task } from '../../../models/project.model';
 import { SkillRadarComponent } from './skill-radar/skill-radar.component';
 import { PersonalBoard } from '../personal-board/personal-board';
+import { FmodeQuery, FmodeObject, FmodeUser } from 'fmode-ng/core';
+import { WxworkAuth } from 'fmode-ng/core';
 
 interface ShiftTask {
   id: string;
@@ -25,6 +27,7 @@ interface ProjectTimelineItem {
 
 @Component({
   selector: 'app-dashboard',
+  standalone: true,
   imports: [CommonModule, RouterModule, SkillRadarComponent, PersonalBoard],
   templateUrl: './dashboard.html',
   styleUrl: './dashboard.scss'
@@ -49,10 +52,68 @@ export class Dashboard implements OnInit {
   // 个人项目饱和度相关属性
   workloadPercentage: number = 0;
   projectTimeline: ProjectTimelineItem[] = [];
+  private wxAuth: WxworkAuth | null = null;
+  private currentUser: FmodeUser | null = null;
 
-  constructor(private projectService: ProjectService) {}
+  constructor(private projectService: ProjectService) {
+    this.initAuth();
+  }
+
+  // 初始化企业微信认证
+  private initAuth(): void {
+    try {
+      this.wxAuth = new WxworkAuth({
+        cid: 'cDL6R1hgSi'  // 公司帐套ID
+      });
+      console.log('✅ 设计师仪表板企业微信认证初始化成功');
+    } catch (error) {
+      console.error('❌ 设计师仪表板企业微信认证初始化失败:', error);
+    }
+  }
+
+  async ngOnInit(): Promise<void> {
+    await this.authenticateAndLoadData();
+  }
+
+  // 认证并加载数据
+  private async authenticateAndLoadData(): Promise<void> {
+    try {
+      // 执行企业微信认证和登录
+      const { user } = await this.wxAuth!.authenticateAndLogin();
+      this.currentUser = user;
+
+      if (user) {
+        console.log('✅ 设计师登录成功:', user.get('username'));
+        await this.loadDashboardData();
+      } else {
+        console.error('❌ 设计师登录失败');
+      }
+    } catch (error) {
+      console.error('❌ 设计师认证过程出错:', error);
+      // 降级到模拟数据
+      this.loadMockData();
+    }
+  }
+
+  // 加载仪表板数据
+  private async loadDashboardData(): Promise<void> {
+    try {
+      await Promise.all([
+        this.loadTasks(),
+        this.loadShiftTasks(),
+        this.calculateWorkloadPercentage(),
+        this.loadProjectTimeline()
+      ]);
+      console.log('✅ 设计师仪表板数据加载完成');
+    } catch (error) {
+      console.error('❌ 设计师仪表板数据加载失败:', error);
+      throw error;
+    }
+  }
 
-  ngOnInit(): void {
+  // 降级到模拟数据
+  private loadMockData(): void {
+    console.warn('⚠️ 使用模拟数据');
     this.loadTasks();
     this.loadShiftTasks();
     this.calculateWorkloadPercentage();

+ 1 - 0
src/app/pages/designer/dashboard/skill-radar/skill-radar.component.ts

@@ -17,6 +17,7 @@ interface SkillAnalysisItem {
 
 @Component({
   selector: 'app-skill-radar',
+  standalone: true,
   imports: [CommonModule],
   templateUrl: './skill-radar.component.html',
   styleUrl: './skill-radar.component.scss'

+ 1 - 0
src/app/pages/designer/material-share/material-share.ts

@@ -20,6 +20,7 @@ interface MaterialItem {
 
 @Component({
   selector: 'app-material-share',
+  standalone: true,
   imports: [CommonModule, FormsModule],
   templateUrl: './material-share.html',
   styleUrl: './material-share.scss'

+ 1 - 0
src/app/pages/designer/personal-board/personal-board.ts

@@ -12,6 +12,7 @@ import {
 
 @Component({
   selector: 'app-personal-board',
+  standalone: true,
   imports: [CommonModule, RouterModule],
   templateUrl: './personal-board.html',
   styleUrl: './personal-board.scss'

+ 2 - 2
src/app/pages/designer/project-detail/components/order-creation/order-creation.component.html

@@ -143,7 +143,7 @@
             <button type="button" class="btn-secondary" (click)="addSpaceType()">添加空间类型</button>
           </div>
           <div class="config-list">
-            @for (space of spaceTypeConfig(); track $index; let i = $index) {
+            @for (space of spaceTypeConfig(); let i = $index; track i) {
               <div class="config-item">
                 <div class="config-fields">
                   <input type="text" [(ngModel)]="space.type" placeholder="空间类型" class="field-input">
@@ -163,7 +163,7 @@
             <button type="button" class="btn-secondary" (click)="addStyleLevel()">添加风格等级</button>
           </div>
           <div class="config-list">
-            @for (style of styleLevelConfig(); track $index; let i = $index) {
+            @for (style of styleLevelConfig(); let i = $index; track i) {
               <div class="config-item">
                 <div class="config-fields">
                   <input type="text" [(ngModel)]="style.level" placeholder="等级代码" class="field-input">

+ 1 - 0
src/app/pages/finance/dashboard/dashboard.ts

@@ -44,6 +44,7 @@ interface DesignerEfficiency {
 
 @Component({
   selector: 'app-dashboard',
+  standalone: true,
   imports: [CommonModule, RouterModule],
   templateUrl: './dashboard.html',
   styleUrl: './dashboard.scss'

+ 1 - 0
src/app/pages/finance/project-records/project-records.ts

@@ -50,6 +50,7 @@ export interface HourlyRateConfig {
 
 @Component({
   selector: 'app-project-records',
+  standalone: true,
   imports: [CommonModule, ReactiveFormsModule, RouterModule],
   templateUrl: './project-records.html',
   styleUrls: ['./project-records.scss']

+ 1 - 0
src/app/pages/finance/reconciliation/reconciliation.ts

@@ -51,6 +51,7 @@ export interface ReconciliationRecord {
 
 @Component({
   selector: 'app-reconciliation',
+  standalone: true,
   imports: [CommonModule, ReactiveFormsModule, FormsModule, RouterModule],
   templateUrl: './reconciliation.html',
   styleUrls: ['./reconciliation.scss']

+ 1 - 1
src/app/pages/hr/recruitment-performance/recruitment-performance.html

@@ -249,7 +249,7 @@
 
           <!-- 阶段时间线 -->
           <div class="stages-timeline">
-            @for (stage of recruitmentStages(); track $index; let i = $index) {
+            @for (stage of recruitmentStages(); let i = $index; track i) {
               <div class="timeline-item" 
                    [class]="getStageStatusClass(stage, i)"
                    (click)="onStageClick(stage, i)">

+ 1 - 0
src/app/pages/team-leader/dashboard/dashboard.ts

@@ -74,6 +74,7 @@ interface EmployeeDetail {
 declare const echarts: any;
 @Component({
   selector: 'app-dashboard',
+  standalone: true,
   imports: [CommonModule, FormsModule, RouterModule],
   templateUrl: './dashboard.html',
   styleUrl: './dashboard.scss'

+ 5 - 6
src/app/pages/team-leader/workload-calendar/workload-calendar.html

@@ -3,9 +3,9 @@
     <h1>负载日历</h1>
     <div class="controls">
       <div class="view-switch" role="tablist" aria-label="视图切换">
-        <button [class.active]="view==='day'" (click)="switchView('day')" role="tab" aria-selected="{{view==='day'}}" title="日视图">日</button>
-        <button [class.active]="view==='week'" (click)="switchView('week')" role="tab" aria-selected="{{view==='week'}}" title="周视图">周</button>
-        <button [class.active]="view==='month'" (click)="switchView('month')" role="tab" aria-selected="{{view==='month'}}" title="月视图">月</button>
+        <button [class.active]="view==='day'" (click)="switchView('day')" role="tab" [attr.aria-selected]="view==='day'" title="日视图">日</button>
+        <button [class.active]="view==='week'" (click)="switchView('week')" role="tab" [attr.aria-selected]="view==='week'" title="周视图">周</button>
+        <button [class.active]="view==='month'" (click)="switchView('month')" role="tab" [attr.aria-selected]="view==='month'" title="月视图">月</button>
       </div>
       <div class="date-nav">
         <button (click)="navigateDate('prev')" aria-label="上一周期">‹</button>
@@ -123,8 +123,7 @@
       <div class="day-panel">
         <div class="date-label large">{{ selectedDate | date:'yyyy-MM-dd EEEE' }}</div>
         <div class="tasks">
-          @let dayTasksLocal = dayTasks;
-          @for (t of dayTasksLocal; track t.id) {
+          @for (t of dayTasks; track t.id) {
             <div class="task-row" [class.overdue]="t.isOverdue" [class.due-soon]="isDueSoon(t.deadline)" title="{{t.title}} - {{t.projectName}} / {{t.assignee}}" (click)="navigateToProject(t, $event)">
               <div class="title">{{ t.title }}</div>
               <div class="project">{{ t.projectName }}</div>
@@ -132,7 +131,7 @@
               <div class="priority" [class.high]="t.priority==='high'">{{ t.priority }}</div>
             </div>
           }
-          @if (dayTasksLocal.length === 0) {
+          @if (dayTasks.length === 0) {
             <div class="no-task">今日暂无任务</div>
           }
         </div>

+ 2 - 2
src/app/shared/components/auto-settlement-config/auto-settlement-config.html

@@ -45,7 +45,7 @@
         <!-- 条件配置 -->
         <div class="conditions-section">
           <h3>条件配置</h3>
-          @for (condition of newRule.conditions; track $index; let i = $index) {
+          @for (condition of newRule.conditions; let i = $index; track i) {
             <div class="condition-item">
               <div class="condition-header">
                 <span>条件 {{ i + 1 }}</span>
@@ -90,7 +90,7 @@
         <!-- 动作配置 -->
         <div class="actions-section">
           <h3>动作配置</h3>
-          @for (action of newRule.actions; track $index; let i = $index) {
+          @for (action of newRule.actions; let i = $index; track i) {
             <div class="action-item">
               <div class="action-header">
                 <span>动作 {{ i + 1 }}</span>

+ 1 - 1
src/app/shared/components/complaint-card/complaint-card.html

@@ -166,7 +166,7 @@
           class="search-input"
           placeholder="搜索投诉内容、客户姓名..."
           [value]="searchKeyword()"
-          (input)="updateSearchKeyword($event.target.value)">
+          (input)="onSearchInput($event)">
       </div>
     </div>
 

+ 7 - 0
src/app/shared/components/complaint-card/complaint-card.ts

@@ -406,6 +406,13 @@ export class ComplaintCardComponent {
     this.searchKeyword.set(keyword);
   }
 
+  onSearchInput(event: Event): void {
+    const target = event.target as HTMLInputElement;
+    if (target) {
+      this.updateSearchKeyword(target.value);
+    }
+  }
+
   updateSourceFilter(event: any) {
     this.sourceFilter.set(event.target.value);
   }

+ 1 - 1
src/app/shared/components/consultation-order-panel/consultation-order-panel.component.scss

@@ -450,7 +450,7 @@ $ios-spacing-xl: 32px;
         &.two-columns {
           .form-group {
             flex: 1;
-            min-width: calc(50% - #{$ios-spacing-md / 2});
+            min-width: calc(50% - (#{$ios-spacing-md} / 2));
           }
         }
 

+ 17 - 17
src/app/shared/components/requirements-confirm-card/requirements-confirm-card.html

@@ -356,12 +356,12 @@
                                 <h6>灯光分析</h6>
                                 @if (material.analysis.lightingAnalysis.ambientAnalysis) {
                                   <div class="lighting-info">
-                                    <span class="mood-tag">{{ material.analysis.lightingAnalysis.ambientAnalysis.lightingMood?.primary || '未知' }}</span>
+                                    <span class="mood-tag">{{ material.analysis.lightingAnalysis.ambientAnalysis.lightingMood.primary || '未知' }}</span>
                                     @if (material.analysis.lightingAnalysis.illuminationAnalysis) {
-                                      <span class="brightness-tag">亮度: {{ material.analysis.lightingAnalysis.illuminationAnalysis.brightness?.overall || 0 }}%</span>
+                                      <span class="brightness-tag">亮度: {{ material.analysis.lightingAnalysis.illuminationAnalysis.brightness.overall || 0 }}%</span>
                                     }
                                     @if (material.analysis.lightingAnalysis.lightSourceIdentification) {
-                                      <span class="source-tag">光源: {{ material.analysis.lightingAnalysis.lightSourceIdentification.lightingSetup?.dominantSource || '未知' }}</span>
+                                      <span class="source-tag">光源: {{ material.analysis.lightingAnalysis.lightSourceIdentification.lightingSetup.dominantSource || '未知' }}</span>
                                     }
                                   </div>
                                 }
@@ -641,9 +641,9 @@
                 <div class="scene-info">
                   <div class="info-row">
                     <span class="label">基础场景:</span>
-                    <span class="value">{{ requirementMapping?.sceneGeneration?.baseScene || '未定义' }}</span>
+                    <span class="value">{{ requirementMapping.sceneGeneration.baseScene || '未定义' }}</span>
                   </div>
-                  @if (requirementMapping?.sceneGeneration?.atmospherePreview) {
+                  @if (requirementMapping.sceneGeneration.atmospherePreview) {
                     <div class="atmosphere-preview">
                       <img [src]="requirementMapping.sceneGeneration.atmospherePreview" 
                            alt="氛围感预览图"
@@ -664,19 +664,19 @@
                     <div class="color-params">
                       <div class="param-item">
                         <span class="label">主要颜色:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.colorParams?.primaryColors?.length || 0 }} 种</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.colorParams.primaryColors.length || 0 }} 种</span>
                       </div>
                       <div class="param-item">
                         <span class="label">色彩和谐:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.colorParams ? getColorHarmonyName(requirementMapping.parameterMapping.colorParams.colorHarmony) : '未知' }}</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.colorParams ? getColorHarmonyName(requirementMapping.parameterMapping.colorParams.colorHarmony) : '未知' }}</span>
                       </div>
                       <div class="param-item">
                         <span class="label">饱和度:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.colorParams?.saturation || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.colorParams.saturation || 0 }}%</span>
                       </div>
                       <div class="param-item">
                         <span class="label">亮度:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.colorParams?.brightness || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.colorParams.brightness || 0 }}%</span>
                       </div>
                     </div>
                   </div>
@@ -687,19 +687,19 @@
                     <div class="space-params">
                       <div class="param-item">
                         <span class="label">布局类型:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.spaceParams?.layout ? getLayoutTypeName(requirementMapping.parameterMapping.spaceParams.layout.type) : '未知' }}</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.spaceParams.layout ? getLayoutTypeName(requirementMapping.parameterMapping.spaceParams.layout.type) : '未知' }}</span>
                       </div>
                       <div class="param-item">
                         <span class="label">空间流线:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.spaceParams?.layout ? getFlowTypeName(requirementMapping.parameterMapping.spaceParams.layout.flow) : '未知' }}</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.spaceParams.layout ? getFlowTypeName(requirementMapping.parameterMapping.spaceParams.layout.flow) : '未知' }}</span>
                       </div>
                       <div class="param-item">
                         <span class="label">家具比例:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.spaceParams?.scale?.furniture || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.spaceParams.scale.furniture || 0 }}%</span>
                       </div>
                       <div class="param-item">
                         <span class="label">开放度:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.spaceParams?.scale?.openness || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.spaceParams.scale.openness || 0 }}%</span>
                       </div>
                     </div>
                   </div>
@@ -710,19 +710,19 @@
                     <div class="material-params">
                       <div class="param-item">
                         <span class="label">纹理缩放:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.materialParams?.textureScale || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.materialParams.textureScale || 0 }}%</span>
                       </div>
                       <div class="param-item">
                         <span class="label">反射率:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.materialParams?.reflectivity || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.materialParams.reflectivity || 0 }}%</span>
                       </div>
                       <div class="param-item">
                         <span class="label">粗糙度:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.materialParams?.roughness || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.materialParams.roughness || 0 }}%</span>
                       </div>
                       <div class="param-item">
                         <span class="label">金属度:</span>
-                        <span class="value">{{ requirementMapping?.parameterMapping?.materialParams?.metallic || 0 }}%</span>
+                        <span class="value">{{ requirementMapping.parameterMapping.materialParams.metallic || 0 }}%</span>
                       </div>
                     </div>
                   </div>

+ 1 - 1
src/app/shared/components/settlement-card/settlement-card.html

@@ -63,7 +63,7 @@
         type="text" 
         placeholder="搜索项目名称或阶段..."
         [value]="searchKeyword()"
-        (input)="updateSearchKeyword($event.target.value)">
+        (input)="onSearchInput($event)">
     </div>
     <div class="filter-buttons">
       <button 

+ 7 - 0
src/app/shared/components/settlement-card/settlement-card.ts

@@ -600,6 +600,13 @@ export class SettlementCardComponent implements OnInit {
   updateSearchKeyword(keyword: string): void {
     this.searchKeyword.set(keyword);
   }
+
+  onSearchInput(event: Event): void {
+    const target = event.target as HTMLInputElement;
+    if (target) {
+      this.updateSearchKeyword(target.value);
+    }
+  }
   
   // 计算逾期天数
   getDaysOverdue(settlement: Settlement): number {

+ 138 - 0
src/app/shared/components/upload-component/upload.component.html

@@ -0,0 +1,138 @@
+<div class="upload-container"
+     [class.drag-over]="dragOver"
+     [class.disabled]="disabled"
+     [class.uploading]="isUploading">
+
+  <!-- 拖拽上传区域 -->
+  <div class="upload-area"
+       *ngIf="!isUploading"
+       (click)="triggerFileSelect()"
+       (dragover)="onDragOver($event)"
+       (dragleave)="onDragLeave($event)"
+       (drop)="onDrop($event)">
+
+    <div class="upload-icon">
+      <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+        <polyline points="17,8 12,3 7,8"></polyline>
+        <line x1="12" y1="3" x2="12" y2="15"></line>
+      </svg>
+    </div>
+
+    <div class="upload-text">
+      <p class="upload-title">
+        拖拽文件到此处或 <span class="upload-link">点击选择文件</span>
+      </p>
+      <p class="upload-hint" *ngIf="accept !== '*/*'">
+        支持格式: {{ accept }}
+      </p>
+      <p class="upload-hint">
+        最大文件大小: {{ maxSize }}MB
+        <span *ngIf="multiple">,支持多文件上传</span>
+      </p>
+      <p class="upload-hint" *ngIf="prefixKey">
+        存储路径: {{ prefixKey }}
+      </p>
+    </div>
+
+    <!-- 隐藏的文件输入 -->
+    <input #fileInput
+           type="file"
+           [accept]="accept"
+           [multiple]="multiple"
+           [disabled]="disabled"
+           (change)="onFileSelect($event)"
+           class="file-input">
+  </div>
+
+  <!-- 上传进度 -->
+  <div class="upload-progress" *ngIf="isUploading">
+    <div class="progress-icon">
+      <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
+        <path d="M21 12a9 9 0 11-6.219-8.56"></path>
+      </svg>
+    </div>
+    <div class="progress-content">
+      <p class="progress-title">正在上传...</p>
+      <div class="progress-bar">
+        <div class="progress-fill" [style.width.%]="uploadProgress"></div>
+      </div>
+      <p class="progress-text">{{ uploadProgress | number:'1.0-0' }}%</p>
+    </div>
+  </div>
+
+  <!-- 上传结果 -->
+  <div class="upload-results" *ngIf="uploadedFiles.length > 0">
+    <div class="results-header">
+      <h4>上传结果</h4>
+      <button class="clear-btn" (click)="clearResults()" *ngIf="!isUploading">
+        清空
+      </button>
+    </div>
+
+    <div class="file-list">
+      <div class="file-item"
+           *ngFor="let file of uploadedFiles; let i = index"
+           [class.success]="file.success"
+           [class.error]="!file.success">
+
+        <!-- 文件图标 -->
+        <div class="file-icon">
+          <svg *ngIf="file.success" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
+            <polyline points="14,2 14,8 20,8"></polyline>
+          </svg>
+          <svg *ngIf="!file.success" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10"></circle>
+            <line x1="15" y1="9" x2="9" y2="15"></line>
+            <line x1="9" y1="9" x2="15" y2="15"></line>
+          </svg>
+        </div>
+
+        <!-- 文件信息 -->
+        <div class="file-info">
+          <p class="file-name">{{ file.success ? '上传成功' : '上传失败' }}</p>
+          <p class="file-error" *ngIf="!file.success && file.error">
+            {{ file.error }}
+          </p>
+          <p class="file-url" *ngIf="file.success && file.url">
+            <a [href]="file.url" target="_blank" rel="noopener noreferrer">
+              查看文件
+            </a>
+          </p>
+        </div>
+
+        <!-- 操作按钮 -->
+        <div class="file-actions">
+          <button class="remove-btn"
+                  (click)="removeUploadedFile(i)"
+                  *ngIf="!isUploading">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <line x1="18" y1="6" x2="6" y2="18"></line>
+              <line x1="6" y1="6" x2="18" y2="18"></line>
+            </svg>
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 预览区域 -->
+  <div class="preview-area" *ngIf="showPreview && uploadedFiles.length > 0">
+    <div class="preview-grid">
+      <div class="preview-item"
+           *ngFor="let file of uploadedFiles; let i = index"
+           [class.hidden]="!(file.success && file.url)">
+        <img [src]="file.url" [alt]="'预览图片 ' + (i + 1)" class="preview-image">
+        <button class="preview-remove"
+                (click)="removeUploadedFile(i)"
+                *ngIf="!isUploading">
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18"></line>
+            <line x1="6" y1="6" x2="18" y2="18"></line>
+          </svg>
+        </button>
+      </div>
+    </div>
+  </div>
+</div>

+ 344 - 0
src/app/shared/components/upload-component/upload.component.scss

@@ -0,0 +1,344 @@
+.upload-container {
+  border: 2px dashed #d1d5db;
+  border-radius: 8px;
+  padding: 24px;
+  text-align: center;
+  transition: all 0.3s ease;
+  background-color: #f9fafb;
+
+  &.drag-over {
+    border-color: #3b82f6;
+    background-color: #eff6ff;
+    transform: scale(1.02);
+  }
+
+  &.disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+
+    .upload-area {
+      cursor: not-allowed;
+    }
+  }
+
+  &.uploading {
+    border-color: #6b7280;
+    background-color: #f3f4f6;
+  }
+}
+
+.upload-area {
+  cursor: pointer;
+  transition: all 0.3s ease;
+
+  &:hover {
+    .upload-icon {
+      color: #3b82f6;
+    }
+  }
+}
+
+.upload-icon {
+  color: #6b7280;
+  margin-bottom: 16px;
+  transition: color 0.3s ease;
+}
+
+.upload-text {
+  color: #374151;
+
+  .upload-title {
+    font-size: 16px;
+    font-weight: 500;
+    margin: 0 0 8px 0;
+
+    .upload-link {
+      color: #3b82f6;
+      text-decoration: underline;
+      cursor: pointer;
+
+      &:hover {
+        color: #2563eb;
+      }
+    }
+  }
+
+  .upload-hint {
+    font-size: 14px;
+    color: #6b7280;
+    margin: 4px 0;
+  }
+}
+
+.file-input {
+  display: none;
+}
+
+.upload-progress {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.progress-icon {
+  color: #3b82f6;
+
+  .spin {
+    animation: spin 1s linear infinite;
+  }
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+
+.progress-content {
+  flex: 1;
+
+  .progress-title {
+    font-size: 16px;
+    font-weight: 500;
+    margin: 0 0 12px 0;
+    color: #374151;
+  }
+
+  .progress-bar {
+    width: 100%;
+    height: 8px;
+    background-color: #e5e7eb;
+    border-radius: 4px;
+    overflow: hidden;
+    margin-bottom: 8px;
+  }
+
+  .progress-fill {
+    height: 100%;
+    background-color: #3b82f6;
+    transition: width 0.3s ease;
+    border-radius: 4px;
+  }
+
+  .progress-text {
+    font-size: 14px;
+    color: #6b7280;
+    margin: 0;
+  }
+}
+
+.upload-results {
+  margin-top: 24px;
+  text-align: left;
+
+  .results-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 16px;
+
+    h4 {
+      margin: 0;
+      font-size: 16px;
+      font-weight: 500;
+      color: #374151;
+    }
+
+    .clear-btn {
+      background: none;
+      border: none;
+      color: #6b7280;
+      cursor: pointer;
+      font-size: 14px;
+      padding: 4px 8px;
+      border-radius: 4px;
+      transition: all 0.2s ease;
+
+      &:hover {
+        background-color: #f3f4f6;
+        color: #374151;
+      }
+    }
+  }
+
+  .file-list {
+    .file-item {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      padding: 12px;
+      border-radius: 6px;
+      margin-bottom: 8px;
+      background-color: #f9fafb;
+      border: 1px solid #e5e7eb;
+      transition: all 0.2s ease;
+
+      &.success {
+        border-color: #d1fae5;
+        background-color: #ecfdf5;
+      }
+
+      &.error {
+        border-color: #fee2e2;
+        background-color: #fef2f2;
+      }
+
+      &:hover {
+        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+      }
+    }
+
+    .file-icon {
+      flex-shrink: 0;
+      width: 32px;
+      height: 32px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 4px;
+      background-color: #f3f4f6;
+
+      .file-item.success & {
+        background-color: #d1fae5;
+        color: #065f46;
+      }
+
+      .file-item.error & {
+        background-color: #fee2e2;
+        color: #991b1b;
+      }
+    }
+
+    .file-info {
+      flex: 1;
+      min-width: 0;
+
+      .file-name {
+        font-size: 14px;
+        font-weight: 500;
+        margin: 0 0 4px 0;
+        color: #374151;
+      }
+
+      .file-error {
+        font-size: 12px;
+        color: #dc2626;
+        margin: 0 0 4px 0;
+      }
+
+      .file-url {
+        margin: 0;
+
+        a {
+          font-size: 12px;
+          color: #3b82f6;
+          text-decoration: none;
+
+          &:hover {
+            text-decoration: underline;
+          }
+        }
+      }
+    }
+
+    .file-actions {
+      flex-shrink: 0;
+
+      .remove-btn {
+        background: none;
+        border: none;
+        color: #6b7280;
+        cursor: pointer;
+        padding: 4px;
+        border-radius: 4px;
+        transition: all 0.2s ease;
+
+        &:hover {
+          background-color: #fee2e2;
+          color: #dc2626;
+        }
+      }
+    }
+  }
+}
+
+.preview-area {
+  margin-top: 24px;
+
+  .preview-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+    gap: 16px;
+  }
+
+  .preview-item {
+    position: relative;
+    border-radius: 8px;
+    overflow: hidden;
+    background-color: #f9fafb;
+    border: 1px solid #e5e7eb;
+
+    .preview-image {
+      width: 100%;
+      height: 120px;
+      object-fit: cover;
+      display: block;
+    }
+
+    .preview-remove {
+      position: absolute;
+      top: 4px;
+      right: 4px;
+      background-color: rgba(239, 68, 68, 0.9);
+      border: none;
+      border-radius: 4px;
+      color: white;
+      cursor: pointer;
+      padding: 4px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      opacity: 0;
+      transition: opacity 0.2s ease;
+
+      &:hover {
+        background-color: rgba(220, 38, 38, 1);
+      }
+    }
+
+    &:hover {
+      .preview-remove {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+// 响应式设计
+@media (max-width: 640px) {
+  .upload-container {
+    padding: 16px;
+  }
+
+  .upload-text .upload-title {
+    font-size: 14px;
+  }
+
+  .upload-text .upload-hint {
+    font-size: 12px;
+  }
+
+  .upload-progress {
+    flex-direction: column;
+    text-align: center;
+    gap: 12px;
+  }
+
+  .preview-grid {
+    grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+    gap: 12px;
+  }
+
+  .preview-item .preview-image {
+    height: 80px;
+  }
+}

+ 332 - 0
src/app/shared/components/upload-component/upload.component.ts

@@ -0,0 +1,332 @@
+import { Component, EventEmitter, Input, Output, ViewChild, ElementRef } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { NovaStorage, NovaFile } from 'fmode-ng/core';
+
+export interface UploadResult {
+  success: boolean;
+  file?: NovaFile;
+  url?: string;
+  error?: string;
+}
+
+@Component({
+  selector: 'app-upload-component',
+  standalone: true,
+  imports: [CommonModule],
+  templateUrl: './upload.component.html',
+  styleUrls: ['./upload.component.scss']
+})
+export class UploadComponent {
+  @Input() accept: string = '*/*'; // 接受的文件类型
+  @Input() multiple: boolean = false; // 是否支持多文件上传
+  @Input() maxSize: number = 10; // 最大文件大小(MB)
+  @Input() allowedTypes: string[] = []; // 允许的文件类型
+  @Input() prefixKey: string = ''; // 文件存储前缀
+  @Input() disabled: boolean = false; // 是否禁用
+  @Input() showPreview: boolean = false; // 是否显示预览
+  @Input() compressImages: boolean = true; // 是否压缩图片
+
+  @Output() fileSelected = new EventEmitter<File[]>(); // 文件选择事件
+  @Output() uploadStart = new EventEmitter<File[]>(); // 上传开始事件
+  @Output() uploadProgressEvent = new EventEmitter<{ completed: number; total: number; currentFile: string }>(); // 上传进度事件
+  @Output() uploadComplete = new EventEmitter<UploadResult[]>(); // 上传完成事件
+  @Output() uploadError = new EventEmitter<string>(); // 上传错误事件
+
+  @ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
+
+  isUploading: boolean = false;
+  uploadProgress: number = 0;
+  uploadedFiles: UploadResult[] = [];
+  dragOver: boolean = false;
+  private storage: NovaStorage | null = null;
+
+  constructor() {
+    this.initStorage();
+  }
+
+  // 初始化 NovaStorage
+  private async initStorage(): Promise<void> {
+    try {
+      const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
+      this.storage = await NovaStorage.withCid(cid);
+      console.log('✅ NovaStorage 初始化成功, cid:', cid);
+    } catch (error) {
+      console.error('❌ NovaStorage 初始化失败:', error);
+    }
+  }
+
+  /**
+   * 触发文件选择
+   */
+  triggerFileSelect(): void {
+    if (!this.disabled) {
+      this.fileInput.nativeElement.click();
+    }
+  }
+
+  /**
+   * 处理文件选择
+   */
+  onFileSelect(event: Event): void {
+    const target = event.target as HTMLInputElement;
+    const files = Array.from(target.files || []);
+
+    if (files.length > 0) {
+      this.handleFiles(files);
+    }
+
+    // 清空input值,允许重复选择同一文件
+    target.value = '';
+  }
+
+  /**
+   * 处理拖拽进入
+   */
+  onDragOver(event: DragEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+
+    if (!this.disabled) {
+      this.dragOver = true;
+    }
+  }
+
+  /**
+   * 处理拖拽离开
+   */
+  onDragLeave(event: DragEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+    this.dragOver = false;
+  }
+
+  /**
+   * 处理文件拖拽放下
+   */
+  onDrop(event: DragEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+    this.dragOver = false;
+
+    if (this.disabled) {
+      return;
+    }
+
+    const files = Array.from(event.dataTransfer?.files || []);
+
+    if (files.length > 0) {
+      this.handleFiles(files);
+    }
+  }
+
+  /**
+   * 处理文件(验证并上传)
+   */
+  private async handleFiles(files: File[]): Promise<void> {
+    // 验证文件
+    const validationError = this.validateFiles(files);
+    if (validationError) {
+      this.uploadError.emit(validationError);
+      return;
+    }
+
+    this.fileSelected.emit(files);
+
+    // 开始上传
+    await this.uploadFiles(files);
+  }
+
+  /**
+   * 验证文件
+   */
+  private validateFiles(files: File[]): string | null {
+    if (files.length === 0) {
+      return '请选择文件';
+    }
+
+    // 检查文件类型
+    if (this.allowedTypes.length > 0) {
+      const invalidFiles = files.filter(file =>
+        !this.validateFileType(file, this.allowedTypes)
+      );
+
+      if (invalidFiles.length > 0) {
+        return `不支持的文件类型: ${invalidFiles.map(f => f.name).join(', ')}`;
+      }
+    }
+
+    // 检查文件大小
+    const oversizedFiles = files.filter(file =>
+      !this.validateFileSize(file, this.maxSize)
+    );
+
+    if (oversizedFiles.length > 0) {
+      return `文件大小超过限制 (${this.maxSize}MB): ${oversizedFiles.map(f => f.name).join(', ')}`;
+    }
+
+    return null;
+  }
+
+  /**
+   * 验证文件类型
+   */
+  private validateFileType(file: File, allowedTypes: string[]): boolean {
+    return allowedTypes.some(type => file.type.includes(type));
+  }
+
+  /**
+   * 验证文件大小
+   */
+  private validateFileSize(file: File, maxSizeInMB: number): boolean {
+    const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
+    return file.size <= maxSizeInBytes;
+  }
+
+  /**
+   * 上传文件
+   */
+  private async uploadFiles(files: File[]): Promise<void> {
+    if (!this.storage) {
+      this.uploadError.emit('存储服务未初始化');
+      return;
+    }
+
+    this.isUploading = true;
+    this.uploadProgress = 0;
+    this.uploadedFiles = [];
+
+    this.uploadStart.emit(files);
+
+    try {
+      const results: UploadResult[] = [];
+
+      for (let i = 0; i < files.length; i++) {
+        const file = files[i];
+
+        // 更新进度
+        const progress = ((i + 1) / files.length) * 100;
+        this.uploadProgress = progress;
+        this.uploadProgressEvent.emit({
+          completed: i + 1,
+          total: files.length,
+          currentFile: file.name
+        });
+
+        try {
+          // 使用 NovaStorage 上传文件
+          const uploaded: NovaFile = await this.storage.upload(file, {
+            prefixKey: this.prefixKey,
+            onProgress: (p) => {
+              const fileProgress = (i / files.length) * 100 + (p.total.percent / files.length);
+              this.uploadProgress = fileProgress;
+              this.uploadProgressEvent.emit({
+                completed: i,
+                total: files.length,
+                currentFile: file.name
+              });
+            }
+          });
+
+          const result: UploadResult = {
+            success: true,
+            file: uploaded,
+            url: uploaded.url
+          };
+
+          results.push(result);
+          console.log('✅ 文件上传成功:', uploaded.key, uploaded.url);
+
+        } catch (error) {
+          const result: UploadResult = {
+            success: false,
+            error: error instanceof Error ? error.message : '上传失败'
+          };
+          results.push(result);
+          console.error('❌ 文件上传失败:', file.name, error);
+        }
+      }
+
+      this.uploadedFiles = results;
+      this.uploadComplete.emit(results);
+
+      // 检查是否有失败的上传
+      const failedUploads = results.filter(r => !r.success);
+      if (failedUploads.length > 0) {
+        const errorMessages = failedUploads.map(r => r.error).filter(Boolean);
+        this.uploadError.emit(`部分文件上传失败: ${errorMessages.join(', ')}`);
+      }
+
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '上传过程中发生错误';
+      this.uploadError.emit(errorMessage);
+    } finally {
+      this.isUploading = false;
+      this.uploadProgress = 0;
+    }
+  }
+
+  /**
+   * 删除已上传的文件
+   */
+  removeUploadedFile(index: number): void {
+    this.uploadedFiles.splice(index, 1);
+  }
+
+  /**
+   * 格式化文件大小
+   */
+  formatFileSize(bytes: number): string {
+    if (bytes === 0) return '0 Bytes';
+
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+  }
+
+  /**
+   * 获取文件扩展名
+   */
+  getFileExtension(filename: string): string {
+    return filename.split('.').pop()?.toLowerCase() || '';
+  }
+
+  /**
+   * 检查是否为图片文件
+   */
+  isImageFile(file: File): boolean {
+    return file.type.startsWith('image/');
+  }
+
+  /**
+   * 生成预览URL
+   */
+  generatePreviewUrl(file: File): string {
+    if (this.isImageFile(file)) {
+      return URL.createObjectURL(file);
+    }
+    return '';
+  }
+
+  /**
+   * 清空上传结果
+   */
+  clearResults(): void {
+    this.uploadedFiles = [];
+    this.uploadProgress = 0;
+  }
+
+  /**
+   * 重置组件状态
+   */
+  reset(): void {
+    this.isUploading = false;
+    this.uploadProgress = 0;
+    this.uploadedFiles = [];
+    this.dragOver = false;
+    if (this.fileInput) {
+      this.fileInput.nativeElement.value = '';
+    }
+  }
+}

+ 125 - 0
src/app/shared/components/upload-example/upload-example.component.html

@@ -0,0 +1,125 @@
+<div class="upload-example-container">
+  <h2>NovaStorage 上传组件示例</h2>
+
+  <!-- 配置选择 -->
+  <div class="config-selector">
+    <h3>选择上传配置:</h3>
+    <div class="config-buttons">
+      <button
+        *ngFor="let config of exampleConfigs"
+        class="config-btn"
+        [class.active]="selectedConfig === config"
+        (click)="selectConfig(config)">
+        {{ config.title }}
+      </button>
+    </div>
+  </div>
+
+  <!-- 当前配置信息 -->
+  <div class="current-config">
+    <h4>当前配置: {{ selectedConfig.title }}</h4>
+    <div class="config-details">
+      <span class="config-item">接受格式: {{ selectedConfig.accept }}</span>
+      <span class="config-item">最大大小: {{ selectedConfig.maxSize }}MB</span>
+      <span class="config-item">多文件: {{ selectedConfig.multiple ? '是' : '否' }}</span>
+      <span class="config-item" *ngIf="selectedConfig.prefixKey">存储路径: {{ selectedConfig.prefixKey }}</span>
+    </div>
+  </div>
+
+  <!-- 上传组件 -->
+  <div class="upload-section">
+    <app-upload-component
+      [accept]="selectedConfig.accept"
+      [multiple]="selectedConfig.multiple"
+      [maxSize]="selectedConfig.maxSize"
+      [showPreview]="selectedConfig.showPreview"
+      [prefixKey]="selectedConfig.prefixKey"
+      (fileSelected)="onFileSelected($event)"
+      (uploadStart)="onUploadStart($event)"
+      (uploadProgressEvent)="onUploadProgress($event)"
+      (uploadComplete)="onUploadComplete($event)"
+      (uploadError)="onUploadError($event)">
+    </app-upload-component>
+  </div>
+
+  <!-- 上传状态 -->
+  <div class="upload-status" *ngIf="isUploading">
+    <div class="status-indicator uploading">
+      <div class="spinner"></div>
+      <span>正在上传...</span>
+    </div>
+  </div>
+
+  <!-- 上传结果 -->
+  <div class="upload-results" *ngIf="uploadResults.length > 0">
+    <div class="results-header">
+      <h3>上传结果</h3>
+      <button class="clear-btn" (click)="clearResults()">清空结果</button>
+    </div>
+
+    <div class="results-grid">
+      <div
+        *ngFor="let result of uploadResults; let i = index"
+        class="result-item"
+        [class]="getStatusClass(result)">
+
+        <!-- 文件信息 -->
+        <div class="file-info">
+          <div class="file-header">
+            <span class="file-status">
+              {{ result.success ? '✅ 成功' : '❌ 失败' }}
+            </span>
+            <span class="file-index">#{{ i + 1 }}</span>
+          </div>
+
+          <div class="file-details" *ngIf="result.success && result.file">
+            <p class="file-name">{{ result.file.name }}</p>
+            <p class="file-meta">
+              大小: {{ formatFileSize(result.file.size) }} |
+              类型: {{ result.file.type || '未知' }}
+            </p>
+            <p class="file-url" *ngIf="result.url">
+              <a [href]="result.url" target="_blank" rel="noopener noreferrer">
+                {{ result.url }}
+              </a>
+              <button class="copy-btn" (click)="copyUrl(result.url!)">复制</button>
+            </p>
+          </div>
+
+          <div class="error-details" *ngIf="!result.success">
+            <p class="error-message">{{ result.error }}</p>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 统计信息 -->
+    <div class="results-summary">
+      <div class="summary-item">
+        <span class="summary-label">总计:</span>
+        <span class="summary-value">{{ uploadResults.length }}</span>
+      </div>
+      <div class="summary-item success">
+        <span class="summary-label">成功:</span>
+        <span class="summary-value">{{ successfulUploads }}</span>
+      </div>
+      <div class="summary-item error">
+        <span class="summary-label">失败:</span>
+        <span class="summary-value">{{ failedUploads }}</span>
+      </div>
+    </div>
+  </div>
+
+  <!-- 使用说明 -->
+  <div class="usage-notes">
+    <h3>使用说明</h3>
+    <ul>
+      <li><strong>NovaStorage</strong>: 使用企业账套ID自动初始化存储服务</li>
+      <li><strong>prefixKey</strong>: 指定文件存储路径前缀,如 'project/pid/'</li>
+      <li><strong>文件路径</strong>: 自动生成格式 storage/company/&lt;cid&gt;/&lt;prefixKey&gt;/&lt;YYYYMMDD&gt;/&lt;HHmmss-rand&gt;-&lt;name&gt;</li>
+      <li><strong>进度监控</strong>: 支持单个文件和整体上传进度显示</li>
+      <li><strong>错误处理</strong>: 自动处理上传失败,显示详细错误信息</li>
+      <li><strong>文件验证</strong>: 支持文件类型和大小验证</li>
+    </ul>
+  </div>
+</div>

+ 380 - 0
src/app/shared/components/upload-example/upload-example.component.scss

@@ -0,0 +1,380 @@
+.upload-example-container {
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 24px;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+
+  h2 {
+    color: #1f2937;
+    margin-bottom: 32px;
+    text-align: center;
+  }
+
+  .config-selector {
+    margin-bottom: 24px;
+    padding: 20px;
+    background-color: #f9fafb;
+    border-radius: 8px;
+    border: 1px solid #e5e7eb;
+
+    h3 {
+      margin: 0 0 16px 0;
+      color: #374151;
+      font-size: 18px;
+    }
+
+    .config-buttons {
+      display: flex;
+      gap: 12px;
+      flex-wrap: wrap;
+
+      .config-btn {
+        padding: 12px 20px;
+        border: 2px solid #e5e7eb;
+        background-color: white;
+        border-radius: 6px;
+        cursor: pointer;
+        transition: all 0.2s ease;
+        font-size: 14px;
+        font-weight: 500;
+
+        &:hover {
+          border-color: #3b82f6;
+          background-color: #eff6ff;
+        }
+
+        &.active {
+          border-color: #3b82f6;
+          background-color: #3b82f6;
+          color: white;
+        }
+      }
+    }
+  }
+
+  .current-config {
+    margin-bottom: 24px;
+    padding: 16px;
+    background-color: #ecfdf5;
+    border-radius: 8px;
+    border: 1px solid #d1fae5;
+
+    h4 {
+      margin: 0 0 12px 0;
+      color: #065f46;
+      font-size: 16px;
+    }
+
+    .config-details {
+      display: flex;
+      gap: 16px;
+      flex-wrap: wrap;
+
+      .config-item {
+        padding: 4px 12px;
+        background-color: white;
+        border-radius: 4px;
+        font-size: 14px;
+        color: #374151;
+        border: 1px solid #d1fae5;
+      }
+    }
+  }
+
+  .upload-section {
+    margin-bottom: 32px;
+  }
+
+  .upload-status {
+    margin-bottom: 24px;
+    text-align: center;
+
+    .status-indicator {
+      display: inline-flex;
+      align-items: center;
+      gap: 12px;
+      padding: 16px 24px;
+      border-radius: 8px;
+      font-weight: 500;
+
+      &.uploading {
+        background-color: #eff6ff;
+        color: #1d4ed8;
+        border: 1px solid #bfdbfe;
+
+        .spinner {
+          width: 20px;
+          height: 20px;
+          border: 2px solid #dbeafe;
+          border-top: 2px solid #3b82f6;
+          border-radius: 50%;
+          animation: spin 1s linear infinite;
+        }
+      }
+    }
+  }
+
+  @keyframes spin {
+    0% { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+  }
+
+  .upload-results {
+    margin-bottom: 32px;
+
+    .results-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 20px;
+
+      h3 {
+        margin: 0;
+        color: #1f2937;
+        font-size: 20px;
+      }
+
+      .clear-btn {
+        padding: 8px 16px;
+        background-color: #f3f4f6;
+        border: 1px solid #d1d5db;
+        border-radius: 6px;
+        cursor: pointer;
+        transition: all 0.2s ease;
+        font-size: 14px;
+
+        &:hover {
+          background-color: #e5e7eb;
+        }
+      }
+    }
+
+    .results-grid {
+      display: grid;
+      gap: 16px;
+      margin-bottom: 20px;
+
+      .result-item {
+        padding: 16px;
+        border-radius: 8px;
+        border: 1px solid #e5e7eb;
+        transition: all 0.2s ease;
+
+        &.success {
+          border-color: #d1fae5;
+          background-color: #f0fdf4;
+
+          .file-status {
+            color: #059669;
+          }
+        }
+
+        &.error {
+          border-color: #fee2e2;
+          background-color: #fef2f2;
+
+          .file-status {
+            color: #dc2626;
+          }
+        }
+
+        &:hover {
+          box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+        }
+
+        .file-info {
+          .file-header {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 12px;
+
+            .file-status {
+              font-weight: 600;
+              font-size: 14px;
+            }
+
+            .file-index {
+              color: #6b7280;
+              font-size: 12px;
+            }
+          }
+
+          .file-details {
+            .file-name {
+              margin: 0 0 8px 0;
+              font-weight: 500;
+              color: #1f2937;
+              word-break: break-all;
+            }
+
+            .file-meta {
+              margin: 0 0 8px 0;
+              font-size: 14px;
+              color: #6b7280;
+            }
+
+            .file-url {
+              margin: 0;
+              display: flex;
+              align-items: center;
+              gap: 8px;
+              flex-wrap: wrap;
+
+              a {
+                color: #3b82f6;
+                text-decoration: none;
+                font-size: 14px;
+                word-break: break-all;
+
+                &:hover {
+                  text-decoration: underline;
+                }
+              }
+
+              .copy-btn {
+                padding: 4px 8px;
+                background-color: #f3f4f6;
+                border: 1px solid #d1d5db;
+                border-radius: 4px;
+                cursor: pointer;
+                font-size: 12px;
+                transition: all 0.2s ease;
+
+                &:hover {
+                  background-color: #e5e7eb;
+                }
+              }
+            }
+          }
+
+          .error-details {
+            .error-message {
+              margin: 0;
+              color: #dc2626;
+              font-size: 14px;
+            }
+          }
+        }
+      }
+    }
+
+    .results-summary {
+      display: flex;
+      gap: 16px;
+      padding: 16px;
+      background-color: #f9fafb;
+      border-radius: 8px;
+      border: 1px solid #e5e7eb;
+
+      .summary-item {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        flex: 1;
+
+        .summary-label {
+          font-size: 14px;
+          color: #6b7280;
+          margin-bottom: 4px;
+        }
+
+        .summary-value {
+          font-size: 24px;
+          font-weight: 600;
+          color: #1f2937;
+        }
+
+        &.success .summary-value {
+          color: #059669;
+        }
+
+        &.error .summary-value {
+          color: #dc2626;
+        }
+      }
+    }
+  }
+
+  .usage-notes {
+    padding: 20px;
+    background-color: #f8fafc;
+    border-radius: 8px;
+    border: 1px solid #e2e8f0;
+
+    h3 {
+      margin: 0 0 16px 0;
+      color: #1e293b;
+      font-size: 18px;
+    }
+
+    ul {
+      margin: 0;
+      padding-left: 20px;
+
+      li {
+        margin-bottom: 8px;
+        color: #475569;
+        line-height: 1.6;
+
+        strong {
+          color: #1e293b;
+        }
+      }
+    }
+  }
+}
+
+// 响应式设计
+@media (max-width: 768px) {
+  .upload-example-container {
+    padding: 16px;
+
+    .config-selector {
+      .config-buttons {
+        flex-direction: column;
+
+        .config-btn {
+          width: 100%;
+        }
+      }
+    }
+
+    .current-config {
+      .config-details {
+        flex-direction: column;
+        gap: 8px;
+
+        .config-item {
+          text-align: center;
+        }
+      }
+    }
+
+    .upload-results {
+      .results-header {
+        flex-direction: column;
+        gap: 12px;
+        align-items: stretch;
+
+        h3 {
+          text-align: center;
+        }
+
+        .clear-btn {
+          width: 100%;
+        }
+      }
+
+      .results-summary {
+        flex-direction: column;
+        gap: 12px;
+
+        .summary-item {
+          flex-direction: row;
+          justify-content: space-between;
+        }
+      }
+    }
+  }
+}

+ 178 - 0
src/app/shared/components/upload-example/upload-example.component.ts

@@ -0,0 +1,178 @@
+import { Component } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { UploadComponent, UploadResult } from '../upload-component/upload.component';
+import { NovaFile } from 'fmode-ng/core';
+
+@Component({
+  selector: 'app-upload-example',
+  standalone: true,
+  imports: [CommonModule, UploadComponent],
+  templateUrl: './upload-example.component.html',
+  styleUrls: ['./upload-example.component.scss']
+})
+export class UploadExampleComponent {
+  uploadResults: UploadResult[] = [];
+  isUploading: boolean = false;
+
+  // 计算属性
+  get successfulUploads(): number {
+    return this.uploadResults.filter(r => r.success).length;
+  }
+
+  get failedUploads(): number {
+    return this.uploadResults.filter(r => !r.success).length;
+  }
+
+  // 示例配置
+  exampleConfigs = [
+    {
+      title: '基础图片上传',
+      accept: 'image/*',
+      multiple: false,
+      maxSize: 5,
+      showPreview: true,
+      prefixKey: 'demo/images/'
+    },
+    {
+      title: '文档批量上传',
+      accept: '.pdf,.doc,.docx,.xls,.xlsx',
+      multiple: true,
+      maxSize: 10,
+      showPreview: false,
+      prefixKey: 'demo/documents/'
+    },
+    {
+      title: '项目文件上传',
+      accept: '*/*',
+      multiple: true,
+      maxSize: 20,
+      showPreview: true,
+      prefixKey: 'demo/projects/'
+    }
+  ];
+
+  selectedConfig = this.exampleConfigs[0];
+
+  onFileSelected(files: File[]): void {
+    console.log('文件选择:', files);
+  }
+
+  onUploadStart(files: File[]): void {
+    this.isUploading = true;
+    console.log('开始上传:', files);
+  }
+
+  onUploadProgress(progress: { completed: number; total: number; currentFile: string }): void {
+    console.log('上传进度:', progress);
+  }
+
+  onUploadComplete(results: UploadResult[]): void {
+    this.isUploading = false;
+    this.uploadResults = results;
+
+    console.log('上传完成:', results);
+
+    // 处理上传结果
+    const successCount = results.filter(r => r.success).length;
+    const failCount = results.filter(r => !r.success).length;
+
+    console.log(`✅ 成功: ${successCount}, ❌ 失败: ${failCount}`);
+
+    // 保存成功的文件信息
+    results.forEach(result => {
+      if (result.success && result.file) {
+        this.saveFileInfo(result.file as NovaFile);
+      }
+    });
+  }
+
+  onUploadError(error: string): void {
+    this.isUploading = false;
+    console.error('上传错误:', error);
+    alert(`上传失败: ${error}`);
+  }
+
+  /**
+   * 保存文件信息到数据库(示例)
+   */
+  private async saveFileInfo(file: NovaFile): Promise<void> {
+    try {
+      // 这里可以将文件信息保存到数据库
+      console.log('保存文件信息:', {
+        key: file.key,
+        url: file.url,
+        name: file.name,
+        type: file.type,
+        size: file.size,
+        metadata: file.metadata,
+        md5: file.md5
+      });
+
+      // 示例:保存到 Attachment 表
+      // const attachment = new FmodeObject('Attachment');
+      // attachment.set('key', file.key);
+      // attachment.set('url', file.url);
+      // attachment.set('name', file.name);
+      // attachment.set('type', file.type);
+      // attachment.set('size', file.size);
+      // attachment.set('md5', file.md5);
+      // await attachment.save();
+
+    } catch (error) {
+      console.error('保存文件信息失败:', error);
+    }
+  }
+
+  /**
+   * 选择不同的配置
+   */
+  selectConfig(config: any): void {
+    this.selectedConfig = config;
+    this.uploadResults = [];
+  }
+
+  /**
+   * 清空结果
+   */
+  clearResults(): void {
+    this.uploadResults = [];
+  }
+
+  /**
+   * 格式化文件大小
+   */
+  formatFileSize(bytes: number): string {
+    if (bytes === 0) return '0 Bytes';
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+  }
+
+  /**
+   * 获取文件状态样式
+   */
+  getStatusClass(result: UploadResult): string {
+    return result.success ? 'success' : 'error';
+  }
+
+  /**
+   * 复制URL到剪贴板
+   */
+  async copyUrl(url: string): Promise<void> {
+    try {
+      await navigator.clipboard.writeText(url);
+      alert('URL已复制到剪贴板');
+    } catch (error) {
+      console.error('复制失败:', error);
+      // 降级方案
+      const textArea = document.createElement('textarea');
+      textArea.value = url;
+      document.body.appendChild(textArea);
+      textArea.select();
+      document.execCommand('copy');
+      document.body.removeChild(textArea);
+      alert('URL已复制到剪贴板');
+    }
+  }
+}

+ 1 - 1
src/app/shared/components/upload-success-modal/upload-success-modal.component.html

@@ -232,7 +232,7 @@
                               </div>
                               <div class="info-item">
                                 <span class="label">饱和度范围:</span>
-                                <span class="value">{{ (analysisResult.enhancedAnalysis.colorWheel.saturationRange?.min || 0) }}% - {{ (analysisResult.enhancedAnalysis.colorWheel.saturationRange?.max || 100) }}%</span>
+                                <span class="value">{{ (analysisResult.enhancedAnalysis.colorWheel.saturationRange.min || 0) }}% - {{ (analysisResult.enhancedAnalysis.colorWheel.saturationRange.max || 100) }}%</span>
                               </div>
                             </div>
                           </div>

+ 1 - 0
src/app/shared/filter/filter.ts

@@ -2,6 +2,7 @@ import { Component } from '@angular/core';
 
 @Component({
   selector: 'app-filter',
+  standalone: true,
   imports: [],
   templateUrl: './filter.html',
   styleUrl: './filter.scss'

+ 1 - 0
src/app/shared/project-card/project-card.ts

@@ -2,6 +2,7 @@ import { Component } from '@angular/core';
 
 @Component({
   selector: 'app-project-card',
+  standalone: true,
   imports: [],
   templateUrl: './project-card.html',
   styleUrl: './project-card.scss'

+ 1 - 0
src/app/shared/status-label/status-label.ts

@@ -2,6 +2,7 @@ import { Component } from '@angular/core';
 
 @Component({
   selector: 'app-status-label',
+  standalone: true,
   imports: [],
   templateUrl: './status-label.html',
   styleUrl: './status-label.scss'

+ 0 - 5
src/assets/images/default-avatar.svg

@@ -1,5 +0,0 @@
-<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
-  <circle cx="32" cy="32" r="32" fill="#E5E7EB"/>
-  <circle cx="32" cy="24" r="10" fill="#9CA3AF"/>
-  <path d="M12 52C12 44.268 18.268 38 26 38H38C45.732 38 52 44.268 52 52V56C52 58.209 50.209 60 48 60H16C13.791 60 12 58.209 12 56V52Z" fill="#9CA3AF"/>
-</svg>

+ 0 - 15
src/assets/images/portfolio-1.svg

@@ -1,15 +0,0 @@
-<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
-  <defs>
-    <linearGradient id="bg1" x1="0%" y1="0%" x2="100%" y2="100%">
-      <stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
-      <stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
-    </linearGradient>
-  </defs>
-  <rect width="400" height="300" fill="url(#bg1)"/>
-  <rect x="50" y="50" width="300" height="200" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
-  <circle cx="120" cy="120" r="30" fill="rgba(255,255,255,0.2)"/>
-  <rect x="170" y="100" width="120" height="40" fill="rgba(255,255,255,0.2)" rx="4"/>
-  <rect x="170" y="160" width="80" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <rect x="170" y="190" width="100" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">现代简约风格</text>
-</svg>

+ 0 - 16
src/assets/images/portfolio-2.svg

@@ -1,16 +0,0 @@
-<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
-  <defs>
-    <linearGradient id="bg2" x1="0%" y1="0%" x2="100%" y2="100%">
-      <stop offset="0%" style="stop-color:#f093fb;stop-opacity:1" />
-      <stop offset="100%" style="stop-color:#f5576c;stop-opacity:1" />
-    </linearGradient>
-  </defs>
-  <rect width="400" height="300" fill="url(#bg2)"/>
-  <rect x="60" y="60" width="280" height="180" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
-  <rect x="80" y="80" width="60" height="60" fill="rgba(255,255,255,0.2)" rx="4"/>
-  <rect x="160" y="80" width="60" height="60" fill="rgba(255,255,255,0.2)" rx="4"/>
-  <rect x="240" y="80" width="60" height="60" fill="rgba(255,255,255,0.2)" rx="4"/>
-  <rect x="80" y="160" width="220" height="30" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <rect x="80" y="200" width="160" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">北欧风格</text>
-</svg>

+ 0 - 16
src/assets/images/portfolio-3.svg

@@ -1,16 +0,0 @@
-<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
-  <defs>
-    <linearGradient id="bg3" x1="0%" y1="0%" x2="100%" y2="100%">
-      <stop offset="0%" style="stop-color:#4facfe;stop-opacity:1" />
-      <stop offset="100%" style="stop-color:#00f2fe;stop-opacity:1" />
-    </linearGradient>
-  </defs>
-  <rect width="400" height="300" fill="url(#bg3)"/>
-  <rect x="40" y="40" width="320" height="220" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
-  <rect x="60" y="60" width="120" height="80" fill="rgba(255,255,255,0.2)" rx="4"/>
-  <rect x="200" y="60" width="140" height="40" fill="rgba(255,255,255,0.2)" rx="4"/>
-  <rect x="200" y="120" width="140" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <rect x="60" y="160" width="280" height="40" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <rect x="60" y="220" width="200" height="20" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">新中式风格</text>
-</svg>

+ 0 - 16
src/assets/images/portfolio-4.svg

@@ -1,16 +0,0 @@
-<svg width="400" height="300" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
-  <defs>
-    <linearGradient id="bg4" x1="0%" y1="0%" x2="100%" y2="100%">
-      <stop offset="0%" style="stop-color:#43e97b;stop-opacity:1" />
-      <stop offset="100%" style="stop-color:#38f9d7;stop-opacity:1" />
-    </linearGradient>
-  </defs>
-  <rect width="400" height="300" fill="url(#bg4)"/>
-  <rect x="50" y="50" width="300" height="200" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.3)" stroke-width="2" rx="8"/>
-  <circle cx="150" cy="130" r="25" fill="rgba(255,255,255,0.2)"/>
-  <circle cx="200" cy="130" r="25" fill="rgba(255,255,255,0.2)"/>
-  <circle cx="250" cy="130" r="25" fill="rgba(255,255,255,0.2)"/>
-  <rect x="100" y="180" width="200" height="30" fill="rgba(255,255,255,0.2)" rx="4"/>
-  <rect x="120" y="220" width="160" height="15" fill="rgba(255,255,255,0.15)" rx="2"/>
-  <text x="200" y="280" font-family="Arial, sans-serif" font-size="16" fill="white" text-anchor="middle">工业风格</text>
-</svg>

+ 70 - 0
src/modules/project/components/project-bottom-card/project-bottom-card.component.html

@@ -0,0 +1,70 @@
+<div class="project-bottom-card">
+  <!-- 加载状态 -->
+  @if (loading) {
+    <div class="card-skeleton">
+      <div class="title-skeleton"></div>
+      <div class="buttons-skeleton">
+        <div class="button-skeleton"></div>
+        <div class="button-skeleton"></div>
+      </div>
+    </div>
+  } @else {
+    <!-- 项目内容 -->
+    <div class="card-content">
+      <!-- 左侧:项目标题和状态 -->
+      <div class="project-info">
+        <h2 class="project-title">{{ getProjectTitle() }}</h2>
+        <div class="project-meta">
+          <span class="status-badge" [class]="getStatusClass()">
+            {{ getProjectStatus() }}
+          </span>
+          @if (project?.get('deadline')) {
+            <span class="deadline">
+              截止: {{ project!.get('deadline') | date:'MM-dd' }}
+            </span>
+          }
+        </div>
+      </div>
+
+      <!-- 右侧:操作按钮 -->
+      <div class="action-buttons">
+        <button
+          class="action-button files-button"
+          (click)="onShowFiles()"
+          [disabled]="loading">
+          <div class="button-content">
+            <svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
+              <polyline points="14,2 14,8 20,8"></polyline>
+              <line x1="16" y1="13" x2="8" y2="13"></line>
+              <line x1="16" y1="17" x2="8" y2="17"></line>
+              <polyline points="10,9 9,9 8,9"></polyline>
+            </svg>
+            <span class="button-text">文件</span>
+            @if (fileCount > 0) {
+              <span class="button-badge">{{ fileCount }}</span>
+            }
+          </div>
+        </button>
+
+        <button
+          class="action-button members-button"
+          (click)="onShowMembers()"
+          [disabled]="loading">
+          <div class="button-content">
+            <svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
+              <circle cx="9" cy="7" r="4"></circle>
+              <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
+              <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
+            </svg>
+            <span class="button-text">成员</span>
+            @if (memberCount > 0) {
+              <span class="button-badge">{{ memberCount }}</span>
+            }
+          </div>
+        </button>
+      </div>
+    </div>
+  }
+</div>

+ 264 - 0
src/modules/project/components/project-bottom-card/project-bottom-card.component.scss

@@ -0,0 +1,264 @@
+.project-bottom-card {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: rgba(255, 255, 255, 0.95);
+  backdrop-filter: blur(10px);
+  border-top: 1px solid #e5e7eb;
+  padding: 12px 16px;
+  z-index: 1000;
+  box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
+
+  .card-skeleton {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    height: 60px;
+
+    .title-skeleton {
+      width: 200px;
+      height: 20px;
+      background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
+      background-size: 200% 100%;
+      animation: skeleton-loading 1.5s infinite;
+      border-radius: 4px;
+    }
+
+    .buttons-skeleton {
+      display: flex;
+      gap: 12px;
+
+      .button-skeleton {
+        width: 80px;
+        height: 36px;
+        background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
+        background-size: 200% 100%;
+        animation: skeleton-loading 1.5s infinite;
+        border-radius: 6px;
+      }
+    }
+  }
+
+  .card-content {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    max-width: 1200px;
+    margin: 0 auto;
+  }
+
+  .project-info {
+    flex: 1;
+    min-width: 0;
+
+    .project-title {
+      font-size: 18px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0 0 4px 0;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .project-meta {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap;
+
+      .status-badge {
+        padding: 2px 8px;
+        border-radius: 12px;
+        font-size: 12px;
+        font-weight: 500;
+
+        &.status-active {
+          background-color: #dcfce7;
+          color: #166534;
+        }
+
+        &.status-completed {
+          background-color: #dbeafe;
+          color: #1e40af;
+        }
+
+        &.status-paused {
+          background-color: #fef3c7;
+          color: #92400e;
+        }
+
+        &.status-default {
+          background-color: #f3f4f6;
+          color: #6b7280;
+        }
+      }
+
+      .deadline {
+        font-size: 12px;
+        color: #6b7280;
+      }
+    }
+  }
+
+  .action-buttons {
+    display: flex;
+    gap: 12px;
+    margin-left: 16px;
+
+    .action-button {
+      background: white;
+      border: 1px solid #e5e7eb;
+      border-radius: 8px;
+      padding: 8px 16px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      min-width: 100px;
+      height: 44px;
+
+      &:hover:not(:disabled) {
+        background-color: #f9fafb;
+        border-color: #d1d5db;
+        transform: translateY(-1px);
+        box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+      }
+
+      &:active:not(:disabled) {
+        transform: translateY(0);
+        box-shadow: none;
+      }
+
+      &:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+      }
+
+      .button-content {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        position: relative;
+
+        .button-icon {
+          width: 18px;
+          height: 18px;
+          flex-shrink: 0;
+        }
+
+        .button-text {
+          font-size: 14px;
+          font-weight: 500;
+          color: #374151;
+        }
+
+        .button-badge {
+          position: absolute;
+          top: -8px;
+          right: -8px;
+          background-color: #ef4444;
+          color: white;
+          font-size: 10px;
+          font-weight: 600;
+          padding: 2px 6px;
+          border-radius: 10px;
+          min-width: 16px;
+          text-align: center;
+          line-height: 1;
+        }
+      }
+
+      &.files-button:hover:not(:disabled) {
+        border-color: #3b82f6;
+        .button-text {
+          color: #3b82f6;
+        }
+      }
+
+      &.members-button:hover:not(:disabled) {
+        border-color: #10b981;
+        .button-text {
+          color: #10b981;
+        }
+      }
+    }
+  }
+}
+
+@keyframes skeleton-loading {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+
+// 响应式设计
+@media (max-width: 768px) {
+  .project-bottom-card {
+    padding: 10px 12px;
+
+    .card-content {
+      flex-direction: column;
+      gap: 12px;
+      align-items: stretch;
+    }
+
+    .project-info {
+      text-align: center;
+
+      .project-title {
+        font-size: 16px;
+      }
+
+      .project-meta {
+        justify-content: center;
+      }
+    }
+
+    .action-buttons {
+      margin-left: 0;
+      justify-content: center;
+
+      .action-button {
+        flex: 1;
+        max-width: 120px;
+
+        .button-content {
+          .button-text {
+            font-size: 13px;
+          }
+        }
+      }
+    }
+  }
+}
+
+@media (max-width: 480px) {
+  .project-bottom-card {
+    .action-buttons {
+      .action-button {
+        min-width: 80px;
+        padding: 6px 12px;
+        height: 40px;
+
+        .button-content {
+          gap: 6px;
+
+          .button-icon {
+            width: 16px;
+            height: 16px;
+          }
+
+          .button-text {
+            font-size: 12px;
+          }
+        }
+      }
+    }
+  }
+}

+ 55 - 0
src/modules/project/components/project-bottom-card/project-bottom-card.component.ts

@@ -0,0 +1,55 @@
+import { Component, Input, Output, EventEmitter } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FmodeObject } from 'fmode-ng/parse';
+
+@Component({
+  selector: 'app-project-bottom-card',
+  standalone: true,
+  imports: [CommonModule],
+  templateUrl: './project-bottom-card.component.html',
+  styleUrls: ['./project-bottom-card.component.scss']
+})
+export class ProjectBottomCardComponent {
+  @Input() project: FmodeObject | null = null;
+  @Input() groupChat: FmodeObject | null = null;
+  @Input() currentUser: FmodeObject | null = null;
+  @Input() cid: string = '';
+  @Input() loading: boolean = false;
+  @Input() fileCount: number = 0;
+  @Input() memberCount: number = 0;
+
+  @Output() showFiles = new EventEmitter<void>();
+  @Output() showMembers = new EventEmitter<void>();
+
+  constructor() {}
+
+  onShowFiles() {
+    this.showFiles.emit();
+  }
+
+  onShowMembers() {
+    this.showMembers.emit();
+  }
+
+  getProjectTitle(): string {
+    return this.project?.get('title') || '项目详情';
+  }
+
+  getProjectStatus(): string {
+    return this.project?.get('status') || '未知';
+  }
+
+  getStatusClass(): string {
+    const status = this.getProjectStatus();
+    switch (status) {
+      case '进行中':
+        return 'status-active';
+      case '已完成':
+        return 'status-completed';
+      case '已暂停':
+        return 'status-paused';
+      default:
+        return 'status-default';
+    }
+  }
+}

+ 280 - 0
src/modules/project/components/project-files-modal/project-files-modal.component.html

@@ -0,0 +1,280 @@
+<!-- 模态框背景 -->
+<div class="modal-overlay"
+     *ngIf="isVisible"
+     (click)="onBackdropClick($event)">
+
+  <!-- 模态框内容 -->
+  <div class="modal-container" (click)="$event.stopPropagation()">
+
+    <!-- 模态框头部 -->
+    <div class="modal-header">
+      <div class="header-left">
+        <h2 class="modal-title">
+          <svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
+            <polyline points="14,2 14,8 20,8"></polyline>
+            <line x1="16" y1="13" x2="8" y2="13"></line>
+            <line x1="16" y1="17" x2="8" y2="17"></line>
+          </svg>
+          项目文件
+        </h2>
+        <div class="file-stats">
+          <span class="stat-item">
+            <span class="stat-number">{{ totalFiles }}</span>
+            <span class="stat-label">文件</span>
+          </span>
+          <span class="stat-item">
+            <span class="stat-number">{{ formatFileSize(totalSize) }}</span>
+            <span class="stat-label">总大小</span>
+          </span>
+          @if (imageCount > 0) {
+            <span class="stat-item">
+              <span class="stat-number">{{ imageCount }}</span>
+              <span class="stat-label">图片</span>
+            </span>
+          }
+          @if (documentCount > 0) {
+            <span class="stat-item">
+              <span class="stat-number">{{ documentCount }}</span>
+              <span class="stat-label">文档</span>
+            </span>
+          }
+        </div>
+      </div>
+
+      <div class="header-right">
+        <!-- 视图切换 -->
+        <div class="view-toggle">
+          <button
+            class="toggle-btn"
+            [class.active]="previewMode === 'grid'"
+            (click)="previewMode = 'grid'"
+            title="网格视图">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <rect x="3" y="3" width="7" height="7"></rect>
+              <rect x="14" y="3" width="7" height="7"></rect>
+              <rect x="14" y="14" width="7" height="7"></rect>
+              <rect x="3" y="14" width="7" height="7"></rect>
+            </svg>
+          </button>
+          <button
+            class="toggle-btn"
+            [class.active]="previewMode === 'list'"
+            (click)="previewMode = 'list'"
+            title="列表视图">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <line x1="8" y1="6" x2="21" y2="6"></line>
+              <line x1="8" y1="12" x2="21" y2="12"></line>
+              <line x1="8" y1="18" x2="21" y2="18"></line>
+              <line x1="3" y1="6" x2="3.01" y2="6"></line>
+              <line x1="3" y1="12" x2="3.01" y2="12"></line>
+              <line x1="3" y1="18" x2="3.01" y2="18"></line>
+            </svg>
+          </button>
+        </div>
+
+        <!-- 搜索框 -->
+        <div class="search-box">
+          <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="11" cy="11" r="8"></circle>
+            <path d="m21 21-4.35-4.35"></path>
+          </svg>
+          <input
+            type="text"
+            class="search-input"
+            placeholder="搜索文件..."
+            [(ngModel)]="searchQuery">
+        </div>
+
+        <!-- 过滤器 -->
+        <select class="filter-select" [(ngModel)]="filterType">
+          <option value="all">全部文件</option>
+          <option value="images">图片</option>
+          <option value="documents">文档</option>
+          <option value="videos">视频</option>
+        </select>
+
+        <!-- 关闭按钮 -->
+        <button class="close-btn" (click)="onClose()">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18"></line>
+            <line x1="6" y1="6" x2="18" y2="18"></line>
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 模态框内容 -->
+    <div class="modal-content">
+
+      <!-- 加载状态 -->
+      @if (loading) {
+        <div class="loading-state">
+          <div class="loading-spinner"></div>
+          <p>加载文件中...</p>
+        </div>
+      } @else if (getFilteredFiles().length === 0) {
+        <!-- 空状态 -->
+        <div class="empty-state">
+          <svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
+            <polyline points="13,2 13,9 20,9"></polyline>
+          </svg>
+          <h3>暂无文件</h3>
+          <p>该项目还没有上传任何文件</p>
+        </div>
+      } @else {
+        <!-- 文件列表 -->
+        @if (previewMode === 'grid') {
+          <div class="files-grid">
+            @for (file of getFilteredFiles(); track file.id) {
+              <div class="file-card" (click)="selectFile(file)">
+                <!-- 文件预览 -->
+                <div class="file-preview">
+                  @if (isImageFile(file)) {
+                    <img [src]="file.url" [alt]="file.name" class="preview-image" />
+                  } @else {
+                    <div class="preview-placeholder">
+                      <div class="file-icon">{{ getFileIcon(file.type) }}</div>
+                      <div class="file-extension">{{ getFileExtension(file.name) }}</div>
+                    </div>
+                  }
+                </div>
+
+                <!-- 文件信息 -->
+                <div class="file-info">
+                  <h4 class="file-name" [title]="file.originalName">{{ file.originalName }}</h4>
+                  <div class="file-meta">
+                    <span class="file-size">{{ formatFileSize(file.size) }}</span>
+                    <span class="file-date">{{ file.uploadedAt | date:'MM-dd HH:mm' }}</span>
+                  </div>
+                  <div class="file-description" *ngIf="file.description">
+                    <p>{{ file.description }}</p>
+                  </div>
+                  <div class="file-footer">
+                    <div class="uploader-info">
+                      @if (file.uploadedBy.avatar) {
+                        <img [src]="file.uploadedBy.avatar" [alt]="file.uploadedBy.name" class="uploader-avatar" />
+                      }
+                      <span class="uploader-name">{{ file.uploadedBy.name }}</span>
+                      <span class="source-badge" [class]="getSourceBadgeClass(file.source)">
+                        {{ getSourceLabel(file.source) }}
+                      </span>
+                    </div>
+                    <div class="file-actions">
+                      <button
+                        class="action-btn download-btn"
+                        (click)="downloadFile(file); $event.stopPropagation()"
+                        title="下载">
+                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                          <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+                          <polyline points="7,10 12,15 17,10"></polyline>
+                          <line x1="12" y1="15" x2="12" y2="3"></line>
+                        </svg>
+                      </button>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            }
+          </div>
+        } @else {
+          <!-- 列表视图 -->
+          <div class="files-list">
+            @for (file of getFilteredFiles(); track file.id) {
+              <div class="file-list-item" (click)="selectFile(file)">
+                <div class="list-file-icon">
+                  @if (isImageFile(file)) {
+                    <img [src]="file.url" [alt]="file.name" class="list-preview-image" />
+                  } @else {
+                    <span class="list-icon">{{ getFileIcon(file.type) }}</span>
+                  }
+                </div>
+                <div class="list-file-info">
+                  <div class="list-file-name">{{ file.originalName }}</div>
+                  <div class="list-file-meta">
+                    <span>{{ formatFileSize(file.size) }}</span>
+                    <span>{{ file.uploadedAt | date:'MM-dd HH:mm' }}</span>
+                    <span>{{ file.uploadedBy.name }}</span>
+                    <span class="source-badge" [class]="getSourceBadgeClass(file.source)">
+                      {{ getSourceLabel(file.source) }}
+                    </span>
+                  </div>
+                  @if (file.description) {
+                    <div class="list-file-description">{{ file.description }}</div>
+                  }
+                </div>
+                <div class="list-file-actions">
+                  <button
+                    class="action-btn download-btn"
+                    (click)="downloadFile(file); $event.stopPropagation()"
+                    title="下载">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+                      <polyline points="7,10 12,15 17,10"></polyline>
+                      <line x1="12" y1="15" x2="12" y2="3"></line>
+                    </svg>
+                  </button>
+                </div>
+              </div>
+            }
+          </div>
+        }
+      }
+    </div>
+  </div>
+
+  <!-- 文件预览模态框 -->
+  @if (selectedFile) {
+    <div class="preview-overlay" (click)="closePreview()">
+      <div class="preview-container" (click)="$event.stopPropagation()">
+        <div class="preview-header">
+          <h3>{{ selectedFile.originalName }}</h3>
+          <button class="preview-close" (click)="closePreview()">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <line x1="18" y1="6" x2="6" y2="18"></line>
+              <line x1="6" y1="6" x2="18" y2="18"></line>
+            </svg>
+          </button>
+        </div>
+
+        <div class="preview-content">
+          @if (isImageFile(selectedFile)) {
+            <img [src]="selectedFile.url" [alt]="selectedFile.originalName" class="preview-full-image" />
+          } @else if (isVideoFile(selectedFile)) {
+            <video [src]="selectedFile.url" controls class="preview-video"></video>
+          } @else {
+            <div class="preview-no-preview">
+              <div class="preview-icon-large">{{ getFileIcon(selectedFile.type) }}</div>
+              <p>该文件类型不支持预览</p>
+              <button
+                class="preview-download-btn"
+                (click)="downloadFile(selectedFile)">
+                下载文件
+              </button>
+            </div>
+          }
+        </div>
+
+        <div class="preview-footer">
+          <div class="preview-meta">
+            <span>{{ formatFileSize(selectedFile.size) }}</span>
+            <span>{{ selectedFile.uploadedAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
+            <span>上传者: {{ selectedFile.uploadedBy.name }}</span>
+            <span class="source-badge" [class]="getSourceBadgeClass(selectedFile.source)">
+              {{ getSourceLabel(selectedFile.source) }}
+            </span>
+          </div>
+          <div class="preview-description">
+            <textarea
+              class="description-textarea"
+              placeholder="添加文件说明..."
+              [(ngModel)]="selectedFile.description"
+              (blur)="updateFileDescription(selectedFile, selectedFile.description || '')">
+            </textarea>
+          </div>
+        </div>
+      </div>
+    </div>
+  }
+</div>

+ 853 - 0
src/modules/project/components/project-files-modal/project-files-modal.component.scss

@@ -0,0 +1,853 @@
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9999;
+  padding: 20px;
+}
+
+.modal-container {
+  background: white;
+  border-radius: 12px;
+  max-width: 1200px;
+  width: 100%;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+  overflow: hidden;
+}
+
+.modal-header {
+  padding: 24px;
+  border-bottom: 1px solid #e5e7eb;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+  flex-shrink: 0;
+
+  .header-left {
+    flex: 1;
+    min-width: 0;
+
+    .modal-title {
+      font-size: 24px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0 0 12px 0;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+
+      .title-icon {
+        width: 28px;
+        height: 28px;
+        color: #3b82f6;
+      }
+    }
+
+    .file-stats {
+      display: flex;
+      gap: 16px;
+      flex-wrap: wrap;
+
+      .stat-item {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        min-width: 50px;
+
+        .stat-number {
+          font-size: 18px;
+          font-weight: 600;
+          color: #1f2937;
+          line-height: 1;
+        }
+
+        .stat-label {
+          font-size: 12px;
+          color: #6b7280;
+          margin-top: 2px;
+        }
+      }
+    }
+  }
+
+  .header-right {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex-shrink: 0;
+
+    .view-toggle {
+      display: flex;
+      border: 1px solid #e5e7eb;
+      border-radius: 6px;
+      overflow: hidden;
+
+      .toggle-btn {
+        background: white;
+        border: none;
+        padding: 8px 12px;
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        transition: all 0.2s ease;
+        color: #6b7280;
+
+        svg {
+          width: 18px;
+          height: 18px;
+        }
+
+        &:hover {
+          background-color: #f9fafb;
+        }
+
+        &.active {
+          background-color: #3b82f6;
+          color: white;
+        }
+
+        &:not(:last-child) {
+          border-right: 1px solid #e5e7eb;
+        }
+      }
+    }
+
+    .search-box {
+      position: relative;
+      display: flex;
+      align-items: center;
+
+      .search-icon {
+        position: absolute;
+        left: 12px;
+        width: 18px;
+        height: 18px;
+        color: #6b7280;
+      }
+
+      .search-input {
+        padding: 8px 12px 8px 36px;
+        border: 1px solid #e5e7eb;
+        border-radius: 6px;
+        font-size: 14px;
+        width: 200px;
+        outline: none;
+        transition: border-color 0.2s ease;
+
+        &:focus {
+          border-color: #3b82f6;
+          box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+        }
+      }
+    }
+
+    .filter-select {
+      padding: 8px 12px;
+      border: 1px solid #e5e7eb;
+      border-radius: 6px;
+      font-size: 14px;
+      outline: none;
+      cursor: pointer;
+      transition: border-color 0.2s ease;
+
+      &:focus {
+        border-color: #3b82f6;
+      }
+    }
+
+    .close-btn {
+      background: none;
+      border: none;
+      padding: 8px;
+      border-radius: 6px;
+      cursor: pointer;
+      color: #6b7280;
+      transition: all 0.2s ease;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      svg {
+        width: 20px;
+        height: 20px;
+      }
+
+      &:hover {
+        background-color: #f3f4f6;
+        color: #374151;
+      }
+    }
+  }
+}
+
+.modal-content {
+  flex: 1;
+  padding: 24px;
+  overflow-y: auto;
+  min-height: 0;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 60px 20px;
+  color: #6b7280;
+
+  .loading-spinner {
+    width: 40px;
+    height: 40px;
+    border: 3px solid #e5e7eb;
+    border-top: 3px solid #3b82f6;
+    border-radius: 50%;
+    animation: spin 1s linear infinite;
+    margin-bottom: 16px;
+  }
+
+  p {
+    margin: 0;
+    font-size: 16px;
+  }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 60px 20px;
+  color: #6b7280;
+
+  .empty-icon {
+    width: 64px;
+    height: 64px;
+    margin: 0 auto 16px;
+    opacity: 0.3;
+  }
+
+  h3 {
+    font-size: 18px;
+    font-weight: 600;
+    margin: 0 0 8px 0;
+    color: #374151;
+  }
+
+  p {
+    margin: 0;
+    font-size: 14px;
+  }
+}
+
+.files-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 20px;
+}
+
+.file-card {
+  background: white;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  overflow: hidden;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover {
+    border-color: #3b82f6;
+    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+    transform: translateY(-2px);
+  }
+
+  .file-preview {
+    aspect-ratio: 16/9;
+    background-color: #f9fafb;
+    position: relative;
+    overflow: hidden;
+
+    .preview-image {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    .preview-placeholder {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      height: 100%;
+      padding: 20px;
+
+      .file-icon {
+        font-size: 48px;
+        margin-bottom: 8px;
+        opacity: 0.6;
+      }
+
+      .file-extension {
+        font-size: 14px;
+        font-weight: 600;
+        color: #6b7280;
+        background-color: #f3f4f6;
+        padding: 4px 8px;
+        border-radius: 4px;
+      }
+    }
+  }
+
+  .file-info {
+    padding: 16px;
+
+    .file-name {
+      font-size: 14px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0 0 8px 0;
+      line-height: 1.4;
+      display: -webkit-box;
+      -webkit-line-clamp: 2;
+      -webkit-box-orient: vertical;
+      overflow: hidden;
+    }
+
+    .file-meta {
+      display: flex;
+      gap: 8px;
+      margin-bottom: 8px;
+      font-size: 12px;
+      color: #6b7280;
+
+      .file-size {
+        font-weight: 500;
+      }
+    }
+
+    .file-description {
+      margin-bottom: 12px;
+
+      p {
+        font-size: 12px;
+        color: #6b7280;
+        margin: 0;
+        line-height: 1.4;
+        display: -webkit-box;
+        -webkit-line-clamp: 2;
+        -webkit-box-orient: vertical;
+        overflow: hidden;
+      }
+    }
+
+    .file-footer {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+
+      .uploader-info {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        flex: 1;
+        min-width: 0;
+
+        .uploader-avatar {
+          width: 24px;
+          height: 24px;
+          border-radius: 50%;
+          object-fit: cover;
+        }
+
+        .uploader-name {
+          font-size: 12px;
+          color: #374151;
+          font-weight: 500;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+
+        .source-badge {
+          font-size: 10px;
+          padding: 2px 6px;
+          border-radius: 10px;
+          font-weight: 500;
+          white-space: nowrap;
+
+          &.source-wxwork {
+            background-color: #dcfce7;
+            color: #166534;
+          }
+
+          &.source-manual {
+            background-color: #dbeafe;
+            color: #1e40af;
+          }
+
+          &.source-default {
+            background-color: #f3f4f6;
+            color: #6b7280;
+          }
+        }
+      }
+
+      .file-actions {
+        .action-btn {
+          background: none;
+          border: none;
+          padding: 6px;
+          border-radius: 4px;
+          cursor: pointer;
+          color: #6b7280;
+          transition: all 0.2s ease;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+
+          svg {
+            width: 16px;
+            height: 16px;
+          }
+
+          &:hover {
+            background-color: #f3f4f6;
+            color: #374151;
+
+            &.download-btn {
+              background-color: #eff6ff;
+              color: #3b82f6;
+            }
+          }
+
+          &.download-btn:hover {
+            background-color: #eff6ff;
+            color: #3b82f6;
+          }
+        }
+      }
+    }
+  }
+}
+
+.files-list {
+  .file-list-item {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    padding: 16px;
+    border-bottom: 1px solid #f3f4f6;
+    cursor: pointer;
+    transition: background-color 0.2s ease;
+
+    &:hover {
+      background-color: #f9fafb;
+    }
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .list-file-icon {
+      width: 48px;
+      height: 48px;
+      border-radius: 6px;
+      overflow: hidden;
+      background-color: #f3f4f6;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+
+      .list-preview-image {
+        width: 100%;
+        height: 100%;
+        object-fit: cover;
+      }
+
+      .list-icon {
+        font-size: 24px;
+      }
+    }
+
+    .list-file-info {
+      flex: 1;
+      min-width: 0;
+
+      .list-file-name {
+        font-size: 14px;
+        font-weight: 600;
+        color: #1f2937;
+        margin-bottom: 4px;
+      }
+
+      .list-file-meta {
+        display: flex;
+        gap: 12px;
+        font-size: 12px;
+        color: #6b7280;
+        flex-wrap: wrap;
+
+        .source-badge {
+          font-size: 10px;
+          padding: 2px 6px;
+          border-radius: 10px;
+          font-weight: 500;
+
+          &.source-wxwork {
+            background-color: #dcfce7;
+            color: #166534;
+          }
+
+          &.source-manual {
+            background-color: #dbeafe;
+            color: #1e40af;
+          }
+
+          &.source-default {
+            background-color: #f3f4f6;
+            color: #6b7280;
+          }
+        }
+      }
+
+      .list-file-description {
+        font-size: 12px;
+        color: #6b7280;
+        margin-top: 4px;
+        display: -webkit-box;
+        -webkit-line-clamp: 1;
+        -webkit-box-orient: vertical;
+        overflow: hidden;
+      }
+    }
+
+    .list-file-actions {
+      .action-btn {
+        background: none;
+        border: none;
+        padding: 8px;
+        border-radius: 4px;
+        cursor: pointer;
+        color: #6b7280;
+        transition: all 0.2s ease;
+
+        svg {
+          width: 18px;
+          height: 18px;
+        }
+
+        &:hover {
+          background-color: #f3f4f6;
+          color: #374151;
+
+          &.download-btn {
+            background-color: #eff6ff;
+            color: #3b82f6;
+          }
+        }
+      }
+    }
+  }
+}
+
+// 预览模态框
+.preview-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.8);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10000;
+  padding: 20px;
+}
+
+.preview-container {
+  background: white;
+  border-radius: 12px;
+  max-width: 90vw;
+  max-height: 90vh;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+
+  .preview-header {
+    padding: 20px;
+    border-bottom: 1px solid #e5e7eb;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    h3 {
+      font-size: 18px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0;
+      flex: 1;
+      min-width: 0;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .preview-close {
+      background: none;
+      border: none;
+      padding: 8px;
+      border-radius: 6px;
+      cursor: pointer;
+      color: #6b7280;
+      transition: all 0.2s ease;
+
+      svg {
+        width: 20px;
+        height: 20px;
+      }
+
+      &:hover {
+        background-color: #f3f4f6;
+        color: #374151;
+      }
+    }
+  }
+
+  .preview-content {
+    flex: 1;
+    padding: 20px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: #f9fafb;
+    min-height: 300px;
+
+    .preview-full-image {
+      max-width: 100%;
+      max-height: 100%;
+      object-fit: contain;
+      border-radius: 8px;
+    }
+
+    .preview-video {
+      max-width: 100%;
+      max-height: 100%;
+      border-radius: 8px;
+    }
+
+    .preview-no-preview {
+      text-align: center;
+      color: #6b7280;
+
+      .preview-icon-large {
+        font-size: 64px;
+        margin-bottom: 16px;
+        opacity: 0.6;
+      }
+
+      p {
+        margin: 0 0 16px 0;
+        font-size: 16px;
+      }
+
+      .preview-download-btn {
+        background: #3b82f6;
+        color: white;
+        border: none;
+        padding: 10px 20px;
+        border-radius: 6px;
+        font-size: 14px;
+        font-weight: 500;
+        cursor: pointer;
+        transition: all 0.2s ease;
+
+        &:hover {
+          background: #2563eb;
+        }
+      }
+    }
+  }
+
+  .preview-footer {
+    padding: 20px;
+    border-top: 1px solid #e5e7eb;
+    background-color: #f9fafb;
+
+    .preview-meta {
+      display: flex;
+      gap: 16px;
+      margin-bottom: 16px;
+      font-size: 14px;
+      color: #6b7280;
+      flex-wrap: wrap;
+
+      .source-badge {
+        font-size: 12px;
+        padding: 4px 8px;
+        border-radius: 12px;
+        font-weight: 500;
+
+        &.source-wxwork {
+          background-color: #dcfce7;
+          color: #166534;
+        }
+
+        &.source-manual {
+          background-color: #dbeafe;
+          color: #1e40af;
+        }
+
+        &.source-default {
+          background-color: #f3f4f6;
+          color: #6b7280;
+        }
+      }
+    }
+
+    .preview-description {
+      .description-textarea {
+        width: 100%;
+        min-height: 60px;
+        padding: 12px;
+        border: 1px solid #e5e7eb;
+        border-radius: 6px;
+        font-size: 14px;
+        font-family: inherit;
+        resize: vertical;
+        outline: none;
+        transition: border-color 0.2s ease;
+
+        &:focus {
+          border-color: #3b82f6;
+          box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+        }
+
+        &::placeholder {
+          color: #9ca3af;
+        }
+      }
+    }
+  }
+}
+
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+
+// 响应式设计
+@media (max-width: 768px) {
+  .modal-overlay {
+    padding: 0;
+  }
+
+  .modal-container {
+    max-height: 100vh;
+    border-radius: 0;
+  }
+
+  .modal-header {
+    padding: 16px;
+    flex-direction: column;
+    gap: 16px;
+
+    .header-left {
+      .modal-title {
+        font-size: 20px;
+      }
+
+      .file-stats {
+        justify-content: center;
+        gap: 12px;
+      }
+    }
+
+    .header-right {
+      width: 100%;
+      flex-direction: column;
+      gap: 12px;
+
+      .search-box .search-input {
+        width: 100%;
+      }
+    }
+  }
+
+  .modal-content {
+    padding: 16px;
+  }
+
+  .files-grid {
+    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+    gap: 16px;
+  }
+
+  .file-card {
+    .file-info {
+      padding: 12px;
+    }
+
+    .file-footer {
+      flex-direction: column;
+      gap: 8px;
+      align-items: stretch;
+
+      .uploader-info {
+        justify-content: center;
+      }
+
+      .file-actions {
+        justify-content: center;
+      }
+    }
+  }
+
+  .files-list {
+    .file-list-item {
+      padding: 12px;
+      gap: 12px;
+
+      .list-file-icon {
+        width: 40px;
+        height: 40px;
+      }
+    }
+  }
+
+  .preview-overlay {
+    padding: 0;
+  }
+
+  .preview-container {
+    max-width: 100vw;
+    max-height: 100vh;
+    border-radius: 0;
+  }
+}
+
+@media (max-width: 480px) {
+  .files-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .modal-header {
+    .header-right {
+      .view-toggle {
+        justify-content: center;
+      }
+
+      .filter-select {
+        width: 100%;
+      }
+    }
+  }
+}

+ 254 - 0
src/modules/project/components/project-files-modal/project-files-modal.component.ts

@@ -0,0 +1,254 @@
+import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FmodeObject, FmodeQuery, FmodeParse } from 'fmode-ng/parse';
+import { NovaStorage, NovaFile } from 'fmode-ng/core';
+
+const Parse = FmodeParse.with('nova');
+
+export interface ProjectFile {
+  id: string;
+  name: string;
+  originalName: string;
+  url: string;
+  key: string;
+  type: string;
+  size: number;
+  uploadedBy: {
+    id: string;
+    name: string;
+    avatar?: string;
+  };
+  uploadedAt: Date;
+  source: string;
+  md5?: string;
+  metadata?: any;
+  description?: string;
+}
+
+@Component({
+  selector: 'app-project-files-modal',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './project-files-modal.component.html',
+  styleUrls: ['./project-files-modal.component.scss']
+})
+export class ProjectFilesModalComponent implements OnInit {
+  @Input() project: FmodeObject | null = null;
+  @Input() currentUser: FmodeObject | null = null;
+  @Input() isVisible: boolean = false;
+  @Output() close = new EventEmitter<void>();
+
+  files: ProjectFile[] = [];
+  loading: boolean = false;
+  selectedFile: ProjectFile | null = null;
+  previewMode: 'grid' | 'list' = 'grid';
+  searchQuery: string = '';
+  filterType: string = 'all';
+
+  // 统计信息
+  totalFiles: number = 0;
+  totalSize: number = 0;
+  imageCount: number = 0;
+  documentCount: number = 0;
+
+  constructor() {}
+
+  ngOnInit(): void {
+    if (this.isVisible && this.project) {
+      this.loadFiles();
+    }
+  }
+
+  ngOnChanges(): void {
+    if (this.isVisible && this.project) {
+      this.loadFiles();
+    }
+  }
+
+  async loadFiles(): Promise<void> {
+    if (!this.project) return;
+
+    try {
+      this.loading = true;
+
+      const query = new FmodeQuery('ProjectFile');
+      query.equalTo('project', this.project.toPointer());
+      query.include('uploadedBy');
+      query.descending('uploadedAt');
+      query.limit(100);
+
+      const results = await query.find();
+
+      this.files = results.map((file: FmodeObject) => ({
+        id: file.id || '',
+        name: file.get('name') || file.get('originalName') || '',
+        originalName: file.get('originalName') || '',
+        url: file.get('url') || '',
+        key: file.get('key') || '',
+        type: file.get('type') || '',
+        size: file.get('size') || 0,
+        uploadedBy: {
+          id: file.get('uploadedBy')?.id || '',
+          name: file.get('uploadedBy')?.get('name') || '未知用户',
+          avatar: file.get('uploadedBy')?.get('data')?.avatar
+        },
+        uploadedAt: file.get('uploadedAt') || file.createdAt,
+        source: file.get('source') || 'unknown',
+        md5: file.get('md5'),
+        metadata: file.get('metadata'),
+        description: file.get('description') || ''
+      }));
+
+      this.calculateStats();
+      console.log(`✅ 加载了 ${this.files.length} 个项目文件`);
+    } catch (error) {
+      console.error('❌ 加载项目文件失败:', error);
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  calculateStats(): void {
+    this.totalFiles = this.files.length;
+    this.totalSize = this.files.reduce((sum, file) => sum + (file.size || 0), 0);
+    this.imageCount = this.files.filter(file => file.type.startsWith('image/')).length;
+    this.documentCount = this.files.filter(file => !file.type.startsWith('image/')).length;
+  }
+
+  onClose(): void {
+    this.close.emit();
+  }
+
+  onBackdropClick(event: MouseEvent): void {
+    if (event.target === event.currentTarget) {
+      this.onClose();
+    }
+  }
+
+  selectFile(file: ProjectFile): void {
+    this.selectedFile = file;
+  }
+
+  closePreview(): void {
+    this.selectedFile = null;
+  }
+
+  downloadFile(file: ProjectFile): void {
+    const link = document.createElement('a');
+    link.href = file.url;
+    link.download = file.originalName;
+    link.target = '_blank';
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+  }
+
+  formatFileSize(bytes: number): string {
+    if (bytes === 0) return '0 Bytes';
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+  }
+
+  getFileIcon(type: string): string {
+    if (type.startsWith('image/')) return '🖼️';
+    if (type.startsWith('video/')) return '🎬';
+    if (type.startsWith('audio/')) return '🎵';
+    if (type.includes('pdf')) return '📄';
+    if (type.includes('word') || type.includes('document')) return '📝';
+    if (type.includes('excel') || type.includes('spreadsheet')) return '📊';
+    if (type.includes('powerpoint') || type.includes('presentation')) return '📑';
+    if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return '📦';
+    if (type.includes('text') || type.includes('plain')) return '📄';
+    return '📎';
+  }
+
+  getFileExtension(name: string): string {
+    return name.split('.').pop()?.toUpperCase() || '';
+  }
+
+  isImageFile(file: ProjectFile): boolean {
+    return file.type.startsWith('image/');
+  }
+
+  isVideoFile(file: ProjectFile): boolean {
+    return file.type.startsWith('video/');
+  }
+
+  getFilteredFiles(): ProjectFile[] {
+    let filtered = this.files;
+
+    // 搜索过滤
+    if (this.searchQuery) {
+      const query = this.searchQuery.toLowerCase();
+      filtered = filtered.filter(file =>
+        file.name.toLowerCase().includes(query) ||
+        file.originalName.toLowerCase().includes(query) ||
+        file.uploadedBy.name.toLowerCase().includes(query)
+      );
+    }
+
+    // 类型过滤
+    if (this.filterType !== 'all') {
+      switch (this.filterType) {
+        case 'images':
+          filtered = filtered.filter(file => this.isImageFile(file));
+          break;
+        case 'documents':
+          filtered = filtered.filter(file => !this.isImageFile(file));
+          break;
+        case 'videos':
+          filtered = filtered.filter(file => this.isVideoFile(file));
+          break;
+      }
+    }
+
+    return filtered;
+  }
+
+  async updateFileDescription(file: ProjectFile, description: string): Promise<void> {
+    try {
+      const query = new FmodeQuery('ProjectFile');
+      const fileObject = await query.get(file.id);
+
+      if (fileObject) {
+        fileObject.set('description', description);
+        await fileObject.save();
+
+        // 更新本地数据
+        const localFile = this.files.find(f => f.id === file.id);
+        if (localFile) {
+          localFile.description = description;
+        }
+
+        console.log('✅ 文件说明更新成功');
+      }
+    } catch (error) {
+      console.error('❌ 更新文件说明失败:', error);
+    }
+  }
+
+  getSourceBadgeClass(source: string): string {
+    switch (source) {
+      case '企业微信拖拽':
+        return 'source-wxwork';
+      case '手动选择':
+        return 'source-manual';
+      default:
+        return 'source-default';
+    }
+  }
+
+  getSourceLabel(source: string): string {
+    switch (source) {
+      case '企业微信拖拽':
+        return '企微拖拽';
+      case '手动选择':
+        return '手动上传';
+      default:
+        return '其他';
+    }
+  }
+}

+ 209 - 0
src/modules/project/components/project-members-modal/project-members-modal.component.html

@@ -0,0 +1,209 @@
+<!-- 模态框背景 -->
+<div class="modal-overlay"
+     *ngIf="isVisible"
+     (click)="onBackdropClick($event)">
+
+  <!-- 模态框内容 -->
+  <div class="modal-container" (click)="$event.stopPropagation()">
+
+    <!-- 模态框头部 -->
+    <div class="modal-header">
+      <div class="header-left">
+        <h2 class="modal-title">
+          <svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
+            <circle cx="9" cy="7" r="4"></circle>
+            <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
+            <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
+          </svg>
+          项目成员
+        </h2>
+        <div class="member-stats">
+          <span class="stat-item">
+            <span class="stat-number">{{ totalMembers }}</span>
+            <span class="stat-label">总成员</span>
+          </span>
+          <span class="stat-item">
+            <span class="stat-number">{{ projectTeamMembers }}</span>
+            <span class="stat-label">项目团队</span>
+          </span>
+          @if (pendingAddMembers > 0) {
+            <span class="stat-item stat-pending">
+              <span class="stat-number">{{ pendingAddMembers }}</span>
+              <span class="stat-label">待加入群聊</span>
+            </span>
+          }
+        </div>
+      </div>
+
+      <div class="header-right">
+        <!-- 环境指示器 -->
+        <div class="environment-indicator">
+          @if (isWxworkEnvironment) {
+            <span class="env-badge env-wxwork">
+              <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
+                <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.88c0 .53-.21.9-.74.26-.18.07-.36.06-.51.14-.75.21-.13.18-.3.36-.3.75.06.39.12.69.12.75.21.37.12.69.12 1.06 0 .39-.13.7-.12.75-.06-.06-.18-.15-.51-.14-.75-.21-.53-.21-.9-.74-.26-.18-.07-.36-.06-.51-.14-.75-.21-.13-.18-.3-.36-.3-.75.06-.39.12-.69.12-.75-.21-.37-.12-.69-.12-1.06 0-.39.13-.7-.12-.75.06-.06.18-.15.51-.14-.75.21z"/>
+              </svg>
+              企业微信环境
+            </span>
+          } @else {
+            <span class="env-badge env-default">
+              <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
+                <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17.88c0 .53-.21.9-.74.26-.18.07-.36.06-.51.14-.75.21-.13.18-.3.36-.3.75.06.39.12.69.12.75.21.37.12.69.12 1.06 0 .39-.13.7-.12.75-.06-.06-.18-.15-.51-.14-.75-.21-.53-.21-.9-.74-.26-.18-.07-.36-.06-.51-.14-.75-.21-.13-.18-.3-.36-.3-.75.06-.39.12-.69.12-.75-.21-.37-.12-.69-.12-1.06 0-.39.13-.7-.12-.75.06-.06.18-.15.51-.14-.75.21z"/>
+              </svg>
+              普通环境
+            </span>
+          }
+        </div>
+
+        <!-- 搜索框 -->
+        <div class="search-box">
+          <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="11" cy="11" r="8"></circle>
+            <path d="m21 21-4.35-4.35"></path>
+          </svg>
+          <input
+            type="text"
+            class="search-input"
+            placeholder="搜索成员..."
+            [(ngModel)]="searchQuery">
+        </div>
+
+        <!-- 过滤器 -->
+        <select class="filter-select" [(ngModel)]="memberFilter">
+          <option value="all">全部成员</option>
+          <option value="team">项目团队</option>
+          <option value="ingroup">已在群聊</option>
+          <option value="pending">待加入群聊</option>
+        </select>
+
+        <!-- 关闭按钮 -->
+        <button class="close-btn" (click)="onClose()">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18"></line>
+            <line x1="6" y1="6" x2="18" y2="18"></line>
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 模态框内容 -->
+    <div class="modal-content">
+
+      <!-- 加载状态 -->
+      @if (loading) {
+        <div class="loading-state">
+          <div class="loading-spinner"></div>
+          <p>加载成员信息...</p>
+        </div>
+      } @else if (error) {
+        <!-- 错误状态 -->
+        <div class="error-state">
+          <svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10"></circle>
+            <line x1="15" y1="9" x2="9" y2="15"></line>
+            <line x1="9" y1="9" x2="15" y2="15"></line>
+          </svg>
+          <h3>加载失败</h3>
+          <p>{{ error }}</p>
+          <button class="retry-btn" (click)="loadMembers()">重试</button>
+        </div>
+      } @else if (getFilteredMembers().length === 0) {
+        <!-- 空状态 -->
+        <div class="empty-state">
+          <svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
+            <circle cx="9" cy="7" r="4"></circle>
+            <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
+            <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
+          </svg>
+          <h3>暂无成员</h3>
+          <p>该项目还没有添加任何成员</p>
+        </div>
+      } @else {
+        <!-- 成员列表 -->
+        <div class="members-list">
+          @for (member of getFilteredMembers(); track member.id) {
+            <div class="member-card" [class]="getMemberStatusClass(member)">
+              <!-- 成员头像 -->
+              <div class="member-avatar">
+                @if (member.avatar) {
+                  <img [src]="member.avatar" [alt]="member.name" class="avatar-image" />
+                } @else {
+                  <div class="avatar-placeholder">
+                    {{ member.name.charAt(0).toUpperCase() }}
+                  </div>
+                }
+              </div>
+
+              <!-- 成员信息 -->
+              <div class="member-info">
+                <h4 class="member-name">{{ member.name }}</h4>
+                <div class="member-meta">
+                  <span class="member-role">
+                    <span class="role-badge" [class]="getRoleBadgeClass(member.role)">
+                      {{ member.role }}
+                    </span>
+                  </span>
+                  @if (member.department) {
+                    <span class="member-department">{{ member.department }}</span>
+                  }
+                  <span class="member-status" [class]="getMemberStatusClass(member)">
+                    {{ getMemberStatusText(member) }}
+                  </span>
+                </div>
+                @if (member.isInProjectTeam && !member.isInGroupChat && isWxworkEnvironment) {
+                  <div class="add-to-group-hint">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <circle cx="12" cy="12" r="10"></circle>
+                      <line x1="12" y1="8" x2="12" y2="16"></line>
+                      <path d="M8 12h8"></path>
+                    </svg>
+                    <span>点击添加到群聊</span>
+                  </div>
+                }
+              </div>
+
+              <!-- 操作按钮 -->
+              <div class="member-actions">
+                @if (member.isInProjectTeam && !member.isInGroupChat && isWxworkEnvironment) {
+                  <button
+                    class="action-btn add-btn"
+                    (click)="addMemberToGroupChat(member)"
+                    title="添加到群聊">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <circle cx="12" cy="12" r="10"></circle>
+                      <line x1="12" y1="8" x2="12" y2="16"></line>
+                      <path d="M8 12h8"></path>
+                    </svg>
+                    <span>添加</span>
+                  </button>
+                }
+
+                <div class="status-indicator" [class]="getMemberStatusClass(member)">
+                  @if (member.isInProjectTeam && member.isInGroupChat) {
+                    <svg viewBox="0 0 24 24" fill="currentColor">
+                      <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7.41l-1.41-1.41z"/>
+                    </svg>
+                  }
+                  @else if (member.isInProjectTeam && !member.isInGroupChat) {
+                    <svg viewBox="0 0 24 24" fill="currentColor">
+                      <circle cx="12" cy="12" r="10" opacity="0.3"/>
+                      <path d="M12 8v8"/>
+                      <path d="M8 12h8"/>
+                    </svg>
+                  }
+                  @else {
+                    <svg viewBox="0 0 24 24" fill="currentColor">
+                      <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15.88c0 .53-.21.9-.74.26-.18.07-.36.06-.51.14-.75.21-.13.18-.3.36-.3.75.06.39.12.69.12.75.21.37.12.69.12 1.06 0 .39-.13.7-.12.75-.06-.06-.18-.15-.51-.14-.75-.21-.53-.21-.9-.74-.26-.18-.07-.36-.06-.51-.14-.75-.21-.13-.18-.3-.36-.3-.75.06-.39.12-.69.12-.75-.21-.37-.12-.69-.12-1.06 0-.39.13-.7-.12-.75.06-.06.18-.15.51-.14-.75.21z"/>
+                    </svg>
+                  }
+                </div>
+              </div>
+            </div>
+          }
+        </div>
+      }
+    </div>
+  </div>
+</div>

+ 650 - 0
src/modules/project/components/project-members-modal/project-members-modal.component.scss

@@ -0,0 +1,650 @@
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9999;
+  padding: 20px;
+}
+
+.modal-container {
+  background: white;
+  border-radius: 12px;
+  max-width: 1000px;
+  width: 100%;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+  overflow: hidden;
+}
+
+.modal-header {
+  padding: 24px;
+  border-bottom: 1px solid #e5e7eb;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 20px;
+  flex-shrink: 0;
+
+  .header-left {
+    flex: 1;
+    min-width: 0;
+
+    .modal-title {
+      font-size: 24px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0 0 12px 0;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+
+      .title-icon {
+        width: 28px;
+        height: 28px;
+        color: #10b981;
+      }
+    }
+
+    .member-stats {
+      display: flex;
+      gap: 16px;
+      flex-wrap: wrap;
+
+      .stat-item {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        min-width: 50px;
+
+        .stat-number {
+          font-size: 18px;
+          font-weight: 600;
+          color: #1f2937;
+          line-height: 1;
+        }
+
+        .stat-label {
+          font-size: 12px;
+          color: #6b7280;
+          margin-top: 2px;
+        }
+
+        &.stat-pending {
+          .stat-number {
+            color: #d97706;
+          }
+
+          .stat-label {
+            color: #92400e;
+          }
+        }
+      }
+    }
+  }
+
+  .header-right {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex-shrink: 0;
+
+    .environment-indicator {
+      .env-badge {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        padding: 6px 12px;
+        border-radius: 20px;
+        font-size: 12px;
+        font-weight: 500;
+
+        &.env-wxwork {
+          background-color: #dcfce7;
+          color: #166534;
+        }
+
+        &.env-default {
+          background-color: #f3f4f6;
+          color: #6b7280;
+        }
+      }
+    }
+
+    .search-box {
+      position: relative;
+      display: flex;
+      align-items: center;
+
+      .search-icon {
+        position: absolute;
+        left: 12px;
+        width: 18px;
+        height: 18px;
+        color: #6b7280;
+      }
+
+      .search-input {
+        padding: 8px 12px 8px 36px;
+        border: 1px solid #e5e7eb;
+        border-radius: 6px;
+        font-size: 14px;
+        width: 200px;
+        outline: none;
+        transition: border-color 0.2s ease;
+
+        &:focus {
+          border-color: #10b981;
+          box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
+        }
+      }
+    }
+
+    .filter-select {
+      padding: 8px 12px;
+      border: 1px solid #e5e7eb;
+      border-radius: 6px;
+      font-size: 14px;
+      outline: none;
+      cursor: pointer;
+      transition: border-color 0.2s ease;
+
+      &:focus {
+        border-color: #10b981;
+      }
+    }
+
+    .close-btn {
+      background: none;
+      border: none;
+      padding: 8px;
+      border-radius: 6px;
+      cursor: pointer;
+      color: #6b7280;
+      transition: all 0.2s ease;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      svg {
+        width: 20px;
+        height: 20px;
+      }
+
+      &:hover {
+        background-color: #f3f4f6;
+        color: #374151;
+      }
+    }
+  }
+}
+
+.modal-content {
+  flex: 1;
+  padding: 24px;
+  overflow-y: auto;
+  min-height: 0;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 60px 20px;
+  color: #6b7280;
+
+  .loading-spinner {
+    width: 40px;
+    height: 40px;
+    border: 3px solid #e5e7eb;
+    border-top: 3px solid #10b981;
+    border-radius: 50%;
+    animation: spin 1s linear infinite;
+    margin-bottom: 16px;
+  }
+
+  p {
+    margin: 0;
+    font-size: 16px;
+  }
+}
+
+.error-state {
+  text-align: center;
+  padding: 60px 20px;
+  color: #ef4444;
+
+  .error-icon {
+    width: 64px;
+    height: 64px;
+    margin: 0 auto 16px;
+    opacity: 0.5;
+  }
+
+  h3 {
+    font-size: 18px;
+    font-weight: 600;
+    margin: 0 0 8px 0;
+    color: #dc2626;
+  }
+
+  p {
+    margin: 0 0 16px 0;
+    font-size: 14px;
+    color: #7f1d1d;
+  }
+
+  .retry-btn {
+    background: #ef4444;
+    color: white;
+    border: none;
+    padding: 10px 20px;
+    border-radius: 6px;
+    font-size: 14px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background: #dc2626;
+    }
+  }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 60px 20px;
+  color: #6b7280;
+
+  .empty-icon {
+    width: 64px;
+    height: 64px;
+    margin: 0 auto 16px;
+    opacity: 0.3;
+  }
+
+  h3 {
+    font-size: 18px;
+    font-weight: 600;
+    margin: 0 0 8px 0;
+    color: #374151;
+  }
+
+  p {
+    margin: 0;
+    font-size: 14px;
+  }
+}
+
+.members-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.member-card {
+  background: white;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 16px;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  transition: all 0.2s ease;
+
+  &:hover {
+    border-color: #10b981;
+    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+  }
+
+  &.status-active {
+    background-color: #f0fdf4;
+    border-color: #86efac;
+  }
+
+  &.status-pending {
+    background-color: #fffbeb;
+    border-color: #fcd34d;
+  }
+
+  &.status-group-only {
+    background-color: #f9fafb;
+    border-color: #d1d5db;
+  }
+
+  .member-avatar {
+    width: 48px;
+    height: 48px;
+    border-radius: 50%;
+    overflow: hidden;
+    flex-shrink: 0;
+
+    .avatar-image {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    .avatar-placeholder {
+      width: 100%;
+      height: 100%;
+      background-color: #10b981;
+      color: white;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 18px;
+      font-weight: 600;
+      text-transform: uppercase;
+    }
+  }
+
+  .member-info {
+    flex: 1;
+    min-width: 0;
+
+    .member-name {
+      font-size: 16px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0 0 8px 0;
+    }
+
+    .member-meta {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap;
+      margin-bottom: 8px;
+
+      .role-badge {
+        font-size: 12px;
+        padding: 4px 8px;
+        border-radius: 12px;
+        font-weight: 500;
+        white-space: nowrap;
+
+        &.role-customer-service {
+          background-color: #dbeafe;
+          color: #1e40af;
+        }
+
+        &.role-designer {
+          background-color: #ede9fe;
+          color: #5b21b6;
+        }
+
+        &.role-team-leader {
+          background-color: #fef3c7;
+          color: #92400e;
+        }
+
+        &.role-admin {
+          background-color: #fee2e2;
+          color: #991b1b;
+        }
+
+        &.role-external {
+          background-color: #f3f4f6;
+          color: #6b7280;
+        }
+
+        &.role-default {
+          background-color: #f3f4f6;
+          color: #6b7280;
+        }
+      }
+
+      .member-department {
+        font-size: 12px;
+        color: #6b7280;
+        background-color: #f3f4f6;
+        padding: 2px 6px;
+        border-radius: 4px;
+      }
+
+      .member-status {
+        font-size: 11px;
+        padding: 2px 8px;
+        border-radius: 12px;
+        font-weight: 500;
+        white-space: nowrap;
+
+        &.status-active {
+          background-color: #dcfce7;
+          color: #166534;
+        }
+
+        &.status-pending {
+          background-color: #fef3c7;
+          color: #92400e;
+        }
+
+        &.status-group-only {
+          background-color: #f3f4f6;
+          color: #6b7280;
+        }
+      }
+    }
+
+    .add-to-group-hint {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      font-size: 12px;
+      color: #d97706;
+      margin-top: 4px;
+
+      svg {
+        width: 14px;
+        height: 14px;
+        flex-shrink: 0;
+      }
+    }
+  }
+
+  .member-actions {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    align-items: flex-end;
+    flex-shrink: 0;
+
+    .action-btn {
+      background: none;
+      border: 1px solid #e5e7eb;
+      border-radius: 6px;
+      padding: 8px 12px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      font-size: 12px;
+      font-weight: 500;
+      white-space: nowrap;
+
+      svg {
+        width: 16px;
+        height: 16px;
+      }
+
+      &:hover {
+        transform: translateY(-1px);
+        box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+      }
+
+      &.add-btn {
+        background-color: #10b981;
+        border-color: #10b981;
+        color: white;
+
+        &:hover {
+          background-color: #059669;
+        }
+      }
+    }
+
+    .status-indicator {
+      width: 24px;
+      height: 24px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 50%;
+      font-size: 12px;
+
+      &.status-active {
+        background-color: #10b981;
+        color: white;
+      }
+
+      &.status-pending {
+        background-color: #f59e0b;
+        color: white;
+      }
+
+      &.status-group-only {
+        background-color: #6b7280;
+        color: white;
+      }
+    }
+  }
+}
+
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+
+// 响应式设计
+@media (max-width: 768px) {
+  .modal-overlay {
+    padding: 0;
+  }
+
+  .modal-container {
+    max-height: 100vh;
+    border-radius: 0;
+  }
+
+  .modal-header {
+    padding: 16px;
+    flex-direction: column;
+    gap: 16px;
+
+    .header-left {
+      .modal-title {
+        font-size: 20px;
+      }
+
+      .member-stats {
+        justify-content: center;
+        gap: 12px;
+      }
+    }
+
+    .header-right {
+      width: 100%;
+      flex-direction: column;
+      gap: 12px;
+
+      .search-box .search-input {
+        width: 100%;
+      }
+
+      .filter-select {
+        width: 100%;
+      }
+    }
+  }
+
+  .modal-content {
+    padding: 16px;
+  }
+
+  .member-card {
+    padding: 12px;
+    gap: 12px;
+
+    .member-avatar {
+      width: 40px;
+      height: 40px;
+    }
+
+    .member-info {
+      .member-name {
+        font-size: 14px;
+      }
+
+      .member-meta {
+        gap: 6px;
+        flex-wrap: wrap;
+
+        .role-badge {
+          font-size: 11px;
+          padding: 2px 6px;
+        }
+
+        .member-department {
+          font-size: 11px;
+        }
+
+        .member-status {
+          font-size: 10px;
+          padding: 2px 6px;
+        }
+      }
+
+      .add-to-group-hint {
+        font-size: 11px;
+        gap: 4px;
+      }
+    }
+
+    .member-actions {
+      flex-direction: row;
+      gap: 8px;
+
+      .action-btn {
+        padding: 6px 10px;
+        font-size: 11px;
+
+        &.add-btn {
+          span {
+            display: none;
+          }
+        }
+      }
+    }
+  }
+}
+
+@media (max-width: 480px) {
+  .member-card {
+    flex-direction: column;
+    align-items: stretch;
+    gap: 12px;
+
+    .member-info {
+      text-align: center;
+
+      .member-meta {
+        justify-content: center;
+      }
+    }
+
+    .member-actions {
+      flex-direction: row;
+      justify-content: center;
+    }
+  }
+}

+ 337 - 0
src/modules/project/components/project-members-modal/project-members-modal.component.ts

@@ -0,0 +1,337 @@
+import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FmodeObject, FmodeQuery, FmodeParse } from 'fmode-ng/parse';
+import { WxworkCorp } from 'fmode-ng/core';
+
+const Parse = FmodeParse.with('nova');
+
+export interface ProjectMember {
+  id: string;
+  name: string;
+  userId: string;
+  avatar?: string;
+  role: string;
+  department?: string;
+  isInGroupChat: boolean;
+  isInProjectTeam: boolean;
+  projectTeamId?: string;
+  profileId?: string;
+}
+
+export interface GroupChatMember {
+  userid: string;
+  name: string;
+  type: number; // 1: 内部成员 2: 外部联系人
+  avatar?: string;
+}
+
+@Component({
+  selector: 'app-project-members-modal',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './project-members-modal.component.html',
+  styleUrls: ['./project-members-modal.component.scss']
+})
+export class ProjectMembersModalComponent implements OnInit {
+  @Input() project: FmodeObject | null = null;
+  @Input() groupChat: FmodeObject | null = null;
+  @Input() currentUser: FmodeObject | null = null;
+  @Input() isVisible: boolean = false;
+  @Input() cid: string = '';
+  @Output() close = new EventEmitter<void>();
+
+  members: ProjectMember[] = [];
+  loading: boolean = false;
+  error: string | null = null;
+  isWxworkEnvironment: boolean = false;
+
+  // 统计信息
+  totalMembers: number = 0;
+  groupChatMembers: number = 0;
+  projectTeamMembers: number = 0;
+  pendingAddMembers: number = 0;
+
+  // 过滤和搜索
+  searchQuery: string = '';
+  memberFilter: 'all' | 'ingroup' | 'team' | 'pending' = 'all';
+
+  // 企业微信API
+  private wecorp: WxworkCorp | null = null;
+
+  constructor() {
+    this.checkWxworkEnvironment();
+  }
+
+  ngOnInit(): void {
+    if (this.isVisible && this.project) {
+      this.loadMembers();
+    }
+  }
+
+  ngOnChanges(): void {
+    if (this.isVisible && this.project) {
+      this.loadMembers();
+    }
+  }
+
+  private checkWxworkEnvironment(): void {
+    // 检查是否在企业微信环境中
+    this.isWxworkEnvironment = typeof window !== 'undefined' &&
+                              ((window as any).wx !== undefined ||
+                               (window as any).WWOpenData !== undefined ||
+                               location.hostname.includes('work.weixin.qq.com'));
+
+    if (this.isWxworkEnvironment && this.cid) {
+      try {
+        this.wecorp = new WxworkCorp(this.cid);
+        console.log('✅ 企业微信环境检测成功');
+      } catch (error) {
+        console.warn('⚠️ 企业微信SDK初始化失败:', error);
+        this.isWxworkEnvironment = false;
+      }
+    }
+  }
+
+  async loadMembers(): Promise<void> {
+    if (!this.project) return;
+
+    try {
+      this.loading = true;
+      this.error = null;
+
+      // 1. 加载项目团队成员
+      const projectTeamMembers = await this.loadProjectTeamMembers();
+
+      // 2. 加载群聊成员
+      const groupChatMembers = await this.loadGroupChatMembers();
+
+      // 3. 合并成员数据
+      this.mergeMembersData(projectTeamMembers, groupChatMembers);
+
+      this.calculateStats();
+      console.log(`✅ 加载了 ${this.members.length} 个成员信息`);
+    } catch (error) {
+      console.error('❌ 加载成员失败:', error);
+      this.error = error instanceof Error ? error.message : '加载成员失败';
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  private async loadProjectTeamMembers(): Promise<FmodeObject[]> {
+    try {
+      const query = new FmodeQuery('ProjectTeam');
+      if (this.project) {
+        query.equalTo('project', this.project.toPointer());
+      }
+      query.include('profile');
+      query.include('department');
+      query.notEqualTo('isDeleted', true);
+
+      return await query.find();
+    } catch (error) {
+      console.error('加载项目团队成员失败:', error);
+      return [];
+    }
+  }
+
+  private async loadGroupChatMembers(): Promise<GroupChatMember[]> {
+    if (!this.groupChat) return [];
+
+    try {
+      const memberList = this.groupChat.get('member_list') || [];
+      return memberList.map((member: any) => ({
+        userid: member.userid,
+        name: member.name,
+        type: member.type || 1,
+        avatar: member.avatar
+      }));
+    } catch (error) {
+      console.error('加载群聊成员失败:', error);
+      return [];
+    }
+  }
+
+  private mergeMembersData(projectTeamMembers: FmodeObject[], groupChatMembers: GroupChatMember[]): void {
+    const memberMap = new Map<string, ProjectMember>();
+
+    // 1. 添加项目团队成员
+    projectTeamMembers.forEach(team => {
+      const profile = team.get('profile');
+      if (profile) {
+        const member: ProjectMember = {
+          id: profile.id,
+          name: profile.get('name') || '未知',
+          userId: profile.get('userid') || '',
+          avatar: profile.get('data')?.avatar,
+          role: profile.get('roleName') || '未知',
+          department: profile.get('department')?.get('name'),
+          isInGroupChat: false,
+          isInProjectTeam: true,
+          projectTeamId: team.id,
+          profileId: profile.id
+        };
+        memberMap.set(profile.id, member);
+      }
+    });
+
+    // 2. 添加群聊成员(包括外部联系人)
+    groupChatMembers.forEach(groupMember => {
+      // 查找是否已在项目团队中
+      const existingMember = Array.from(memberMap.values()).find(
+        m => m.userId === groupMember.userid || m.name === groupMember.name
+      );
+
+      if (existingMember) {
+        existingMember.isInGroupChat = true;
+      } else {
+        // 添加仅存在于群聊中的成员
+        const member: ProjectMember = {
+          id: groupMember.userid,
+          name: groupMember.name,
+          userId: groupMember.userid,
+          avatar: groupMember.avatar,
+          role: groupMember.type === 1 ? '外部联系人' : '内部成员',
+          isInGroupChat: true,
+          isInProjectTeam: false,
+          projectTeamId: undefined,
+          profileId: undefined
+        };
+        memberMap.set(groupMember.userid, member);
+      }
+    });
+
+    this.members = Array.from(memberMap.values());
+  }
+
+  calculateStats(): void {
+    this.totalMembers = this.members.length;
+    this.groupChatMembers = this.members.filter(m => m.isInGroupChat).length;
+    this.projectTeamMembers = this.members.filter(m => m.isInProjectTeam).length;
+    this.pendingAddMembers = this.members.filter(m => m.isInProjectTeam && !m.isInGroupChat).length;
+  }
+
+  onClose(): void {
+    this.close.emit();
+  }
+
+  onBackdropClick(event: MouseEvent): void {
+    if (event.target === event.currentTarget) {
+      this.onClose();
+    }
+  }
+
+  getFilteredMembers(): ProjectMember[] {
+    let filtered = this.members;
+
+    // 搜索过滤
+    if (this.searchQuery) {
+      const query = this.searchQuery.toLowerCase();
+      filtered = filtered.filter(member =>
+        member.name.toLowerCase().includes(query) ||
+        member.role.toLowerCase().includes(query) ||
+        member.department?.toLowerCase().includes(query)
+      );
+    }
+
+    // 状态过滤
+    switch (this.memberFilter) {
+      case 'ingroup':
+        filtered = filtered.filter(m => m.isInGroupChat);
+        break;
+      case 'team':
+        filtered = filtered.filter(m => m.isInProjectTeam);
+        break;
+      case 'pending':
+        filtered = filtered.filter(m => m.isInProjectTeam && !m.isInGroupChat);
+        break;
+    }
+
+    return filtered.sort((a, b) => {
+      // 优先级:项目团队成员 > 仅群聊成员
+      if (a.isInProjectTeam && !b.isInProjectTeam) return -1;
+      if (!a.isInProjectTeam && b.isInProjectTeam) return 1;
+
+      // 按名称排序
+      return a.name.localeCompare(b.name, 'zh-CN');
+    });
+  }
+
+  async addMemberToGroupChat(member: ProjectMember): Promise<void> {
+    if (!this.isWxworkEnvironment || !this.wecorp || !this.groupChat) {
+      alert('当前环境不支持此操作');
+      return;
+    }
+
+    if (!member.userId) {
+      alert('该成员没有用户ID,无法添加到群聊');
+      return;
+    }
+
+    try {
+      const chatId = this.groupChat.get('chat_id');
+      if (!chatId) {
+        alert('群聊ID不存在');
+        return;
+      }
+
+      console.log(`🚀 开始添加成员 ${member.name} (${member.userId}) 到群聊 ${chatId}`);
+
+      // TODO: 实现正确的企业微信API调用
+      // await this.wecorp.appchat.updateEnterpriseChat({
+      //   chatId: chatId,
+      //   userIdsToAdd: [member.userId]
+      // });
+
+      // 临时:直接更新本地状态用于演示
+      member.isInGroupChat = true;
+      this.calculateStats();
+
+      alert(`✅ 已将 ${member.name} 添加到群聊`);
+      console.log(`✅ 成功添加成员 ${member.name} 到群聊`);
+
+    } catch (error) {
+      console.error('❌ 添加成员到群聊失败:', error);
+      alert(`添加失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  }
+
+  getRoleBadgeClass(role: string): string {
+    switch (role) {
+      case '客服':
+        return 'role-customer-service';
+      case '组员':
+      case '设计师':
+        return 'role-designer';
+      case '组长':
+        return 'role-team-leader';
+      case '管理员':
+        return 'role-admin';
+      case '外部联系人':
+        return 'role-external';
+      default:
+        return 'role-default';
+    }
+  }
+
+  getMemberStatusClass(member: ProjectMember): string {
+    if (member.isInProjectTeam && member.isInGroupChat) {
+      return 'status-active';
+    } else if (member.isInProjectTeam) {
+      return 'status-pending';
+    } else {
+      return 'status-group-only';
+    }
+  }
+
+  getMemberStatusText(member: ProjectMember): string {
+    if (member.isInProjectTeam && member.isInGroupChat) {
+      return '已加入';
+    } else if (member.isInProjectTeam) {
+      return '待加入';
+    } else {
+      return '仅群聊';
+    }
+  }
+}

+ 3 - 3
src/modules/project/pages/contact/contact.component.ts

@@ -274,7 +274,7 @@ export class CustomerProfileComponent implements OnInit {
 
       // 过滤包含该客户的群聊
       const externalUserId = this.contactInfo!.get('external_userid');
-      const filteredGroups = groups.filter(g => {
+      const filteredGroups = groups.filter((g: any) => {
         const memberList = g.get('member_list') || [];
         return memberList.some((m: any) =>
           m.type === 2 && m.userid === externalUserId
@@ -283,7 +283,7 @@ export class CustomerProfileComponent implements OnInit {
 
       // 加载群聊关联的项目
       this.profile.groups = await Promise.all(
-        filteredGroups.map(async (groupChat) => {
+        filteredGroups.map(async (groupChat: any) => {
           const projectPointer = groupChat.get('project');
           let project = null;
 
@@ -334,7 +334,7 @@ export class CustomerProfileComponent implements OnInit {
 
       const communications = await query.find();
 
-      this.profile.followUpRecords = communications.map(comm => ({
+      this.profile.followUpRecords = communications.map((comm: any) => ({
         time: comm.get('createdAt'),
         type: comm.get('communicationType') || 'message',
         content: comm.get('content') || '',

+ 56 - 46
src/modules/project/pages/project-detail/project-detail.component.html

@@ -1,49 +1,29 @@
-<div class="header">
-  <div class="toolbar">
-    <!-- <div class="buttons-start">
-      <button class="back-button" (click)="goBack()">
-        <svg class="icon" viewBox="0 0 512 512">
-          <path fill="currentColor" d="M256 48C141.13 48 48 141.13 48 256s93.13 208 208 208 208-93.13 208-208S370.87 48 256 48zm35.31 292.69a16 16 0 11-22.62 22.62l-96-96a16 16 0 010-22.62l96-96a16 16 0 0122.62 22.62L206.63 256z"/>
-        </svg>
-      </button>
-    </div> -->
-    <div class="title">{{ project?.get('title') || '项目详情' }}</div>
-    <!-- <div class="buttons-end">
-      <button class="icon-button">
-        <svg class="icon" viewBox="0 0 512 512">
-          <path fill="currentColor" d="M256 176a32 32 0 11-32 32 32 32 0 0132-32zM256 80a32 32 0 11-32 32 32 32 0 0132-32zM256 272a32 32 0 11-32 32 32 32 0 0132-32z"/>
-        </svg>
-      </button>
-    </div> -->
-  </div>
-
-  <!-- 四阶段导航 -->
-  <div class="stage-toolbar">
-    <div class="stage-navigation">
-      @for (stage of stages; track stage.id) {
-        <div
-          class="stage-item"
-          [class.completed]="getStageStatus(stage.id) === 'completed'"
-          [class.active]="getStageStatus(stage.id) === 'active'"
-          [class.pending]="getStageStatus(stage.id) === 'pending'"
-          (click)="switchStage(stage.id)">
-          <div class="stage-circle">
-            @if (getStageStatus(stage.id) === 'completed') {
-              <svg class="icon" viewBox="0 0 512 512">
-                <path fill="currentColor" d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" opacity=".3"/>
-                <path fill="currentColor" d="M352 176L217.6 336 160 272"/>
-              </svg>
-            } @else {
-              <span>{{ stage.number }}</span>
-            }
-          </div>
-          <div class="stage-label">{{ stage.name }}</div>
+<!-- 四阶段导航 -->
+<div class="stage-toolbar">
+  <div class="stage-navigation">
+    @for (stage of stages; track stage.id) {
+      <div
+        class="stage-item"
+        [class.completed]="getStageStatus(stage.id) === 'completed'"
+        [class.active]="getStageStatus(stage.id) === 'active'"
+        [class.pending]="getStageStatus(stage.id) === 'pending'"
+        (click)="switchStage(stage.id)">
+        <div class="stage-circle">
+          @if (getStageStatus(stage.id) === 'completed') {
+            <svg class="icon" viewBox="0 0 512 512">
+              <path fill="currentColor" d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192 192-86 192-192z" opacity=".3"/>
+              <path fill="currentColor" d="M352 176L217.6 336 160 272"/>
+            </svg>
+          } @else {
+            <span>{{ stage.number }}</span>
+          }
         </div>
-        @if (!$last) {
-          <div class="stage-connector" [class.completed]="getStageStatus(stage.id) === 'completed'"></div>
-        }
+        <div class="stage-label">{{ stage.name }}</div>
+      </div>
+      @if (!$last) {
+        <div class="stage-connector" [class.completed]="getStageStatus(stage.id) === 'completed'"></div>
       }
-    </div>
+    }
   </div>
 </div>
 
@@ -88,8 +68,8 @@
             <div class="info-text">
               <h3>{{ customer?.get('name') || '待设置' }}</h3>
               @if (customer && canViewCustomerPhone) {
-                <p>{{ customer?.get('mobile') }}</p>
-                <p class="wechat-id">微信: {{ customer?.get('data')?.wechat || customer?.get('external_userid') }}</p>
+                <p>{{ customer.get('mobile') }}</p>
+                <p class="wechat-id">微信: {{ customer.get('data')?.wechat || customer.get('external_userid') }}</p>
               } @else if (customer) {
                 <p class="info-limited">仅客服可查看联系方式</p>
               }
@@ -117,4 +97,34 @@
       <router-outlet></router-outlet>
     </div>
   }
+
+  <!-- 项目底部卡片 -->
+  @if (!loading && !error && project) {
+    <app-project-bottom-card
+      [project]="project"
+      [groupChat]="groupChat"
+      [currentUser]="currentUser"
+      [cid]="cid"
+      (showFiles)="showFiles()"
+      (showMembers)="showMembers()">
+    </app-project-bottom-card>
+  }
+
+  <!-- 文件模态框 -->
+  <app-project-files-modal
+    [project]="project"
+    [currentUser]="currentUser"
+    [isVisible]="showFilesModal"
+    (close)="closeFilesModal()">
+  </app-project-files-modal>
+
+  <!-- 成员模态框 -->
+  <app-project-members-modal
+    [project]="project"
+    [groupChat]="groupChat"
+    [currentUser]="currentUser"
+    [cid]="cid"
+    [isVisible]="showMembersModal"
+    (close)="closeMembersModal()">
+  </app-project-members-modal>
 </div>

+ 6 - 11
src/modules/project/pages/project-detail/project-detail.component.scss

@@ -200,7 +200,7 @@
 .content {
   position: relative;
   width: 100%;
-  height: calc(100vh - 140px); // 减去 header 的高度
+  height: calc(100vh - 80px); // 减去阶段导航工具栏的高度,为底部卡片留空间
   overflow-y: auto;
   overflow-x: hidden;
   -webkit-overflow-scrolling: touch;
@@ -390,7 +390,7 @@
 // 阶段内容区域
 .stage-content {
   padding: 12px;
-  padding-bottom: 80px; // 为底部操作栏留空间
+  padding-bottom: 120px; // 为新的固定底部卡片留空间
 }
 
 // 通用卡片样式
@@ -876,14 +876,9 @@
 
 // 移动端优化
 @media (max-width: 480px) {
-  .header {
-    .toolbar {
-      padding: 0 12px;
-
-      .title {
-        font-size: 16px;
-      }
-    }
+  // 移动端内容高度调整
+  .content {
+    height: calc(100vh - 60px); // 移动端调整高度
   }
 
   .stage-toolbar {
@@ -962,7 +957,7 @@
 
   .stage-content {
     padding: 8px;
-    padding-bottom: 70px;
+    padding-bottom: 100px; // 移动端为底部卡片留空间
   }
 
   .footer-toolbar {

+ 36 - 1
src/modules/project/pages/project-detail/project-detail.component.ts

@@ -5,6 +5,9 @@ import { IonicModule } from '@ionic/angular';
 import { WxworkSDK, WxworkCorp } from 'fmode-ng/core';
 import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
 import { ProfileService } from '../../../../app/services/profile.service';
+import { ProjectBottomCardComponent } from '../../components/project-bottom-card/project-bottom-card.component';
+import { ProjectFilesModalComponent } from '../../components/project-files-modal/project-files-modal.component';
+import { ProjectMembersModalComponent } from '../../components/project-members-modal/project-members-modal.component';
 
 const Parse = FmodeParse.with('nova');
 
@@ -22,7 +25,7 @@ const Parse = FmodeParse.with('nova');
 @Component({
   selector: 'app-project-detail',
   standalone: true,
-  imports: [CommonModule, IonicModule, RouterModule],
+  imports: [CommonModule, IonicModule, RouterModule, ProjectBottomCardComponent, ProjectFilesModalComponent, ProjectMembersModalComponent],
   templateUrl: './project-detail.component.html',
   styleUrls: ['./project-detail.component.scss']
 })
@@ -66,6 +69,10 @@ export class ProjectDetailComponent implements OnInit {
   canViewCustomerPhone: boolean = false;
   role: string = '';
 
+  // 模态框状态
+  showFilesModal: boolean = false;
+  showMembersModal: boolean = false;
+
   constructor(
     private router: Router,
     private route: ActivatedRoute,
@@ -408,4 +415,32 @@ export class ProjectDetailComponent implements OnInit {
       throw err;
     }
   }
+
+  /**
+   * 显示文件模态框
+   */
+  showFiles() {
+    this.showFilesModal = true;
+  }
+
+  /**
+   * 显示成员模态框
+   */
+  showMembers() {
+    this.showMembersModal = true;
+  }
+
+  /**
+   * 关闭文件模态框
+   */
+  closeFilesModal() {
+    this.showFilesModal = false;
+  }
+
+  /**
+   * 关闭成员模态框
+   */
+  closeMembersModal() {
+    this.showMembersModal = false;
+  }
 }

+ 2 - 2
src/modules/project/pages/project-detail/stages/stage-aftercare.component.ts

@@ -85,7 +85,7 @@ export class StageAftercareComponent implements OnInit {
   saving: boolean = false;
 
   // 注入上传服务
-  private uploadService = inject(NovaUploadService);
+  private uploadService: NovaUploadService = inject(NovaUploadService);
 
   constructor(
     private route: ActivatedRoute
@@ -185,7 +185,7 @@ export class StageAftercareComponent implements OnInit {
       this.uploading = true;
 
       // 使用 NovaUploadService 上传文件
-      const fileResult = await this.uploadService.upload(file);
+      const fileResult: any = await this.uploadService.upload(file);
       const url = fileResult.url;
 
       // 暂时不使用OCR,需要手动输入金额和支付方式

+ 2 - 2
src/modules/project/pages/project-detail/stages/stage-delivery.component.ts

@@ -116,7 +116,7 @@ export class StageDeliveryComponent implements OnInit {
   saving: boolean = false;
 
   // 注入上传服务
-  private uploadService = inject(NovaUploadService);
+  private uploadService: NovaUploadService = inject(NovaUploadService);
 
   constructor(
     private route: ActivatedRoute
@@ -238,7 +238,7 @@ export class StageDeliveryComponent implements OnInit {
       this.uploading = true;
 
       // 使用 NovaUploadService 上传文件
-      const fileResult = await this.uploadService.upload(file);
+      const fileResult: any = await this.uploadService.upload(file);
       const url = fileResult.url;
 
       const deliverable = this.getDeliverable(spaceName, processType);

+ 154 - 1
src/modules/project/pages/project-detail/stages/stage-order.component.html

@@ -528,7 +528,160 @@
       </div>
     </div>
 
-    <!-- 6. 操作按钮 -->
+    <!-- 6. 项目文件管理 -->
+    <div class="card files-card">
+      <div class="card-header">
+        <h3 class="card-title">
+          <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+            <path fill="currentColor" d="M416 221.25V416a48 48 0 01-48 48H144a48 48 0 01-48-48V96a48 48 0 0148-48h98.75a32 32 0 0122.62 9.37l141.26 141.26a32 32 0 019.37 22.62z"/>
+            <path fill="currentColor" d="M256 56v120a32 32 0 0032 32h120"/>
+          </svg>
+          项目文件
+        </h3>
+        <p class="card-subtitle">
+          支持手动上传和企业微信拖拽上传,文件存储在项目目录中
+          @if (wxFileDropSupported) {
+            <span class="wx-support-indicator">📱 支持企业微信拖拽</span>
+          }
+        </p>
+      </div>
+      <div class="card-content">
+        <!-- 上传区域 -->
+        @if (canEdit) {
+          <div class="upload-section">
+            <div
+              #dropZone
+              class="drop-zone"
+              [class.drag-over]="dragOver"
+              [class.uploading]="isUploading"
+              (click)="triggerFileSelect()"
+              (dragover)="onDragOver($event)"
+              (dragleave)="onDragLeave($event)"
+              (drop)="onDrop($event)">
+
+              <!-- 上传图标和提示 -->
+              @if (!isUploading) {
+                <div class="upload-content">
+                  <div class="upload-icon">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+                      <polyline points="17,8 12,3 7,8"></polyline>
+                      <line x1="12" y1="3" x2="12" y2="15"></line>
+                    </svg>
+                  </div>
+                  <div class="upload-text">
+                    <p class="upload-title">
+                      拖拽文件到此处或 <span class="upload-link">点击选择文件</span>
+                    </p>
+                    <p class="upload-hint">
+                      @if (wxFileDropSupported) {
+                        支持从企业微信拖拽文件 •
+                      }
+                      支持多文件上传
+                    </p>
+                  </div>
+                </div>
+              }
+
+              <!-- 上传进度 -->
+              @if (isUploading) {
+                <div class="upload-progress">
+                  <div class="progress-circle">
+                    <svg class="progress-svg" viewBox="0 0 36 36">
+                      <path class="progress-bg"
+                        d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
+                      />
+                      <path class="progress-bar"
+                        d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
+                        [style.stroke-dasharray]="uploadProgress + ', 100'"
+                      />
+                    </svg>
+                    <div class="progress-text">{{ uploadProgress | number:'1.0-0' }}%</div>
+                  </div>
+                  <p class="progress-title">正在上传文件...</p>
+                </div>
+              }
+
+              <!-- 隐藏的文件输入 -->
+              <input #fileInput
+                     type="file"
+                     multiple
+                     [disabled]="isUploading"
+                     (change)="onFileSelect($event)"
+                     class="file-input">
+            </div>
+          </div>
+        }
+
+        <!-- 文件列表 -->
+        @if (projectFiles.length > 0) {
+          <div class="files-list">
+            <h4 class="section-title">
+              项目文件 ({{ projectFiles.length }})
+            </h4>
+            <div class="files-grid">
+              @for (file of projectFiles; track file.id) {
+                <div class="file-item">
+                  <div class="file-preview">
+                    @if (isImageFile(file.type)) {
+                      <img [src]="file.url" [alt]="file.name" class="file-image" />
+                    } @else {
+                      <div class="file-icon-large">
+                        {{ getFileIcon(file.type) }}
+                      </div>
+                    }
+                  </div>
+                  <div class="file-info">
+                    <h5 class="file-name" [title]="file.name">{{ file.name }}</h5>
+                    <div class="file-meta">
+                      <span class="file-size">{{ formatFileSize(file.size) }}</span>
+                      <span class="file-date">{{ file.uploadedAt | date:'MM-dd HH:mm' }}</span>
+                      <span class="file-uploader">{{ file.uploadedBy }}</span>
+                    </div>
+                  </div>
+                  <div class="file-actions">
+                    <button
+                      class="action-btn"
+                      (click)="downloadFile(file.url, file.name)"
+                      title="下载文件">
+                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor">
+                        <path d="M376 232H216V72c0-13.3-10.7-24-24-24s-24 10.7-24 24v160H8c-13.3 0-24 10.7-24 24s10.7 24 24 24h160v160c0 13.3 10.7 24 24 24s24-10.7 24-24V280h160c13.3 0 24-10.7 24-24s-10.7-24-24-24z"/>
+                      </svg>
+                    </button>
+                    @if (canEdit) {
+                      <button
+                        class="action-btn delete-btn"
+                        (click)="deleteProjectFile(file.id)"
+                        title="删除文件">
+                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor">
+                          <path d="M296 64h-80a7.91 7.91 0 00-8 8v56H136a56.16 56.16 0 00-56 56v208a56.16 56.16 0 0056 56h240a56.16 56.16 0 0056-56V184a56.16 56.16 0 00-56-56h-72V72a7.91 7.91 0 00-8-8zm-72 264h96a8 8 0 018 8v16a8 8 0 01-8 8h-96a8 8 0 01-8-8v-16a8 8 0 018-8z"/>
+                        </svg>
+                      </button>
+                    }
+                  </div>
+                </div>
+              }
+            </div>
+          </div>
+        } @else {
+          <div class="empty-files">
+            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor" class="empty-icon">
+              <path d="M64 464V48a48 48 0 0148-48h192a48 48 0 0133.94 13.94l83.05 83.05A48 48 0 01384 176v288a48 48 0 01-48 48H112a48 48 0 01-48-48zm176-304h144a16 16 0 0016-16v-16a16 16 0 00-16-16H240a16 16 0 00-16 16v16a16 16 0 0016 16zm48 0h96a16 16 0 0016-16v-16a16 16 0 00-16-16H288a16 16 0 00-16 16v16a16 16 0 0016 16zm-48 96h144a16 16 0 0016-16v-16a16 16 0 00-16-16H240a16 16 0 00-16 16v16a16 16 0 0016 16zm48 0h96a16 16 0 0016-16v-16a16 16 0 00-16-16H288a16 16 0 00-16 16v16a16 16 0 0016 16z"/>
+            </svg>
+            <p class="empty-text">暂无项目文件</p>
+            <p class="empty-hint">
+              @if (wxFileDropSupported) {
+                尝试从企业微信拖拽文件,或点击上方按钮上传
+              } @else {
+                点击上方按钮上传项目文件
+              }
+            </p>
+          </div>
+        }
+      </div>
+    </div>
+
+    <!-- 7. 操作按钮 -->
     @if (canEdit) {
       <div class="action-buttons">
         <button

+ 374 - 0
src/modules/project/pages/project-detail/stages/stage-order.component.scss

@@ -1,5 +1,379 @@
 // 订单分配阶段样式 - 纯 div+scss 实现
 
+// 文件上传相关样式
+.files-card {
+  .wx-support-indicator {
+    background-color: #dcfce7;
+    color: #166534;
+    padding: 4px 8px;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 500;
+  }
+
+  .upload-section {
+    margin-bottom: 24px;
+  }
+
+  .drop-zone {
+    border: 2px dashed #d1d5db;
+    border-radius: 8px;
+    padding: 32px;
+    text-align: center;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    background-color: #f9fafb;
+    position: relative;
+    min-height: 120px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    &:hover {
+      border-color: #3b82f6;
+      background-color: #eff6ff;
+    }
+
+    &.drag-over {
+      border-color: #3b82f6;
+      background-color: #eff6ff;
+      transform: scale(1.02);
+    }
+
+    &.uploading {
+      border-color: #6b7280;
+      background-color: #f3f4f6;
+      cursor: not-allowed;
+    }
+  }
+
+  .upload-content {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 16px;
+
+    .upload-icon {
+      color: #6b7280;
+      width: 48px;
+      height: 48px;
+      transition: color 0.3s ease;
+    }
+
+    .upload-text {
+      .upload-title {
+        font-size: 16px;
+        font-weight: 500;
+        margin: 0 0 8px 0;
+        color: #374151;
+
+        .upload-link {
+          color: #3b82f6;
+          text-decoration: underline;
+          cursor: pointer;
+
+          &:hover {
+            color: #2563eb;
+          }
+        }
+      }
+
+      .upload-hint {
+        font-size: 14px;
+        color: #6b7280;
+        margin: 0;
+      }
+    }
+  }
+
+  .upload-progress {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 16px;
+
+    .progress-circle {
+      position: relative;
+      width: 80px;
+      height: 80px;
+
+      .progress-svg {
+        transform: rotate(-90deg);
+        width: 100%;
+        height: 100%;
+
+        .progress-bg {
+          fill: none;
+          stroke: #e5e7eb;
+          stroke-width: 3;
+        }
+
+        .progress-bar {
+          fill: none;
+          stroke: #3b82f6;
+          stroke-width: 3;
+          stroke-linecap: round;
+          transition: stroke-dasharray 0.3s ease;
+        }
+      }
+
+      .progress-text {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        font-size: 16px;
+        font-weight: 600;
+        color: #1f2937;
+      }
+    }
+
+    .progress-title {
+      font-size: 16px;
+      font-weight: 500;
+      color: #374151;
+      margin: 0;
+    }
+  }
+
+  .file-input {
+    display: none;
+  }
+
+  .files-list {
+    .section-title {
+      font-size: 18px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0 0 16px 0;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+  }
+
+  .files-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+    gap: 16px;
+  }
+
+  .file-item {
+    background-color: #f9fafb;
+    border: 1px solid #e5e7eb;
+    border-radius: 8px;
+    padding: 16px;
+    display: flex;
+    gap: 12px;
+    transition: all 0.2s ease;
+
+    &:hover {
+      box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+      border-color: #d1d5db;
+    }
+  }
+
+  .file-preview {
+    flex-shrink: 0;
+    width: 48px;
+    height: 48px;
+    border-radius: 6px;
+    overflow: hidden;
+    background-color: #f3f4f6;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .file-image {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    .file-icon-large {
+      font-size: 24px;
+      line-height: 1;
+    }
+  }
+
+  .file-info {
+    flex: 1;
+    min-width: 0;
+
+    .file-name {
+      font-size: 14px;
+      font-weight: 500;
+      color: #1f2937;
+      margin: 0 0 8px 0;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .file-meta {
+      display: flex;
+      flex-direction: column;
+      gap: 4px;
+      font-size: 12px;
+      color: #6b7280;
+
+      @media (min-width: 768px) {
+        flex-direction: row;
+        align-items: center;
+        gap: 12px;
+
+        &::before {
+          content: '•';
+          color: #d1d5db;
+        }
+
+        & > span:first-child::before {
+          display: none;
+        }
+      }
+    }
+  }
+
+  .file-actions {
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+
+    @media (min-width: 768px) {
+      flex-direction: row;
+      gap: 8px;
+    }
+  }
+
+  .action-btn {
+    background: none;
+    border: none;
+    color: #6b7280;
+    cursor: pointer;
+    padding: 6px;
+    border-radius: 4px;
+    transition: all 0.2s ease;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+
+    &:hover {
+      background-color: #f3f4f6;
+      color: #374151;
+
+      &.delete-btn {
+        background-color: #fee2e2;
+        color: #dc2626;
+      }
+    }
+
+    &.delete-btn {
+      &:hover {
+        background-color: #fee2e2;
+        color: #dc2626;
+      }
+    }
+  }
+
+  .empty-files {
+    text-align: center;
+    padding: 48px 24px;
+    color: #6b7280;
+
+    .empty-icon {
+      width: 64px;
+      height: 64px;
+      margin: 0 auto 16px;
+      opacity: 0.3;
+    }
+
+    .empty-text {
+      font-size: 16px;
+      font-weight: 500;
+      margin: 0 0 8px 0;
+      color: #374151;
+    }
+
+    .empty-hint {
+      font-size: 14px;
+      margin: 0;
+      line-height: 1.5;
+    }
+  }
+}
+
+// 响应式设计
+@media (max-width: 768px) {
+  .files-card {
+    .drop-zone {
+      padding: 24px 16px;
+      min-height: 100px;
+    }
+
+    .upload-content {
+      .upload-icon {
+        width: 36px;
+        height: 36px;
+      }
+
+      .upload-text {
+        .upload-title {
+          font-size: 14px;
+        }
+
+        .upload-hint {
+          font-size: 12px;
+        }
+      }
+    }
+
+    .upload-progress {
+      .progress-circle {
+        width: 60px;
+        height: 60px;
+
+        .progress-text {
+          font-size: 14px;
+        }
+      }
+
+      .progress-title {
+        font-size: 14px;
+      }
+    }
+
+    .files-grid {
+      grid-template-columns: 1fr;
+      gap: 12px;
+    }
+
+    .file-item {
+      padding: 12px;
+    }
+
+    .file-preview {
+      width: 40px;
+      height: 40px;
+    }
+
+    .file-info {
+      .file-name {
+        font-size: 13px;
+      }
+
+      .file-meta {
+        font-size: 11px;
+      }
+    }
+  }
+}
+
 // CSS 变量定义
 :host {
   --primary-color: #3880ff;

+ 357 - 3
src/modules/project/pages/project-detail/stages/stage-order.component.ts

@@ -1,8 +1,8 @@
-import { Component, OnInit, Input } from '@angular/core';
+import { Component, OnInit, Input, ViewChild, ElementRef } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
-import { FmodeObject, FmodeParse } from 'fmode-ng/parse';
+import { FmodeObject, FmodeParse, NovaStorage, NovaFile } from 'fmode-ng/core';
 import {
   QUOTATION_PRICE_TABLE,
   STYLE_LEVELS,
@@ -151,7 +151,108 @@ export class StageOrderComponent implements OnInit {
   homeDefaultRooms = HOME_DEFAULT_ROOMS;
   adjustmentRules = ADJUSTMENT_RULES;
 
-  constructor(private route: ActivatedRoute) {}
+  // 文件上传相关
+  @ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
+  @ViewChild('dropZone') dropZone!: ElementRef<HTMLDivElement>;
+
+  private storage: NovaStorage | null = null;
+  isUploading: boolean = false;
+  uploadProgress: number = 0;
+  projectFiles: Array<{
+    id: string;
+    name: string;
+    url: string;
+    type: string;
+    size: number;
+    uploadedBy: string;
+    uploadedAt: Date;
+  }> = [];
+
+  // 企业微信拖拽相关
+  dragOver: boolean = false;
+  wxFileDropSupported: boolean = false;
+
+  constructor(private route: ActivatedRoute) {
+    this.initStorage();
+    this.checkWxWorkSupport();
+  }
+
+  // 初始化 NovaStorage
+  private async initStorage(): Promise<void> {
+    try {
+      const cid = localStorage.getItem('company') || this.cid || 'cDL6R1hgSi';
+      this.storage = await NovaStorage.withCid(cid);
+      console.log('✅ Stage-order NovaStorage 初始化成功, cid:', cid);
+    } catch (error) {
+      console.error('❌ Stage-order NovaStorage 初始化失败:', error);
+    }
+  }
+
+  // 检查企业微信拖拽支持
+  private checkWxWorkSupport(): void {
+    // 检查是否在企业微信环境中
+    if (typeof window !== 'undefined' && (window as any).wx) {
+      this.wxFileDropSupported = true;
+      console.log('✅ 企业微信环境,支持文件拖拽');
+
+      // 监听企业微信文件拖拽事件
+      this.initWxWorkFileDrop();
+    }
+  }
+
+  // 初始化企业微信文件拖拽
+  private initWxWorkFileDrop(): void {
+    if (!this.wxFileDropSupported) return;
+
+    // 监听企业微信的文件拖拽事件
+    document.addEventListener('dragover', (e) => {
+      if (this.isWxWorkFileDrop(e)) {
+        e.preventDefault();
+        this.dragOver = true;
+      }
+    });
+
+    document.addEventListener('dragleave', (e) => {
+      if (this.isWxWorkFileDrop(e)) {
+        e.preventDefault();
+        this.dragOver = false;
+      }
+    });
+
+    document.addEventListener('drop', (e) => {
+      if (this.isWxWorkFileDrop(e)) {
+        e.preventDefault();
+        this.dragOver = false;
+        this.handleWxWorkFileDrop(e);
+      }
+    });
+  }
+
+  // 判断是否为企业微信文件拖拽
+  private isWxWorkFileDrop(event: DragEvent): boolean {
+    if (!this.wxFileDropSupported) return false;
+
+    // 检查拖拽的数据是否包含企业微信文件信息
+    const dataTransfer = event.dataTransfer;
+    if (dataTransfer && dataTransfer.types) {
+      return dataTransfer.types.includes('Files') ||
+             dataTransfer.types.includes('application/x-wx-work-file');
+    }
+
+    return false;
+  }
+
+  // 处理企业微信文件拖拽
+  private async handleWxWorkFileDrop(event: DragEvent): Promise<void> {
+    if (!this.project || !this.storage) return;
+
+    const files = Array.from(event.dataTransfer?.files || []);
+    if (files.length === 0) return;
+
+    console.log('🎯 接收到企业微信拖拽文件:', files.map(f => f.name));
+
+    await this.uploadFiles(files, '企业微信拖拽');
+  }
 
   async ngOnInit() {
     if (!this.project || !this.customer || !this.currentUser) {
@@ -237,6 +338,9 @@ export class StageOrderComponent implements OnInit {
       // 加载已分配的项目团队
       await this.loadProjectTeams();
 
+      // 加载项目文件
+      await this.loadProjectFiles();
+
     } catch (err) {
       console.error('加载失败:', err);
     } finally {
@@ -797,4 +901,254 @@ export class StageOrderComponent implements OnInit {
     }
   }
 
+  /**
+   * 加载项目文件
+   */
+  private async loadProjectFiles(): Promise<void> {
+    if (!this.project) return;
+
+    try {
+      const query = new Parse.Query('ProjectFile');
+      query.equalTo('project', this.project.toPointer());
+      query.include('uploadedBy');
+      query.descending('uploadedAt');
+      query.limit(50);
+
+      const files = await query.find();
+
+      this.projectFiles = files.map((file: FmodeObject) => ({
+        id: file.id || '',
+        name: file.get('name') || file.get('originalName') || '',
+        url: file.get('url') || '',
+        type: file.get('type') || '',
+        size: file.get('size') || 0,
+        uploadedBy: file.get('uploadedBy')?.get('name') || '未知用户',
+        uploadedAt: file.get('uploadedAt') || file.createdAt
+      }));
+
+      console.log(`✅ 加载了 ${this.projectFiles.length} 个项目文件`);
+    } catch (error) {
+      console.error('❌ 加载项目文件失败:', error);
+    }
+  }
+
+  /**
+   * 触发文件选择
+   */
+  triggerFileSelect(): void {
+    if (!this.canEdit || !this.project) return;
+    this.fileInput.nativeElement.click();
+  }
+
+  /**
+   * 处理文件选择
+   */
+  onFileSelect(event: Event): void {
+    const target = event.target as HTMLInputElement;
+    const files = Array.from(target.files || []);
+
+    if (files.length > 0) {
+      this.uploadFiles(files, '手动选择');
+    }
+
+    // 清空input值,允许重复选择同一文件
+    target.value = '';
+  }
+
+  /**
+   * 上传文件到 ProjectFile 表
+   */
+  private async uploadFiles(files: File[], source: string): Promise<void> {
+    if (!this.project || !this.storage || !this.currentUser) {
+      console.error('❌ 缺少必要信息,无法上传文件');
+      return;
+    }
+
+    this.isUploading = true;
+    this.uploadProgress = 0;
+
+    try {
+      const results = [];
+
+      for (let i = 0; i < files.length; i++) {
+        const file = files[i];
+
+        // 更新进度
+        this.uploadProgress = ((i + 1) / files.length) * 100;
+
+        try {
+          // 使用 NovaStorage 上传文件,指定项目路径前缀
+          const uploaded: NovaFile = await this.storage.upload(file, {
+            prefixKey: `projects/${this.projectId}/`,
+            onProgress: (p) => {
+              const fileProgress = (i / files.length) * 100 + (p.total.percent / files.length);
+              this.uploadProgress = fileProgress;
+            }
+          });
+
+          // 保存文件信息到 ProjectFile 表
+          const projectFile = new Parse.Object('ProjectFile');
+          projectFile.set('project', this.project.toPointer());
+          projectFile.set('name', file.name);
+          projectFile.set('originalName', file.name);
+          projectFile.set('url', uploaded.url);
+          projectFile.set('key', uploaded.key);
+          projectFile.set('type', file.type);
+          projectFile.set('size', file.size);
+          projectFile.set('uploadedBy', this.currentUser.toPointer());
+          projectFile.set('uploadedAt', new Date());
+          projectFile.set('source', source); // 标记来源:企业微信拖拽或手动选择
+          projectFile.set('md5', uploaded.md5);
+          projectFile.set('metadata', uploaded.metadata);
+
+          const savedFile = await projectFile.save();
+
+          // 添加到本地列表
+          this.projectFiles.unshift({
+            id: savedFile.id || '',
+            name: file.name,
+            url: uploaded.url || '',
+            type: file.type,
+            size: file.size,
+            uploadedBy: this.currentUser.get('name'),
+            uploadedAt: new Date()
+          });
+
+          results.push({
+            success: true,
+            file: uploaded,
+            name: file.name
+          });
+
+          console.log('✅ 文件上传成功:', file.name, uploaded.key);
+
+        } catch (error) {
+          console.error('❌ 文件上传失败:', file.name, error);
+          results.push({
+            success: false,
+            error: error instanceof Error ? error.message : '上传失败',
+            name: file.name
+          });
+        }
+      }
+
+      // 显示上传结果
+      const successCount = results.filter(r => r.success).length;
+      const failCount = results.filter(r => !r.success).length;
+
+      if (failCount === 0) {
+        console.log(`🎉 所有 ${successCount} 个文件上传成功`);
+        // 可以显示成功提示
+      } else {
+        console.warn(`⚠️ ${successCount} 个文件成功,${failCount} 个文件失败`);
+        // 可以显示部分失败的提示
+      }
+
+    } catch (error) {
+      console.error('❌ 批量上传过程中发生错误:', error);
+    } finally {
+      this.isUploading = false;
+      this.uploadProgress = 0;
+    }
+  }
+
+  /**
+   * 处理拖拽事件
+   */
+  onDragOver(event: DragEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+    this.dragOver = true;
+  }
+
+  onDragLeave(event: DragEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+    this.dragOver = false;
+  }
+
+  onDrop(event: DragEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+    this.dragOver = false;
+
+    if (!this.canEdit || !this.project) return;
+
+    const files = Array.from(event.dataTransfer?.files || []);
+    if (files.length > 0) {
+      console.log('🎯 接收到拖拽文件:', files.map(f => f.name));
+      this.uploadFiles(files, '拖拽上传');
+    }
+  }
+
+  /**
+   * 删除项目文件
+   */
+  async deleteProjectFile(fileId: string): Promise<void> {
+    if (!this.canEdit) return;
+
+    try {
+      const query = new Parse.Query('ProjectFile');
+      const file = await query.get(fileId);
+
+      if (file) {
+        await file.destroy();
+
+        // 从本地列表中移除
+        this.projectFiles = this.projectFiles.filter(f => f.id !== fileId);
+
+        console.log('✅ 文件删除成功:', fileId);
+      }
+    } catch (error) {
+      console.error('❌ 文件删除失败:', error);
+      alert('删除失败,请稍后重试');
+    }
+  }
+
+  /**
+   * 格式化文件大小
+   */
+  formatFileSize(bytes: number): string {
+    if (bytes === 0) return '0 Bytes';
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+  }
+
+  /**
+   * 获取文件图标
+   */
+  getFileIcon(type: string): string {
+    if (type.startsWith('image/')) return '🖼️';
+    if (type.startsWith('video/')) return '🎬';
+    if (type.startsWith('audio/')) return '🎵';
+    if (type.includes('pdf')) return '📄';
+    if (type.includes('word') || type.includes('document')) return '📝';
+    if (type.includes('excel') || type.includes('spreadsheet')) return '📊';
+    if (type.includes('powerpoint') || type.includes('presentation')) return '📑';
+    if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return '📦';
+    return '📎';
+  }
+
+  /**
+   * 检查是否为图片文件
+   */
+  isImageFile(type: string): boolean {
+    return type.startsWith('image/');
+  }
+
+  /**
+   * 下载文件
+   */
+  downloadFile(url: string, name: string): void {
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = name;
+    link.target = '_blank';
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+  }
+
 }

+ 2 - 2
src/modules/project/pages/project-loader/project-loader.component.ts

@@ -246,8 +246,8 @@ export class ProjectLoaderComponent implements OnInit {
       const projectGroups = await pgQuery.find();
 
       this.historyProjects = projectGroups
-        .map(pg => pg.get('project'))
-        .filter(p => p && !p.get('isDeleted'));
+        .map((pg: any) => pg.get('project'))
+        .filter((p: any) => p && !p.get('isDeleted'));
 
       wxdebug('找到历史项目', { count: this.historyProjects.length });
     } catch (err) {

+ 1 - 1
src/modules/project/services/upload.service.ts

@@ -47,7 +47,7 @@ export class ProjectUploadService {
       });
 
       // 返回文件URL
-      return fileResult.url;
+      return fileResult.url || '';
     } catch (error: any) {
       console.error('文件上传失败:', error);
       throw new Error('文件上传失败: ' + (error?.message || '未知错误'));

+ 3 - 3
src/modules/project/services/wxwork-sdk.service.ts

@@ -409,10 +409,10 @@ export class WxworkSDKService {
         groupName: options.groupName,
         userIds: options.userIds,
         externalUserIds: options.externalUserIds,
-        success: (data) => {
+        success: (data: any) => {
           resolve(data);
         },
-        fail: (err) => {
+        fail: (err: any) => {
           reject(err);
         }
       });
@@ -460,7 +460,7 @@ export class WxworkSDKService {
         success: () => {
           resolve();
         },
-        fail: (err) => {
+        fail: (err: any) => {
           reject(err);
         }
       });

+ 17 - 15
src/styles.scss

@@ -1,10 +1,6 @@
 
 
-// Include theming for Angular Material with `mat.theme()`.
-// This Sass mixin will define CSS variables that are used for styling Angular Material
-// components according to the Material 3 design spec.
-// Learn more about theming and how to use it for your application's
-// custom components at https://material.angular.dev/guide/theming
+// Include theming for Angular Material
 @use '@angular/material' as mat;
 
 // 导入变量
@@ -13,16 +9,22 @@
 // 自定义字体配置
 @include mat.core();
 
-html {
-  @include mat.theme((
-    color: (
-      primary: mat.$azure-palette,
-      tertiary: mat.$blue-palette,
-    ),
-    // 不指定typography配置,避免自动加载
-    density: 0,
-  ));
-}
+// 定义调色板
+$primary-palette: mat.define-palette(mat.$indigo-palette);
+$accent-palette: mat.define-palette(mat.$pink-palette, A200, A100, A400);
+$warn-palette: mat.define-palette(mat.$red-palette);
+
+// 创建主题
+$theme: mat.define-light-theme((
+  color: (
+    primary: $primary-palette,
+    accent: $accent-palette,
+    warn: $warn-palette,
+  )
+));
+
+// 应用主题
+@include mat.all-component-themes($theme);
 
 body {
   // Default the application to a light color theme. This can be changed to

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