Browse Source

feat: project bottom card in wxwork

ryanemax 11 hours ago
parent
commit
c1e828aa0c
51 changed files with 4224 additions and 237 deletions
  1. 70 0
      docs/dynamic-data-integration.md
  2. 27 0
      docs/task/20251019-project-detail.md
  3. 4 4
      package-lock.json
  4. 1 1
      package.json
  5. 0 0
      public/assets/images/case-1-cover.svg
  6. 0 0
      public/assets/images/case-2-cover.svg
  7. 0 0
      public/assets/images/case-3-cover.svg
  8. 0 0
      public/assets/images/case-4-cover.svg
  9. 4 12
      public/assets/images/default-avatar.svg
  10. 0 10
      public/assets/images/hr-icon.svg
  11. 0 11
      public/assets/images/hr-logo.svg
  12. 11 10
      public/assets/images/portfolio-1.svg
  13. 12 10
      public/assets/images/portfolio-2.svg
  14. 12 10
      public/assets/images/portfolio-3.svg
  15. 12 10
      public/assets/images/portfolio-4.svg
  16. 0 0
      public/assets/logo.jpg
  17. 0 0
      public/assets/presets/living_room_modern_1.jpg
  18. 0 0
      public/assets/presets/living_room_modern_2.jpg
  19. 0 0
      public/assets/presets/living_room_modern_3.jpg
  20. 62 0
      rules/storage.md
  21. 1 9
      src/app/app.html
  22. 2 2
      src/app/pages/admin/dashboard/dashboard.ts
  23. 3 3
      src/app/pages/auth/login/login.html
  24. 25 6
      src/app/pages/auth/login/login.scss
  25. 2 2
      src/app/pages/customer-service/dashboard/dashboard.ts
  26. 2 2
      src/app/pages/designer/dashboard/dashboard.ts
  27. 1 1
      src/app/shared/components/upload-component/upload.component.html
  28. 7 2
      src/app/shared/components/upload-component/upload.component.ts
  29. 3 3
      src/app/shared/components/upload-example/upload-example.component.html
  30. 9 0
      src/app/shared/components/upload-example/upload-example.component.ts
  31. 0 5
      src/assets/images/default-avatar.svg
  32. 0 15
      src/assets/images/portfolio-1.svg
  33. 0 16
      src/assets/images/portfolio-2.svg
  34. 0 16
      src/assets/images/portfolio-3.svg
  35. 0 16
      src/assets/images/portfolio-4.svg
  36. 70 0
      src/modules/project/components/project-bottom-card/project-bottom-card.component.html
  37. 264 0
      src/modules/project/components/project-bottom-card/project-bottom-card.component.scss
  38. 55 0
      src/modules/project/components/project-bottom-card/project-bottom-card.component.ts
  39. 280 0
      src/modules/project/components/project-files-modal/project-files-modal.component.html
  40. 853 0
      src/modules/project/components/project-files-modal/project-files-modal.component.scss
  41. 254 0
      src/modules/project/components/project-files-modal/project-files-modal.component.ts
  42. 209 0
      src/modules/project/components/project-members-modal/project-members-modal.component.html
  43. 650 0
      src/modules/project/components/project-members-modal/project-members-modal.component.scss
  44. 337 0
      src/modules/project/components/project-members-modal/project-members-modal.component.ts
  45. 54 44
      src/modules/project/pages/project-detail/project-detail.component.html
  46. 6 11
      src/modules/project/pages/project-detail/project-detail.component.scss
  47. 36 1
      src/modules/project/pages/project-detail/project-detail.component.ts
  48. 154 1
      src/modules/project/pages/project-detail/stages/stage-order.component.html
  49. 374 0
      src/modules/project/pages/project-detail/stages/stage-order.component.scss
  50. 357 3
      src/modules/project/pages/project-detail/stages/stage-order.component.ts
  51. 1 1
      src/modules/project/services/upload.service.ts

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

@@ -249,8 +249,78 @@ export class MyComponent {
 
 **示例组件:** `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** - 项目表

+ 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 验证,并修复所有问题

+ 4 - 4
package-lock.json

@@ -55,7 +55,7 @@
         "echarts": "^6.0.0",
         "esdk-obs-browserjs": "^3.25.6",
         "eventemitter3": "^5.0.1",
-        "fmode-ng": "^0.0.219",
+        "fmode-ng": "^0.0.221",
         "highlight.js": "^11.11.1",
         "jquery": "^3.7.1",
         "markdown-it": "^14.1.0",
@@ -9504,9 +9504,9 @@
       "license": "ISC"
     },
     "node_modules/fmode-ng": {
-      "version": "0.0.219",
-      "resolved": "https://registry.npmjs.org/fmode-ng/-/fmode-ng-0.0.219.tgz",
-      "integrity": "sha512-ymPuDM7lRH9W60Pt9dpnDizxOFY922hZiRi6dcn+s+aEygZIrLbgdBZHvs2an9ygvhSij8rV2pa4xl/o3UUy5w==",
+      "version": "0.0.221",
+      "resolved": "https://registry.npmmirror.com/fmode-ng/-/fmode-ng-0.0.221.tgz",
+      "integrity": "sha512-Veafi8p8efJ2LDGPWZ2Gm6Zj6pDu2IBBNkPtV9WTxQVWCCLJg+6xiCI9KQAd17M84vhO4pQR0JFReMvAN1E1fQ==",
       "license": "COPYRIGHT © 未来飞马 未来全栈 www.fmode.cn All RIGHTS RESERVED",
       "dependencies": {
         "tslib": "^2.3.0"

+ 1 - 1
package.json

@@ -68,7 +68,7 @@
     "echarts": "^6.0.0",
     "esdk-obs-browserjs": "^3.25.6",
     "eventemitter3": "^5.0.1",
-    "fmode-ng": "^0.0.220",
+    "fmode-ng": "^0.0.222",
     "highlight.js": "^11.11.1",
     "jquery": "^3.7.1",
     "markdown-it": "^14.1.0",

+ 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


+ 62 - 0
rules/storage.md

@@ -29,3 +29,65 @@ const uploaded: NovaFile = await storage.upload(file, {
 });
 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` 值,确保关联关系尽可能完整。

+ 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 />

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

@@ -123,7 +123,7 @@ 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;
+  private wxAuth: WxworkAuth | null = null;
   private currentUser: FmodeUser | null = null;
 
   constructor(private dashboardService: AdminDashboardService) {
@@ -150,7 +150,7 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
   private async authenticateAndLoadData(): Promise<void> {
     try {
       // 执行企业微信认证和登录
-      const { user } = await this.wxAuth.authenticateAndLogin();
+      const { user } = await this.wxAuth!.authenticateAndLogin();
       this.currentUser = user;
 
       if (user) {

+ 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; }

+ 2 - 2
src/app/pages/customer-service/dashboard/dashboard.ts

@@ -158,7 +158,7 @@ export class Dashboard implements OnInit, OnDestroy {
     return date.toISOString().split('T')[0];
   }
 
-  private wxAuth: WxworkAuth;
+  private wxAuth: WxworkAuth | null = null;
   private currentUser: FmodeUser | null = null;
 
   constructor(
@@ -192,7 +192,7 @@ export class Dashboard implements OnInit, OnDestroy {
   private async authenticateAndLoadData(): Promise<void> {
     try {
       // 执行企业微信认证和登录
-      const { user } = await this.wxAuth.authenticateAndLogin();
+      const { user } = await this.wxAuth!.authenticateAndLogin();
       this.currentUser = user;
 
       if (user) {

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

@@ -52,7 +52,7 @@ export class Dashboard implements OnInit {
   // 个人项目饱和度相关属性
   workloadPercentage: number = 0;
   projectTimeline: ProjectTimelineItem[] = [];
-  private wxAuth: WxworkAuth;
+  private wxAuth: WxworkAuth | null = null;
   private currentUser: FmodeUser | null = null;
 
   constructor(private projectService: ProjectService) {
@@ -79,7 +79,7 @@ export class Dashboard implements OnInit {
   private async authenticateAndLoadData(): Promise<void> {
     try {
       // 执行企业微信认证和登录
-      const { user } = await this.wxAuth.authenticateAndLogin();
+      const { user } = await this.wxAuth!.authenticateAndLogin();
       this.currentUser = user;
 
       if (user) {

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

@@ -122,7 +122,7 @@
     <div class="preview-grid">
       <div class="preview-item"
            *ngFor="let file of uploadedFiles; let i = index"
-           *ngIf="file.success && file.url">
+           [class.hidden]="!(file.success && file.url)">
         <img [src]="file.url" [alt]="'预览图片 ' + (i + 1)" class="preview-image">
         <button class="preview-remove"
                 (click)="removeUploadedFile(i)"

+ 7 - 2
src/app/shared/components/upload-component/upload.component.ts

@@ -28,7 +28,7 @@ export class UploadComponent {
 
   @Output() fileSelected = new EventEmitter<File[]>(); // 文件选择事件
   @Output() uploadStart = new EventEmitter<File[]>(); // 上传开始事件
-  @Output() uploadProgress = new EventEmitter<{ completed: number; total: number; currentFile: string }>(); // 上传进度事件
+  @Output() uploadProgressEvent = new EventEmitter<{ completed: number; total: number; currentFile: string }>(); // 上传进度事件
   @Output() uploadComplete = new EventEmitter<UploadResult[]>(); // 上传完成事件
   @Output() uploadError = new EventEmitter<string>(); // 上传错误事件
 
@@ -206,7 +206,7 @@ export class UploadComponent {
         // 更新进度
         const progress = ((i + 1) / files.length) * 100;
         this.uploadProgress = progress;
-        this.uploadProgress.emit({
+        this.uploadProgressEvent.emit({
           completed: i + 1,
           total: files.length,
           currentFile: file.name
@@ -219,6 +219,11 @@ export class UploadComponent {
             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
+              });
             }
           });
 

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

@@ -36,7 +36,7 @@
       [prefixKey]="selectedConfig.prefixKey"
       (fileSelected)="onFileSelected($event)"
       (uploadStart)="onUploadStart($event)"
-      (uploadProgress)="onUploadProgress($event)"
+      (uploadProgressEvent)="onUploadProgress($event)"
       (uploadComplete)="onUploadComplete($event)"
       (uploadError)="onUploadError($event)">
     </app-upload-component>
@@ -101,11 +101,11 @@
       </div>
       <div class="summary-item success">
         <span class="summary-label">成功:</span>
-        <span class="summary-value">{{ uploadResults.filter(r => r.success).length }}</span>
+        <span class="summary-value">{{ successfulUploads }}</span>
       </div>
       <div class="summary-item error">
         <span class="summary-label">失败:</span>
-        <span class="summary-value">{{ uploadResults.filter(r => !r.success).length }}</span>
+        <span class="summary-value">{{ failedUploads }}</span>
       </div>
     </div>
   </div>

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

@@ -14,6 +14,15 @@ 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 = [
     {

+ 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 '仅群聊';
+    }
+  }
+}

+ 54 - 44
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>
 
@@ -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;
+  }
 }

+ 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);
+  }
+
 }

+ 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 || '未知错误'));