fix(frontend): v4 原型样式原样移植——竖向地铁图(圆环dot/站名/N个对象+贯穿线▶+pending琥珀pulse)+edges六条着色渲染+健康水位独立卡五chip+决策卡chip徽标与去处理按钮 [vps]
CI/CD / test (push) Successful in 56s
CI/CD / nas-deploy (push) Successful in 33s
CI/CD / nas-verify (push) Successful in 9s

This commit is contained in:
2026-10-10 20:44:56 +08:00
parent c0f19a1dfe
commit 6aa0632595
3 changed files with 119 additions and 85 deletions
+22 -12
View File
@@ -26,6 +26,11 @@ const stationsMock = [
{ name: '实盘', count: 1, pending: 0, who: null },
]
const edgesMock = [
{ from: '假设池', to: '因子工厂', cond: '落成+注册', trig: '人工' as const },
{ from: '因子工厂', to: '晋级评审', cond: 't≥2', trig: '自动上板' as const },
]
const healthMock = [
{ name: '衰减告警', value: '0', state: 'ok' as const },
{ name: '集体水位', value: '正常', state: 'ok' as const },
@@ -34,32 +39,37 @@ const healthMock = [
{ name: 'as_of', value: '2026-09-30', state: 'idle' as const },
]
describe('Home.vue 流水线全景(v4 落地:站点条+健康水位+等你决策)', () => {
describe('Home.vue 流水线全景(v4 原样移植:竖向地铁图+edges+健康水位独立卡)', () => {
beforeEach(() => vi.clearAllMocks())
it('站点条七站渲染+琥珀 pending 标注+健康水位五格', async () => {
it('地铁条七站竖向渲染+who 直显+edges 着色+健康水位五 chip', async () => {
vi.mocked(api.getTodos).mockResolvedValue(todosMock)
vi.mocked(api.getStations).mockResolvedValue({ stations: stationsMock, edges: [], health: healthMock })
vi.mocked(api.getStations).mockResolvedValue(
{ stations: stationsMock, edges: edgesMock, health: healthMock })
const w = mount(Home, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
for (const s of ['假设池', '因子工厂', '晋级评审', '合成层', '策略出生', '孵化梯', '实盘']) {
expect(w.text()).toContain(s)
}
expect(w.text()).toContain('+1') // 晋级评审 pending 琥珀
// who 走 hover title 属性(非文本节点),从 hot 站点元素断言
const hot = w.find('.station.hot')
expect(hot.exists()).toBe(true)
expect(hot.attributes('title')).toContain('fa_hot 待五问')
expect(w.text()).toContain('衰减告警')
expect(w.text()).toContain('集体水位')
expect(w.text()).toContain('2026-09-30')
expect(w.text()).toContain('个对象') // 站点计数行(原型 .st-c)
expect(w.text()).toContain('fa_hot 待五问') // who 琥珀直显(非 hover)
expect(w.find('.st-dot.pending').exists()).toBe(true)
expect(w.text()).toContain('假设池→因子工厂') // edges 六条迁移边渲染(D4)
expect(w.text()).toContain('落成+注册')
// 健康水位独立卡:五 chip 形态
expect(w.text()).toContain('健康水位')
const chips = w.findAll('.hrow .chip')
expect(chips.length).toBe(5)
expect(chips[2].text()).toContain('数据缺口 1')
})
it('等你决策卡:tag 徽标+灯+明细', async () => {
it('等你决策卡:chip 徽标+两行文字+去处理按钮', async () => {
vi.mocked(api.getTodos).mockResolvedValue(todosMock)
vi.mocked(api.getStations).mockResolvedValue({ stations: [], edges: [], health: [] })
const w = mount(Home, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(w.text()).toContain('等你决策')
expect(w.text()).toContain('fa_gross_margin 衰减告警')
const btns = w.findAll('.btn.pri')
expect(btns.length).toBeGreaterThanOrEqual(1) // 可跳转项=去处理按钮
})
it('零待办=安静态文案(0 件是好消息)', async () => {
vi.mocked(api.getTodos).mockResolvedValue([])
+42 -39
View File
@@ -6,7 +6,9 @@ import type { TodoItem, Touchpoint, HealthCell } from '@/api/pipeline'
import PanelCard from './PanelCard.vue'
import SubwayBar from './SubwayBar.vue'
// v4 高保真稿首页落地(D1 地铁图融总览):站点条+健康水位行+等你决策卡
// v4 高保真稿总览页原样移植(D1):全景卡(地铁条+edges)+等你决策卡
// (chip 徽标+两行文字+去处理按钮)+健康水位独立卡。样式照搬稿内
// 类名与值,不二次翻译;数据源=真 API(stations/todos)。
const router = useRouter()
const items = ref<TodoItem[]>([])
const health = ref<HealthCell[]>([])
@@ -21,67 +23,68 @@ const TAG: Record<Touchpoint, string> = {
hypothesis: '假设', promotion: '评审', composite: '合成',
ladder: '出生', alert: '告警',
}
const SEV_LAMP: Record<TodoItem['severity'], string> = {
info: 'lamp lamp-ok', warn: 'lamp lamp-warn', critical: 'lamp lamp-critical',
// chip 色=severity(原型 chip(t.sev,t.tag) 同构)
const SEV_CHIP: Record<TodoItem['severity'], string> = {
info: 'ok', warn: 'warn', critical: 'crit',
}
const HEALTH_CHIP: Record<HealthCell['state'], string> = {
ok: 'ok', warn: 'warn', idle: 'idle',
}
const ordered = computed(() => {
const w: Record<string, number> = { critical: 0, warn: 1, info: 2 }
return [...items.value].sort((a, b) => (w[a.severity] ?? 9) - (w[b.severity] ?? 9))
})
const HEALTH_TONE: Record<HealthCell['state'], string> = {
ok: 'h-ok', warn: 'h-warn', idle: 'h-idle',
}
</script>
<template>
<div class="pl-home">
<PanelCard title="流水线全景" sub="站点进度 · 健康水位 · 等你决策 · 其余时间流水线自动跑">
<PanelCard title="流水线全景" sub="琥珀=卡点等你 · 自动任务实况在月度批状态页">
<SubwayBar />
<div v-if="health.length" class="health">
<div v-for="h in health" :key="h.name" class="hcell" :class="HEALTH_TONE[h.state]">
<span class="hk">{{ h.name }}</span>
<span class="hv">{{ h.value }}</span>
</div>
</div>
</PanelCard>
<PanelCard title="等你决策" :sub="`${items.length} 件 · 灯色=轻重 · 点卡进入处理`">
<PanelCard title="等你决策" :sub="`${items.length} 件 · 卡点不自动放行`">
<div v-if="!ordered.length" class="allquiet">
暂无等你决策——流水线自动跑,有事它叫你(琥珀站点=有人工卡点)
</div>
<div v-for="t in ordered" :key="t.id" class="todo" @click="router.push(t.path)">
<span class="tag">{{ TAG[t.touchpoint] }}</span>
<span :class="SEV_LAMP[t.severity]"></span>
<div class="todo-main">
<span class="todo-title">{{ t.title }}</span>
<span class="todo-detail">{{ t.detail }}</span>
<span class="chip" :class="SEV_CHIP[t.severity]">{{ TAG[t.touchpoint] }}</span>
<div class="tx">
<div class="a">{{ t.title }}</div>
<div class="b">{{ t.detail }}</div>
</div>
<span class="todo-go">→</span>
<button class="btn pri" @click.stop="router.push(t.path)">去处理</button>
</div>
</PanelCard>
<PanelCard title="健康水位" sub="月报四源 + 新鲜度">
<div v-if="health.length" class="hrow">
<span
v-for="h in health" :key="h.name"
class="chip" :class="HEALTH_CHIP[h.state]"
>{{ h.name }} {{ h.value }}</span>
</div>
<div v-else class="allquiet">暂无月报数据——月度链首跑后五格就位</div>
</PanelCard>
</div>
</template>
<style scoped>
.pl-home { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
.health { display: flex; gap: 10px; flex-wrap: wrap; border-top: 1px dashed var(--border); padding-top: 8px; }
.hcell { display: inline-flex; align-items: baseline; gap: 6px; border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px 10px; }
.hk { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
.hv { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.h-ok .hv { color: var(--lamp-ok); }
.h-warn { border-color: rgba(255, 184, 77, 0.45); }
.h-warn .hv { color: #ffb84d; }
.h-idle .hv { color: var(--text-3); font-weight: 400; }
/* —— 以下照搬 v4 稿类样式(值不改) —— */
.chip { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; border-radius: 99px; padding: 0 9px; line-height: 18px; border: 1px solid var(--border); color: var(--text-2); white-space: nowrap; flex-shrink: 0; }
.chip.ok { color: var(--lamp-ok); border-color: rgba(46, 230, 138, 0.35); }
.chip.warn { color: var(--lamp-warn); border-color: rgba(255, 176, 0, 0.4); }
.chip.crit { color: var(--lamp-crit); border-color: rgba(255, 77, 94, 0.4); }
.chip.idle { color: var(--text-3); }
.btn { border: 1px solid var(--cyan-dim); color: var(--brand); border-radius: 6px; padding: 3px 12px; font-size: 12px; background: transparent; cursor: pointer; font-family: inherit; }
.btn:hover { background: var(--cyan-soft); }
.btn.pri { background: var(--brand); color: #03242b; border-color: var(--brand); font-weight: 600; }
.todo { display: flex; align-items: center; gap: 12px; padding: 11px 4px; border-bottom: 1px solid var(--border-2); cursor: pointer; }
.todo:last-child { border-bottom: none; }
.todo:hover { background: var(--bg-hover); }
.todo .tx { flex: 1; min-width: 0; }
.todo .tx .a { font-size: 13px; color: var(--text); }
.todo .tx .b { font-size: 11px; color: var(--text-3); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hrow { display: flex; flex-wrap: wrap; gap: 8px; }
.allquiet { font-size: 12px; color: var(--text-3); padding: 14px 0; border: 1px dashed var(--border); border-radius: var(--r-sm); text-align: center; }
.todo { display: flex; align-items: center; gap: 10px; padding: 9px 6px; border-top: 1px solid var(--border); cursor: pointer; transition: background 0.15s var(--ease); }
.todo:hover { background: var(--cyan-soft); }
.tag { font-family: var(--mono); font-size: 10px; color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.35); border-radius: var(--r-sm); padding: 0 6px; flex-shrink: 0; }
.todo-main { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.todo-title { font-size: 12.5px; color: var(--text); }
.todo-detail { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.todo-go { color: var(--text-3); font-family: var(--mono); }
.lamp { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--text-3); }
.lamp-ok { background: var(--lamp-ok); }
.lamp-warn { background: #ffb84d; }
.lamp-critical { background: #ff5f6d; box-shadow: 0 0 8px rgba(255, 95, 109, 0.6); }
</style>
+55 -34
View File
@@ -2,15 +2,18 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getStations } from '@/api/pipeline'
import type { PipelineStation } from '@/api/pipeline'
import type { PipelineStation, StationEdge } from '@/api/pipeline'
// v4 高保真稿 SUBWAY 落地(D1 地铁图融总览):七站横条+计数徽标+
// 琥珀 pending(人工卡点等你)+hover who;站可点跳对应页
// v4 高保真稿 SUBWAY 原样移植(D1):竖向三行站点(圆环 dot/站名/N 个对象·who)
// +贯穿线▶+pending 琥珀 pulse;edges 六条迁移边着色渲染(D4 边一等公民)。
// 样式照搬稿内类名与值,不二次翻译。
const router = useRouter()
const stations = ref<PipelineStation[]>([])
const edges = ref<StationEdge[]>([])
onMounted(async () => {
const r = await getStations().catch(() => ({ stations: [] as PipelineStation[] }))
const r = await getStations().catch(() => ({ stations: [] as PipelineStation[], edges: [] as StationEdge[] }))
stations.value = r.stations
edges.value = r.edges || []
})
const ROUTE: Record<string, string> = {
@@ -20,48 +23,66 @@ const ROUTE: Record<string, string> = {
实盘: '/strategy',
}
const go = (name: string) => ROUTE[name] && router.push(ROUTE[name])
const totalPending = computed(() =>
stations.value.reduce((a, s) => a + (s.pending || 0), 0))
// trig 着色:人工=琥珀 / 自动=青(「人工+自动判」两段各归各色)
const trigHtml = (t: string) => t
.replace(/人工/g, '<span class="trig-h">人工</span>')
.replace(/自动/g, '<span class="trig-a">自动</span>')
const nPending = computed(() =>
stations.value.reduce((a, s) => a + (s.pending ? 1 : 0), 0))
</script>
<template>
<div class="subway">
<div class="subway-line">
<template v-for="(s, i) in stations" :key="s.name">
<div v-if="i > 0" class="seg" :class="{ lit: s.count > 0 }" />
<div>
<div class="subway-wrap">
<div class="subway-line" />
<div class="subway-arrow">▶</div>
<div class="subway">
<div
class="station"
:class="{ empty: s.count === 0, hot: s.pending > 0 }"
:title="s.who ? `等你: ${s.who}` : `${s.name} ${s.count} 项`"
v-for="s in stations" :key="s.name" class="st"
:title="`${s.name} · ${s.count} 个对象${s.who ? ' · ' + s.who : ''}`"
@click="go(s.name)"
>
<span class="dot" />
<span class="badge" :class="{ amber: s.pending > 0 }">{{ s.count }}</span>
<span v-if="s.pending > 0" class="pend">+{{ s.pending }}</span>
<span class="nm">{{ s.name }}</span>
<div class="st-dot" :class="{ pending: s.pending > 0, empty: s.count === 0 }" />
<div class="st-nm">{{ s.name }}</div>
<div class="st-c">
<b>{{ s.count }}</b> 个对象<template v-if="s.who"> · <span class="pd">{{ s.who }}</span></template>
</div>
</div>
</template>
</div>
</div>
<div v-if="edges.length" class="edges">
<span v-for="e in edges" :key="e.from + e.to" class="ed">
{{ e.from }}→{{ e.to }}:<b>{{ e.cond }}</b>(<span v-html="trigHtml(e.trig)" />)
</span>
</div>
<div class="subway-note mono">
琥珀 =人工卡点等你({{ totalPending }} 件)· 点站进入 · 青灯=自动在跑
琥珀=卡点等你({{ nPending }} 站)· 点站进入 · 青灯=自动在跑
</div>
</div>
</template>
<style scoped>
.subway { margin-bottom: 4px; }
.subway-line { display: flex; align-items: center; gap: 0; flex-wrap: wrap; padding: 6px 0 2px; }
.seg { width: 26px; height: 2px; background: var(--border); flex-shrink: 0; }
.seg.lit { background: linear-gradient(90deg, rgba(0, 229, 255, 0.7), rgba(0, 229, 255, 0.25)); }
.station { position: relative; display: flex; align-items: center; gap: 5px; cursor: pointer; padding: 3px 4px; border-radius: var(--r-sm); transition: background 0.15s var(--ease); }
.station:hover { background: var(--cyan-soft); }
.station.empty { opacity: 0.45; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--brand); box-shadow: 0 0 8px rgba(0, 229, 255, 0.5); flex-shrink: 0; }
.station.empty .dot { background: var(--text-3); box-shadow: none; }
.station.hot .dot { background: #ffb84d; box-shadow: 0 0 8px rgba(255, 184, 77, 0.6); }
.badge { font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--brand); font-variant-numeric: tabular-nums; }
.badge.amber { color: #ffb84d; }
.pend { font-family: var(--mono); font-size: 10px; color: #ffb84d; }
.nm { font-size: 11px; color: var(--text-3); }
.subway-note { font-size: 10px; color: var(--text-3); padding: 2px 4px 6px; }
/* —— 以下照搬 v4 稿类样式(值不改) —— */
.subway-wrap { position: relative; padding: 30px 26px 4px; overflow-x: auto; }
.subway-line { position: absolute; top: 43px; left: 66px; right: 66px; height: 2px; background: var(--border); }
.subway-arrow { position: absolute; top: 39.5px; right: 52px; color: var(--text-3); font-size: 12px; }
.subway { display: flex; position: relative; }
.st { flex: 1; min-width: 104px; text-align: center; cursor: pointer; padding-bottom: 4px; }
.st-dot { width: 16px; height: 16px; border-radius: 50%; border: 3px solid var(--cyan-dim); background: var(--bg); margin: 4px auto 7px; position: relative; z-index: 1; }
.st:hover .st-dot { border-color: var(--brand); box-shadow: 0 0 0 4px var(--cyan-soft); }
.st-dot.pending { border-color: var(--lamp-warn); animation: pulse 1.6s infinite; }
.st-dot.pending::after { content: ""; position: absolute; inset: 2.5px; border-radius: 50%; background: var(--lamp-warn); }
.st-dot.empty { border-color: var(--text-3); }
.st-nm { font-size: 12px; color: var(--text-2); }
.st:hover .st-nm { color: var(--text); }
.st-c { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.st-c b { color: var(--text); font-weight: 600; }
.st-c .pd { color: var(--lamp-warn); }
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 3px rgba(255, 176, 0, 0.15); } 50% { box-shadow: 0 0 0 6px rgba(255, 176, 0, 0.05); } }
.edges { display: flex; flex-wrap: wrap; gap: 5px 14px; margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border); font-size: 11.5px; color: var(--text-3); }
.edges .ed { white-space: nowrap; }
.edges b { color: var(--text-2); font-weight: 500; }
.trig-a { color: var(--cyan-dim); }
.trig-h { color: var(--amber); }
.subway-note { font-size: 10px; color: var(--text-3); padding: 6px 4px 0; }
</style>