ソースを参照

feat: auth & upload storage.md

ryanemax 14 時間 前
コミット
06045f5e0a

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

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

+ 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.219",
+    "fmode-ng": "^0.0.220",
     "highlight.js": "^11.11.1",
     "jquery": "^3.7.1",
     "markdown-it": "^14.1.0",

+ 3 - 0
rules/schemas.md

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

+ 31 - 0
rules/storage.md

@@ -0,0 +1,31 @@
+# NovaStorage 使用规则(cc 编程工具)
+
+面向 cc 的编程规则,帮助你在任意页面仅通过 `cid` 即可加载与使用存储工具,无需关注 Provider 细节。
+
+## 快速约定
+- 仅需 `cid`:使用 `NovaStorage.withCid(cid)` 自动选择企业账套 Provider。
+- 统一返回:上传结果为 `NovaFile`,含 `key`, `url`, `name`, `type`, `size`, `metadata`, `md5` 等。
+- 生成 Key:默认格式 `storage/company/<cid>/<prefixKey?>/<YYYYMMDD>/<HHmmss-rand>-<name>`。
+- 可选前缀:通过 `prefixKey` 在公司段后、日期目录前插入自定义目录(自动去除首尾斜杠)。
+- 查重与保存:封装 `getAttachmentByMd5` 与 `saveAttachment`,避免重复上传并统一写入 `Attachment`。
+
+## 依赖导入
+```ts
+import { NovaStorage, NovaFile } from 'fmode-ng/core';
+```
+
+## 初始化(仅凭 cid)
+```ts
+const cid = localStorage.getItem('company')!; // 或服务获取
+const storage = await NovaStorage.withCid(cid);
+```
+
+## 上传文件(含进度与 prefixKey)
+```ts
+const file = (event.target as HTMLInputElement).files![0];
+const uploaded: NovaFile = await storage.upload(file, {
+  prefixKey: 'project/pid/', // 项目页需要加项目前缀
+  onProgress: (p) => console.log('progress %', p.total.percent),
+});
+console.log(uploaded.key, uploaded.url);
+```

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

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

+ 6 - 2
rules/wxwork/auth.md

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

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

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

+ 25 - 11
src/app/app.ts

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

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

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

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

@@ -5,6 +5,8 @@ import { FormsModule } from '@angular/forms';
 import { RouterModule, Router, ActivatedRoute } from '@angular/router';
 import { ProjectService } from '../../../services/project.service';
 import { Project, Task, CustomerFeedback } from '../../../models/project.model';
+import { FmodeQuery, FmodeObject, FmodeUser } from 'fmode-ng/core';
+import { WxworkAuth } from 'fmode-ng/core';
 
 @Component({
   selector: 'app-dashboard',
@@ -156,22 +158,116 @@ export class Dashboard implements OnInit, OnDestroy {
     return date.toISOString().split('T')[0];
   }
 
+  private wxAuth: WxworkAuth;
+  private currentUser: FmodeUser | null = null;
+
   constructor(
     private projectService: ProjectService,
     private router: Router,
     private activatedRoute: ActivatedRoute
-  ) {}
+  ) {
+    this.initAuth();
+  }
+
+  // 初始化企业微信认证
+  private initAuth(): void {
+    try {
+      this.wxAuth = new WxworkAuth({
+        cid: 'cDL6R1hgSi'  // 公司帐套ID
+      });
+      console.log('✅ 客服仪表板企业微信认证初始化成功');
+    } catch (error) {
+      console.error('❌ 客服仪表板企业微信认证初始化失败:', error);
+    }
+  }
+
+  async ngOnInit(): Promise<void> {
+    await this.authenticateAndLoadData();
 
-  ngOnInit(): void {
-    this.loadUrgentTasks();
-    this.loadProjectUpdates();
-    this.loadCRMQueues(); // 新增:加载新客户触达与老客户回访队列
-    this.loadPendingFinalPaymentProjects(); // 新增:加载待跟进尾款项目
-    
     // 添加滚动事件监听
     window.addEventListener('scroll', this.onScroll.bind(this));
   }
 
+  // 认证并加载数据
+  private async authenticateAndLoadData(): Promise<void> {
+    try {
+      // 执行企业微信认证和登录
+      const { user } = await this.wxAuth.authenticateAndLogin();
+      this.currentUser = user;
+
+      if (user) {
+        console.log('✅ 客服登录成功:', user.get('username'));
+        await this.loadDashboardData();
+      } else {
+        console.error('❌ 客服登录失败');
+      }
+    } catch (error) {
+      console.error('❌ 客服认证过程出错:', error);
+      // 降级到模拟数据
+      this.loadMockData();
+    }
+  }
+
+  // 加载仪表板数据
+  private async loadDashboardData(): Promise<void> {
+    try {
+      await Promise.all([
+        this.loadConsultationStats(),
+        this.loadUrgentTasks(),
+        this.loadProjectUpdates(),
+        this.loadCRMQueues(),
+        this.loadPendingFinalPaymentProjects()
+      ]);
+      console.log('✅ 客服仪表板数据加载完成');
+    } catch (error) {
+      console.error('❌ 客服仪表板数据加载失败:', error);
+      throw error;
+    }
+  }
+
+  // 加载咨询统计数据
+  private async loadConsultationStats(): Promise<void> {
+    try {
+      // 新咨询数
+      const consultationQuery = new FmodeQuery('Consultation');
+      consultationQuery.equalTo('status', 'new');
+      consultationQuery.greaterThanOrEqualTo('createdAt', new Date(new Date().setHours(0,0,0,0)));
+      const newConsultations = await consultationQuery.count();
+      this.stats.newConsultations.set(newConsultations);
+
+      // 待派单数
+      consultationQuery.equalTo('status', 'pending_assignment');
+      const pendingAssignments = await consultationQuery.count();
+      this.stats.pendingAssignments.set(pendingAssignments);
+
+      // 异常项目数
+      const projectQuery = new FmodeQuery('Project');
+      projectQuery.equalTo('status', 'exception');
+      const exceptionProjects = await projectQuery.count();
+      this.stats.exceptionProjects.set(exceptionProjects);
+
+      // 售后服务数量
+      const afterSalesQuery = new FmodeQuery('AfterSales');
+      afterSalesQuery.equalTo('status', 'pending');
+      const afterSalesCount = await afterSalesQuery.count();
+      this.stats.afterSalesCount.set(afterSalesCount);
+
+      console.log(`✅ 咨询统计: 新咨询${newConsultations}, 待派单${pendingAssignments}, 异常${exceptionProjects}, 售后${afterSalesCount}`);
+    } catch (error) {
+      console.error('❌ 咨询统计加载失败:', error);
+      throw error;
+    }
+  }
+
+  // 降级到模拟数据
+  private loadMockData(): void {
+    console.warn('⚠️ 使用模拟数据');
+    this.loadUrgentTasks();
+    this.loadProjectUpdates();
+    this.loadCRMQueues();
+    this.loadPendingFinalPaymentProjects();
+  }
+
   // 添加滚动事件处理方法
   private onScroll(): void {
     this.showBackToTopSignal.set(window.scrollY > 300);

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

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

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

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

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

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

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

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

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

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

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

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

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

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