Browse Source

feat: new color-get in project-files

Future 11 hours ago
parent
commit
f648bc1014

+ 42 - 4
rules/schemas.md

@@ -148,7 +148,7 @@ TABLE(Product, "Product\n产品即交付物表") {
 TABLE(ProjectFile, "ProjectFile\n项目文件表") {
     FIELD(objectId, String)
     FIELD(project, Pointer→Project)
-    FIELD(attach, Attachment) 
+    FIELD(attach, Pointer→Attachment) 
     FIELD(uploadedBy, Pointer→Profile)
     FIELD(fileType, String)
     FIELD(fileUrl, String)
@@ -935,7 +935,7 @@ Product.quotation 产品报价字段
 |--------|------|------|------|--------|
 | objectId | String | 是 | 主键ID | "file001" |
 | project | Pointer | 是 | 所属项目 | → Project |
-| attach | Pointer | 是 | 所属项目 | → Attachment | NovaFile.id为Attachment.objectId
+| attach | Pointer | 是 | 所属项目 | → Attachment | NovaFile.id为Attachment.objectId|
 | uploadedBy | Pointer | 是 | 上传人 | → Profile |
 | fileType | String | 是 | 文件类型 | "cad" / "reference" / "document" |
 | fileUrl | String | 是 | 文件URL | "https://..." |
@@ -943,13 +943,51 @@ Product.quotation 产品报价字段
 | fileSize | Number | 否 | 文件大小(字节) | 1024000 |
 | stage | String | 否 | 关联阶段 | "需求沟通" |
 | data | Object | 否 | 扩展数据 | { thumbnailUrl, ... } |
-| analysis | Object 
+| analysis | Object | 否 | 分析结果 | 详见下方 `analysis.color` 结构 |
 | isDeleted | Boolean | 否 | 软删除标记 | false |
 | createdAt | Date | 自动 | 创建时间 | 2024-01-01T00:00:00.000Z |
 | updatedAt | Date | 自动 | 更新时间 | 2024-01-01T00:00:00.000Z |
 
+**analysis.color - 色彩分析结果结构**
 
-analysis.color 存放色彩分析插件解析的结果
+用于存放“色彩分析插件(color-get)”生成的分析报告,并支持重新分析覆盖。该结构与组件内 `ColorAnalysisReport` 对齐,并补充元信息。
+
+```json
+{
+  "version": "1.0",              // 结构版本,便于未来演进
+  "source": "color-get",          // 生成来源(组件/脚本/服务)
+  "pixelSize": 100,                // 马赛克像素块大小(用于重现)
+  "createdAt": "2025-10-20T12:00:00.000Z", // 生成时间
+
+  "palette": [                     // 主色卡(按占比降序,最多20)
+    { "rgb": { "r": 240, "g": 200, "b": 160 }, "hex": "#F0C8A0", "percentage": 28.5 }
+  ],
+
+  "mosaicUrl": "data:image/png;base64,....", // 马赛克图(可选,dataURL 或存储链接)
+
+  "metrics": {
+    "warmCoolBalance": 12.0,       // 冷暖平衡:-100(冷) 到 100(暖)
+    "averageBrightness": 56.0,     // 平均亮度:0-100
+    "averageSaturation": 42.5,     // 平均饱和度:0-100
+    "diversity": 18                // 色彩多样度(主色数量)
+  },
+
+  "histogram": {
+    "brightnessBins": [0, 1, 3, 5, 8, 12, 9, 6, 3, 1],
+    "saturationBins": [0, 0, 2, 4, 7, 10, 9, 5, 2, 1]
+  },
+
+  "splitPoints": [                 // 色彩拆分散点(冷暖/明暗/占比/色值)
+    { "temp": 14.2, "brightness": 62.1, "size": 3.5, "color": "#F0C8A0" }
+  ]
+}
+```
+
+**读写约定**
+- 写入:将以上对象保存至 `ProjectFile.analysis.color`。
+- 读取:若存在 `analysis.color`,组件直接使用并渲染(无需重复计算)。
+- 重新分析:覆盖同一字段,保留 `version`、`source`、`pixelSize` 与 `createdAt` 更新。
+- 存储位置:优先内嵌对象;如需持久化图像(大体积 `mosaicUrl`),可改为文件存储链接,并在此字段写入其URL。
 
 **fileType 枚举值**:
 - `cad`: CAD图纸

+ 4 - 3
src/app/pages/admin/dashboard/dashboard.ts

@@ -231,13 +231,14 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
     try {
       // 设计师统计
       const designerQuery = new FmodeQuery('Profile');
-      designerQuery.equalTo('role', 'designer');
+      designerQuery.contains('roleName', '设计师');
+      designerQuery.equalTo('company', localStorage.getItem("company") || 'unknonw');
       const designers = await designerQuery.count();
       this.stats.totalDesigners.set(designers);
 
       // 客户统计
-      const customerQuery = new FmodeQuery('Profile');
-      customerQuery.equalTo('role', 'customer');
+      const customerQuery = new FmodeQuery('ContactInfo');
+      customerQuery.equalTo('company', localStorage.getItem("company") || 'unknonw');
       const customers = await customerQuery.count();
       this.stats.totalCustomers.set(customers);
 

+ 6 - 1
src/modules/project/components/color-get/color-get-dialog.component.html

@@ -58,7 +58,7 @@
           </div>
         </div>
 
-        <div class="charts" *ngIf="!loading && report">
+        <div class="charts" *ngIf="report">
           <div class="chart-block">
             <div class="chart-title">色彩分布直方图(亮度/饱和度)</div>
             <div #histogramChart class="chart"></div>
@@ -76,6 +76,11 @@
         <span class="name">{{ imageName }}</span>
         <span class="size" *ngIf="imageSize">{{ imageSize / 1024 | number:'1.0-1' }} KB</span>
       </div>
+      <div class="actions">
+        <button class="save-btn" (click)="saveAnalysis()" [disabled]="saving || !report">{{ saving ? '保存中...' : '保存结果到文件' }}</button>
+        <button class="re-btn" (click)="reAnalyze()" [disabled]="loading">重新分析</button>
+        <span class="save-msg" *ngIf="saveMessage">{{ saveMessage }}</span>
+      </div>
       <button class="close-btn" (click)="close()">关闭</button>
     </div>
   </div>

+ 114 - 2
src/modules/project/components/color-get/color-get-dialog.component.ts

@@ -2,6 +2,8 @@ import { Component, Inject, OnDestroy, OnInit, ViewChild, ElementRef } from '@an
 import { CommonModule } from '@angular/common';
 import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
 import * as echarts from 'echarts';
+import { FmodeObject, FmodeQuery, FmodeParse } from 'fmode-ng/parse';
+const Parse = FmodeParse.with('nova');
 
 export interface ProjectFileLike {
   id?: string;
@@ -58,17 +60,33 @@ export class ColorGetDialogComponent implements OnInit, OnDestroy {
   private histogramChart?: echarts.ECharts;
   private splitChart?: echarts.ECharts;
 
+  fileObject?: FmodeObject;
+  saving = false;
+  saveMessage = '';
+
   constructor(
     private dialogRef: MatDialogRef<ColorGetDialogComponent>,
-    @Inject(MAT_DIALOG_DATA) public data: { file?: ProjectFileLike; url?: string; name?: string }
+    @Inject(MAT_DIALOG_DATA) public data: { file?: ProjectFileLike; fileObject?: FmodeObject; fileId?: string; url?: string; name?: string }
   ) {}
 
-  ngOnInit(): void {
+  async ngOnInit(): Promise<void> {
     const file = this.data?.file;
     this.imageUrl = file?.url || this.data?.url || '';
     this.imageName = file?.originalName || file?.name || this.data?.name || '未命名图片';
     this.imageSize = file?.size;
 
+    // 解析文件对象
+    this.fileObject = this.data?.fileObject || await this.fetchFileObjectById(file?.id || this.data?.fileId);
+    if (this.fileObject) {
+      const urlFromObj = (this.fileObject.get('url') || this.fileObject.get('fileUrl')) as string | undefined;
+      if (!this.imageUrl && urlFromObj) this.imageUrl = urlFromObj;
+
+      const loaded = await this.tryLoadExistingAnalysis();
+      if (loaded) {
+        return;
+      }
+    }
+
     if (!this.imageUrl) {
       this.loading = false;
       return;
@@ -362,4 +380,98 @@ export class ColorGetDialogComponent implements OnInit, OnDestroy {
     if (hue >= 300 && hue <= 360) return 80 - (360 - hue) * (80 / 60); // 80→0
     return 0;
   }
+
+  private async fetchFileObjectById(id?: string): Promise<FmodeObject | undefined> {
+    if (!id) return undefined;
+    try {
+      const query = new FmodeQuery('ProjectFile');
+      const obj = await query.get(id);
+      return obj;
+    } catch (e) {
+      console.error('获取文件对象失败:', e);
+      return undefined;
+    }
+  }
+
+  private async tryLoadExistingAnalysis(): Promise<boolean> {
+    try {
+      const existing = this.fileObject?.get('analysis')?.color as any;
+      if (existing) {
+        this.report = existing;
+        this.loading = false;
+        setTimeout(() => {
+          this.renderHistogram();
+          this.renderSplitChart();
+        }, 0);
+        return true;
+      }
+    } catch (e) {
+      console.warn('读取已有色彩分析失败或不存在:', e);
+    }
+    return false;
+  }
+
+  async saveAnalysis(): Promise<void> {
+    if (!this.report) return;
+    this.saving = true;
+    this.saveMessage = '';
+    try {
+      let obj = this.fileObject;
+      if (!obj) {
+        obj = await this.fetchFileObjectById(this.data?.file?.id || this.data?.fileId);
+      }
+      if (!obj) throw new Error('无法定位文件对象,保存失败');
+
+      const current = obj.get('analysis') || {};
+      current.color = {
+        ...this.report,
+        version: '1.0',
+        pixelSize: this.pixelSize,
+        source: 'color-get',
+        createdAt: new Date().toISOString()
+      };
+      obj.set('analysis', current);
+      await obj.save();
+      this.fileObject = obj;
+      this.saveMessage = '已保存色彩分析';
+    } catch (e) {
+      console.error('保存色彩分析失败:', e);
+      this.saveMessage = '保存失败';
+    } finally {
+      this.saving = false;
+    }
+  }
+
+  async reAnalyze(): Promise<void> {
+    if (!this.imageUrl) return;
+
+    // 重新分析前清理现有图表与画布,避免内存泄漏与残留数据
+    if (this.histogramChart) {
+      this.histogramChart.dispose();
+      this.histogramChart = undefined;
+    }
+    if (this.splitChart) {
+      this.splitChart.dispose();
+      this.splitChart = undefined;
+    }
+
+    const mosaicCanvasEl = this.mosaicCanvasRef?.nativeElement;
+    if (mosaicCanvasEl) {
+      const ctx = mosaicCanvasEl.getContext('2d');
+      if (ctx) {
+        ctx.clearRect(0, 0, mosaicCanvasEl.width, mosaicCanvasEl.height);
+      }
+    }
+
+    this.loading = true;
+    this.progress = 0;
+    this.report = undefined;
+
+    try {
+      await this.loadAndAnalyze(this.imageUrl);
+    } catch (err) {
+      console.error('重新分析失败:', err);
+      this.loading = false;
+    }
+  }
 }

+ 18 - 13
src/modules/project/components/project-files-modal/project-files-modal.component.html

@@ -128,9 +128,9 @@
         @if (previewMode === 'grid') {
           <div class="files-grid">
             @for (file of getFilteredFiles(); track file.id) {
-              <div class="file-card" (click)="selectFile(file)">
+              <div class="file-card" >
                 <!-- 文件预览 -->
-                <div class="file-preview">
+                <div class="file-preview" (click)="selectFile(file)">
                   @if (isImageFile(file)) {
                     <img [src]="file.url" [alt]="file.name" class="preview-image" />
                   } @else {
@@ -153,15 +153,19 @@
                   </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" />
+                      @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="uploader-name">{{ file.uploadedBy?.name }}</span>
                       <span class="source-badge" [class]="getSourceBadgeClass(file.source)">
                         {{ getSourceLabel(file.source) }}
                       </span>
                     </div>
                     <div class="file-actions">
+                      
+                      @if(file && isImageFile(file)){
+                        <button class="analyze-btn" (click)="openColorAnalysis(file)">色彩分析</button>
+                      }
                       <button
                         class="action-btn download-btn"
                         (click)="downloadFile(file); $event.stopPropagation()"
@@ -182,8 +186,8 @@
           <!-- 列表视图 -->
           <div class="files-list">
             @for (file of getFilteredFiles(); track file.id) {
-              <div class="file-list-item" (click)="selectFile(file)">
-                <div class="list-file-icon">
+              <div class="file-list-item" >
+                <div class="list-file-icon" (click)="selectFile(file)">
                   @if (isImageFile(file)) {
                     <img [src]="file.url" [alt]="file.name" class="list-preview-image" />
                   } @else {
@@ -195,7 +199,7 @@
                   <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>{{ file.uploadedBy?.name }}</span>
                     <span class="source-badge" [class]="getSourceBadgeClass(file.source)">
                       {{ getSourceLabel(file.source) }}
                     </span>
@@ -205,6 +209,9 @@
                   }
                 </div>
                 <div class="list-file-actions">
+                  @if(isImageFile(file)){
+                    <button class="analyze-btn" (click)="openColorAnalysis(file)">色彩分析</button>
+                  }
                   <button
                     class="action-btn download-btn"
                     (click)="downloadFile(file); $event.stopPropagation()"
@@ -260,7 +267,7 @@
           <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>上传者: {{ selectedFile.uploadedBy?.name }}</span>
             <span class="source-badge" [class]="getSourceBadgeClass(selectedFile.source)">
               {{ getSourceLabel(selectedFile.source) }}
             </span>
@@ -273,12 +280,10 @@
               (blur)="updateFileDescription(selectedFile, selectedFile.description || '')">
             </textarea>
           </div>
+
+
         </div>
       </div>
     </div>
   }
-</div>
-<div class="file-actions" *ngIf="selectedFile">
-  <button class="download-btn" (click)="downloadFile(selectedFile)">下载</button>
-  <button class="analyze-btn" *ngIf="isImageFile(selectedFile)" (click)="openColorAnalysis(selectedFile)">色彩分析</button>
 </div>

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

@@ -8,7 +8,7 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  z-index: 9999;
+  z-index: 99;
   padding: 20px;
 }
 
@@ -568,7 +568,7 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  z-index: 10000;
+  z-index: 100;
   padding: 20px;
 }
 

+ 18 - 17
src/modules/project/components/project-files-modal/project-files-modal.component.ts

@@ -76,31 +76,32 @@ export class ProjectFilesModalComponent implements OnInit {
 
       const query = new FmodeQuery('ProjectFile');
       query.equalTo('project', this.project.toPointer());
-      query.include('uploadedBy');
+      query.include('uploadedBy','attach');
       query.descending('uploadedAt');
       query.limit(100);
 
       const results = await query.find();
 
-      this.files = results.map((file: FmodeObject) => ({
+      this.files = results.map((file: FmodeObject) => {
+        let attach = file?.get("attach")
+        return {
         id: file.id || '',
-        name: file.get('name') || file.get('originalName') || '',
-        originalName: file.get('originalName') || '',
-        url: file.get('url') || '',
+        name: attach.get('name') || attach.get('originalName') || '',
+        originalName: attach.get('originalName') || '',
+        url: attach.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
-        },
+        type: attach.get('mime') || '',
+        mime: attach.get('mime') || '',
+        size: attach.get('size') || 0,
+        uploadedBy: file.get('uploadedBy')?.toJSON(),
         uploadedAt: file.get('uploadedAt') || file.createdAt,
-        source: file.get('source') || 'unknown',
-        md5: file.get('md5'),
-        metadata: file.get('metadata'),
-        description: file.get('description') || ''
-      }));
+        source: attach.get('source') || 'unknown',
+        md5: attach.get('md5'),
+        metadata: attach.get('metadata'),
+        description: attach.get('description') || ''
+      }
+      
+    });
 
       this.calculateStats();
       console.log(`✅ 加载了 ${this.files.length} 个项目文件`);

+ 69 - 10
src/modules/project/pages/project-detail/stages/stage-order.component.scss

@@ -244,6 +244,68 @@
       flex-direction: row;
       gap: 8px;
     }
+
+    .action-btn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      gap: 6px;
+      height: 32px;
+      padding: 0 10px;
+      border-radius: 6px;
+      border: 1px solid var(--light-shade);
+      background: var(--white);
+      color: var(--dark-color);
+      font-size: 12px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+
+      &:hover {
+        background: var(--light-color);
+        border-color: var(--primary-color);
+        color: var(--primary-color);
+        transform: translateY(-1px);
+      }
+
+      &:active {
+        transform: scale(0.98);
+      }
+
+      &:disabled {
+        opacity: 0.6;
+        cursor: not-allowed;
+      }
+
+      svg {
+        width: 16px;
+        height: 16px;
+      }
+
+      &.download-btn {
+        &::before {
+          content: '⬇';
+          font-size: 14px;
+        }
+      }
+
+      &.delete-btn {
+        border-color: var(--danger-color);
+        color: var(--danger-color);
+
+        &:hover {
+          background: rgba(var(--danger-rgb), 0.08);
+        }
+      }
+
+      &.analyze-btn {
+        border-color: var(--tertiary-color);
+        color: var(--tertiary-color);
+
+        &:hover {
+          background: rgba(var(--tertiary-rgb), 0.08);
+        }
+      }
+    }
   }
 
   .action-btn {
@@ -889,13 +951,9 @@
           transform: translateX(2px);
         }
 
-        &:active {
-          transform: scale(0.98);
-        }
-
         &.selected {
-          background-color: white;
           border-color: var(--primary-color);
+          background: rgba(var(--primary-rgb), 0.08);
           box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.2);
 
           .designer-info h4 {
@@ -906,10 +964,9 @@
         .designer-avatar {
           width: 48px;
           height: 48px;
-          flex-shrink: 0;
           border-radius: 50%;
           overflow: hidden;
-          background-color: var(--light-shade);
+          background: var(--light-shade);
           display: flex;
           align-items: center;
           justify-content: center;
@@ -950,10 +1007,12 @@
         }
 
         .selected-icon {
-          width: 28px;
-          height: 28px;
+          position: absolute;
+          top: 8px;
+          right: 8px;
+          width: 20px;
+          height: 20px;
           color: var(--primary-color);
-          flex-shrink: 0;
         }
       }
     }

+ 51 - 29
src/modules/project/pages/project-detail/stages/stage-order.component.ts

@@ -911,20 +911,24 @@ export class StageOrderComponent implements OnInit {
       const query = new Parse.Query('ProjectFile');
       query.equalTo('project', this.project.toPointer());
       query.include('uploadedBy');
+      query.include('attach');
       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
-      }));
+      this.projectFiles = files.map((file: FmodeObject) => {
+        const attach = file.get('attach');
+        return {
+          id: file.id || '',
+          name: attach?.get('name') || attach?.get('originalName') || file.get('name') || file.get('originalName') || '',
+          url: attach?.get('url') || file.get('url') || '',
+          type: attach?.get('type') || file.get('type') || '',
+          size: attach?.get('size') || file.get('size') || 0,
+          uploadedBy: file.get('uploadedBy')?.get('name') || '未知用户',
+          uploadedAt: file.get('uploadedAt') || file.createdAt
+        };
+      });
 
       console.log(`✅ 加载了 ${this.projectFiles.length} 个项目文件`);
     } catch (error) {
@@ -986,30 +990,43 @@ export class StageOrderComponent implements OnInit {
             }
           });
 
-          // 保存文件信息到 ProjectFile 表
+          // 创建 Attachment 记录并设置元数据
+          const attachment = new Parse.Object('Attachment');
+          attachment.set('name', file.name);
+          attachment.set('originalName', file.name);
+          attachment.set('url', uploaded.url);
+          attachment.set('key', uploaded.key);
+          attachment.set('type', file.type || (uploaded?.metadata ? uploaded.metadata['mime'] : '') || '');
+          attachment.set('size', file.size);
+          attachment.set('md5', uploaded.md5);
+          attachment.set('metadata', uploaded.metadata || {});
+          attachment.set('source', source);
+          attachment.set('user', this.currentUser.toPointer());
+          const companyPtr = this.project?.get('company');
+          if (companyPtr) {
+            attachment.set('company', companyPtr.toPointer ? companyPtr.toPointer() : companyPtr);
+          }
+          const savedAttachment = await attachment.save();
+
+          // 保存文件信息到 ProjectFile 表,指向 attach
           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('attach', savedAttachment.toPointer ? savedAttachment.toPointer() : savedAttachment);
           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);
+          // 兼容字段:保留 key(若旧代码需要)
+          projectFile.set('key', uploaded.key);
 
           const savedFile = await projectFile.save();
 
-          // 添加到本地列表
+          // 添加到本地列表(从 attach 读取展示字段)
           this.projectFiles.unshift({
             id: savedFile.id || '',
-            name: file.name,
-            url: uploaded.url || '',
-            type: file.type,
-            size: file.size,
+            name: savedAttachment.get('name'),
+            url: savedAttachment.get('url') || '',
+            type: savedAttachment.get('type') || '',
+            size: savedAttachment.get('size') || 0,
             uploadedBy: this.currentUser.get('name'),
             uploadedAt: new Date()
           });
@@ -1108,12 +1125,17 @@ export class StageOrderComponent implements OnInit {
   /**
    * 格式化文件大小
    */
-  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];
+  formatFileSize(size: number): string {
+    if (size == null || isNaN(size)) return '0 B';
+    const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+    let idx = 0;
+    let value = size;
+    while (value >= 1024 && idx < units.length - 1) {
+      value /= 1024;
+      idx++;
+    }
+    const formatted = value >= 100 ? Math.round(value).toString() : value.toFixed(1);
+    return `${formatted} ${units[idx]}`;
   }
 
   /**