kf-nav.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. /**
  2. * layout/kf-nav.js —— <kf-nav> 主导航,两种形态同一份路由表:
  3. * 桌面 / 平板(≥768px):左侧整片玻璃竖栏(图标 + 中英文 + 计数)
  4. * 手机(≤767px):底部固定 Tab 栏 —— 5 个主入口,图标上文字下;当前页实心图标 + 品牌色 + 顶部 2px 指示条;
  5. * 角标缩小压在图标右上;整条是上层玻璃 .78 + blur(32px),底部让出 iOS 手势条(safe-area)。
  6. * 证据台账在手机上从 Tab 移到「工作台」页脚入口(5 格是单手拇指舒适上限),链接仍是真实 <a>。
  7. * 只画路由表与计数;跳转交给 page 层的 click 处理(经 router.navigate,保留部署前缀)。
  8. * 属性:current(应用内路径)/ counts({ pool, stuck })
  9. */
  10. import { LitElement, html, css } from 'lit';
  11. import { useTokens, define } from '../components/base.js';
  12. import { icon } from '../components/icons.js';
  13. import { t } from '../components/cjk.js';
  14. const ITEMS = [
  15. { path: '/', label: '工作台', en: 'Workbench', icon: 'home' },
  16. { path: '/pool', label: '机会卡池', en: 'Opportunity pool', icon: 'pool', badge: 'pool', short: '卡池' },
  17. { path: '/stages', label: '五阶段产出', en: 'Stages S1–S5', icon: 'bars', short: '阶段' },
  18. { path: '/report', label: '分析报告', en: 'Reports', icon: 'doc', short: '报告' },
  19. { path: '/ledger', label: '证据台账', en: 'Ledger', icon: 'ledger', mobileHidden: true },
  20. { path: '/members', label: '成员', en: 'Members', icon: 'users', badge: 'stuck' },
  21. ];
  22. export const NAV_ITEMS = ITEMS;
  23. export class KfNav extends LitElement {
  24. static properties = {
  25. current: { type: String },
  26. counts: { type: Object },
  27. connection: { type: String },
  28. };
  29. constructor() {
  30. super();
  31. this.current = '/';
  32. this.counts = { pool: 0, stuck: 0 };
  33. this.connection = '';
  34. }
  35. connectedCallback() {
  36. super.connectedCallback();
  37. useTokens(this);
  38. }
  39. static styles = css`
  40. :host { display: block; height: 100%; }
  41. /* 大面板玻璃:和 chip 同档(.55 + blur 12),背后光场在动;背景 / 模糊 / 发丝边来自共享表 .kf-glass.is-panel */
  42. /* 浮层(侧栏)保留玻璃:.58 白面 + blur(28px) saturate(180%) —— 板块六「浮层保留、内容卡减薄」 */
  43. .side { height: 100%; box-sizing: border-box; display: flex; flex-direction: column; padding: 20px 12px 16px;
  44. --kf-glass-alpha: rgba(255, 255, 255, 0.58); --kf-glass-blur: 28px; --kf-glass-sat: 180%;
  45. border-radius: 0 var(--kf-radius-lg) var(--kf-radius-lg) 0; border-left: 0; }
  46. .brand { display: flex; gap: 12px; align-items: center; padding: 4px 8px 18px; }
  47. /* 品牌块:v10 收成一块实色强调色(过去是四色 conic 渐变 + 彩色投影,过饱和) */
  48. .mark { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; color: #fff; font-weight: 600; font-size: 20px;
  49. background: var(--kf-accent); }
  50. .name { font-size: 14px; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; }
  51. .sub { font-size: 11px; color: var(--kf-ink-3); }
  52. nav { display: grid; gap: 4px; }
  53. a { display: flex; align-items: center; gap: 12px; height: 40px; padding: 0 12px; border-radius: 10px; color: var(--kf-ink-2);
  54. text-decoration: none; font-size: 14px; font-weight: 500; position: relative; }
  55. a { transition: var(--kf-t); }
  56. a:hover { background: rgba(255,255,255,0.5); }
  57. a[aria-current="page"] { background: rgba(255,255,255,0.92); color: var(--kf-ink); box-shadow: var(--elev-1); }
  58. a[aria-current="page"]::before { content: ""; position: absolute; left: 0; top: 11px; bottom: 11px; width: 3px; border-radius: 0 3px 3px 0; background: var(--kf-accent); }
  59. svg.i { width: 18px; height: 18px; }
  60. .lbl { flex: 1; min-width: 0; }
  61. .en { display: block; font-size: 11px; color: var(--kf-ink-3); letter-spacing: 0.03em; font-weight: 500; }
  62. .short { display: none; }
  63. .badge { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 6px; display: grid; place-items: center;
  64. font-size: 11px; font-weight: 500; letter-spacing: 0.02em; background: var(--kf-fill); color: var(--kf-ink-2); font-variant-numeric: tabular-nums; }
  65. .badge.danger { background-color: var(--kf-s); color: #fff; }
  66. .foot { margin-top: auto; padding: 12px 10px 0; border-top: 1px solid var(--kf-line); font-size: 11px; color: var(--kf-ink-3); display: flex; align-items: center; gap: 8px; }
  67. /* 状态点:实色圆点 + 细描边(v11:原来三种颜色的 3px 发光环是「装饰性阴影」) */
  68. .live { width: 8px; height: 8px; border-radius: 50%; background: #2e7d5b; outline: 3px solid rgba(46,125,91,0.12); flex: none; }
  69. .live.err { background: var(--kf-s); outline-color: rgba(179,38,30,0.12); }
  70. .live.loading { background: #b27a00; outline-color: rgba(178,122,0,0.12); animation: pulse 1.2s ease-in-out infinite; }
  71. @keyframes pulse { 50% { opacity: 0.4; } }
  72. /* ===== 手机:底部 Tab 栏 ===== */
  73. @media (max-width: 767px) {
  74. .side { display: none; }
  75. .tabbar {
  76. display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); align-items: start;
  77. box-sizing: border-box; padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px));
  78. border-radius: 16px 16px 0 0; border-bottom: 0; border-left: 0; border-right: 0;
  79. background:
  80. linear-gradient(rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.78)) padding-box,
  81. linear-gradient(0deg, rgba(10, 20, 40, 0.10), rgba(255, 255, 255, 0.95)) border-box;
  82. -webkit-backdrop-filter: blur(32px) saturate(190%);
  83. backdrop-filter: blur(32px) saturate(190%);
  84. box-shadow: var(--kf-shadow-tabbar); /* = elev-3 的上下镜像 */
  85. }
  86. .tabbar a { height: 52px; min-width: 0; padding: 0; display: grid; justify-items: center; align-content: center; gap: 4px;
  87. position: relative; border-radius: 10px; color: var(--kf-ink-3); background: none; box-shadow: none; }
  88. .tabbar a::before { display: none; }
  89. .tabbar a:active { background: rgba(18, 32, 64, 0.06); transform: scale(0.985); transition: transform 80ms cubic-bezier(0.4, 0, 0.2, 1); }
  90. .tabbar .ico { position: relative; width: 24px; height: 24px; display: grid; place-items: center; }
  91. .tabbar svg.i { width: 22px; height: 22px; stroke-width: 2.1; }
  92. .tabbar .lbl, .tabbar .en { display: none; }
  93. .tabbar .short { display: block; font-size: 11px; line-height: 12px; font-weight: 600; letter-spacing: 0.02em; white-space: nowrap; }
  94. .tabbar a[aria-current="page"] { color: var(--kf-accent-ink); background: none; box-shadow: none; }
  95. .tabbar .ico .sl { display: none; stroke-width: 2.6; }
  96. .tabbar a[aria-current="page"] .ico .ln { display: none; }
  97. .tabbar a[aria-current="page"] .ico .sl { display: block; }
  98. .tabbar a[aria-current="page"] .ico::after { content: ""; position: absolute; inset: 4px; border-radius: 7px; z-index: -1;
  99. background: var(--kf-accent-soft); }
  100. .tabbar a[aria-current="page"]::after { content: ""; position: absolute; top: -6px; left: 50%; width: 20px; height: 2px; margin-left: -10px;
  101. border-radius: 0 0 2px 2px; background: var(--kf-accent); }
  102. /* 移动 Tab 角标:不绝对定位出头(v10 实测悬在栏外 20px 处,形成一条悬空的小色块) */
  103. .tabbar .badge { position: static; margin: -4px 0 0 20px; min-width: 16px; height: 16px; padding: 0 4px; font-size: 11px; border-radius: 6px; }
  104. }
  105. @media (min-width: 768px) { .tabbar { display: none; } }
  106. `;
  107. #cur() {
  108. return this.current === '/' ? '/' : `/${this.current.split('/')[1] || ''}`;
  109. }
  110. render() {
  111. const cur = this.#cur();
  112. // 直接绑字符串:`?aria-current=${...}` 渲染成空的 aria-current="",当前页永远不高亮
  113. const active = (it) => (it.path === '/' ? cur === '/' : cur === it.path);
  114. const curAria = (it) => (active(it) ? 'page' : 'false');
  115. const badge = (it, cls) => {
  116. const n = it.badge ? this.counts?.[it.badge] : null;
  117. return n ? html`<span class="badge ${cls} ${it.badge === 'stuck' ? 'danger' : ''}" title=${it.badge === 'stuck' ? '卡住的人' : '池中卡片'} data-count=${it.badge}>${n}</span>` : '';
  118. };
  119. return html`
  120. <aside class="side kf-glass is-panel" aria-label="主导航">
  121. <div class="brand">
  122. <span class="mark" aria-hidden="true">康</span>
  123. <div><div class="name">产品创新看板</div><div class="sub">${t('研发中心 · AI 实战营')}</div></div>
  124. </div>
  125. <nav>
  126. ${ITEMS.map((it) => html`<a href=${it.path} data-link data-path=${it.path} aria-current=${curAria(it)}>
  127. ${icon(it.icon, 18)}
  128. <span class="lbl">${it.label}<span class="en">${it.en}</span></span>
  129. ${badge(it, '')}
  130. </a>`)}
  131. </nav>
  132. <div class="foot" role="status" aria-live="polite">
  133. <span class="live ${this.connection === 'error' ? 'err' : this.connection === 'loading' ? 'loading' : ''}"></span>
  134. <span>${this.connection === 'error' ? '数据源连接失败' : this.connection === 'loading' ? '正在同步…' : this.connection || '已同步'}</span>
  135. </div>
  136. </aside>
  137. <nav class="tabbar" aria-label="主导航(手机)" data-tabbar>
  138. ${ITEMS.filter((it) => !it.mobileHidden).map((it) => html`<a href=${it.path} data-link data-path=${it.path} aria-current=${curAria(it)}
  139. aria-label=${it.label}>
  140. <span class="ico">${icon(it.icon, 22, 'ln')}${icon(`${it.icon}Solid`, 22, 'sl')}${badge(it, '')}</span>
  141. <span class="short">${it.short || it.label}</span>
  142. </a>`)}
  143. </nav>
  144. `;
  145. }
  146. }
  147. define('kf-nav', KfNav);