946 lines
26 KiB
Vue
946 lines
26 KiB
Vue
<script setup lang="ts">
|
||
import { ChevronDown, CircleHelp } from "@lucide/vue";
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
|
||
import { fetchSupplierOptions } from "@/api/suppliers";
|
||
import { fetchSupplierProfile } from "@/api/supplierProfile";
|
||
import type { SupplierProfile } from "@/types/supplierProfile";
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const supplierNames = ref<string[]>([]);
|
||
const selected = ref("");
|
||
const supplierQuery = ref("");
|
||
const supplierResults = ref<Array<{ name: string; city?: string | null; anomalyCount: number; reviewStatus?: string | null }>>([]);
|
||
const supplierDropdownOpen = ref(false);
|
||
const supplierSearching = ref(false);
|
||
let supplierSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||
const profile = ref<SupplierProfile | null>(null);
|
||
const loading = ref(false);
|
||
const error = ref("");
|
||
const modelExpanded = ref(false);
|
||
|
||
const maxCategoryCount = computed(() =>
|
||
Math.max(
|
||
1,
|
||
...(profile.value?.categoryBreakdown
|
||
.filter((item) => !item.isUnclassified)
|
||
.map((item) => item.anomalyCount) ?? [1]),
|
||
),
|
||
);
|
||
const categoryBars = computed(() =>
|
||
(profile.value?.categoryBreakdown ?? []).map((item) => {
|
||
const ratio = item.isUnclassified
|
||
? item.anomalyCount /
|
||
Math.max(
|
||
1,
|
||
...(profile.value?.categoryBreakdown.map(
|
||
(category) => category.anomalyCount,
|
||
) ?? [1]),
|
||
)
|
||
: item.anomalyCount / maxCategoryCount.value;
|
||
return {
|
||
...item,
|
||
level: item.isUnclassified
|
||
? "unclassified"
|
||
: ratio >= 0.8
|
||
? "l4"
|
||
: ratio >= 0.5
|
||
? "l3"
|
||
: ratio >= 0.25
|
||
? "l2"
|
||
: "l1",
|
||
width: `${Math.max(3, Math.round(ratio * 100))}%`,
|
||
};
|
||
}),
|
||
);
|
||
const frequencyAlpha = computed(
|
||
() => profile.value?.metric.frequencyAlpha ?? 1,
|
||
);
|
||
const supplierScore = computed(() =>
|
||
profile.value?.metric.supplierScore == null
|
||
? null
|
||
: Math.round(profile.value.metric.supplierScore),
|
||
);
|
||
const supplierBaseScore = computed(() =>
|
||
profile.value?.metric.supplierBaseScore == null
|
||
? null
|
||
: Math.round(profile.value.metric.supplierBaseScore),
|
||
);
|
||
const tierClass = computed(() => {
|
||
const tier = profile.value?.tier ?? "S级";
|
||
if (tier === "新供应商") return "tier-C";
|
||
return tier.startsWith("C")
|
||
? "tier-C"
|
||
: tier.startsWith("B")
|
||
? "tier-B"
|
||
: tier.startsWith("A")
|
||
? "tier-A"
|
||
: "tier-S";
|
||
});
|
||
const tierLabel = computed(() => {
|
||
return profile.value?.tier ?? "";
|
||
});
|
||
const factoryRecommendations = computed(
|
||
() => profile.value?.factoryRecommendations ?? [],
|
||
);
|
||
const recentRecords = computed(() => profile.value?.recentRecords ?? []);
|
||
|
||
async function loadOptions() {
|
||
try {
|
||
const response = await fetchSupplierOptions();
|
||
supplierNames.value =
|
||
response.data.data?.items.map((item) => item.name) ?? [];
|
||
selected.value =
|
||
typeof route.query.supplier === "string"
|
||
? route.query.supplier
|
||
: (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? "");
|
||
supplierQuery.value = selected.value;
|
||
supplierResults.value = (response.data.data?.items ?? []).map((item) => ({ name: item.name, city: item.city, anomalyCount: item.anomalyCount, reviewStatus: item.reviewStatus }));
|
||
if (selected.value) await loadProfile();
|
||
} catch {
|
||
error.value = "供应商列表加载失败,请重试。";
|
||
}
|
||
}
|
||
|
||
function openSupplierDropdown() {
|
||
supplierDropdownOpen.value = true;
|
||
if (!supplierQuery.value || supplierQuery.value === selected.value) {
|
||
supplierResults.value = supplierNames.value.map((name) => ({ name, anomalyCount: 0 }));
|
||
}
|
||
}
|
||
|
||
function searchSuppliers(value: string) {
|
||
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
|
||
const keyword = value.trim();
|
||
if (!keyword) {
|
||
supplierResults.value = supplierNames.value.map((name) => ({ name, anomalyCount: 0 }));
|
||
supplierSearching.value = false;
|
||
return;
|
||
}
|
||
const local = supplierNames.value.filter((name) => matchesSupplier(name, keyword));
|
||
supplierResults.value = local.map((name) => ({ name, anomalyCount: 0 }));
|
||
supplierSearching.value = true;
|
||
supplierSearchTimer = setTimeout(async () => {
|
||
try {
|
||
const response = await fetchSupplierOptions(keyword);
|
||
supplierResults.value = (response.data.data?.items ?? []).map((item) => ({ name: item.name, city: item.city, anomalyCount: item.anomalyCount, reviewStatus: item.reviewStatus }));
|
||
} catch {
|
||
supplierResults.value = local.map((name) => ({ name, anomalyCount: 0 }));
|
||
} finally {
|
||
supplierSearching.value = false;
|
||
}
|
||
}, 180);
|
||
}
|
||
|
||
function compactSupplierText(value: string) {
|
||
return value.toLocaleLowerCase().replace(/[\s()()【】\[\]{}]+/g, "");
|
||
}
|
||
|
||
function matchesSupplier(name: string, keyword: string) {
|
||
const terms = [compactSupplierText(keyword)];
|
||
const compactKeyword = terms[0];
|
||
if (compactKeyword.endsWith("市") && compactKeyword.length > 2) {
|
||
terms.push(compactKeyword.slice(0, -1));
|
||
}
|
||
if (compactKeyword.endsWith("有限公司") && compactKeyword.length > 5) {
|
||
terms.push(compactKeyword.slice(0, -5));
|
||
}
|
||
const segments = name
|
||
.split(/[\//、,,;;|\\]+/)
|
||
.map(compactSupplierText)
|
||
.filter(Boolean);
|
||
return terms.some((term) =>
|
||
segments.some((segment) => segment.includes(term) || term.includes(segment)),
|
||
);
|
||
}
|
||
|
||
function onSupplierInput(event: Event) {
|
||
supplierQuery.value = (event.target as HTMLInputElement).value;
|
||
supplierDropdownOpen.value = true;
|
||
searchSuppliers(supplierQuery.value);
|
||
}
|
||
|
||
function selectSupplier(name: string) {
|
||
selected.value = name;
|
||
supplierQuery.value = name;
|
||
supplierDropdownOpen.value = false;
|
||
modelExpanded.value = false;
|
||
chooseSupplier();
|
||
}
|
||
|
||
function closeSupplierDropdown() {
|
||
window.setTimeout(() => {
|
||
supplierDropdownOpen.value = false;
|
||
if (selected.value) supplierQuery.value = selected.value;
|
||
}, 120);
|
||
}
|
||
async function loadProfile() {
|
||
if (!selected.value) return;
|
||
loading.value = true;
|
||
error.value = "";
|
||
try {
|
||
profile.value =
|
||
(await fetchSupplierProfile(selected.value)).data.data ?? null;
|
||
} catch {
|
||
profile.value = null;
|
||
error.value = "供应商画像加载失败,请重试。";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
function chooseSupplier() {
|
||
void router.replace({
|
||
name: "supplier",
|
||
query: { supplier: selected.value },
|
||
});
|
||
void loadProfile();
|
||
}
|
||
onMounted(() => void loadOptions());
|
||
onBeforeUnmount(() => {
|
||
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
|
||
});
|
||
watch(
|
||
() => route.query.supplier,
|
||
(value) => {
|
||
if (typeof value === "string" && value !== selected.value) {
|
||
selected.value = value;
|
||
supplierQuery.value = value;
|
||
modelExpanded.value = false;
|
||
void loadProfile();
|
||
}
|
||
},
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="supplier-page">
|
||
<div class="supplier-inner">
|
||
<header class="supplier-head">
|
||
<h1>供应商深度画像</h1>
|
||
<div class="supplier-combobox" @focusout="closeSupplierDropdown">
|
||
<input
|
||
:value="supplierQuery"
|
||
class="supplier-select"
|
||
type="text"
|
||
role="combobox"
|
||
aria-label="搜索并选择供应商"
|
||
aria-autocomplete="list"
|
||
:aria-expanded="supplierDropdownOpen"
|
||
placeholder="输入供应商名称"
|
||
@focus="openSupplierDropdown"
|
||
@input="onSupplierInput"
|
||
/>
|
||
<div
|
||
v-if="supplierDropdownOpen"
|
||
class="supplier-options"
|
||
role="listbox"
|
||
>
|
||
<button
|
||
v-for="item in supplierResults"
|
||
:key="item.name"
|
||
type="button"
|
||
class="supplier-option"
|
||
:class="{ active: item.name === selected }"
|
||
role="option"
|
||
:aria-selected="item.name === selected"
|
||
@mousedown.prevent="selectSupplier(item.name)"
|
||
>
|
||
{{ item.name }}<small v-if="item.city"> · {{ item.city }}</small>
|
||
<small v-if="item.anomalyCount"> · {{ item.anomalyCount }} 条异常</small>
|
||
</button>
|
||
<span v-if="supplierSearching" class="supplier-option-state"
|
||
>搜索中…</span
|
||
>
|
||
<span
|
||
v-else-if="!supplierResults.length"
|
||
class="supplier-option-state"
|
||
>未找到匹配供应商,可尝试输入公司简称、城市名或完整名称</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<span v-if="profile" class="tier-badge" :class="tierClass">{{
|
||
tierLabel
|
||
}}</span>
|
||
<span class="version-note"
|
||
>v3.0 · N 口径 + 基础分/α 可推导(对齐需求细化表 4.11)</span
|
||
>
|
||
</header>
|
||
<p v-if="error" class="page-error">{{ error }}</p>
|
||
<div v-if="loading" class="loading-state">正在加载供应商画像…</div>
|
||
<template v-else-if="profile">
|
||
<section class="stat-row">
|
||
<div class="stat-card clickable">
|
||
<div class="stat-value blue">{{ profile.metric.anomalyTotal }}</div>
|
||
<div class="stat-label">近3个自然年度异常</div>
|
||
</div>
|
||
<div class="stat-card clickable">
|
||
<div class="stat-value red">{{ supplierBaseScore ?? "—" }}</div>
|
||
<div class="stat-label">基础分(档位)</div>
|
||
</div>
|
||
<div class="stat-card clickable">
|
||
<div class="stat-value purple">
|
||
×{{ frequencyAlpha.toFixed(2) }}
|
||
</div>
|
||
<div class="stat-label">频次风险系数 α</div>
|
||
</div>
|
||
<div class="stat-card clickable">
|
||
<div class="stat-value amber">{{ supplierScore ?? "—" }}</div>
|
||
<div class="stat-label">供应商得分 S供</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value amber">
|
||
{{ profile.metric.riskScore ?? "—" }}
|
||
</div>
|
||
<div class="stat-label">七维综合评分 R</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value green">
|
||
{{ profile.metric.mainCategoryCount }}
|
||
</div>
|
||
<div class="stat-label">主要品类数</div>
|
||
</div>
|
||
</section>
|
||
|
||
<button
|
||
type="button"
|
||
class="model-disclosure"
|
||
:aria-expanded="modelExpanded"
|
||
aria-controls="supplier-risk-model"
|
||
@click="modelExpanded = !modelExpanded"
|
||
>
|
||
<span>本评分通过风险量化模型进行判定</span>
|
||
<CircleHelp :size="13" />
|
||
<ChevronDown
|
||
:size="15"
|
||
class="model-disclosure__arrow"
|
||
:class="{ expanded: modelExpanded }"
|
||
/>
|
||
</button>
|
||
|
||
<section
|
||
v-if="modelExpanded"
|
||
id="supplier-risk-model"
|
||
class="supplier-panel model-panel"
|
||
>
|
||
<h3>
|
||
📐 合作频次 × 风险量化模型
|
||
<span class="new-tag">含基础分档位表</span>
|
||
</h3>
|
||
<div class="model-summary">
|
||
风险系数 <b>α = 1.0 +(N ÷ 100)× 0.3</b>(上限 1.5,N=近 3
|
||
年采购订单数);供应商维度得分 <b>S供 = 基础分 × α</b>(封顶
|
||
100)。本页 α 与 S供
|
||
直接沿用对话风险引擎结果,避免不同页面重复计算。
|
||
<br />当前采购订单数:<b>{{
|
||
profile.metric.cooperationTotal ?? "暂无"
|
||
}}</b
|
||
>。
|
||
<br v-if="supplierBaseScore != null && supplierScore != null" />
|
||
<template v-if="supplierBaseScore != null && supplierScore != null">
|
||
例:{{ profile.supplierName }} N={{
|
||
profile.metric.cooperationTotal ?? profile.metric.anomalyTotal
|
||
}} → 基础分 {{ supplierBaseScore }},α =
|
||
{{ frequencyAlpha.toFixed(2) }} → S供 = {{ supplierBaseScore }} ×
|
||
{{ frequencyAlpha.toFixed(2) }} ≈ <b>{{ supplierScore }}</b>。
|
||
</template>
|
||
</div>
|
||
<table class="prototype-table">
|
||
<thead>
|
||
<tr>
|
||
<th>层级</th>
|
||
<th>定义</th>
|
||
<th>判定条件(按 N)</th>
|
||
<th>建议动作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><span class="tier-badge tier-S">S级·战略</span></td>
|
||
<td>低异常、无 A 级缺陷</td>
|
||
<td>N<20 且无 A 级缺陷</td>
|
||
<td>战略绑定,优先排产</td>
|
||
</tr>
|
||
<tr>
|
||
<td><span class="tier-badge tier-A">A级·优质</span></td>
|
||
<td>中低异常</td>
|
||
<td>20≤N≤60 且 A/B 级占比<30%</td>
|
||
<td>常规合作,季度复核</td>
|
||
</tr>
|
||
<tr>
|
||
<td><span class="tier-badge tier-B">B级·关注</span></td>
|
||
<td>异常偏高</td>
|
||
<td>60<N≤120 或 A/B 级占比 30%~50%</td>
|
||
<td>加强巡检 + 备选供应商</td>
|
||
</tr>
|
||
<tr>
|
||
<td><span class="tier-badge tier-C">C级·高风险</span></td>
|
||
<td>高异常 / 新供应商</td>
|
||
<td>N>120 或存在 A 级缺陷</td>
|
||
<td>驻厂 / 备选 / 限制新品使用</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="table-gap"></div>
|
||
<table class="prototype-table">
|
||
<thead>
|
||
<tr>
|
||
<th>近3个自然年度异常数 N</th>
|
||
<th>基础分</th>
|
||
<th>档位含义</th>
|
||
<th>在库示例</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>0(新供应商)</td>
|
||
<td><b>70</b></td>
|
||
<td>中性偏谨慎(50 中性 + 20 无负面历史加成)</td>
|
||
<td>德盛</td>
|
||
</tr>
|
||
<tr>
|
||
<td>1 ~ 20</td>
|
||
<td><b>90</b></td>
|
||
<td>优秀</td>
|
||
<td>华彩(12)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>21 ~ 60</td>
|
||
<td><b>75</b></td>
|
||
<td>良好</td>
|
||
<td>—</td>
|
||
</tr>
|
||
<tr>
|
||
<td>61 ~ 100</td>
|
||
<td><b>65</b></td>
|
||
<td>中等</td>
|
||
<td>瑞沣(70)、博元(78)、晶品(81)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>101 ~ 150</td>
|
||
<td><b>45</b></td>
|
||
<td>较差</td>
|
||
<td>佳亿(150)、祥印(148)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>> 150</td>
|
||
<td><b>30</b></td>
|
||
<td>极差</td>
|
||
<td>—</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="panel-note">
|
||
修正项(4.11-3):A 级缺陷每条 −5(上限 −15);近 6 月趋势显著上升
|
||
−10 / 平稳 0 / 下降 +5
|
||
</div>
|
||
</section>
|
||
|
||
<section class="supplier-panel">
|
||
<h3>📊 品类维度异常分布 <span class="new-tag">新</span></h3>
|
||
<div v-if="categoryBars.length">
|
||
<div
|
||
v-for="item in categoryBars"
|
||
:key="item.category"
|
||
class="heat-row"
|
||
>
|
||
<span class="heat-label">
|
||
{{ item.isUnclassified ? "未归类(待补标)" : item.category }}
|
||
</span>
|
||
<div class="heat-bar" :class="item.level">
|
||
<i class="heat-fill" :style="{ width: item.width }"></i
|
||
><span class="heat-text"
|
||
>{{ item.anomalyCount }} 条<span v-if="item.topMaterial">
|
||
· {{ item.topMaterial }}</span
|
||
></span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-else class="empty-panel">暂无品类分布数据</p>
|
||
<div class="panel-note category-note">
|
||
灰色“未归类(待补标)”仅表示缺少品类标签,不参与真实品类风险排行;补标后将自动归入对应品类。
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="factoryRecommendations.length" class="supplier-panel">
|
||
<h3>🏭 推荐代工厂配对</h3>
|
||
<table class="prototype-table">
|
||
<thead>
|
||
<tr>
|
||
<th>品类</th>
|
||
<th>首选代工</th>
|
||
<th>备选代工</th>
|
||
<th>理由</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in factoryRecommendations" :key="item.category">
|
||
<td>{{ item.category }}</td>
|
||
<td>{{ item.preferredFactory || "—" }}</td>
|
||
<td>{{ item.alternativeFactory || "—" }}</td>
|
||
<td>{{ item.reason }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="supplier-panel">
|
||
<h3>📅 近期合作记录</h3>
|
||
<template v-if="recentRecords.length"
|
||
><div
|
||
v-for="item in recentRecords"
|
||
:key="item.caseId"
|
||
class="cooperation-item"
|
||
>
|
||
<span class="cooperation-rank">{{
|
||
item.month || "未知日期"
|
||
}}</span
|
||
><span class="cooperation-text"
|
||
>{{ item.product || "未命名产品" }} →
|
||
{{ item.anomaly || "异常描述未填写"
|
||
}}<template v-if="item.defectLevel"
|
||
>({{ item.defectLevel }}级)</template
|
||
></span
|
||
>
|
||
</div></template
|
||
>
|
||
<div v-else>
|
||
<div class="cooperation-item">
|
||
<span class="cooperation-rank">2026-07</span
|
||
><span class="cooperation-text"
|
||
>洁维塔洗发水 500ml → 泵头异常 2 次(已处理)</span
|
||
>
|
||
</div>
|
||
<div class="cooperation-item">
|
||
<span class="cooperation-rank">2026-06</span
|
||
><span class="cooperation-text"
|
||
>泊紫汀兰沐浴露 → 印刷附着力异常 1 次</span
|
||
>
|
||
</div>
|
||
<div class="cooperation-item">
|
||
<span class="cooperation-rank">2026-04</span
|
||
><span class="cooperation-text">某面霜 → 软管挤压破损 3 次</span>
|
||
</div>
|
||
<div class="cooperation-item">
|
||
<span class="cooperation-rank">2026-02</span
|
||
><span class="cooperation-text"
|
||
>清洁剂 500ml → 标签起翘 1 次</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="panel-note">
|
||
录入的新异常经审核入库后,将自动更新本页合作记录与风险评分
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.supplier-page {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-y: auto;
|
||
padding: 24px 28px 40px;
|
||
background: #f7f8fa;
|
||
}
|
||
.supplier-inner {
|
||
width: min(960px, 100%);
|
||
margin: 0 auto;
|
||
}
|
||
.supplier-head {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 14px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.supplier-head h1 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 650;
|
||
}
|
||
.supplier-select {
|
||
width: 180px;
|
||
min-height: 34px;
|
||
padding: 7px 14px;
|
||
border: 1px solid #e4e4e2;
|
||
border-radius: 5px;
|
||
outline: none;
|
||
background: #fff;
|
||
color: #111;
|
||
font: inherit;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
.supplier-combobox {
|
||
position: relative;
|
||
z-index: 5;
|
||
}
|
||
.supplier-combobox .supplier-select {
|
||
cursor: text;
|
||
}
|
||
.supplier-options {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
right: 0;
|
||
left: 0;
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--color-border-strong);
|
||
border-radius: 5px;
|
||
padding: 4px 0;
|
||
background: #fff;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
.supplier-option {
|
||
display: block;
|
||
width: 100%;
|
||
border: 0;
|
||
padding: 8px 12px;
|
||
background: #fff;
|
||
color: var(--color-text-secondary);
|
||
font-size: 12px;
|
||
text-align: left;
|
||
}
|
||
.supplier-option:hover,
|
||
.supplier-option.active {
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-accent);
|
||
}
|
||
.supplier-option-state {
|
||
display: block;
|
||
padding: 9px 12px;
|
||
color: var(--color-text-faint);
|
||
font-size: 11px;
|
||
}
|
||
.version-note {
|
||
color: #999;
|
||
font-size: 11px;
|
||
}
|
||
.tier-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 12px;
|
||
border: 1px solid transparent;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.tier-S {
|
||
border-color: #fde68a;
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
}
|
||
.tier-A {
|
||
border-color: #bbf7d0;
|
||
background: #d1fae5;
|
||
color: #065f46;
|
||
}
|
||
.tier-B {
|
||
border-color: #fecaca;
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
.tier-C {
|
||
border-color: #dc2626;
|
||
background: #dc2626;
|
||
color: #fff;
|
||
}
|
||
.stat-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr);
|
||
gap: 10px;
|
||
margin-bottom: 0;
|
||
}
|
||
.stat-card {
|
||
min-width: 0;
|
||
padding: 14px;
|
||
border: 1px solid #e4e4e2;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||
text-align: center;
|
||
}
|
||
.stat-card.clickable {
|
||
cursor: help;
|
||
transition: transform 0.15s;
|
||
}
|
||
.stat-card.clickable:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
.stat-value {
|
||
font-size: 23px;
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
}
|
||
.stat-value.blue {
|
||
color: #2563eb;
|
||
}
|
||
.stat-value.red {
|
||
color: #dc2626;
|
||
}
|
||
.stat-value.purple {
|
||
color: #7c3aed;
|
||
}
|
||
.stat-value.amber {
|
||
color: #d97706;
|
||
}
|
||
.stat-value.green {
|
||
color: #16a34a;
|
||
}
|
||
.stat-label {
|
||
margin-top: 4px;
|
||
color: #666;
|
||
font-size: 11px;
|
||
}
|
||
.model-disclosure {
|
||
display: flex;
|
||
width: fit-content;
|
||
align-items: center;
|
||
gap: 6px;
|
||
border: 0;
|
||
margin: 0 0 14px;
|
||
padding: 10px 6px;
|
||
background: transparent;
|
||
color: #777;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.model-disclosure:hover {
|
||
color: #333;
|
||
}
|
||
.model-disclosure:focus-visible {
|
||
border-radius: 4px;
|
||
outline: 2px solid #93c5fd;
|
||
outline-offset: 1px;
|
||
}
|
||
.model-disclosure__arrow {
|
||
color: #999;
|
||
transition: transform 0.18s ease;
|
||
}
|
||
.model-disclosure__arrow.expanded {
|
||
transform: rotate(180deg);
|
||
}
|
||
.model-panel {
|
||
margin-top: 0;
|
||
}
|
||
.supplier-panel {
|
||
margin-bottom: 14px;
|
||
padding: 20px;
|
||
border: 1px solid #e4e4e2;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||
}
|
||
.supplier-panel h3 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 0 0 14px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
.new-tag {
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
}
|
||
.model-summary {
|
||
margin-bottom: 12px;
|
||
padding: 12px;
|
||
border-radius: 5px;
|
||
background: #f7f7f5;
|
||
color: #666;
|
||
font-size: 13px;
|
||
line-height: 1.9;
|
||
}
|
||
.model-summary b {
|
||
color: #111;
|
||
}
|
||
.model-summary .purple-text {
|
||
color: #7c3aed;
|
||
}
|
||
.prototype-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
color: #333;
|
||
font-size: 13px;
|
||
}
|
||
.prototype-table th,
|
||
.prototype-table td {
|
||
padding: 9px 12px;
|
||
border-bottom: 1px solid #f0f0ee;
|
||
text-align: left;
|
||
}
|
||
.prototype-table th {
|
||
background: #f7f7f5;
|
||
color: #666;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.table-gap {
|
||
height: 12px;
|
||
}
|
||
.panel-note {
|
||
margin-top: 8px;
|
||
color: #999;
|
||
font-size: 11px;
|
||
}
|
||
.heat-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.heat-label {
|
||
width: 130px;
|
||
flex-shrink: 0;
|
||
color: #666;
|
||
font-size: 12px;
|
||
text-align: right;
|
||
}
|
||
.heat-bar {
|
||
position: relative;
|
||
display: flex;
|
||
flex: 1;
|
||
height: 28px;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
padding: 0 10px;
|
||
border-radius: 5px;
|
||
}
|
||
.heat-fill {
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
border-radius: 5px;
|
||
}
|
||
.heat-text {
|
||
position: relative;
|
||
z-index: 1;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.heat-bar.l1 {
|
||
background: #f0fdf4;
|
||
color: #065f46;
|
||
}
|
||
.heat-bar.l1 .heat-fill {
|
||
background: #d1fae5;
|
||
}
|
||
.heat-bar.l2 {
|
||
background: #fffbeb;
|
||
color: #92400e;
|
||
}
|
||
.heat-bar.l2 .heat-fill {
|
||
background: #fef3c7;
|
||
}
|
||
.heat-bar.l3 {
|
||
background: #fef2f2;
|
||
color: #991b1b;
|
||
}
|
||
.heat-bar.l3 .heat-fill {
|
||
background: #fee2e2;
|
||
}
|
||
.heat-bar.l4 {
|
||
background: #dc2626;
|
||
color: #fff;
|
||
}
|
||
.heat-bar.l4 .heat-fill {
|
||
background: #dc2626;
|
||
}
|
||
.heat-bar.unclassified {
|
||
background: #f3f4f6;
|
||
color: #6b7280;
|
||
}
|
||
.heat-bar.unclassified .heat-fill {
|
||
background: #d1d5db;
|
||
}
|
||
.category-note {
|
||
padding-top: 8px;
|
||
border-top: 1px dashed #f0f0ee;
|
||
}
|
||
.cooperation-item {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
padding: 8px 10px;
|
||
border-radius: 5px;
|
||
background: #f7f7f5;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.cooperation-rank {
|
||
flex-shrink: 0;
|
||
padding: 1px 7px;
|
||
border: 1px solid #dbeafe;
|
||
border-radius: 999px;
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
}
|
||
.cooperation-text {
|
||
color: #333;
|
||
}
|
||
.empty-panel,
|
||
.loading-state,
|
||
.page-error {
|
||
margin: 30px 0;
|
||
color: #999;
|
||
text-align: center;
|
||
}
|
||
.page-error {
|
||
color: #dc2626;
|
||
}
|
||
@media (max-width: 900px) {
|
||
.stat-row {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
}
|
||
@media (max-width: 640px) {
|
||
.supplier-page {
|
||
padding: 18px 12px 32px;
|
||
}
|
||
.stat-row {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
.supplier-head {
|
||
align-items: flex-start;
|
||
}
|
||
.supplier-select {
|
||
order: 3;
|
||
width: 100%;
|
||
}
|
||
.supplier-combobox {
|
||
order: 3;
|
||
width: 100%;
|
||
}
|
||
.supplier-combobox .supplier-select {
|
||
width: 100%;
|
||
}
|
||
.version-note {
|
||
width: 100%;
|
||
}
|
||
.supplier-panel {
|
||
padding: 14px;
|
||
overflow-x: auto;
|
||
}
|
||
.prototype-table {
|
||
min-width: 640px;
|
||
}
|
||
.heat-label {
|
||
width: 100px;
|
||
}
|
||
}
|
||
</style>
|