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

refactor: reuse shared UI across monitoring pages

gangvy 1 месяц назад
Родитель
Сommit
01aa07f284

+ 2 - 0
TASKS.md

@@ -17,6 +17,8 @@
 - [x] 复用原 `PageHeader`、`SummaryMetricCard`、`ContentCard`、`DataTableShell`、`ProductIdentity`、`BoardTabs`、`NeuButton`、`LoadingSpinner` 和 `EmptyState`
 - [x] 将左侧导航整理为“经营分析”“市场与竞品”“VOC 全局监测”“VOC 深度洞察”和“数据接入状态”五组结构
 - [x] 将原 `FilterBar` 解耦为可关闭旧数据选项自动加载的外部受控模式
+- [x] 将 VOC 总览、类目 VOC、风险预警和数据状态页的卡片、页签、按钮统一回原共享组件
+- [x] 为数据状态、VOC 总览和类目 VOC 补齐共享加载态与错误态
 - [x] 接通商品列表到经营详情、经营详情到 VOC 的真实商品上下文
 - [x] 增加数据接入状态页
 - [x] 将 Parse 和电商中转默认地址改为同源路径

+ 43 - 53
src/modules/domestic-voc/data-status.component.ts

@@ -1,26 +1,36 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { catchError, of } from 'rxjs';
 import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
 import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
 import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
+import { ErrorStateComponent } from '../shared/components/error-state/error-state.component';
+import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
+import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
 import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
 
 @Component({
   selector: 'app-domestic-data-status',
   standalone: true,
-  imports: [CommonModule, ContentCardComponent, DataTableShellComponent, SummaryMetricCardComponent],
+  imports: [CommonModule, ContentCardComponent, DataTableShellComponent, ErrorStateComponent, LoadingSpinnerComponent, PageHeaderComponent, SummaryMetricCardComponent],
   changeDetection: ChangeDetectionStrategy.OnPush,
   template: `
-    <section class="status-page">
-      <header class="status-header">
-        <div>
-          <p>Domestic Ecommerce</p>
-          <h1>数据接入状态</h1>
-          <span>德玛仕案例的导入结果、质量问题与外部依赖</span>
-        </div>
-      </header>
+    <section class="domestic-page">
+      <app-page-header
+        title="数据接入状态"
+        eyebrow="Domestic Ecommerce"
+        description="查看德玛仕案例的导入结果、质量问题和当前处理状态。"
+        icon="▤">
+      </app-page-header>
 
-      @if (dataset$ | async; as data) {
+      @if (loadError) {
+        <app-error-state
+          title="数据状态加载失败"
+          description="无法读取当前案例的汇总信息。"
+          (retry)="retry()">
+        </app-error-state>
+      } @else {
+        @if (dataset$ | async; as data) {
         <div class="metric-grid">
           <app-summary-metric-card label="日指标记录" [value]="data.summary.metricRows" icon="▤" tone="blue"></app-summary-metric-card>
           <app-summary-metric-card label="经营商品" [value]="data.summary.metricProducts" icon="□" tone="green"></app-summary-metric-card>
@@ -28,9 +38,9 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
           <app-summary-metric-card label="评论样本" [value]="data.summary.reviewCount" icon="◇" tone="red" description="当前暂无样本"></app-summary-metric-card>
         </div>
 
-        <div class="status-grid">
+        <div class="detail-grid">
           <app-content-card title="当前数据源" subtitle="规范化文件已就绪" tone="green">
-            <dl class="source-list">
+            <dl class="info-list">
               <div><dt>案例</dt><dd>{{ data.caseName }}</dd></div>
               <div><dt>平台</dt><dd>{{ data.platform }}</dd></div>
               <div><dt>源文件</dt><dd>{{ data.source.sourceFile }}</dd></div>
@@ -39,69 +49,49 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
           </app-content-card>
           <app-content-card title="待接通依赖" subtitle="不会访问原跨境线上数据库" tone="amber">
             <ol class="dependency-list">
-              <li><span class="done">已完成</span> 原 VOC 前端框架迁入</li>
-              <li><span class="done">已完成</span> 商品 ID 兼容层与本地案例数据</li>
-              <li><span class="waiting">待处理</span> 独立 PostgreSQL / Parse 应用</li>
-              <li><span class="waiting">待处理</span> 评论采集服务</li>
+              <li><span class="status-pill success">已完成</span> 原 VOC 前端框架迁入</li>
+              <li><span class="status-pill success">已完成</span> 商品 ID 兼容层与本地案例数据</li>
+              <li><span class="status-pill">待处理</span> 独立 PostgreSQL / Parse 应用</li>
+              <li><span class="status-pill">待处理</span> 评论采集服务</li>
             </ol>
           </app-content-card>
         </div>
 
         <app-data-table-shell title="数据质量问题" [count]="issueCount(data.quality)" variant="admin" [scrollable]="true">
-          <table class="quality-table">
+          <table class="analytics-table">
             <thead><tr><th>类型</th><th>商品ID</th><th>详情</th></tr></thead>
             <tbody>
               @for (item of data.quality.orphanMappings; track item.ownProductId) {
-                <tr><td><span class="issue warning">经营数据缺失</span></td><td>{{ item.ownProductId }}</td><td>{{ item.model }} · {{ item.category }}</td></tr>
+                <tr><td><span class="status-pill">经营数据缺失</span></td><td>{{ item.ownProductId }}</td><td>{{ item.model }} · {{ item.category }}</td></tr>
               }
               @for (item of data.quality.mappingsWithoutCompetitor; track item.ownProductId) {
-                <tr><td><span class="issue warning">未配置竞品</span></td><td>{{ item.ownProductId }}</td><td>{{ item.model }} · {{ item.category }}</td></tr>
+                <tr><td><span class="status-pill">未配置竞品</span></td><td>{{ item.ownProductId }}</td><td>{{ item.model }} · {{ item.category }}</td></tr>
               }
               @for (item of data.quality.brandWithoutProductId; track item.ownProductId + item.slot) {
-                <tr><td><span class="issue info">竞品编码缺失</span></td><td>{{ item.ownProductId }}</td><td>{{ item.brand }} · 第 {{ item.slot }} 个竞品位</td></tr>
+                <tr><td><span class="status-pill info">竞品编码缺失</span></td><td>{{ item.ownProductId }}</td><td>{{ item.brand }} · 第 {{ item.slot }} 个竞品位</td></tr>
               }
             </tbody>
           </table>
         </app-data-table-shell>
+        } @else {
+          <app-loading-spinner variant="card" tone="blue" text="正在加载数据状态"></app-loading-spinner>
+        }
       }
     </section>
   `,
-  styles: [`
-    :host { display: block; }
-    .status-page { display: grid; gap: 20px; padding: 28px; }
-    .status-header p { margin: 0 0 4px; color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; }
-    .status-header h1 { margin: 0; color: #111827; font-size: 28px; line-height: 1.25; }
-    .status-header span { display: block; margin-top: 7px; color: #64748b; font-size: 14px; }
-    .metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
-    .status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
-    .source-list { display: grid; gap: 12px; margin: 0; }
-    .source-list div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; }
-    .source-list dt { color: #64748b; }
-    .source-list dd { margin: 0; overflow: hidden; color: #1f2937; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
-    .dependency-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
-    .dependency-list li { display: flex; align-items: center; gap: 10px; color: #374151; }
-    .dependency-list span { min-width: 54px; padding: 3px 7px; border-radius: 5px; font-size: 11px; font-weight: 700; text-align: center; }
-    .done { color: #166534; background: #dcfce7; }
-    .waiting { color: #92400e; background: #fef3c7; }
-    .quality-table { width: 100%; border-collapse: collapse; }
-    .quality-table th, .quality-table td { height: 46px; padding: 0 16px; border-bottom: 1px solid #e5e7eb; text-align: left; }
-    .quality-table th { color: #64748b; background: #f8fafc; font-size: 12px; }
-    .quality-table td { color: #374151; font-size: 13px; }
-    .issue { display: inline-flex; padding: 3px 8px; border-radius: 5px; font-size: 11px; font-weight: 700; }
-    .issue.warning { color: #92400e; background: #fef3c7; }
-    .issue.info { color: #1d4ed8; background: #dbeafe; }
-    @media (max-width: 960px) { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
-    @media (max-width: 720px) {
-      .status-page { padding: 18px 14px; }
-      .metric-grid, .status-grid { grid-template-columns: 1fr; }
-      .status-header h1 { font-size: 23px; }
-      .quality-table { min-width: 640px; }
-    }
-  `],
+  styleUrls: ['./domestic-page.shared.scss'],
 })
 export class DomesticDataStatusComponent {
   private readonly dataset = inject(DomesticDatasetService);
-  readonly dataset$ = this.dataset.dataset$;
+  loadError = false;
+  readonly dataset$ = this.dataset.dataset$.pipe(catchError(() => {
+    this.loadError = true;
+    return of(null);
+  }));
+
+  retry(): void {
+    window.location.reload();
+  }
 
   issueCount(quality: { orphanMappings: unknown[]; mappingsWithoutCompetitor: unknown[]; brandWithoutProductId: unknown[] }): number {
     return quality.orphanMappings.length + quality.mappingsWithoutCompetitor.length + quality.brandWithoutProductId.length;

+ 25 - 0
src/modules/domestic-voc/domestic-page.shared.scss

@@ -227,6 +227,31 @@
   white-space: nowrap;
 }
 
+.status-pill.success {
+  background: #dcfce7;
+  color: #166534;
+}
+
+.status-pill.info {
+  background: #dbeafe;
+  color: #1d4ed8;
+}
+
+.dependency-list {
+  display: grid;
+  gap: 12px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.dependency-list li {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  color: #374151;
+}
+
 .empty-note {
   color: #94a3b8;
   font-size: 12px;

+ 87 - 43
src/modules/monitoring/pages/category-voc/category-voc.component.ts

@@ -8,10 +8,15 @@ import { CategoryVocDensityItem } from '../../../shared/interfaces/monitoring-in
 import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
+import { BoardTabItem, BoardTabsComponent } from '../../../shared/components/board-tabs/board-tabs.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
 import { DataTableShellComponent } from '../../../shared/components/data-table-shell/data-table-shell.component';
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
 import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
+import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
+import { PriorityAccordionComponent, PriorityAccordionSection } from '../../../shared/components/priority-accordion/priority-accordion.component';
 
 /** 下钻维度 */
 type CategoryDimension = 'category' | 'subCategory' | 'salesRange' | 'negRange';
@@ -36,7 +41,7 @@ interface TableGroup {
 @Component({
   selector: 'app-category-voc',
   standalone: true,
-  imports: [CommonModule, FormsModule, FilterBarComponent, PageHeaderComponent, SummaryMetricCardComponent, ContentCardComponent, DataTableShellComponent, EmptyStateComponent, LoadingSpinnerComponent],
+  imports: [CommonModule, FormsModule, FilterBarComponent, PageHeaderComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, DataTableShellComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, MsqDropdownComponent, NeuButtonComponent, PriorityAccordionComponent],
   template: `
     <div class="category-voc-container">
       <app-page-header
@@ -69,26 +74,36 @@ interface TableGroup {
         (filtersChange)="onFilterChange($event)">
       </app-filter-bar>
 
+      @if (loadError) {
+        <app-error-state
+          title="类目 VOC 加载失败"
+          description="无法读取当前筛选范围内的类目分析。"
+          (retry)="loadData()">
+        </app-error-state>
+      } @else {
       <section class="category-workspace">
         <div class="workspace-main">
           <!-- 维度切换 -->
           <div class="dim-bar">
             <span class="dim-label">下钻维度:</span>
-            <button class="dim-btn" [class.active]="dimension === 'category'"    (click)="setDimension('category')">品类</button>
-            <button class="dim-btn" [class.active]="dimension === 'subCategory'" (click)="setDimension('subCategory')">子品类</button>
-            <button class="dim-btn" [class.active]="dimension === 'salesRange'"  (click)="setDimension('salesRange')">销量区间</button>
-            <button class="dim-btn" [class.active]="dimension === 'negRange'"    (click)="setDimension('negRange')">差评率区间</button>
+            <app-board-tabs
+              [tabs]="dimensionTabs"
+              [activeKey]="dimension"
+              density="compact"
+              (activeKeyChange)="setDimension($event)">
+            </app-board-tabs>
 
             <!-- 子品类视图:父品类选择器 -->
             @if (dimension === 'subCategory') {
               <span class="dim-sep">|</span>
               <span class="dim-label">父品类:</span>
-              <select class="dim-select" [(ngModel)]="selectedParentCategory" (change)="onParentCategoryChange()">
-                <option value="">请选择...</option>
-                @for (cat of categories; track cat.category) {
-                  <option [value]="cat.category">{{ cat.category || '未知' }}</option>
-                }
-              </select>
+              <msq-dropdown
+                class="dim-select"
+                placeholder="请选择父品类"
+                [options]="parentCategoryOptions"
+                [ngModel]="selectedParentCategory"
+                (valueChange)="onParentCategorySelected($event)">
+              </msq-dropdown>
             }
             <span class="dim-hint">点击气泡或表格行更新右侧诊断</span>
           </div>
@@ -333,8 +348,8 @@ interface TableGroup {
                 </div>
               }
               <div class="diagnosis-actions">
-                <button type="button" class="side-btn primary" (click)="openSelectedSubCategory()">查看子品类/品牌</button>
-                <button type="button" class="side-btn" (click)="setDimension('category')">返回品类视图</button>
+                <app-neu-button size="sm" color="primary" (btnClick)="openSelectedSubCategory()">查看子品类/品牌</app-neu-button>
+                <app-neu-button size="sm" (btnClick)="setDimension('category')">返回品类视图</app-neu-button>
               </div>
             } @else {
               <app-empty-state title="暂无选中品类" description="点击左侧气泡或下方表格行后展示诊断" icon="📦" size="sm"></app-empty-state>
@@ -345,22 +360,24 @@ interface TableGroup {
             @if (priorityCategories.length === 0) {
               <app-empty-state title="暂无优先项" icon="📭" size="sm"></app-empty-state>
             } @else {
-              <div class="priority-list">
-                @for (cat of priorityCategories; track cat.category; let i = $index) {
-                  <button type="button" class="priority-item" [class.active]="activeCategory?.category === cat.category" (click)="selectCategory(cat)">
-                    <span class="priority-rank">{{ i + 1 }}</span>
-                    <span class="priority-copy">
-                      <strong>{{ cat.category || '未知' }}</strong>
-                      <em>{{ getQuadrantLabel(cat) }} · 差评率 {{ cat.negativeRate }}%</em>
-                    </span>
-                    <span class="priority-score" [ngClass]="getCategoryTone(cat)">{{ getPriorityLabel(cat) }}</span>
-                  </button>
-                }
-              </div>
+              <app-priority-accordion
+                [sections]="prioritySections"
+                [expandedIds]="activeCategory ? [activeCategory.category] : []"
+                [multiple]="false"
+                density="compact"
+                variant="outlined"
+                (sectionToggle)="selectPriorityCategory($event.id)">
+                <ng-template #sectionBody let-section>
+                  @if (getCategoryById(section.id); as category) {
+                    <p class="priority-action">{{ getCategoryAction(category) }}</p>
+                  }
+                </ng-template>
+              </app-priority-accordion>
             }
           </app-content-card>
         </aside>
       </section>
+      }
     </div>
   `,
   styles: [`
@@ -381,10 +398,7 @@ interface TableGroup {
     .dim-label { font-size: 12px; color: #6b7280; font-weight: 500; }
     .dim-hint { margin-left: auto; color: #94a3b8; font-size: 12px; }
     .dim-sep { color: #d1d5db; margin: 0 4px; }
-    .dim-btn { padding: 6px 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; font-size: 13px; color: #374151; cursor: pointer; transition: all 0.15s; font-weight: 500; }
-    .dim-btn.active { background: #111827; color: #fff; border-color: #111827; }
-    .dim-btn:hover:not(.active) { background: #f9fafb; }
-    .dim-select { padding: 5px 10px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; font-size: 13px; color: #374151; cursor: pointer; }
+    .dim-select { min-width: 190px; }
 
     /* 图表面板 */
     app-content-card { margin-bottom: 20px; }
@@ -404,12 +418,10 @@ interface TableGroup {
     .chart-actions { display: flex; justify-content: flex-end; }
     .selected-chip,
     .priority-badge,
-    .quadrant-badge,
-    .priority-score { display: inline-flex; align-items: center; border-radius: 999px; font-weight: 700; white-space: nowrap; }
+    .quadrant-badge { display: inline-flex; align-items: center; border-radius: 999px; font-weight: 700; white-space: nowrap; }
     .selected-chip { padding: 5px 9px; font-size: 12px; }
     .priority-badge { padding: 4px 8px; font-size: 11px; }
     .quadrant-badge { padding: 3px 8px; font-size: 12px; }
-    .priority-score { padding: 3px 7px; font-size: 11px; }
     .green { background: #dcfce7; color: #15803d; }
     .blue { background: #e0f2fe; color: #0369a1; }
     .amber { background: #fef3c7; color: #b45309; }
@@ -442,16 +454,7 @@ interface TableGroup {
     .brand-section { margin-bottom: 12px; }
     .side-section-title { color: #111827; font-size: 12px; font-weight: 700; margin-bottom: 8px; }
     .diagnosis-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
-    .side-btn { border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #374151; padding: 8px 10px; font-size: 12px; font-weight: 700; cursor: pointer; }
-    .side-btn.primary { background: #111827; border-color: #111827; color: #fff; }
-    .priority-list { display: flex; flex-direction: column; gap: 8px; }
-    .priority-item { width: 100%; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px; border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; cursor: pointer; text-align: left; transition: all 0.15s; }
-    .priority-item:hover,
-    .priority-item.active { border-color: #f59e0b; box-shadow: 0 8px 18px rgba(245, 158, 11, 0.12); transform: translateY(-1px); }
-    .priority-rank { width: 24px; height: 24px; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; background: #f8fafc; color: #64748b; font-size: 12px; font-weight: 800; }
-    .priority-copy { min-width: 0; }
-    .priority-copy strong { display: block; color: #111827; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-    .priority-copy em { display: block; color: #64748b; font-size: 11px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .priority-action { margin: 0; color: #64748b; font-size: 12px; line-height: 1.6; }
 
     /* 明细表内部样式(外壳由 app-data-table-shell 提供) */
     app-data-table-shell { display: block; }
@@ -490,6 +493,7 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
   private loadSeq = 0;
   private designerOptionsSeq = 0;
   loading = false;
+  loadError = false;
   currentFilters: any = { timeRange: '30days' };
   designerFilterOptions: string[] = [];
 
@@ -502,6 +506,12 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
 
   // ─── 维度状态 ───
   dimension: CategoryDimension = 'category';
+  readonly dimensionTabs: BoardTabItem[] = [
+    { key: 'category', label: '品类', iconKey: 'category' },
+    { key: 'subCategory', label: '子品类', iconKey: 'product' },
+    { key: 'salesRange', label: '销量区间', iconKey: 'trend' },
+    { key: 'negRange', label: '差评率区间', iconKey: 'alert' },
+  ];
   selectedParentCategory = '';   // 子品类视图下的父品类筛选
   hoverBubble: BubblePoint | null = null;
   activeCategory: CategoryVocDensityItem | null = null;
@@ -568,6 +578,7 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
     const seq = ++this.loadSeq;
     this.dataSub?.unsubscribe();
     this.loading = true;
+    this.loadError = false;
     this.dataSub = this.monitoringService.getCategoryVocDensity(this.currentFilters)
       .pipe(takeUntil(this.destroy$))
       .subscribe({
@@ -583,6 +594,7 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
           if (seq !== this.loadSeq) return;
           this.ngZone.run(() => {
             this.loading = false;
+            this.loadError = true;
             this.cdr.markForCheck();
           });
         }
@@ -651,7 +663,8 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
   }
 
   // ─── 维度切换 ───
-  setDimension(d: CategoryDimension): void {
+  setDimension(d: string): void {
+    if (d !== 'category' && d !== 'subCategory' && d !== 'salesRange' && d !== 'negRange') return;
     if (this.dimension === d) return;
     this.dimension = d;
     if (d === 'subCategory' && !this.selectedParentCategory && this.categories.length > 0) {
@@ -665,6 +678,18 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
     if (cat) this.selectCategory(cat);
   }
 
+  onParentCategorySelected(value: string): void {
+    this.selectedParentCategory = value;
+    this.onParentCategoryChange();
+  }
+
+  get parentCategoryOptions(): MsqDropdownOption[] {
+    return this.categories.map((category) => ({
+      value: category.category,
+      label: category.category || '未知',
+    }));
+  }
+
   getDimensionLabel(d: CategoryDimension): string {
     return ({
       category: '品类',
@@ -694,6 +719,25 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
       .slice(0, 5);
   }
 
+  get prioritySections(): PriorityAccordionSection[] {
+    return this.priorityCategories.map((category) => ({
+      id: category.category,
+      title: category.category || '未知',
+      subtitle: `${this.getQuadrantLabel(category)} · 差评率 ${category.negativeRate}%`,
+      tone: this.getCategoryTone(category),
+      badge: this.getPriorityLabel(category),
+    }));
+  }
+
+  selectPriorityCategory(categoryId: string): void {
+    const category = this.getCategoryById(categoryId);
+    if (category) this.selectCategory(category);
+  }
+
+  getCategoryById(categoryId: string): CategoryVocDensityItem | undefined {
+    return this.categories.find((category) => category.category === categoryId);
+  }
+
   // ─── 四象限气泡数据 ───
   get bubblePoints(): BubblePoint[] {
     if (this.dimension === 'subCategory') return [];

+ 50 - 56
src/modules/monitoring/pages/risk-alert/risk-alert.component.ts

@@ -11,15 +11,18 @@ import { FilterBarComponent } from '../../../shared/components/filter-bar/filter
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { BoardTabsComponent, BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component';
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
 import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
 import { ProductIdentityComponent } from '../../../shared/components/product-identity/product-identity.component';
+import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
 
 type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
 
 @Component({
   selector: 'app-risk-alert',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterModule, MsqSearchBoxComponent, FilterBarComponent, PageHeaderComponent, BoardTabsComponent, EmptyStateComponent, LoadingSpinnerComponent, ProductIdentityComponent],
+  imports: [CommonModule, FormsModule, RouterModule, MsqSearchBoxComponent, FilterBarComponent, PageHeaderComponent, BoardTabsComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, NeuButtonComponent, ProductIdentityComponent, SummaryMetricCardComponent],
   template: `
     <div class="risk-alert-container">
       <app-page-header
@@ -29,9 +32,9 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
         icon="🛡️">
         <div pageHeaderActions class="header-right">
           <msq-search-box placeholder="搜索商品ID或名称" [(ngModel)]="searchQuery"></msq-search-box>
-          <button class="refresh-btn" (click)="refreshData()" [disabled]="loading">
+          <app-neu-button size="sm" [disabled]="loading" (btnClick)="refreshData()">
             <span [class.spinning]="loading">🔄</span> 刷新
-          </button>
+          </app-neu-button>
         </div>
       </app-page-header>
 
@@ -49,24 +52,19 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
         (filtersChange)="onFilterChange($event)">
       </app-filter-bar>
 
+      @if (loadError) {
+        <app-error-state
+          title="风险预警加载失败"
+          description="无法读取当前筛选范围内的预警信息。"
+          (retry)="loadData()">
+        </app-error-state>
+      } @else {
       <!-- 预警概览 -->
       <div class="alert-summary-row">
-        <div class="summary-card critical" [class.active-filter]="severityFilter === 'critical'" (click)="toggleSeverityFilter('critical')">
-          <span class="summary-num">{{ criticalCount }}</span>
-          <span class="summary-label">严重</span>
-        </div>
-        <div class="summary-card warning" [class.active-filter]="severityFilter === 'warning'" (click)="toggleSeverityFilter('warning')">
-          <span class="summary-num">{{ warningCount }}</span>
-          <span class="summary-label">警告</span>
-        </div>
-        <div class="summary-card normal" [class.active-filter]="severityFilter === 'normal'" (click)="toggleSeverityFilter('normal')">
-          <span class="summary-num">{{ normalCount }}</span>
-          <span class="summary-label">正常</span>
-        </div>
-        <div class="summary-card total" [class.active-filter]="severityFilter === null" (click)="severityFilter = null">
-          <span class="summary-num">{{ riskItems.length }}</span>
-          <span class="summary-label">全部监控</span>
-        </div>
+        <app-summary-metric-card label="严重" [value]="criticalCount" icon="!" tone="red" [clickable]="true" [emphasis]="severityFilter === 'critical' ? 'primary' : 'default'" (activate)="toggleSeverityFilter('critical')"></app-summary-metric-card>
+        <app-summary-metric-card label="警告" [value]="warningCount" icon="△" tone="amber" [clickable]="true" [emphasis]="severityFilter === 'warning' ? 'primary' : 'default'" (activate)="toggleSeverityFilter('warning')"></app-summary-metric-card>
+        <app-summary-metric-card label="正常" [value]="normalCount" icon="✓" tone="green" [clickable]="true" [emphasis]="severityFilter === 'normal' ? 'primary' : 'default'" (activate)="toggleSeverityFilter('normal')"></app-summary-metric-card>
+        <app-summary-metric-card label="全部监控" [value]="riskItems.length" icon="◎" tone="blue" [clickable]="true" [emphasis]="severityFilter === null ? 'primary' : 'default'" (activate)="severityFilter = null"></app-summary-metric-card>
       </div>
 
       <!-- Tab 切换 -->
@@ -81,12 +79,15 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
       @if (activeTab === 'products') {
         <div class="sort-bar">
           <span class="sort-label">排序:</span>
-          <button class="sort-btn" [class.active]="sortField === 'negativeRate'" (click)="sortField = 'negativeRate'">差评率</button>
-          <button class="sort-btn" [class.active]="sortField === 'totalReviews'" (click)="sortField = 'totalReviews'">评论数</button>
-          <button class="sort-btn" [class.active]="sortField === 'avgStar'" (click)="sortField = 'avgStar'">评分</button>
+          <app-board-tabs
+            [tabs]="sortTabs"
+            [activeKey]="sortField"
+            density="compact"
+            (activeKeyChange)="setSortField($event)">
+          </app-board-tabs>
           <span class="filter-hint" *ngIf="severityFilter">
             筛选: {{ severityFilter === 'critical' ? '严重' : severityFilter === 'warning' ? '警告' : '正常' }}
-            <button class="clear-filter" (click)="severityFilter = null">✕</button>
+            <app-neu-button variant="icon" size="sm" (btnClick)="severityFilter = null">✕</app-neu-button>
           </span>
         </div>
         <div class="risk-product-list">
@@ -115,9 +116,9 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                   <span class="rp-severity-badge" [class]="item.severity">
                     {{ item.severity === 'critical' ? '🔴 严重' : item.severity === 'warning' ? '🟡 警告' : '🟢 正常' }}
                   </span>
-                  <button class="rp-expand-btn" (click)="toggleExpand(item.asin)" title="展开详情">
+                  <app-neu-button size="sm" (btnClick)="toggleExpand(item.asin)">
                     {{ expandedAsin === item.asin ? '收起 ▲' : '详情 ▼' }}
-                  </button>
+                  </app-neu-button>
                 </div>
                 @if (item.designerName || item.operatorName || item.fulfillmentType) {
                   <div class="rp-owner-tags">
@@ -166,9 +167,9 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                 @if (expandedAsin === item.asin) {
                   <div class="rp-detail-panel">
                     <div class="rp-detail-actions">
-                      <button class="action-btn primary" (click)="navigateToVocAnalysis(item.asin)">📊 VOC 深度分析</button>
-                      <button class="action-btn" (click)="navigateToEmotionAnalysis(item.asin)">😊 情感分析</button>
-                      <button class="action-btn" (click)="openProductDetail(item.asin)">商品经营详情</button>
+                      <app-neu-button size="sm" color="primary" (btnClick)="navigateToVocAnalysis(item.asin)">VOC 深度分析</app-neu-button>
+                      <app-neu-button size="sm" (btnClick)="navigateToEmotionAnalysis(item.asin)">情感分析</app-neu-button>
+                      <app-neu-button size="sm" (btnClick)="openProductDetail(item.asin)">商品经营详情</app-neu-button>
                     </div>
                     <div class="rp-detail-info">
                       <span>预警数: <b>{{ item.alerts.length }}</b></span>
@@ -201,7 +202,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                      [style.background]="sc.pct > 25 ? 'linear-gradient(90deg, #fca5a5, #ef4444)' : sc.pct > 10 ? 'linear-gradient(90deg, #fde68a, #f59e0b)' : 'linear-gradient(90deg, #bbf7d0, #22c55e)'"></div>
               </div>
               <div class="sc-meta">
-                <span>{{ sc.reviewCount }} 条投诉 · {{ sc.asinCount }} ASIN</span>
+                <span>{{ sc.reviewCount }} 条投诉 · {{ sc.asinCount }} 个商品</span>
                 <span class="sc-severity-tag" [class]="sc.pct > 25 ? 'high' : sc.pct > 10 ? 'medium' : 'low'">
                   {{ sc.pct > 25 ? '高频' : sc.pct > 10 ? '中频' : '低频' }}
                 </span>
@@ -254,33 +255,18 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
           }
         </div>
       }
+      }
     </div>
   `,
   styles: [`
     .risk-alert-container { padding: 24px; max-width: 1280px; margin: 0 auto; }
 
     .alert-summary-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 20px; }
-    .summary-card { display: flex; flex-direction: column; align-items: center; padding: 16px; border-radius: 12px; border: 1px solid #e5e7eb; background: #fff; cursor: pointer; transition: all 0.15s; }
-    .summary-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
-    .summary-card.active-filter { box-shadow: 0 0 0 2px #6366f1; }
-    .summary-card.critical { border-left: 4px solid #ef4444; }
-    .summary-card.warning { border-left: 4px solid #f59e0b; }
-    .summary-card.normal { border-left: 4px solid #22c55e; }
-    .summary-card.total { border-left: 4px solid #6366f1; }
-    .summary-num { font-size: 28px; font-weight: 800; }
-    .summary-card.critical .summary-num { color: #ef4444; }
-    .summary-card.warning .summary-num { color: #f59e0b; }
-    .summary-card.normal .summary-num { color: #22c55e; }
-    .summary-card.total .summary-num { color: #6366f1; }
-    .summary-label { font-size: 12px; color: #6b7280; margin-top: 2px; }
+    .alert-summary-row app-summary-metric-card { min-width: 0; }
 
     .sort-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
     .sort-label { font-size: 12px; color: #6b7280; font-weight: 500; }
-    .sort-btn { padding: 4px 12px; border: 1px solid #e5e7eb; border-radius: 16px; font-size: 11px; background: #fff; color: #6b7280; cursor: pointer; transition: all 0.15s; }
-    .sort-btn:hover { border-color: #6366f1; color: #6366f1; }
-    .sort-btn.active { background: #6366f1; color: #fff; border-color: #6366f1; }
     .filter-hint { font-size: 11px; color: #6366f1; margin-left: auto; display: flex; align-items: center; gap: 4px; }
-    .clear-filter { background: none; border: none; color: #6366f1; cursor: pointer; font-size: 12px; padding: 0; }
 
     .risk-product-list { display: flex; flex-direction: column; gap: 12px; }
     .risk-product-card { display: flex; gap: 16px; padding: 16px; background: #fff; border-radius: 12px; border: 1px solid #e5e7eb; transition: box-shadow 0.15s; }
@@ -356,23 +342,14 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
     .header-right { display: flex; gap: 8px; align-items: center; }
     .search-input { padding: 6px 14px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; width: 180px; outline: none; transition: border-color 0.15s; }
     .search-input:focus { border-color: #6366f1; }
-    .refresh-btn { padding: 6px 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: all 0.15s; }
-    .refresh-btn:hover:not(:disabled) { border-color: #6366f1; color: #6366f1; }
-    .refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
     .spinning { display: inline-block; animation: spin 0.8s linear infinite; }
 
     .rp-asin-link { text-decoration: none; color: #111827; transition: color 0.15s; }
     .rp-asin-link:hover { color: #6366f1; }
-    .rp-expand-btn { margin-left: auto; padding: 2px 10px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; font-size: 11px; color: #6b7280; cursor: pointer; transition: all 0.15s; }
-    .rp-expand-btn:hover { border-color: #6366f1; color: #6366f1; }
     .risk-product-card.expanded { box-shadow: 0 4px 16px rgba(0,0,0,0.08); border-color: #c7d2fe; }
 
     .rp-detail-panel { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #e5e7eb; animation: fadeIn 0.2s; }
     .rp-detail-actions { display: flex; gap: 8px; margin-bottom: 8px; }
-    .action-btn { padding: 5px 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; font-size: 12px; cursor: pointer; transition: all 0.15s; }
-    .action-btn:hover { border-color: #6366f1; color: #6366f1; background: #eef2ff; }
-    .action-btn.primary { background: #6366f1; color: #fff; border-color: #6366f1; }
-    .action-btn.primary:hover { background: #4f46e5; }
     .rp-detail-info { display: flex; gap: 16px; font-size: 12px; color: #6b7280; }
     .rp-detail-info b.critical { color: #dc2626; }
     .rp-detail-info b.warning { color: #d97706; }
@@ -405,6 +382,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
 export class RiskAlertComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
   loading = true;
+  loadError = false;
   activeTab: RiskAlertTabKey = 'products';
   severityFilter: 'critical' | 'warning' | 'normal' | null = null;
   sortField: 'negativeRate' | 'totalReviews' | 'avgStar' = 'negativeRate';
@@ -453,12 +431,24 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
     ];
   }
 
+  readonly sortTabs: BoardTabItem[] = [
+    { key: 'negativeRate', label: '差评率' },
+    { key: 'totalReviews', label: '评论数' },
+    { key: 'avgStar', label: '评分' },
+  ];
+
   setActiveTab(key: string): void {
     if (key === 'products' || key === 'scenarios' || key === 'spikes') {
       this.activeTab = key;
     }
   }
 
+  setSortField(key: string): void {
+    if (key === 'negativeRate' || key === 'totalReviews' || key === 'avgStar') {
+      this.sortField = key;
+    }
+  }
+
   ngOnInit(): void {
     this.loadData();
   }
@@ -565,6 +555,7 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
 
   loadData(): void {
     this.loading = true;
+    this.loadError = false;
     forkJoin({
       risks: this.monitoringService.getRiskAlertItems(this.currentFilters),
       scenarios: this.monitoringService.getScenarioComplaints(this.currentFilters),
@@ -580,7 +571,10 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
         this.normalCount = risks.filter(r => r.severity === 'normal').length;
         this.loading = false;
       },
-      error: () => { this.loading = false; }
+      error: () => {
+        this.loading = false;
+        this.loadError = true;
+      }
     });
   }
 }

+ 85 - 69
src/modules/monitoring/pages/voc-overview/voc-overview.component.ts

@@ -1,6 +1,6 @@
 import { Component, OnInit, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
-import { RouterModule } from '@angular/router';
+import { Router, RouterModule } from '@angular/router';
 import { Subject, forkJoin } from 'rxjs';
 import { takeUntil, catchError } from 'rxjs/operators';
 import { of } from 'rxjs';
@@ -9,13 +9,18 @@ import { EmotionHealthScore, CoreIssueSummary, KeywordCloud, DesignerRiskRanking
 import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
+import { BoardTabItem, BoardTabsComponent } from '../../../shared/components/board-tabs/board-tabs.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { EntryCardComponent } from '../../../shared/components/entry-card/entry-card.component';
+import { EntryCardGridComponent } from '../../../shared/components/entry-card/entry-card-grid.component';
+import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
 import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
 
 @Component({
   selector: 'app-voc-overview',
   standalone: true,
-  imports: [CommonModule, RouterModule, FilterBarComponent, PageHeaderComponent, SummaryMetricCardComponent, EmptyStateComponent, LoadingSpinnerComponent],
+  imports: [CommonModule, RouterModule, FilterBarComponent, PageHeaderComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, EmptyStateComponent, EntryCardComponent, EntryCardGridComponent, ErrorStateComponent, LoadingSpinnerComponent],
   template: `
     <div class="voc-overview-container">
       <app-page-header
@@ -48,12 +53,15 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
         (filtersChange)="onFilterChange($event)">
       </app-filter-bar>
 
+      @if (loadError) {
+        <app-error-state
+          title="VOC 总览加载失败"
+          description="无法读取当前筛选范围内的 VOC 汇总信息。"
+          (retry)="loadData()">
+        </app-error-state>
+      } @else {
       @if (designerRiskRanking.length > 0) {
-        <div class="panel designer-risk-panel">
-          <div class="panel-header-row">
-            <h2 class="panel-title">设计师风险排行</h2>
-            <span class="panel-subtitle">按严重预警、警告预警与平均差评率排序</span>
-          </div>
+        <app-content-card class="designer-risk-panel" title="设计师风险排行" subtitle="按严重预警、警告预警与平均差评率排序" density="compact" variant="muted">
           <div class="designer-risk-list">
             @for (item of designerRiskRanking; track item.designerName; let i = $index) {
               <div class="designer-risk-row">
@@ -87,14 +95,13 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
               </div>
             }
           </div>
-        </div>
+        </app-content-card>
       }
 
       <!-- 情绪健康度仪表盘 + 核心问题集中度 -->
       <div class="dashboard-row">
         <!-- 左:健康度仪表盘 -->
-        <div class="panel health-gauge-panel">
-          <h2 class="panel-title">情绪健康度仪表盘</h2>
+        <app-content-card class="health-gauge-panel" title="情绪健康度仪表盘" density="compact" variant="muted">
           @if (healthScore && healthScore.totalReviews > 0) {
             <div class="gauge-wrapper">
               <div class="gauge-ring">
@@ -139,17 +146,17 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
           } @else {
             <app-empty-state title="暂无情绪样本" description="页面结构已就绪,当前保持真实空状态。" icon="◌" size="sm"></app-empty-state>
           }
-        </div>
+        </app-content-card>
 
         <!-- 右:核心问题集中度 -->
-        <div class="panel issues-panel">
-          <div class="panel-header-row">
-            <h2 class="panel-title">核心问题集中度</h2>
-            <div class="issue-filter-pills">
-              <button class="pill" [class.active]="issueFilter === 'all'" (click)="issueFilter = 'all'">全部</button>
-              <button class="pill" [class.active]="issueFilter === 'negative'" (click)="issueFilter = 'negative'">负面</button>
-              <button class="pill" [class.active]="issueFilter === 'positive'" (click)="issueFilter = 'positive'">正面</button>
-            </div>
+        <app-content-card class="issues-panel" title="核心问题集中度" [hasHeaderActions]="true" density="compact" variant="muted">
+          <div contentCardActions>
+            <app-board-tabs
+              [tabs]="issueTabs"
+              [activeKey]="issueFilter"
+              density="compact"
+              (activeKeyChange)="setIssueFilter($event)">
+            </app-board-tabs>
           </div>
           @if (filteredIssues.length === 0 && !loading) {
             <app-empty-state title="暂无核心问题数据" icon="📭" size="sm"></app-empty-state>
@@ -171,13 +178,12 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
               </div>
             }
           </div>
-        </div>
+        </app-content-card>
       </div>
 
       <!-- 关键词热度 TOP -->
       <div class="dashboard-row">
-        <div class="panel keyword-panel">
-          <h2 class="panel-title">正向热词 Top5</h2>
+        <app-content-card class="keyword-panel" title="正向热词 Top5" density="compact" variant="muted" tone="green">
           @if (topPositiveKeywords.length === 0 && !loading) {
             <app-empty-state title="暂无数据" icon="📭" size="sm"></app-empty-state>
           }
@@ -193,9 +199,8 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
               </div>
             }
           </div>
-        </div>
-        <div class="panel keyword-panel">
-          <h2 class="panel-title">负向热词 Top5</h2>
+        </app-content-card>
+        <app-content-card class="keyword-panel" title="负向热词 Top5" density="compact" variant="muted" tone="red">
           @if (topNegativeKeywords.length === 0 && !loading) {
             <app-empty-state title="暂无数据" icon="📭" size="sm"></app-empty-state>
           }
@@ -211,30 +216,38 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
               </div>
             }
           </div>
-        </div>
+        </app-content-card>
       </div>
 
       <!-- 快速导航 -->
-      <div class="nav-cards">
-        <a class="nav-card" routerLink="/domestic/risk-alerts">
-          <span class="nav-card-icon">🛡️</span>
-          <span class="nav-card-title">风险预警看板</span>
-          <span class="nav-card-desc">异常波动 · 场景化投诉 · 舆情信号</span>
-          @if (warningCount > 0) {
-            <span class="nav-card-badge critical">{{ warningCount }} 项预警</span>
-          }
-        </a>
-        <a class="nav-card" routerLink="/domestic/category-voc">
-          <span class="nav-card-icon">📦</span>
-          <span class="nav-card-title">品类分布</span>
-          <span class="nav-card-desc">品类VOC密度 · 类目情绪排行</span>
-        </a>
-        <a class="nav-card" routerLink="/voc-insight/voice">
-          <span class="nav-card-icon">◍</span>
-          <span class="nav-card-title">用户声音分类</span>
-          <span class="nav-card-desc">情绪分类 · 评论样本 · 用户表达</span>
-        </a>
-      </div>
+      <app-entry-card-grid columns="3">
+        <app-entry-card
+          title="风险预警看板"
+          description="异常波动 · 场景化投诉 · 舆情信号"
+          icon="🛡️"
+          [badge]="warningCount > 0 ? warningCount + ' 项预警' : ''"
+          cta="查看预警"
+          tone="red"
+          (activate)="navigate('/domestic/risk-alerts')">
+        </app-entry-card>
+        <app-entry-card
+          title="类目 VOC"
+          description="品类 VOC 密度 · 类目情绪排行"
+          icon="📦"
+          cta="查看类目"
+          tone="blue"
+          (activate)="navigate('/domestic/category-voc')">
+        </app-entry-card>
+        <app-entry-card
+          title="用户声音分类"
+          description="情绪分类 · 评论样本 · 用户表达"
+          icon="◍"
+          cta="查看声音"
+          tone="purple"
+          (activate)="navigate('/voc-insight/voice')">
+        </app-entry-card>
+      </app-entry-card-grid>
+      }
     </div>
   `,
   styles: [`
@@ -243,11 +256,7 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
     .metrics-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 24px; }
 
     .dashboard-row { display: grid; grid-template-columns: 1fr 1.5fr; gap: 20px; margin-bottom: 24px; }
-    .panel { background: #fff; border-radius: 12px; border: 1px solid #e5e7eb; padding: 20px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
-    .panel-title { font-size: 15px; font-weight: 700; color: #111827; margin: 0 0 16px; }
-    .panel-subtitle { font-size: 12px; color: #6b7280; }
-    .panel-header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
-    .panel-header-row .panel-title { margin: 0; }
+    .dashboard-row app-content-card { min-width: 0; }
 
     .designer-risk-panel { margin-bottom: 24px; }
     .designer-risk-list { display: flex; flex-direction: column; gap: 10px; }
@@ -288,11 +297,6 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
     .legend-item.warn { background: #fef3c7; color: #d97706; }
     .legend-item.bad { background: #fee2e2; color: #dc2626; }
 
-    .issue-filter-pills { display: flex; gap: 4px; }
-    .pill { padding: 4px 12px; border: 1px solid #e5e7eb; border-radius: 16px; font-size: 11px; background: #fff; color: #6b7280; cursor: pointer; transition: all 0.15s; }
-    .pill:hover { border-color: #6366f1; color: #6366f1; }
-    .pill.active { background: #6366f1; color: #fff; border-color: #6366f1; }
-
     .issues-list { display: flex; flex-direction: column; gap: 8px; }
     .issue-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
     .issue-row.top-issue { background: #fef2f2; margin: 0 -8px; padding: 6px 8px; border-radius: 8px; }
@@ -306,7 +310,6 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
     .issue-pct { font-size: 12px; color: #6b7280; font-weight: 600; min-width: 32px; text-align: right; }
     .issue-count { font-size: 11px; color: #9ca3af; min-width: 40px; }
 
-    .keyword-panel .panel-title { margin-bottom: 12px; }
     .kw-list { display: flex; flex-direction: column; gap: 8px; }
     .kw-row { display: flex; align-items: center; gap: 10px; }
     .kw-rank { width: 24px; height: 24px; border-radius: 50%; background: #f3f4f6; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: #6b7280; flex-shrink: 0; }
@@ -321,26 +324,22 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
     .pos-text { color: #22c55e; }
     .neg-text { color: #ef4444; }
 
-    .nav-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
-    .nav-card { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 24px 16px; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; text-decoration: none; transition: all 0.2s; cursor: pointer; text-align: center; position: relative; }
-    .nav-card:hover { border-color: #3b82f6; box-shadow: 0 4px 12px rgba(59,130,246,0.1); transform: translateY(-2px); }
-    .nav-card-icon { font-size: 32px; }
-    .nav-card-title { font-size: 15px; font-weight: 700; color: #111827; }
-    .nav-card-desc { font-size: 12px; color: #6b7280; }
-    .nav-card-badge { position: absolute; top: 10px; right: 10px; font-size: 10px; padding: 2px 8px; border-radius: 8px; font-weight: 600; }
-    .nav-card-badge.critical { background: #fee2e2; color: #dc2626; }
-
     @media (max-width: 900px) {
       .metrics-row { grid-template-columns: repeat(3, 1fr); }
       .dashboard-row { grid-template-columns: 1fr; }
-      .nav-cards { grid-template-columns: 1fr; }
     }
   `]
 })
 export class VocOverviewComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
   loading = true;
+  loadError = false;
   issueFilter: 'all' | 'negative' | 'positive' = 'all';
+  readonly issueTabs: BoardTabItem[] = [
+    { key: 'all', label: '全部' },
+    { key: 'negative', label: '负面' },
+    { key: 'positive', label: '正面' },
+  ];
   currentFilters: any = { timeRange: '30days' };
 
   vocCount = 0;
@@ -352,7 +351,10 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
   topNegativeKeywords: KeywordCloud[] = [];
   designerRiskRanking: DesignerRiskRankingItem[] = [];
 
-  constructor(private monitoringService: DomesticMonitoringViewService) {}
+  constructor(
+    private monitoringService: DomesticMonitoringViewService,
+    private router: Router,
+  ) {}
 
   ngOnInit(): void {
     this.loadData();
@@ -380,6 +382,7 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
 
   loadData(): void {
     this.loading = true;
+    this.loadError = false;
     forkJoin({
       metrics: this.monitoringService.getVocMetrics(this.currentFilters),
       health: this.monitoringService.getEmotionHealthScore(this.currentFilters),
@@ -404,10 +407,23 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
         this.designerRiskRanking = designerRisks || [];
         this.loading = false;
       },
-      error: () => { this.loading = false; }
+      error: () => {
+        this.loading = false;
+        this.loadError = true;
+      }
     });
   }
 
+  setIssueFilter(key: string): void {
+    if (key === 'all' || key === 'negative' || key === 'positive') {
+      this.issueFilter = key;
+    }
+  }
+
+  navigate(route: string): void {
+    this.router.navigateByUrl(route);
+  }
+
   get filteredIssues(): CoreIssueSummary[] {
     if (this.issueFilter === 'all') return this.coreIssues;
     return this.coreIssues.filter(i => i.sentiment === this.issueFilter);