Browse Source

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

ryanemax 10 hours ago
parent
commit
56ed0cdf71

+ 13 - 5
.trae/rules/project_rules.md

@@ -1,6 +1,14 @@
-<<<<<<< HEAD
-项目规范
-========
 
-- 模板页面显示的条件和循环应统一使用控制流指令:@if 与 @for。
-========
+# 项目开发规则
+- rules/ 规则目录
+    - comp/*.md 组件调用
+    - schema/*.md 数据范式
+    - agents.md 智能体开发
+    - frontend.md 前端开发
+    - parse.md 数据服务 FmodeParse
+
+# 项目文档管理
+- docs/ 文档目录
+    - product.md 整体产品描述
+    - prd/<端>-<入口>-<页面>.md 各功能页产品结构
+    - schemas.md 数据范式文档 

+ 59 - 14
rules/schemas.md

@@ -168,12 +168,8 @@ TABLE(ProjectFile, "ProjectFile\n项目文件表") {
     FIELD(objectId, String)
     FIELD(project, Pointer→Project)
     FIELD(product, Pointer→Product)
-    FIELD(attach, Attachment)
+    FIELD(attach, Pointer→Attachment) 
     FIELD(uploadedBy, Pointer→Profile)
-    FIELD(fileCategory, String)
-    FIELD(fileUrl, String)
-    FIELD(fileName, String)
-    FIELD(fileSize, Number)
     FIELD(stage, String)
     FIELD(data, Object)
     FIELD(isDeleted, Boolean)
@@ -572,24 +568,73 @@ const designerProducts = await designerProductQuery.find();
 | objectId | String | 是 | 主键ID | "file001" |
 | project | Pointer | 是 | 所属项目 | → Project |
 | **product** | **Pointer** | **否** | **关联空间产品** | **→ Product** |
-| attach | Pointer | 是 | 附件 | → Attachment |
+| attach | Pointer | 是 | 附件 | → Attachment | NovaFile.id为Attachment.objectId|
 | uploadedBy | Pointer | 是 | 上传人 | → Profile |
-| **fileCategory** | **String** | **是** | **文件分类** | **"quotation" / "panorama"** |
-| fileUrl | String | 是 | 文件URL | "https://..." |
-| fileName | String | 是 | 文件名 | "主卧全景图.jpg" |
-| fileSize | Number | 否 | 文件大小(字节) | 1024000 |
 | stage | String | 否 | 关联阶段 | "需求沟通" |
+| category | String | 否 | 关联阶段 | "需求沟通" |
 | data | Object | 否 | 扩展数据 | { thumbnailUrl, ... } |
+| analysis | Object | 否 | 分析结果 | 详见下方 `analysis.color` 结构 |
 | isDeleted | Boolean | 否 | 软删除标记 | false |
 | createdAt | Date | 自动 | 创建时间 | 2024-01-01T00:00:00.000Z |
 | updatedAt | Date | 自动 | 更新时间 | 2024-01-01T00:00:00.000Z |
 
-**fileCategory 枚举值**:
-- `quotation`: 报价文件
-- `panorama`: 全景图文件
-- `delivery`: 交付物文件
+**stage 枚举值**
+'订单分配': '订单分配'
+'方案深化': '方案深化'
+'交付执行': '交付执行'
+'售后归档': '售后归档'
+
+**category 枚举值**:
+- `quotation`: 财务凭据
+- `panorama`: 全景素材
+- `delivery`: 交付文件
 - `reference`: 参考文件
 - `requirement`: 需求文件
+
+**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图纸
+- `reference`: 参考图片
 - `document`: 文档资料
 - `contract`: 合同文件
 - `voucher`: 付款凭证

+ 8 - 9
src/app/pages/admin/dashboard/dashboard.ts

@@ -204,7 +204,7 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
   private async loadProjectStats(): Promise<void> {
     try {
       const projectQuery = new FmodeQuery('Project');
-
+      projectQuery.equalTo('company', localStorage.getItem("company") || 'unknonw');
       // 总项目数
       const totalProjects = await projectQuery.count();
       this.stats.totalProjects.set(totalProjects);
@@ -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);
 
@@ -513,7 +514,7 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
   // 加载项目详情数据
   private async loadProjectDetailData(type: 'totalProjects' | 'active' | 'completed'): Promise<void> {
     const projectQuery = new FmodeQuery('Project');
-
+    projectQuery.include("onwer")
     if (type === 'active') {
       projectQuery.equalTo('status', '进行中');
     } else if (type === 'completed') {
@@ -538,7 +539,7 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
   // 加载设计师详情数据
   private async loadDesignerDetailData(): Promise<void> {
     const designerQuery = new FmodeQuery('Profile');
-    designerQuery.equalTo('role', 'designer');
+    designerQuery.equalTo('roleName', '组员');
 
     const designers = await designerQuery.descending('createdAt').find();
 
@@ -557,9 +558,7 @@ export class AdminDashboard implements OnInit, AfterViewInit, OnDestroy {
 
   // 加载客户详情数据
   private async loadCustomerDetailData(): Promise<void> {
-    const customerQuery = new FmodeQuery('Profile');
-    customerQuery.equalTo('role', 'customer');
-
+    const customerQuery = new FmodeQuery('ContactInfo');
     const customers = await customerQuery.descending('createdAt').find();
 
     const detailItems = customers.map((customer: FmodeObject) => ({

+ 87 - 0
src/modules/project/components/color-get/color-get-dialog.component.html

@@ -0,0 +1,87 @@
+<div class="color-get-dialog">
+  <div class="dialog-header">
+    <div class="title">色彩分析报告</div>
+    <button class="close-btn" (click)="close()">×</button>
+  </div>
+
+  <div class="dialog-body">
+    <div class="layout">
+      <div class="image-panel">
+        <div class="panel-title">原图 / 马赛克</div>
+        <div class="image-wrapper">
+          <img [src]="imageUrl" [alt]="imageName" />
+        </div>
+        <div class="mosaic-wrapper">
+          <canvas #mosaicCanvas></canvas>
+        </div>
+      </div>
+
+      <div class="analysis-panel">
+        <div class="panel-title">色彩分析</div>
+
+        <div class="loading" *ngIf="loading">
+          <div class="spinner"></div>
+          <div class="progress">分析中... {{ progress }}%</div>
+        </div>
+
+        <div class="metrics" *ngIf="!loading && report">
+          <div class="metric-item">
+            <div class="label">冷暖平衡</div>
+            <div class="value" [class.cold]="report.metrics.warmCoolBalance < 0" [class.warm]="report.metrics.warmCoolBalance > 0">
+              {{ report.metrics.warmCoolBalance | number:'1.0-0' }}
+            </div>
+          </div>
+          <div class="metric-item">
+            <div class="label">平均亮度</div>
+            <div class="value">{{ report.metrics.averageBrightness | number:'1.0-0' }}</div>
+          </div>
+          <div class="metric-item">
+            <div class="label">平均饱和度</div>
+            <div class="value">{{ report.metrics.averageSaturation | number:'1.0-0' }}</div>
+          </div>
+          <div class="metric-item">
+            <div class="label">色彩多样度</div>
+            <div class="value">{{ report.metrics.diversity }}</div>
+          </div>
+        </div>
+
+        <div class="palette" *ngIf="!loading && report">
+          <div class="palette-title">主要色卡(Top 20)</div>
+          <div class="palette-grid">
+            <div class="palette-item" *ngFor="let c of report.palette">
+              <div class="swatch" [style.background]="c.hex"></div>
+              <div class="info">
+                <div class="hex">{{ c.hex }}</div>
+                <div class="pct">{{ c.percentage | number:'1.0-1' }}%</div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="charts" *ngIf="report">
+          <div class="chart-block">
+            <div class="chart-title">色彩分布直方图(亮度/饱和度)</div>
+            <div #histogramChart class="chart"></div>
+          </div>
+          <div class="chart-block">
+            <div class="chart-title">色彩拆分图(纵轴明暗,横轴冷暖)</div>
+            <div #splitChart class="chart"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="footer">
+      <div class="file-info">
+        <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>
+</div>

+ 169 - 0
src/modules/project/components/color-get/color-get-dialog.component.scss

@@ -0,0 +1,169 @@
+.color-get-dialog {
+  width: 90vw;
+  max-width: 1200px;
+  max-height: 80vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.dialog-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 16px;
+  border-bottom: 1px solid #eee;
+
+  .title {
+    font-size: 18px;
+    font-weight: 600;
+  }
+
+  .close-btn {
+    border: none;
+    background: transparent;
+    font-size: 20px;
+    cursor: pointer;
+  }
+}
+
+.dialog-body {
+  padding: 12px 16px;
+  overflow: auto;
+}
+
+.layout {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+
+  @media (max-width: 768px) {
+    grid-template-columns: 1fr;
+  }
+}
+
+.image-panel, .analysis-panel {
+  border: 1px solid #eee;
+  border-radius: 8px;
+  padding: 12px;
+}
+
+.panel-title {
+  font-size: 14px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.image-wrapper {
+  border: 1px solid #f0f0f0;
+  border-radius: 8px;
+  overflow: hidden;
+  margin-bottom: 8px;
+
+  img {
+    width: 100%;
+    display: block;
+  }
+}
+
+.mosaic-wrapper {
+  border: 1px dashed #ddd;
+  border-radius: 8px;
+  padding: 8px;
+
+  canvas {
+    width: 100%;
+    height: auto;
+    display: block;
+  }
+}
+
+.loading {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+
+  .spinner {
+    width: 20px;
+    height: 20px;
+    border-radius: 50%;
+    border: 2px solid #ddd;
+    border-top-color: #409eff;
+    animation: spin 1s linear infinite;
+  }
+}
+
+@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
+
+.metrics {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 8px;
+  margin-bottom: 12px;
+
+  .metric-item {
+    border: 1px solid #eee;
+    border-radius: 8px;
+    padding: 8px;
+    text-align: center;
+
+    .label { font-size: 12px; color: #666; }
+    .value { font-size: 16px; font-weight: 600; }
+    .value.cold { color: #409eff; }
+    .value.warm { color: #e67e22; }
+  }
+}
+
+.palette {
+  .palette-title { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
+  .palette-grid {
+    display: grid;
+    grid-template-columns: repeat(5, 1fr);
+    gap: 8px;
+
+    @media (max-width: 768px) {
+      grid-template-columns: repeat(3, 1fr);
+    }
+
+    .palette-item {
+      display: flex;
+      gap: 6px;
+      align-items: center;
+      border: 1px solid #eee;
+      border-radius: 8px;
+      padding: 6px;
+
+      .swatch { width: 28px; height: 28px; border-radius: 4px; }
+      .info { font-size: 12px; display: flex; gap: 10px; }
+      .hex { color: #333; }
+      .pct { color: #666; }
+    }
+  }
+}
+
+.charts {
+  margin-top: 12px;
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: 12px;
+
+  .chart-block { border: 1px solid #eee; border-radius: 8px; padding: 8px; }
+  .chart-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
+  .chart { width: 100%; height: 260px; }
+}
+
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 0;
+
+  .file-info { font-size: 12px; color: #666; display: flex; gap: 8px; }
+  .close-btn {
+    background: #409eff;
+    color: #fff;
+    border: none;
+    padding: 6px 12px;
+    border-radius: 6px;
+    cursor: pointer;
+  }
+}

+ 477 - 0
src/modules/project/components/color-get/color-get-dialog.component.ts

@@ -0,0 +1,477 @@
+import { Component, Inject, OnDestroy, OnInit, ViewChild, ElementRef } from '@angular/core';
+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;
+  name?: string;
+  originalName?: string;
+  url: string;
+  type?: string;
+  size?: number;
+}
+
+interface PaletteColor {
+  rgb: { r: number; g: number; b: number };
+  hex: string;
+  percentage: number;
+}
+
+interface ColorAnalysisReport {
+  palette: PaletteColor[];
+  mosaicUrl?: string;
+  metrics: {
+    warmCoolBalance: number; // -100(冷) 到 100(暖)
+    averageBrightness: number; // 0-100
+    averageSaturation: number; // 0-100
+    diversity: number; // 色彩多样度:主色数量
+  };
+  histogram: {
+    brightnessBins: number[]; // 0..100
+    saturationBins: number[]; // 0..100
+  };
+  splitPoints: Array<{ temp: number; brightness: number; size: number; color: string }>;
+}
+
+@Component({
+  selector: 'app-color-get-dialog',
+  standalone: true,
+  imports: [CommonModule, MatDialogModule],
+  templateUrl: './color-get-dialog.component.html',
+  styleUrls: ['./color-get-dialog.component.scss']
+})
+export class ColorGetDialogComponent implements OnInit, OnDestroy {
+  @ViewChild('mosaicCanvas') mosaicCanvasRef!: ElementRef<HTMLCanvasElement>;
+  @ViewChild('histogramChart') histogramChartRef!: ElementRef<HTMLDivElement>;
+  @ViewChild('splitChart') splitChartRef!: ElementRef<HTMLDivElement>;
+
+  imageUrl = '';
+  imageName = '';
+  imageSize?: number;
+  loading = true;
+  progress = 0;
+
+  pixelSize = 100; // 参考图马赛克像素块大小
+  report?: ColorAnalysisReport;
+
+  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; fileObject?: FmodeObject; fileId?: string; url?: string; name?: string }
+  ) {}
+
+  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;
+    }
+
+    // 开始分析
+    this.loadAndAnalyze(this.imageUrl).catch(err => {
+      console.error('色彩分析失败:', err);
+      this.loading = false;
+    });
+  }
+
+  ngOnDestroy(): void {
+    if (this.histogramChart) {
+      this.histogramChart.dispose();
+    }
+    if (this.splitChart) {
+      this.splitChart.dispose();
+    }
+  }
+
+  close(): void {
+    this.dialogRef.close();
+  }
+
+  private async loadAndAnalyze(url: string): Promise<void> {
+    this.progress = 5;
+    const img = await this.loadImage(url);
+    this.progress = 20;
+
+    const { mosaicCanvas, blockColors } = this.createMosaic(img, this.pixelSize);
+    this.progress = 50;
+
+    // 显示马赛克图
+    const canvas = this.mosaicCanvasRef.nativeElement;
+    canvas.width = mosaicCanvas.width;
+    canvas.height = mosaicCanvas.height;
+    const ctx = canvas.getContext('2d');
+    if (ctx) ctx.drawImage(mosaicCanvas, 0, 0);
+
+    // 构建色彩报告
+    this.report = this.buildReport(blockColors);
+    this.progress = 80;
+
+    // 渲染图表
+    setTimeout(() => {
+      this.renderHistogram();
+      this.renderSplitChart();
+      this.progress = 100;
+      this.loading = false;
+    }, 0);
+  }
+
+  private loadImage(url: string): Promise<HTMLImageElement> {
+    return new Promise((resolve, reject) => {
+      const img = new Image();
+      img.crossOrigin = 'anonymous';
+      img.onload = () => resolve(img);
+      img.onerror = reject;
+      img.src = url;
+    });
+  }
+
+  private createMosaic(image: HTMLImageElement, pixelSize: number): { mosaicCanvas: HTMLCanvasElement; blockColors: { r: number; g: number; b: number }[] } {
+    const width = image.width;
+    const height = image.height;
+    const canvas = document.createElement('canvas');
+    canvas.width = width;
+    canvas.height = height;
+    const ctx = canvas.getContext('2d')!;
+    ctx.drawImage(image, 0, 0, width, height);
+
+    const blockColors: { r: number; g: number; b: number }[] = [];
+
+    for (let y = 0; y < height; y += pixelSize) {
+      for (let x = 0; x < width; x += pixelSize) {
+        const bw = Math.min(pixelSize, width - x);
+        const bh = Math.min(pixelSize, height - y);
+        const data = ctx.getImageData(x, y, bw, bh).data;
+
+        let r = 0, g = 0, b = 0;
+        const count = bw * bh;
+        for (let i = 0; i < data.length; i += 4) {
+          r += data[i];
+          g += data[i + 1];
+          b += data[i + 2];
+        }
+        r = Math.round(r / count);
+        g = Math.round(g / count);
+        b = Math.round(b / count);
+
+        blockColors.push({ r, g, b });
+
+        ctx.fillStyle = `rgb(${r},${g},${b})`;
+        ctx.fillRect(x, y, bw, bh);
+      }
+    }
+
+    return { mosaicCanvas: canvas, blockColors };
+  }
+
+  private buildReport(blockColors: { r: number; g: number; b: number }[]): ColorAnalysisReport {
+    // 统计颜色频率
+    const colorMap = new Map<string, number>();
+    const totalBlocks = blockColors.length;
+
+    blockColors.forEach(({ r, g, b }) => {
+      const key = `${r},${g},${b}`; // 精确块平均色
+      colorMap.set(key, (colorMap.get(key) || 0) + 1);
+    });
+
+    // 合并近似色(降低抖动),按 16 级量化
+    const quantMap = new Map<string, number>();
+    colorMap.forEach((count, key) => {
+      const [r, g, b] = key.split(',').map(Number);
+      const qr = Math.round(r / 16) * 16;
+      const qg = Math.round(g / 16) * 16;
+      const qb = Math.round(b / 16) * 16;
+      const qkey = `${qr},${qg},${qb}`;
+      quantMap.set(qkey, (quantMap.get(qkey) || 0) + count);
+    });
+
+    const palette = Array.from(quantMap.entries())
+      .map(([k, c]) => {
+        const [r, g, b] = k.split(',').map(Number);
+        const hex = this.rgbToHex(r, g, b);
+        return {
+          rgb: { r, g, b },
+          hex,
+          percentage: +(c / totalBlocks * 100).toFixed(2)
+        } as PaletteColor;
+      })
+      .sort((a, b) => b.percentage - a.percentage)
+      .slice(0, 20);
+
+    // 计算指标与分布
+    const brightnessBins = new Array(10).fill(0); // 10等分
+    const saturationBins = new Array(10).fill(0);
+    let sumBrightness = 0;
+    let sumSaturation = 0;
+    let sumTempScore = 0;
+
+    const splitPoints: Array<{ temp: number; brightness: number; size: number; color: string }> = [];
+
+    blockColors.forEach(({ r, g, b }) => {
+      const { h, s, v } = this.rgbToHsv(r, g, b);
+      const brightness = v * 100;
+      const saturation = s * 100;
+      const temp = this.warmCoolScoreFromHue(h); // -100..100
+
+      sumBrightness += brightness;
+      sumSaturation += saturation;
+      sumTempScore += temp;
+
+      const bIdx = Math.min(9, Math.floor(brightness / 10));
+      const sIdx = Math.min(9, Math.floor(saturation / 10));
+      brightnessBins[bIdx]++;
+      saturationBins[sIdx]++;
+
+      splitPoints.push({ temp, brightness, size: 1, color: `rgb(${r},${g},${b})` });
+    });
+
+    const avgBrightness = +(sumBrightness / totalBlocks).toFixed(1);
+    const avgSaturation = +(sumSaturation / totalBlocks).toFixed(1);
+    const warmCoolBalance = +((sumTempScore / totalBlocks)).toFixed(1);
+
+    return {
+      palette,
+      metrics: {
+        warmCoolBalance,
+        averageBrightness: avgBrightness,
+        averageSaturation: avgSaturation,
+        diversity: palette.length
+      },
+      histogram: {
+        brightnessBins: brightnessBins.map(v => Math.round(v)),
+        saturationBins: saturationBins.map(v => Math.round(v))
+      },
+      splitPoints
+    };
+  }
+
+  private renderHistogram(): void {
+    if (!this.report) return;
+    const el = this.histogramChartRef.nativeElement;
+    this.histogramChart = echarts.init(el);
+
+    const option: echarts.EChartsOption = {
+      tooltip: {},
+      grid: { left: 40, right: 20, top: 30, bottom: 40 },
+      legend: { data: ['亮度', '饱和度'] },
+      xAxis: {
+        type: 'category',
+        data: ['0-10', '10-20', '20-30', '30-40', '40-50', '50-60', '60-70', '70-80', '80-90', '90-100']
+      },
+      yAxis: { type: 'value' },
+      series: [
+        {
+          name: '亮度',
+          type: 'bar',
+          data: this.report.histogram.brightnessBins,
+          itemStyle: { color: '#4ECDC4' }
+        },
+        {
+          name: '饱和度',
+          type: 'bar',
+          data: this.report.histogram.saturationBins,
+          itemStyle: { color: '#FF6B6B' }
+        }
+      ]
+    };
+
+    this.histogramChart.setOption(option);
+  }
+
+  private renderSplitChart(): void {
+    if (!this.report) return;
+    const el = this.splitChartRef.nativeElement;
+    this.splitChart = echarts.init(el);
+
+    const data = this.report.splitPoints.map(p => [p.temp, p.brightness, p.size, p.color]);
+
+    const option: echarts.EChartsOption = {
+      tooltip: {
+        formatter: (params: any) => {
+          const [temp, bright, , color] = params.data;
+          return `冷暖: ${temp.toFixed(1)}<br/>亮度: ${bright.toFixed(1)}<br/><span style="display:inline-block;width:12px;height:12px;background:${color};margin-right:6px"></span>${color}`;
+        }
+      },
+      xAxis: {
+        type: 'value',
+        min: -100,
+        max: 100,
+        name: '冷 ← → 暖'
+      },
+      yAxis: {
+        type: 'value',
+        min: 0,
+        max: 100,
+        name: '暗 ↑ ↓ 亮'
+      },
+      series: [
+        {
+          name: '色彩拆分',
+          type: 'scatter',
+          symbolSize: 8,
+          data,
+          itemStyle: {
+            color: (p: any) => p.data[3]
+          }
+        }
+      ]
+    };
+
+    this.splitChart.setOption(option);
+  }
+
+  // --- 色彩工具函数 ---
+  private rgbToHex(r: number, g: number, b: number): string {
+    const toHex = (n: number) => n.toString(16).padStart(2, '0');
+    return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+  }
+
+  private rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
+    r /= 255; g /= 255; b /= 255;
+    const max = Math.max(r, g, b), min = Math.min(r, g, b);
+    const v = max;
+    const d = max - min;
+    const s = max === 0 ? 0 : d / max;
+    let h = 0;
+    if (max === min) {
+      h = 0;
+    } else {
+      switch (max) {
+        case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+        case g: h = (b - r) / d + 2; break;
+        case b: h = (r - g) / d + 4; break;
+      }
+      h /= 6;
+    }
+    return { h: h * 360, s, v };
+  }
+
+  private warmCoolScoreFromHue(hue: number): number {
+    // 简化冷暖评分:红橙黄(0-60)与洋红(300-360)偏暖;青蓝紫(180-300)偏冷;绿色(60-180)中性略冷
+    if (hue >= 0 && hue < 60) return 80 - (60 - hue) * (80 / 60); // 80→0
+    if (hue >= 60 && hue < 120) return 20 - (120 - hue) * (20 / 60); // 20→0
+    if (hue >= 120 && hue < 180) return -20 + (hue - 120) * (20 / 60); // -20→0
+    if (hue >= 180 && hue < 240) return -60 + (hue - 180) * (40 / 60); // -60→-20
+    if (hue >= 240 && hue < 300) return -80 + (hue - 240) * (20 / 60); // -80→-60
+    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 - 9
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,6 +280,8 @@
               (blur)="updateFileDescription(selectedFile, selectedFile.description || '')">
             </textarea>
           </div>
+
+
         </div>
       </div>
     </div>

+ 25 - 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;
 }
 
@@ -850,4 +850,27 @@
       }
     }
   }
+}
+
+.file-actions {
+  display: flex;
+  gap: 8px;
+  margin-top: 8px;
+}
+
+.download-btn, .analyze-btn {
+  padding: 6px 12px;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.download-btn {
+  background: #1976d2;
+  color: #fff;
+}
+
+.analyze-btn {
+  background: #6a1b9a;
+  color: #fff;
 }

+ 41 - 20
src/modules/project/components/project-files-modal/project-files-modal.component.ts

@@ -3,6 +3,8 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { FmodeObject, FmodeQuery, FmodeParse } from 'fmode-ng/parse';
 import { NovaStorage, NovaFile } from 'fmode-ng/core';
+import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { ColorGetDialogComponent } from '../color-get/color-get-dialog.component';
 
 const Parse = FmodeParse.with('nova');
 
@@ -29,7 +31,7 @@ export interface ProjectFile {
 @Component({
   selector: 'app-project-files-modal',
   standalone: true,
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule, FormsModule, MatDialogModule],
   templateUrl: './project-files-modal.component.html',
   styleUrls: ['./project-files-modal.component.scss']
 })
@@ -52,7 +54,7 @@ export class ProjectFilesModalComponent implements OnInit {
   imageCount: number = 0;
   documentCount: number = 0;
 
-  constructor() {}
+  constructor(private dialog: MatDialog) {}
 
   ngOnInit(): void {
     if (this.isVisible && this.project) {
@@ -74,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} 个项目文件`);
@@ -144,6 +147,24 @@ export class ProjectFilesModalComponent implements OnInit {
     document.body.removeChild(link);
   }
 
+  openColorAnalysis(file: ProjectFile): void {
+    if (!this.isImageFile(file)) return;
+    this.dialog.open(ColorGetDialogComponent, {
+      data: {
+        file: {
+          id: file.id,
+          name: file.name,
+          originalName: file.originalName,
+          url: file.url,
+          type: file.type,
+          size: file.size
+        }
+      },
+      width: '90vw',
+      maxWidth: '1200px'
+    });
+  }
+
   formatFileSize(bytes: number): string {
     if (bytes === 0) return '0 Bytes';
     const k = 1024;
@@ -166,7 +187,7 @@ export class ProjectFilesModalComponent implements OnInit {
   }
 
   getFileExtension(name: string): string {
-    return name.split('.').pop()?.toUpperCase() || '';
+    return name.split('.')?.pop()?.toUpperCase() || '';
   }
 
   isImageFile(file: ProjectFile): boolean {

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

+ 72 - 41
src/modules/project/pages/project-detail/stages/stage-order.component.ts

@@ -1137,19 +1137,27 @@ export class StageOrderComponent implements OnInit {
     if (!this.project) return;
 
     try {
-      // 使用ProjectFileService加载项目文件
-      const files = await this.projectFileService.getProjectFiles(this.projectId);
-      this.projectFiles = files.map(file => ({
-        id: file.id,
-        name: file.get('name') || '',
-        url: file.get('url') || '',
-        type: file.get('type') || '',
-        size: file.get('size') || 0,
-        fileType: file.get('fileType') || '',
-        uploadedBy: file.get('uploadedBy') || '',
-        uploadedAt: file.get('uploadedAt') || new Date(),
-        spaceId: file.get('spaceId') || undefined
-      }));
+      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) => {
+        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) {
@@ -1226,29 +1234,47 @@ export class StageOrderComponent implements OnInit {
             }
           );
 
-          // 保存文件信息到 Attachment表和ProjectFile表
-          const projectFile = await this.projectFileService.saveToProjectFile(
-            uploadedFile.attachment,
-            this.projectId,
-            'order',
-            spaceId,
-            undefined
-          );
+          // 创建 Attachment 记录并设置元数据
+          const attachment = new Parse.Object('Attachment');
+          attachment.set('name', file.name);
+          attachment.set('originalName', file.name);
+          attachment.set('url', uploadedFile.url);
+          attachment.set('key', uploadedFile.key);
+          attachment.set('type', file.type || (uploadedFile?.metadata ? uploadedFile.metadata['mime'] : '') || '');
+          attachment.set('size', file.size);
+          attachment.set('md5', uploadedFile.md5);
+          attachment.set('metadata', uploadedFile.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('attach', savedAttachment.toPointer ? savedAttachment.toPointer() : savedAttachment);
+          projectFile.set('uploadedBy', this.currentUser.toPointer());
+          projectFile.set('uploadedAt', new Date());
+          projectFile.set('source', source); // 标记来源:企业微信拖拽或手动选择
+          // 兼容字段:保留 key(若旧代码需要)
+          projectFile.set('key', uploadedFile.key);
+
+          const savedFile = await projectFile.save();
+
+          // 添加到本地列表(从 attach 读取展示字段)
+          this.projectFiles.unshift({
+            id: savedFile.id || '',
+            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()
+          });
 
-          // 添加到本地列表
-          const fileData = {
-            id: projectFile.id || '',
-            name: file.name,
-            url: uploadedFile.url,
-            type: file.type,
-            size: file.size,
-            fileType: this.projectFileService.getFileTypeLabel(file.type),
-            uploadedBy: this.currentUser.get('name') || '',
-            uploadedAt: new Date(),
-            spaceId: spaceId || ''
-          };
-
-          this.projectFiles.unshift(fileData);
           results.push({
             success: true,
             file: uploadedFile,
@@ -1338,12 +1364,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]}`;
   }
 
   /**