fix(frontend): v4 原型样式原样移植——竖向地铁图(圆环dot/站名/N个对象+贯穿线▶+pending琥珀pulse)+edges六条着色渲染+健康水位独立卡五chip+决策卡chip徽标与去处理按钮 [vps]
This commit is contained in:
@@ -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([])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user