1498 lines
33 KiB
Vue
1498 lines
33 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
AlertTriangle,
|
||
BarChart3,
|
||
ChevronDown,
|
||
ClipboardList,
|
||
Factory,
|
||
FileWarning,
|
||
LoaderCircle,
|
||
RefreshCw,
|
||
ShieldCheck,
|
||
TrendingUp,
|
||
} from "@lucide/vue";
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
|
||
import { getDashboard } from "@/services/dashboard.service";
|
||
import type { DashboardData, DashboardYear } from "@/types/dashboard";
|
||
|
||
const router = useRouter();
|
||
const year = ref<DashboardYear>("all");
|
||
const data = ref<DashboardData | null>(null);
|
||
const loading = ref(false);
|
||
const error = ref("");
|
||
const selected = ref("");
|
||
const metricIcons = [
|
||
ClipboardList,
|
||
FileWarning,
|
||
Factory,
|
||
TrendingUp,
|
||
ShieldCheck,
|
||
];
|
||
const metricTones = ["neutral", "red", "amber", "green", "blue"];
|
||
const defectColors: Record<string, string> = {
|
||
A: "#dc2626",
|
||
B: "#fde68a",
|
||
C: "#bbf7d0",
|
||
D: "#e5e7eb",
|
||
};
|
||
|
||
const maxStage = computed(() =>
|
||
Math.max(...(data.value?.stages.map((item) => item.count) ?? [1]), 1),
|
||
);
|
||
const maxCategory = computed(() =>
|
||
Math.max(...(data.value?.categories.map((item) => item.count) ?? [1]), 1),
|
||
);
|
||
const maxSupplier = computed(() =>
|
||
Math.max(
|
||
...(data.value?.supplierRanking.map((item) => item.count) ?? [1]),
|
||
1,
|
||
),
|
||
);
|
||
const maxTrend = computed(() =>
|
||
Math.max(...(data.value?.trend.map((item) => item.count) ?? [1]), 1),
|
||
);
|
||
const totalDefects = computed(
|
||
() => data.value?.defects.reduce((sum, item) => sum + item.count, 0) ?? 0,
|
||
);
|
||
const donutStyle = computed(() => {
|
||
const items = data.value?.defects ?? [];
|
||
const total = totalDefects.value || 1;
|
||
let cursor = 0;
|
||
const stops = items.map((item) => {
|
||
const start = cursor;
|
||
cursor += (item.count / total) * 360;
|
||
return `${defectColors[item.level] ?? defectColors.D} ${start}deg ${cursor}deg`;
|
||
});
|
||
return { background: `conic-gradient(${stops.join(", ")})` };
|
||
});
|
||
|
||
function formatNumber(value: number | null | undefined) {
|
||
return value === null || value === undefined
|
||
? "--"
|
||
: value.toLocaleString("zh-CN");
|
||
}
|
||
function formatChange(value: number | null) {
|
||
return value === null
|
||
? "暂无同比"
|
||
: `${value >= 0 ? "↑" : "↓"}${Math.abs(value)}%`;
|
||
}
|
||
function tierClass(tier: string) {
|
||
return `tier-${tier}`;
|
||
}
|
||
function riskClass(level: string) {
|
||
if (level === "高风险") return "risk-high";
|
||
if (level === "中高") return "risk-elevated";
|
||
if (level === "中等") return "risk-medium";
|
||
return "risk-low";
|
||
}
|
||
function heatClass(value: number, max: number) {
|
||
if (!value) return "heat-0";
|
||
const ratio = value / Math.max(max, 1);
|
||
return ratio >= 0.75
|
||
? "heat-4"
|
||
: ratio >= 0.5
|
||
? "heat-3"
|
||
: ratio >= 0.25
|
||
? "heat-2"
|
||
: "heat-1";
|
||
}
|
||
function rankClass(index: number) {
|
||
return index < 2 ? "rank-red" : index < 4 ? "rank-amber" : "rank-blue";
|
||
}
|
||
function selectDrilldown(label: string) {
|
||
selected.value = label;
|
||
}
|
||
function openAssessment(label: string) {
|
||
router.push({ name: "assessment", query: { dashboardFilter: label } });
|
||
}
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = "";
|
||
try {
|
||
data.value = await getDashboard(year.value);
|
||
} catch {
|
||
error.value = "看板数据加载失败,请确认后端服务已启动。";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
async function changeYear(value: DashboardYear) {
|
||
year.value = value;
|
||
await load();
|
||
}
|
||
onMounted(() => void load());
|
||
</script>
|
||
|
||
<template>
|
||
<div class="dashboard-page">
|
||
<header class="dashboard-header">
|
||
<div>
|
||
<p class="eyebrow">RISK INTELLIGENCE</p>
|
||
<h1>数据看板 <small>v2.3</small></h1>
|
||
<p class="dashboard-subtitle">
|
||
历史异常、供应商风险与标准覆盖的全局视图
|
||
</p>
|
||
</div>
|
||
<div class="year-filter" role="group" aria-label="年份筛选">
|
||
<button
|
||
v-for="item in [
|
||
['all', '全部'],
|
||
['2024', '2024'],
|
||
['2025', '2025'],
|
||
['2026', '2026'],
|
||
] as const"
|
||
:key="item[0]"
|
||
type="button"
|
||
:class="{ active: year === item[0] }"
|
||
@click="changeYear(item[0])"
|
||
>
|
||
{{ item[1] }}</button
|
||
><ChevronDown :size="14" />
|
||
</div>
|
||
</header>
|
||
<div v-if="loading" class="dashboard-state">
|
||
<LoaderCircle :size="18" class="spin" />正在加载看板数据…
|
||
</div>
|
||
<div v-else-if="error" class="dashboard-state dashboard-state--error">
|
||
<AlertTriangle :size="18" />{{ error
|
||
}}<button type="button" @click="load">
|
||
<RefreshCw :size="14" />重试
|
||
</button>
|
||
</div>
|
||
<template v-else-if="data">
|
||
<section class="stats-row">
|
||
<article
|
||
v-for="(metric, index) in data.metrics"
|
||
:key="metric.label"
|
||
class="stat-card"
|
||
>
|
||
<span class="stat-icon" :class="`stat-icon--${metricTones[index]}`"
|
||
><component :is="metricIcons[index]" :size="17"
|
||
/></span>
|
||
<div class="stat-copy">
|
||
<span>{{ metric.label }}</span
|
||
><strong
|
||
>{{
|
||
metric.label === "预测准确率"
|
||
? "--"
|
||
: formatNumber(metric.value)
|
||
}}<small v-if="metric.label === '预测准确率'"
|
||
>暂无对照数据</small
|
||
></strong
|
||
>
|
||
</div>
|
||
<em
|
||
v-if="metric.changePercent !== null"
|
||
:class="metric.changePercent >= 0 ? 'change-up' : 'change-down'"
|
||
>{{ formatChange(metric.changePercent) }}</em
|
||
>
|
||
</article>
|
||
</section>
|
||
<div v-if="selected" class="selection-bar">
|
||
当前查看:<b>{{ selected }}</b
|
||
><button type="button" @click="selected = ''">清除</button
|
||
><button type="button" @click="openAssessment(selected)">
|
||
进入对话分析
|
||
</button>
|
||
</div>
|
||
<section class="grid-two dashboard-primary-grid">
|
||
<article class="panel">
|
||
<header class="panel-header">
|
||
<div>
|
||
<BarChart3 :size="15" />
|
||
<h2>五阶段风险热力图</h2>
|
||
</div>
|
||
<span>异常数量 / 占比</span>
|
||
</header>
|
||
<div class="stage-list">
|
||
<button
|
||
v-for="item in data.stages"
|
||
:key="item.name"
|
||
type="button"
|
||
class="stage-row"
|
||
@click="selectDrilldown(item.name)"
|
||
>
|
||
<span>{{ item.name }}</span
|
||
><span class="bar-track" :class="riskClass(item.riskLevel)"
|
||
><i
|
||
:class="riskClass(item.riskLevel)"
|
||
:style="{
|
||
width: `${Math.max(3, (item.count / maxStage) * 100)}%`,
|
||
}"
|
||
></i
|
||
><b
|
||
>{{ formatNumber(item.count) }} 条 · {{ item.share }}%</b
|
||
></span
|
||
>
|
||
</button>
|
||
</div>
|
||
<p class="panel-note">
|
||
产品开发阶段是风险高发期,点击阶段可带入对话继续分析。
|
||
</p>
|
||
</article>
|
||
<article class="panel">
|
||
<header class="panel-header">
|
||
<div>
|
||
<BarChart3 :size="15" />
|
||
<h2>品类维度风险分布</h2>
|
||
<span class="new-tag">新</span>
|
||
</div>
|
||
<span>全部分类 · 默认展示 5 条</span>
|
||
</header>
|
||
<div class="category-list">
|
||
<button
|
||
v-for="item in data.categories"
|
||
:key="item.name"
|
||
type="button"
|
||
class="category-row"
|
||
@click="selectDrilldown(item.name)"
|
||
>
|
||
<span class="category-name">{{ item.name }}</span
|
||
><span class="bar-track" :class="riskClass(item.riskLevel)"
|
||
><i
|
||
:class="riskClass(item.riskLevel)"
|
||
:style="{
|
||
width: `${Math.max(3, (item.count / maxCategory) * 100)}%`,
|
||
}"
|
||
></i
|
||
><b
|
||
>{{ formatNumber(item.count) }} 条 · {{ item.riskLevel }}</b
|
||
></span
|
||
>
|
||
</button>
|
||
</div>
|
||
<p class="panel-note">
|
||
产品名称按扩展品类字典归并;仅缺少产品品类但有物料字段的记录归入包材/物料专项,缺少必要字段的记录归入待补标品类。
|
||
</p>
|
||
</article>
|
||
</section>
|
||
<section class="panel panel-wide">
|
||
<header class="panel-header">
|
||
<div>
|
||
<Factory :size="15" />
|
||
<h2>供应商 / 加工厂 × 品类交叉热力图</h2>
|
||
<span class="new-tag">新</span>
|
||
</div>
|
||
<span>全部主体 × 全部品类 · 默认展示 5 条</span>
|
||
</header>
|
||
<div class="table-scroll matrix-scroll">
|
||
<table class="cross-table">
|
||
<thead>
|
||
<tr>
|
||
<th>主体</th>
|
||
<th v-for="category in data.matrixCategories" :key="category">
|
||
{{ category.replace("/洗护类", "").replace("类", "") }}
|
||
</th>
|
||
<th>合作总数</th>
|
||
<th>异常率</th>
|
||
<th>层级</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="row in data.matrix"
|
||
:key="`${row.partyType}-${row.name}`"
|
||
>
|
||
<th>
|
||
<button type="button" @click="selectDrilldown(row.name)">
|
||
{{ row.name }}<small>{{ row.partyType }}</small>
|
||
</button>
|
||
</th>
|
||
<td v-for="category in data.matrixCategories" :key="category">
|
||
<button
|
||
type="button"
|
||
:class="
|
||
heatClass(
|
||
row.categories[category] ?? 0,
|
||
row.cooperationTotal,
|
||
)
|
||
"
|
||
@click="selectDrilldown(`${row.name} · ${category}`)"
|
||
>
|
||
{{ row.categories[category] || "--" }}
|
||
</button>
|
||
</td>
|
||
<td>
|
||
<b>{{ formatNumber(row.cooperationTotal) }}</b>
|
||
</td>
|
||
<td>
|
||
<b>{{ row.anomalyRate }}%</b>
|
||
</td>
|
||
<td>
|
||
<span class="tier-badge" :class="tierClass(row.tier)">{{
|
||
row.tier
|
||
}}</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p class="panel-note">
|
||
色块越深表示对应品类异常频次越高;异常率按异常记录数 ÷ 合作总数计算。
|
||
</p>
|
||
</section>
|
||
<section class="grid-two">
|
||
<article class="panel">
|
||
<header class="panel-header">
|
||
<div>
|
||
<FileWarning :size="15" />
|
||
<h2>缺陷等级分布</h2>
|
||
</div>
|
||
<span>共 {{ formatNumber(totalDefects) }} 条</span>
|
||
</header>
|
||
<div class="defect-content">
|
||
<div class="donut" :style="donutStyle">
|
||
<div>
|
||
<strong>{{ formatNumber(totalDefects) }}</strong
|
||
><span>总异常</span>
|
||
</div>
|
||
</div>
|
||
<div class="defect-legend">
|
||
<button
|
||
v-for="item in data.defects"
|
||
:key="item.level"
|
||
type="button"
|
||
@click="selectDrilldown(item.label)"
|
||
>
|
||
<i :style="{ background: defectColors[item.level] }"></i
|
||
>{{ item.label }}<b>{{ item.share }}% · {{ item.count }}</b>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="panel-note">
|
||
未标注缺陷等级的早期记录按 D 级/未标注展示,保留原始数据。
|
||
</p>
|
||
</article>
|
||
<article class="panel">
|
||
<header class="panel-header">
|
||
<div>
|
||
<TrendingUp :size="15" />
|
||
<h2>高风险供应商 / 加工厂排行</h2>
|
||
<span class="new-tag">新</span>
|
||
</div>
|
||
<span>全部主体 · 默认展示 5 条</span>
|
||
</header>
|
||
<div class="rank-list">
|
||
<button
|
||
v-for="(item, index) in data.supplierRanking"
|
||
:key="`${item.partyType}-${item.name}`"
|
||
type="button"
|
||
class="rank-row"
|
||
@click="selectDrilldown(item.name)"
|
||
>
|
||
<span class="rank-number">{{ index + 1 }}</span
|
||
><span class="rank-name"
|
||
>{{ item.name }}<small>{{ item.partyType }}</small></span
|
||
><span class="rank-track"
|
||
><i
|
||
:class="rankClass(index)"
|
||
:style="{ width: `${(item.count / maxSupplier) * 100}%` }"
|
||
></i></span
|
||
><b>{{ item.count }}</b
|
||
><span class="tier-badge rank-tier" :class="rankClass(index)">{{
|
||
item.tier
|
||
}}</span>
|
||
</button>
|
||
</div>
|
||
<p class="panel-note">
|
||
层级按异常记录次数和 A 级缺陷判定;点击主体可继续进入对话分析。
|
||
</p>
|
||
</article>
|
||
</section>
|
||
<section class="panel panel-wide">
|
||
<header class="panel-header">
|
||
<div>
|
||
<TrendingUp :size="15" />
|
||
<h2>月度异常趋势</h2>
|
||
</div>
|
||
<span>近 12 个月</span>
|
||
</header>
|
||
<div v-if="!data.trend.length" class="empty-state">
|
||
该时间段暂无数据
|
||
</div>
|
||
<div v-else class="trend-chart">
|
||
<button
|
||
v-for="item in data.trend"
|
||
:key="item.month"
|
||
type="button"
|
||
class="trend-column"
|
||
@click="selectDrilldown(item.month)"
|
||
>
|
||
<span class="trend-value">{{ item.count }}</span
|
||
><i
|
||
:class="{ latest: item === data.trend[data.trend.length - 1] }"
|
||
:style="{
|
||
height: `${Math.max(3, (item.count / maxTrend) * 100)}%`,
|
||
}"
|
||
></i
|
||
><small>{{ item.label }}</small>
|
||
</button>
|
||
</div>
|
||
<div class="insight">
|
||
<TrendingUp :size="15" /><span>{{ data.insight }}</span>
|
||
</div>
|
||
</section>
|
||
<section v-if="data.warnings.length" class="warning-list">
|
||
<div v-for="warning in data.warnings" :key="warning">
|
||
<AlertTriangle :size="14" />{{ warning }}
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dashboard-page {
|
||
width: min(1320px, 100%);
|
||
margin: 0 auto;
|
||
padding: 28px 34px 48px;
|
||
overflow: auto;
|
||
}
|
||
.dashboard-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
margin-bottom: 22px;
|
||
}
|
||
.dashboard-header h1 {
|
||
margin: 4px 0 3px;
|
||
color: var(--color-text);
|
||
font-size: 24px;
|
||
font-weight: 750;
|
||
}
|
||
.dashboard-header h1 small {
|
||
color: var(--color-text-faint);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
.dashboard-subtitle {
|
||
margin: 0;
|
||
color: var(--color-text-muted);
|
||
font-size: 11px;
|
||
}
|
||
.year-filter {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
padding: 3px;
|
||
background: var(--color-surface);
|
||
}
|
||
.year-filter button {
|
||
min-width: 46px;
|
||
height: 29px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
font-size: 11px;
|
||
}
|
||
.year-filter button.active {
|
||
background: var(--color-text);
|
||
color: white;
|
||
}
|
||
.year-filter svg {
|
||
margin: 0 5px;
|
||
color: var(--color-text-faint);
|
||
}
|
||
.stats-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 7px;
|
||
background: var(--color-surface);
|
||
}
|
||
.stat-card {
|
||
display: flex;
|
||
min-width: 0;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 16px 14px;
|
||
}
|
||
.stat-card + .stat-card {
|
||
border-left: 1px solid var(--color-border-light);
|
||
}
|
||
.stat-icon {
|
||
display: grid;
|
||
width: 35px;
|
||
height: 35px;
|
||
flex: 0 0 35px;
|
||
place-items: center;
|
||
border-radius: 6px;
|
||
}
|
||
.stat-icon--neutral {
|
||
background: var(--color-bg-warm);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.stat-icon--red {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.stat-icon--amber {
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
}
|
||
.stat-icon--green {
|
||
background: var(--color-success-soft);
|
||
color: var(--color-success);
|
||
}
|
||
.stat-icon--blue {
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-accent);
|
||
}
|
||
.stat-copy {
|
||
display: grid;
|
||
min-width: 0;
|
||
gap: 2px;
|
||
}
|
||
.stat-copy > span {
|
||
overflow: hidden;
|
||
color: var(--color-text-muted);
|
||
font-size: 10px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.stat-copy strong {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 4px;
|
||
color: var(--color-text);
|
||
font-size: 19px;
|
||
line-height: 1.1;
|
||
}
|
||
.stat-copy strong small {
|
||
color: var(--color-text-faint);
|
||
font-size: 8px;
|
||
font-weight: 400;
|
||
}
|
||
.stat-card em {
|
||
margin-left: auto;
|
||
border-radius: 4px;
|
||
padding: 2px 5px;
|
||
font-size: 9px;
|
||
font-style: normal;
|
||
font-weight: 650;
|
||
white-space: nowrap;
|
||
}
|
||
.change-up {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.change-down {
|
||
background: var(--color-success-soft);
|
||
color: var(--color-success);
|
||
}
|
||
.grid-two {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.dashboard-primary-grid {
|
||
grid-template-rows: 356px;
|
||
margin-top: 0;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.dashboard-primary-grid > .panel {
|
||
display: flex;
|
||
height: 356px;
|
||
min-height: 0;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.dashboard-primary-grid .stage-list,
|
||
.dashboard-primary-grid .category-list {
|
||
min-height: 0;
|
||
}
|
||
|
||
.dashboard-primary-grid .stage-list {
|
||
flex: 1;
|
||
align-content: start;
|
||
}
|
||
|
||
.dashboard-primary-grid .category-list {
|
||
flex: 1;
|
||
max-height: 180px;
|
||
overflow-y: auto;
|
||
align-content: start;
|
||
scrollbar-color: var(--color-border-strong) transparent;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.dashboard-primary-grid .category-list::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.dashboard-primary-grid .category-list::-webkit-scrollbar-thumb {
|
||
border-radius: 4px;
|
||
background: var(--color-border-strong);
|
||
}
|
||
.panel {
|
||
min-width: 0;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 7px;
|
||
padding: 18px;
|
||
background: var(--color-surface);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.panel-wide {
|
||
margin-top: 14px;
|
||
}
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
margin-bottom: 15px;
|
||
}
|
||
.panel-header > div {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.panel-header svg {
|
||
color: var(--color-brand);
|
||
}
|
||
.panel-header h2 {
|
||
margin: 0;
|
||
color: var(--color-text);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
.panel-header > span {
|
||
color: var(--color-text-faint);
|
||
font-size: 9px;
|
||
white-space: nowrap;
|
||
}
|
||
.new-tag {
|
||
border-radius: 3px;
|
||
padding: 1px 4px;
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-accent) !important;
|
||
font-size: 8px !important;
|
||
font-weight: 700;
|
||
}
|
||
.stage-list,
|
||
.category-list {
|
||
display: grid;
|
||
gap: 9px;
|
||
}
|
||
.stage-row,
|
||
.category-row {
|
||
display: grid;
|
||
min-height: 28px;
|
||
grid-template-columns: 92px minmax(80px, 1fr) 44px 38px;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
text-align: left;
|
||
}
|
||
.category-row {
|
||
grid-template-columns: 112px minmax(80px, 1fr) 44px 40px;
|
||
}
|
||
.stage-row > span:first-child,
|
||
.category-name {
|
||
overflow: hidden;
|
||
font-size: 10px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.bar-track,
|
||
.rank-track {
|
||
display: block;
|
||
height: 10px;
|
||
overflow: hidden;
|
||
border-radius: 3px;
|
||
background: var(--color-bg-warm);
|
||
}
|
||
.bar-track i,
|
||
.rank-track i {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
}
|
||
.risk-high {
|
||
background: var(--color-danger);
|
||
}
|
||
.risk-medium {
|
||
background: var(--color-warning);
|
||
}
|
||
.risk-low {
|
||
background: var(--color-success);
|
||
}
|
||
.risk-elevated {
|
||
background: #f97316;
|
||
}
|
||
.risk-moderately-low {
|
||
background: var(--color-accent);
|
||
}
|
||
.stage-row b,
|
||
.category-row b {
|
||
color: var(--color-text);
|
||
font-size: 10px;
|
||
text-align: right;
|
||
}
|
||
.stage-row small {
|
||
color: var(--color-text-faint);
|
||
font-size: 9px;
|
||
text-align: right;
|
||
}
|
||
.category-row em {
|
||
color: var(--color-text-muted);
|
||
font-size: 9px;
|
||
font-style: normal;
|
||
text-align: right;
|
||
}
|
||
.category-row em.risk-high {
|
||
color: var(--color-danger);
|
||
}
|
||
.category-row em.risk-medium {
|
||
color: var(--color-warning);
|
||
}
|
||
.category-row em.risk-low {
|
||
color: var(--color-success);
|
||
}
|
||
.panel-note {
|
||
margin: 14px 0 0;
|
||
border-top: 1px dashed var(--color-border-light);
|
||
padding-top: 9px;
|
||
color: var(--color-text-faint);
|
||
font-size: 10px;
|
||
line-height: 1.55;
|
||
}
|
||
.table-scroll {
|
||
overflow-x: auto;
|
||
}
|
||
.matrix-scroll {
|
||
max-height: 244px;
|
||
overflow-y: auto;
|
||
scrollbar-color: var(--color-border-strong) transparent;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.matrix-scroll::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.matrix-scroll::-webkit-scrollbar-thumb {
|
||
border-radius: 4px;
|
||
background: var(--color-border-strong);
|
||
}
|
||
|
||
.matrix-scroll {
|
||
max-height: 244px;
|
||
overflow-y: auto;
|
||
scrollbar-color: var(--color-border-strong) transparent;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.matrix-scroll::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.matrix-scroll::-webkit-scrollbar-thumb {
|
||
border-radius: 4px;
|
||
background: var(--color-border-strong);
|
||
}
|
||
|
||
.cross-table {
|
||
width: 100%;
|
||
min-width: 780px;
|
||
border-collapse: collapse;
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.cross-table th,
|
||
.cross-table td {
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
padding: 8px 7px;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
.cross-table thead th {
|
||
color: var(--color-text-faint);
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
}
|
||
.cross-table tbody th {
|
||
text-align: left;
|
||
}
|
||
.cross-table tbody th button {
|
||
display: grid;
|
||
border: 0;
|
||
background: transparent;
|
||
color: #111;
|
||
font-size: 12px;
|
||
text-align: left;
|
||
}
|
||
.cross-table tbody th small {
|
||
margin-top: 2px;
|
||
color: #555;
|
||
font-size: 10px;
|
||
}
|
||
.cross-table td button {
|
||
min-width: 38px;
|
||
border: 0;
|
||
border-radius: 3px;
|
||
padding: 5px 4px;
|
||
color: #111;
|
||
font-size: 11px;
|
||
}
|
||
.heat-0 {
|
||
background: var(--color-bg-warm);
|
||
color: var(--color-text-faint) !important;
|
||
}
|
||
.heat-1 {
|
||
background: #f0fdf4;
|
||
color: #065f46;
|
||
}
|
||
.heat-2 {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
.heat-3 {
|
||
background: #fed7aa;
|
||
color: #9a3412;
|
||
}
|
||
.heat-4 {
|
||
background: #fecaca;
|
||
color: #991b1b !important;
|
||
}
|
||
.rank-red {
|
||
background: var(--color-danger);
|
||
}
|
||
.rank-amber {
|
||
background: var(--color-warning);
|
||
}
|
||
.rank-blue {
|
||
background: var(--color-accent);
|
||
}
|
||
.rank-tier.rank-red {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.rank-tier.rank-amber {
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
}
|
||
.rank-tier.rank-blue {
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-accent);
|
||
}
|
||
.tier-badge {
|
||
display: inline-block;
|
||
min-width: 26px;
|
||
border-radius: 4px;
|
||
padding: 3px 5px;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
}
|
||
.tier-S {
|
||
background: var(--color-success-soft);
|
||
color: var(--color-success);
|
||
}
|
||
.tier-A {
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-accent);
|
||
}
|
||
.tier-B {
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
}
|
||
.tier-C {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.defect-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 25px;
|
||
}
|
||
.donut {
|
||
display: grid;
|
||
width: 140px;
|
||
height: 140px;
|
||
flex: 0 0 140px;
|
||
place-items: center;
|
||
border-radius: 50%;
|
||
}
|
||
.donut > div {
|
||
display: grid;
|
||
width: 91px;
|
||
height: 91px;
|
||
place-items: center;
|
||
align-content: center;
|
||
border-radius: 50%;
|
||
background: var(--color-surface);
|
||
}
|
||
.donut strong {
|
||
color: var(--color-text);
|
||
font-size: 18px;
|
||
}
|
||
.donut span {
|
||
color: var(--color-text-faint);
|
||
font-size: 9px;
|
||
}
|
||
.defect-legend {
|
||
display: grid;
|
||
min-width: 0;
|
||
flex: 1;
|
||
gap: 9px;
|
||
}
|
||
.defect-legend button {
|
||
display: grid;
|
||
grid-template-columns: 9px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 6px;
|
||
border: 0;
|
||
background: transparent;
|
||
color: #111;
|
||
font-size: 12px;
|
||
text-align: left;
|
||
}
|
||
.defect-legend i {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
}
|
||
.defect-legend b {
|
||
color: #111;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
.rank-list {
|
||
display: grid;
|
||
gap: 7px;
|
||
max-height: 190px;
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
scrollbar-color: var(--color-border-strong) transparent;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.rank-list::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.rank-list::-webkit-scrollbar-thumb {
|
||
border-radius: 4px;
|
||
background: var(--color-border-strong);
|
||
}
|
||
.rank-row {
|
||
display: grid;
|
||
grid-template-columns: 22px 92px minmax(70px, 1fr) 30px 29px;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border: 0;
|
||
background: transparent;
|
||
text-align: left;
|
||
}
|
||
.rank-number {
|
||
color: #111;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
}
|
||
.rank-name {
|
||
display: grid;
|
||
color: #111;
|
||
font-size: 12px;
|
||
}
|
||
.rank-name small {
|
||
margin-top: 2px;
|
||
color: #555;
|
||
font-size: 10px;
|
||
}
|
||
.rank-row > b {
|
||
color: #111;
|
||
font-size: 12px;
|
||
text-align: right;
|
||
}
|
||
.rank-track {
|
||
height: 8px;
|
||
}
|
||
.trend-chart {
|
||
display: flex;
|
||
height: 190px;
|
||
align-items: flex-end;
|
||
gap: 10px;
|
||
overflow-x: auto;
|
||
border-bottom: 1px solid var(--color-border);
|
||
padding: 0 7px;
|
||
}
|
||
.trend-column {
|
||
display: grid;
|
||
min-width: 42px;
|
||
height: 100%;
|
||
grid-template-rows: 21px minmax(30px, 1fr) 24px;
|
||
align-items: end;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--color-text-faint);
|
||
}
|
||
.trend-value {
|
||
color: #111;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
}
|
||
.trend-column i {
|
||
display: block;
|
||
width: 25px;
|
||
justify-self: center;
|
||
border-radius: 4px 4px 0 0;
|
||
background: var(--color-accent);
|
||
}
|
||
.trend-column i.latest {
|
||
background: var(--color-danger);
|
||
}
|
||
.trend-column small {
|
||
padding-top: 7px;
|
||
font-size: 9px;
|
||
text-align: center;
|
||
}
|
||
.insight {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin-top: 12px;
|
||
border-radius: 5px;
|
||
padding: 9px 11px;
|
||
background: var(--color-bg-warm);
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.insight svg {
|
||
flex: 0 0 auto;
|
||
color: var(--color-accent);
|
||
}
|
||
.warning-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
margin-top: 14px;
|
||
}
|
||
.warning-list div {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
border: 1px solid var(--color-warning-border);
|
||
border-radius: 5px;
|
||
padding: 8px 10px;
|
||
background: var(--color-warning-soft);
|
||
color: #92400e;
|
||
font-size: 10px;
|
||
}
|
||
.dashboard-state {
|
||
display: flex;
|
||
min-height: 180px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 7px;
|
||
color: var(--color-text-muted);
|
||
font-size: 12px;
|
||
}
|
||
.dashboard-state--error {
|
||
flex-wrap: wrap;
|
||
color: var(--color-danger);
|
||
}
|
||
.dashboard-state button,
|
||
.selection-bar button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
padding: 5px 8px;
|
||
background: var(--color-surface);
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.spin {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
.selection-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin-top: 12px;
|
||
border: 1px solid var(--color-accent-soft);
|
||
border-radius: 5px;
|
||
padding: 8px 10px;
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.selection-bar b {
|
||
color: var(--color-accent);
|
||
}
|
||
.selection-bar button:first-of-type {
|
||
margin-left: auto;
|
||
}
|
||
.empty-state {
|
||
padding: 55px 0;
|
||
color: var(--color-text-faint);
|
||
font-size: 11px;
|
||
text-align: center;
|
||
}
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
@media (max-width: 1050px) {
|
||
.stats-row {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
.stat-card:nth-child(4) {
|
||
border-left: 0;
|
||
border-top: 1px solid var(--color-border-light);
|
||
}
|
||
.stat-card:nth-child(5) {
|
||
border-top: 1px solid var(--color-border-light);
|
||
}
|
||
}
|
||
@media (max-width: 760px) {
|
||
.dashboard-page {
|
||
padding: 20px 12px 36px;
|
||
}
|
||
.dashboard-header {
|
||
flex-direction: column;
|
||
}
|
||
.grid-two {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.stats-row {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
.stat-card:nth-child(3) {
|
||
border-left: 0;
|
||
border-top: 1px solid var(--color-border-light);
|
||
}
|
||
.stat-card:nth-child(5) {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.defect-content {
|
||
gap: 14px;
|
||
}
|
||
.donut {
|
||
width: 120px;
|
||
height: 120px;
|
||
flex-basis: 120px;
|
||
}
|
||
.donut > div {
|
||
width: 78px;
|
||
height: 78px;
|
||
}
|
||
}
|
||
@media (max-width: 430px) {
|
||
.stats-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.stat-card,
|
||
.stat-card:nth-child(4),
|
||
.stat-card:nth-child(5) {
|
||
border-left: 0;
|
||
border-top: 1px solid var(--color-border-light);
|
||
}
|
||
.stat-card:first-child {
|
||
border-top: 0;
|
||
}
|
||
.category-row {
|
||
grid-template-columns: 90px minmax(65px, 1fr) 40px 34px;
|
||
}
|
||
.defect-content {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
.defect-legend {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
/* Match the prototype dashboard density and visual hierarchy. */
|
||
.dashboard-page {
|
||
width: min(1160px, 100%);
|
||
max-width: none;
|
||
padding: 24px 28px 40px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.dashboard-header {
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.dashboard-header h1 {
|
||
font-size: 20px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.year-filter {
|
||
gap: 4px;
|
||
border: 0;
|
||
padding: 0;
|
||
background: transparent;
|
||
}
|
||
|
||
.year-filter button {
|
||
min-width: auto;
|
||
height: auto;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 999px;
|
||
padding: 5px 12px;
|
||
background: var(--color-surface);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.year-filter button.active {
|
||
border-color: var(--color-text);
|
||
background: var(--color-text);
|
||
}
|
||
|
||
.year-filter svg {
|
||
display: none;
|
||
}
|
||
|
||
.stats-row {
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 12px;
|
||
border: 0;
|
||
background: transparent;
|
||
}
|
||
|
||
.stat-card {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 10px;
|
||
padding: 18px;
|
||
background: var(--color-surface);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.stat-card + .stat-card,
|
||
.stat-card:nth-child(3),
|
||
.stat-card:nth-child(4),
|
||
.stat-card:nth-child(5) {
|
||
border-left: 1px solid var(--color-border);
|
||
}
|
||
|
||
.grid-two {
|
||
gap: 12px;
|
||
margin-top: 0;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.panel,
|
||
.panel-wide {
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.panel-wide {
|
||
margin-top: 0;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.panel-header {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.panel-header h2 {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stage-list,
|
||
.category-list {
|
||
gap: 6px;
|
||
}
|
||
|
||
.stage-row,
|
||
.category-row {
|
||
display: grid;
|
||
min-height: 28px;
|
||
grid-template-columns: 130px minmax(0, 1fr);
|
||
gap: 10px;
|
||
}
|
||
|
||
.category-row {
|
||
grid-template-columns: 130px minmax(0, 1fr);
|
||
}
|
||
|
||
.stage-row > span:first-child,
|
||
.category-name {
|
||
overflow: visible;
|
||
font-size: 12px;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.bar-track {
|
||
position: relative;
|
||
display: flex;
|
||
height: 28px;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
border-radius: 6px;
|
||
padding: 0 10px;
|
||
background: var(--color-bg-warm);
|
||
}
|
||
|
||
.bar-track i {
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
height: 100%;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.bar-track i.risk-low {
|
||
background: #d1fae5;
|
||
}
|
||
.bar-track i.risk-medium {
|
||
background: #fef3c7;
|
||
}
|
||
.bar-track i.risk-high {
|
||
background: #dc2626;
|
||
}
|
||
.bar-track i.risk-very-high {
|
||
background: #fecaca;
|
||
}
|
||
.bar-track i.risk-elevated {
|
||
background: #fee2e2;
|
||
}
|
||
.bar-track i.risk-moderately-low {
|
||
background: #dbeafe;
|
||
}
|
||
|
||
.bar-track.risk-high b {
|
||
color: #fff;
|
||
}
|
||
.bar-track b {
|
||
position: relative;
|
||
z-index: 1;
|
||
color: var(--color-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.panel-note {
|
||
margin-top: 10px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.cross-table {
|
||
font-size: 11px;
|
||
color: #111;
|
||
}
|
||
|
||
.cross-table th,
|
||
.cross-table td {
|
||
padding: 6px 8px;
|
||
border: 1px solid var(--color-border-light);
|
||
}
|
||
|
||
.cross-table thead th {
|
||
background: var(--color-bg-warm);
|
||
color: #111;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.defect-content {
|
||
gap: 24px;
|
||
}
|
||
|
||
.donut {
|
||
width: 140px;
|
||
height: 140px;
|
||
flex-basis: 140px;
|
||
}
|
||
|
||
.donut > div {
|
||
width: 91px;
|
||
height: 91px;
|
||
}
|
||
|
||
.trend-chart {
|
||
height: 180px;
|
||
gap: 3px;
|
||
padding: 0 6px;
|
||
}
|
||
|
||
.trend-column {
|
||
min-width: 0;
|
||
flex: 1;
|
||
grid-template-rows: 21px minmax(30px, 1fr) 28px;
|
||
gap: 3px;
|
||
}
|
||
|
||
.trend-column i {
|
||
width: 100%;
|
||
max-width: 36px;
|
||
border-radius: 4px 4px 0 0;
|
||
background: var(--color-accent);
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.trend-column i.latest {
|
||
background: var(--color-danger);
|
||
opacity: 1;
|
||
}
|
||
|
||
.trend-column small {
|
||
transform: rotate(-45deg);
|
||
padding-top: 3px;
|
||
color: #111;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.warning-list {
|
||
margin-top: 0;
|
||
}
|
||
|
||
@media (max-width: 1050px) {
|
||
.stats-row {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.dashboard-page {
|
||
padding: 20px 12px 36px;
|
||
}
|
||
|
||
.dashboard-header {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.stats-row {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.stat-card,
|
||
.stat-card + .stat-card,
|
||
.stat-card:nth-child(3),
|
||
.stat-card:nth-child(4),
|
||
.stat-card:nth-child(5) {
|
||
border-left: 1px solid var(--color-border);
|
||
}
|
||
|
||
.stage-row,
|
||
.category-row {
|
||
grid-template-columns: 94px minmax(0, 1fr);
|
||
}
|
||
|
||
.stage-row > span:first-child,
|
||
.category-name {
|
||
font-size: 10px;
|
||
}
|
||
}
|
||
</style>
|