|
|
@@ -1,8 +1,13 @@
|
|
|
/**
|
|
|
- * pages/view-workbench.js —— <view-workbench> 工作台首页。
|
|
|
+ * pages/view-workbench.js —— <view-workbench> 工作台首页(v15 Bento 重构)。
|
|
|
*
|
|
|
- * 三块:我的进度(阶段色块)/ 机会卡池(最新的卡)/ 现场(谁卡住了)。
|
|
|
- * 页面只放数字和卡,不放口径说明;口径在 core.stats。
|
|
|
+ * 骨架(12 栅格):
|
|
|
+ * A 我的推进(8 列,浅色) | B 现场(4 列,深色:全页唯一一块深卡,锚定注意力)
|
|
|
+ * C 卡池 | D 最高分 | E 已决策 ← 三张短数据卡,一行等高:标签 / 大数字 / 一行说明
|
|
|
+ * F 最近动过(通栏,最多 3 条)
|
|
|
+ *
|
|
|
+ * 视觉纪律(v15):浅底嵌深卡、单一强调色(--kf-accent 只给一个关键动作)、字号只留 12/14/16/20/32、
|
|
|
+ * 区块之间 32、卡与卡 24、卡内边距 20、卡内 gap 12。不再放柱状装饰(kf-spark 已移除)。
|
|
|
*/
|
|
|
import { LitElement, css, html } from 'lit';
|
|
|
import { useTokens } from '../components/base.js';
|
|
|
@@ -15,6 +20,17 @@ import { cardFooter } from './report-card.js';
|
|
|
import { icon } from '../components/icons.js';
|
|
|
|
|
|
const TONE = { S1: 'blue', S2: 'violet', S3: 'teal', S4: 'amber', S5: 'rose' };
|
|
|
+/** 「最近动过」的时间写法:今天 / 昨天 / N 天前 / 日期 */
|
|
|
+const ago = (iso) => {
|
|
|
+ const t0 = Date.parse(iso || '');
|
|
|
+ if (!t0) return '';
|
|
|
+ const d = Math.floor((Date.now() - t0) / 86400000);
|
|
|
+ if (d <= 0) return '今天';
|
|
|
+ if (d === 1) return '昨天';
|
|
|
+ if (d < 7) return `${d} 天前`;
|
|
|
+ const dt = new Date(t0);
|
|
|
+ return `${dt.getMonth() + 1}月${dt.getDate()}日`;
|
|
|
+};
|
|
|
|
|
|
export class ViewWorkbench extends LitElement {
|
|
|
static properties = {
|
|
|
@@ -33,136 +49,137 @@ export class ViewWorkbench extends LitElement {
|
|
|
useTokens(this);
|
|
|
}
|
|
|
|
|
|
-
|
|
|
static styles = [VIEW_CSS, css`
|
|
|
- /* 跨级间距(v11 3.1,可校验):卡片内 16 / 卡内元素 12 / 卡与卡 24 / 区块之间 48。
|
|
|
- 这里内紧外松:两个一组(我的进度 + 现场)24,一组结束 → 下一个区块 48,区块标题 → 内容 12。 */
|
|
|
- /* 卡片之间 24;卡片在图里会被拉伸到同一行高,所以卡片本身就是 flex 列:位置靠上,空的地方露出来 */
|
|
|
- .grid2 { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--kf-space-card-gap); align-items: start; }
|
|
|
- /* 两张卡内容高度不同:各自按内容收缩,不被栅格拉伸(v11b 4.2) */
|
|
|
- /* v12 P3:左右两卡等高(栅格 stretch,kf-glass-card 宿主 height:100% 吃满行高) */
|
|
|
- .grid2 > kf-glass-card { height: 100%; }
|
|
|
- /* 页顶 → 首个区块 16(任务书 3.1) */
|
|
|
- /* 页顶 → 首个区块 16(任务书 3.1):页头、第二个区块标题都收这条 */
|
|
|
- .p-head { padding-top: var(--kf-space-top); padding-bottom: var(--kf-space-top); }
|
|
|
- .hd:nth-of-type(n+2) { margin-top: var(--kf-space-card-gap); }
|
|
|
- /* 卡与卡 24(任务书 3.1);四张卡里元素 12 */
|
|
|
- .grid4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--kf-space-card-gap); align-items: start; margin-top: var(--kf-space-in); }
|
|
|
- .hd { display: flex; align-items: center; gap: var(--kf-space-in); margin: var(--kf-space-section) 0 0; min-height: 28px; }
|
|
|
- .who { display: flex; align-items: center; gap: var(--kf-space-in); flex-wrap: wrap; }
|
|
|
- .who h3 { margin: 0; font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
|
|
|
- .who .n { margin-left: auto; display: flex; gap: var(--kf-space-card-gap); }
|
|
|
- .who .n div { text-align: right; }
|
|
|
- .who .n b { display: block; font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
|
|
|
- .who .n span { font-size: 11px; font-weight: 500; line-height: 1.4; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); }
|
|
|
+ /* 纵向节奏(v15):区块之间 32 / 卡与卡 24 / 卡内元素 12。页头到第一块也是 32(Bento 第一块就是内容)。 */
|
|
|
+ .bento { display: flex; flex-direction: column; gap: var(--kf-space-section); margin-top: var(--kf-space-section); }
|
|
|
+ .bento > .row { display: grid; gap: var(--kf-space-card-gap); align-items: stretch; }
|
|
|
+ .bento > .row.ab { grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
|
+ .bento > .row.ab > kf-glass-card { height: auto; }
|
|
|
+ .bento > .row.ab > kf-glass-card[data-role="a"] { grid-column: span 8; }
|
|
|
+ .bento > .row.ab > kf-glass-card[data-role="b"] { grid-column: span 4; }
|
|
|
+ .bento > .row.cde { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
|
+ .p-head { padding-top: var(--kf-space-top); padding-bottom: 0; }
|
|
|
+
|
|
|
+ /* ---------- 通用小件 ---------- */
|
|
|
+ .lbl { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 500; line-height: 1.4; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); }
|
|
|
+ .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; display: inline-block; }
|
|
|
+ .go { display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 500; color: var(--kf-accent-ink); text-decoration: none; }
|
|
|
+ .go:hover { color: var(--kf-accent); }
|
|
|
+ /* 全页唯一一个强调色实心元素:C 卡上的「全部机会卡 →」 */
|
|
|
+ .btn-key { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: var(--r-sm);
|
|
|
+ background: var(--kf-accent); color: #fff; font-size: 14px; font-weight: 500; text-decoration: none; flex: none; }
|
|
|
+ .btn-key:hover { background: var(--kf-accent-strong); }
|
|
|
+ .empty { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 12px 14px; color: var(--kf-ink-3); font-size: 14px; line-height: 1.5;
|
|
|
+ border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 24%, transparent); border-radius: var(--r-md); }
|
|
|
+ .empty .go { margin-left: auto; }
|
|
|
+
|
|
|
+ /* ---------- A 卡:我的推进 ---------- */
|
|
|
+ .me { display: grid; gap: var(--kf-space-in); height: 100%; align-content: start; }
|
|
|
+ .me-id { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
|
.avatar { width: 44px; height: 44px; border-radius: var(--r-md); display: grid; place-items: center; font-size: 20px; font-weight: 600; color: #fff;
|
|
|
- background: var(--kf-accent); flex: none; }
|
|
|
- .hd h3 { font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
|
|
|
- .hd .go { margin-left: auto; font-variant-numeric: tabular-nums; }
|
|
|
- /* KPI 卡:数值 + 单位分层(1.2)+ 右上角 sparkline(板块二:形状替一句话)。一眼给:大数字 / 近 7 天走势 / 标签 */
|
|
|
- .kpi { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "v spark" "l l"; row-gap: 6px; column-gap: 8px; align-items: end; }
|
|
|
- /* v11b:修剪后的行盒只有「容器是盒子」才生效 —— display:flex 的容器不参与首末行修剪(实测仍 32px),
|
|
|
- 所以这里用 block + height: max-content;内部数字与单位仍是 inline,靠字号的 vertical-align 对齐基线。 */
|
|
|
- .kpi b { grid-area: v; display: block; height: max-content; font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12);
|
|
|
- font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; text-box-trim: trim-both; text-box-edge: cap alphabetic; white-space: nowrap; min-width: 0; }
|
|
|
- /* 单位切到最近的字号档(11),留出 0.12em(单位是 11px 时 em 也跟着单位走,所以这里用固定 1.5px 对齐视觉) */
|
|
|
- .kpi b .u { margin-left: 0.14em; font-size: 11px; font-weight: 500; color: var(--kf-ink-3); letter-spacing: 0; }
|
|
|
- .kpi b.dim { color: var(--kf-ink-3); }
|
|
|
- .kpi .lbl { grid-area: l; font-size: 11px; font-weight: 500; line-height: 1.4; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3);
|
|
|
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
- .kpi kf-spark { grid-area: spark; color: var(--kf-accent); align-self: end; margin-bottom: 3px; }
|
|
|
- /* 阶段条:五格一格一句「S1 · 3 份」,徽标 + 数值同行 */
|
|
|
+ background: var(--kf-ink); flex: none; }
|
|
|
+ .avatar.q { background: var(--kf-fill); color: var(--kf-ink-3); border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 34%, transparent); }
|
|
|
+ .me-id h3 { margin: 0; font-size: 20px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
|
|
|
+ .me-id .sub { font-size: 14px; color: var(--kf-ink-3); }
|
|
|
+ .me-id .go { margin-left: auto; }
|
|
|
+ .me-nums { margin-left: auto; display: flex; gap: 24px; }
|
|
|
+ .me-nums div { text-align: right; }
|
|
|
+ .me-nums b { display: block; font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12);
|
|
|
+ font-variant-numeric: tabular-nums; }
|
|
|
+ .me-nums span { font-size: 12px; font-weight: 500; color: var(--kf-ink-3); }
|
|
|
+
|
|
|
+ /* 阶段条:五格一格一句「S1 · 份数」,徽标 + 大数字 + 一根细进度条 */
|
|
|
.stagebar { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--kf-space-in); }
|
|
|
- .stg { padding: var(--kf-space-in); border-radius: var(--r-md); background: var(--kf-fill); border: 1px solid var(--kf-line);
|
|
|
- display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 4px 8px; min-width: 0; }
|
|
|
- .stg kf-ring { grid-row: 1 / 3; grid-column: 3; color: var(--tone, var(--kf-accent)); }
|
|
|
- .stg .b { font: 600 11px/1 var(--kf-font); color: #fff; padding: 4px 6px; border-radius: var(--r-sm); letter-spacing: 0.02em; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
|
|
|
+ .stg { padding: 12px; border-radius: var(--r-md); background: var(--kf-fill); border: 1px solid transparent;
|
|
|
+ display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 4px 8px; min-width: 0; }
|
|
|
+ .stg .b { font-size: 12px; font-weight: 600; line-height: 1; color: #fff; padding: 4px 6px; border-radius: var(--r-sm); letter-spacing: 0.02em; justify-self: start; }
|
|
|
.stg .b.blue { background: var(--kf-s1); } .stg .b.violet { background: var(--kf-s2); } .stg .b.teal { background: var(--kf-s3); }
|
|
|
.stg .b.amber { background: var(--kf-s4); } .stg .b.rose { background: var(--kf-s5); }
|
|
|
- .stg .n { justify-self: end; font-size: 16px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12);
|
|
|
- font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
|
|
|
- .stg .f { grid-column: 1 / -1; height: 3px; border-radius: 2px; background: rgba(16, 24, 40, 0.07); overflow: hidden; }
|
|
|
+ .stg .n { justify-self: end; font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-variant-numeric: tabular-nums; }
|
|
|
+ .stg .f { grid-column: 1 / -1; height: 4px; border-radius: 2px; background: rgba(16, 24, 40, 0.07); overflow: hidden; margin-top: 4px; }
|
|
|
.stg .f i { display: block; height: 100%; border-radius: 2px; background: currentColor; width: calc(var(--v) * 100%); }
|
|
|
- .stg.todo { color: var(--kf-ink-3); background: transparent; border-style: dashed; }
|
|
|
- .stg.todo .n { color: var(--kf-ink-3); font-weight: 500; white-space: nowrap; }
|
|
|
- .stg .n.ph { letter-spacing: 0.1em; }
|
|
|
.stg.blue { color: var(--kf-s1); } .stg.violet { color: var(--kf-s2); } .stg.teal { color: var(--kf-s3); } .stg.amber { color: var(--kf-s4); } .stg.rose { color: var(--kf-s5); }
|
|
|
- /* 最近动过:3 行「标签 ···· 值」(2.5,比标签/值两行省一半高度) */
|
|
|
- .recent { margin-top: var(--kf-space-in); }
|
|
|
- .recent-h { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
|
|
- .recent-h span { font-size: 11px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); }
|
|
|
- .recent .kv { font-size: 13px; }
|
|
|
- .recent .kv .v b { font-weight: 600; font-size: 14px; margin-right: 2px; }
|
|
|
- .recent .kv .u { margin-left: 0.10em; font-size: 11px; color: var(--kf-ink-3); }
|
|
|
- /* 现场:一行一人(名字 · 阶段 · 时长),不再每个都套一个红色小盒子 */
|
|
|
- .stalled { margin-top: var(--kf-space-in); border-top: 1px solid var(--kf-line); }
|
|
|
- .stalled li { display: flex; gap: 8px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--kf-line); font-size: 13px; }
|
|
|
- .stalled .nm { font-weight: 500; color: var(--kf-ink); }
|
|
|
- .stalled .meta { color: var(--kf-ink-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
- .stalled .h { margin-left: auto; font-weight: 600; color: var(--kf-s); font-variant-numeric: tabular-nums; }
|
|
|
- .ok { display: flex; align-items: center; gap: 8px; margin-top: var(--kf-space-in); font-size: 13px; color: var(--kf-ok-text); font-weight: 500; }
|
|
|
- .todo { display: grid; gap: 0; margin-top: var(--kf-space-in); }
|
|
|
- .todo li { display: flex; align-items: center; gap: 8px; font-size: 13px; min-height: 28px; }
|
|
|
- .todo li a { color: var(--kf-ink); font-weight: 500; text-decoration: none; border-bottom: 1px dashed var(--kf-line-strong); }
|
|
|
- .todo li .s { margin-left: auto; font-size: 13px; font-weight: 600; color: var(--kf-accent-ink); font-variant-numeric: tabular-nums; }
|
|
|
- .go { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: var(--kf-accent-ink); text-decoration: none; }
|
|
|
- .go:hover { color: var(--kf-accent); }
|
|
|
+ .stg.todo { color: var(--kf-ink-3); background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--kf-ink-3) 22%, transparent); }
|
|
|
+ .stg.todo .n { color: var(--kf-ink-3); font-weight: 500; }
|
|
|
+ /* 待评分:只有这几行时 A 卡会矮一截,min-height 让 A 在有卡住的人时仍撑得起来 */
|
|
|
+ .pending { display: grid; gap: 0; }
|
|
|
+ .pending li { display: flex; align-items: baseline; gap: 8px; min-height: 32px; font-size: 14px; }
|
|
|
+ .pending li a { color: var(--kf-ink); text-decoration: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+ .pending li a:hover { color: var(--kf-accent-ink); }
|
|
|
+ .pending li .s { margin-left: auto; font-weight: 600; color: var(--kf-accent-ink); font-variant-numeric: tabular-nums; }
|
|
|
+
|
|
|
+ /* ---------- B 卡:现场(深色) ---------- */
|
|
|
+ .live { display: grid; gap: var(--kf-space-in); height: 100%; align-content: start;
|
|
|
+ --live-ink: #F4F4F5; --live-ink-3: #A1A1AA; --live-line: rgba(255, 255, 255, 0.12); }
|
|
|
+ .live .lbl { color: var(--live-ink-3); }
|
|
|
+ .live-head { display: flex; align-items: center; gap: 8px; }
|
|
|
+ .live-head .go { margin-left: auto; color: var(--live-ink-3); }
|
|
|
+ .live-head .go:hover { color: var(--live-ink); }
|
|
|
+ .live-num { display: flex; align-items: baseline; gap: 8px; color: var(--live-ink); }
|
|
|
+ .live-num b { font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); font-variant-numeric: tabular-nums; }
|
|
|
+ .live-num span { font-size: 14px; color: var(--live-ink-3); }
|
|
|
+ .live-title { font-size: 16px; font-weight: 600; line-height: 1.3; color: var(--live-ink); }
|
|
|
+ .stalled { border-top: 1px solid var(--live-line); }
|
|
|
+ .stalled li { display: flex; gap: 8px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--live-line); font-size: 14px; color: var(--live-ink); }
|
|
|
+ .stalled li:last-child { border-bottom: 0; }
|
|
|
+ .stalled .nm { font-weight: 500; }
|
|
|
+ .stalled .meta { color: var(--live-ink-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+ .stalled .h { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
|
+ .live-ok { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 14px; font-weight: 500; color: var(--live-ink); }
|
|
|
+ .live-foot { margin-top: auto; font-size: 12px; color: var(--live-ink-3); }
|
|
|
+
|
|
|
+ /* ---------- C/D/E:三张短数据卡 ---------- */
|
|
|
+ .stat { display: grid; grid-template-rows: auto auto auto; gap: 8px; height: 100%; align-content: start; }
|
|
|
+ .stat .top { display: flex; align-items: center; gap: 8px; min-height: 24px; }
|
|
|
+ .stat .top .go { margin-left: auto; font-size: 14px; }
|
|
|
+ .stat b { font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12);
|
|
|
+ font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 0; }
|
|
|
+ .stat b.dim { color: var(--kf-ink-3); }
|
|
|
+ .stat b .u { margin-left: 0.14em; font-size: 14px; font-weight: 500; color: var(--kf-ink-3); letter-spacing: 0; }
|
|
|
+ .stat .note { font-size: 12px; line-height: 1.5; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3);
|
|
|
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-height: 18px; }
|
|
|
+
|
|
|
+ /* ---------- F:最近动过(通栏) ---------- */
|
|
|
+ .feed { display: grid; gap: 0; }
|
|
|
+ .feed li { display: flex; align-items: baseline; gap: 12px; min-height: 44px; padding: 10px 0; border-top: 1px solid var(--kf-line); font-size: 14px; }
|
|
|
+ .feed li:first-child { border-top: 0; padding-top: 0; }
|
|
|
+ .feed li a { color: var(--kf-ink); font-weight: 500; text-decoration: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+ .feed li a:hover { color: var(--kf-accent-ink); }
|
|
|
+ .feed .st { font-size: 12px; color: var(--kf-ink-3); flex: none; }
|
|
|
+ .feed .tm { margin-left: auto; font-size: 12px; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; flex: none; }
|
|
|
+ .feed-head { display: flex; align-items: center; gap: 8px; }
|
|
|
+ .feed-head .go { margin-left: auto; }
|
|
|
+ .feed-empty { font-size: 14px; color: var(--kf-ink-3); padding: 8px 0; }
|
|
|
.foot-links { display: none; }
|
|
|
- /* 空态(板块八 8.1):虚线框与有数据时近似等高,不闪、不跳 */
|
|
|
- /* 虚线占位(v11b 4.2):高度 = 内容(一行 18px + 上下 10px)≈ 38px,不再撑到 72px / 半张卡;
|
|
|
- 卡片本身 align-items: start,内容多高卡就多高,不留「撑高留白」。 */
|
|
|
- .empty { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 10px 12px; color: var(--kf-ink-3); font-size: 13px; line-height: 18px;
|
|
|
- border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 24%, transparent); border-radius: var(--r-md); }
|
|
|
- .empty .go { margin-left: auto; }
|
|
|
- .stagebar { margin-top: var(--kf-space-in); }
|
|
|
- .latest { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--kf-space-card-gap); align-items: stretch; margin-top: var(--kf-space-in); }
|
|
|
- .latest kf-opportunity-card { --i: 0; }
|
|
|
- @media (max-width: 1100px) { .grid4, .latest { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid2 { grid-template-columns: 1fr; } }
|
|
|
- /* 手机:单列;KPI 两列;阶段条五格压缩;可点区域 44px */
|
|
|
+
|
|
|
+ /* 平板:A/B 变 7/5 太挤,直接上下堆;C/D/E 保持一行 */
|
|
|
+ @media (max-width: 1100px) {
|
|
|
+ .bento > .row.ab { grid-template-columns: minmax(0, 1fr); }
|
|
|
+ .bento > .row.ab > kf-glass-card[data-role="a"], .bento > .row.ab > kf-glass-card[data-role="b"] { grid-column: auto; }
|
|
|
+ }
|
|
|
+ /* 手机 393:A/B 竖排,C/D/E 三卡一行(每卡 3 行文字仍放得下),可点区域 44px */
|
|
|
@media (max-width: 767px) {
|
|
|
- .grid2 { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-in); }
|
|
|
- .grid4 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--kf-space-in); }
|
|
|
- .latest { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-in); }
|
|
|
- .kpi b { font-size: 28px; }
|
|
|
- .kpi kf-spark { display: none; }
|
|
|
- /* 手机:五格放不下一行(39.3 宽 × 5 = 197px 还挤着环),改成两行三列 —— 不再把第五格挤出卡片 */
|
|
|
- .stagebar { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
|
|
- .stg { padding: 8px; grid-template-columns: auto 1fr auto; }
|
|
|
- .stg kf-ring { width: 32px; height: 32px; }
|
|
|
- .todo li { font-size: 14px; min-height: 44px; }
|
|
|
- .todo li a, .go, .hd .go { min-height: 44px; display: inline-flex; align-items: center; }
|
|
|
- .todo li a { flex: 1; min-width: 0; }
|
|
|
- .go { min-width: 44px; justify-content: center; padding: 0 6px; }
|
|
|
- .stalled li { flex-wrap: wrap; }
|
|
|
+ .bento { gap: var(--kf-space-card); margin-top: var(--kf-space-card); }
|
|
|
+ .bento > .row { gap: var(--kf-space-card); }
|
|
|
+ .bento > .row.cde { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
|
+ .stagebar { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
|
|
|
+ .stg { padding: 8px 6px; grid-template-columns: auto; gap: 4px; justify-items: start; }
|
|
|
+ .stg .n { justify-self: start; font-size: 16px; }
|
|
|
+ .me-nums { margin-left: 0; width: 100%; justify-content: space-between; gap: 12px; }
|
|
|
+ .me-nums div { text-align: left; }
|
|
|
+ .stat b { font-size: 24px; }
|
|
|
+ .stat .top .go { display: none; }
|
|
|
+ .btn-key { display: none; }
|
|
|
+ .feed li { flex-wrap: wrap; }
|
|
|
+ .feed li a { white-space: normal; flex: 1 1 100%; }
|
|
|
+ .go, .hd .go, .me-id .go { min-height: 44px; min-width: 44px; justify-content: center; }
|
|
|
+ .pending li { min-height: 44px; }
|
|
|
.foot-links { display: flex; gap: 8px; margin-top: var(--kf-space-card); }
|
|
|
.foot-links a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; border-radius: var(--r-md); font-size: 14px; font-weight: 600;
|
|
|
- color: var(--kf-accent-ink); text-decoration: none; border: 1px solid transparent;
|
|
|
- background: linear-gradient(var(--kf-glass-chip), var(--kf-glass-chip)) padding-box, var(--kf-edge-chip) border-box; }
|
|
|
+ color: var(--kf-accent-ink); text-decoration: none; background: #fff; border: 1px solid rgba(0, 0, 0, 0.06); }
|
|
|
}
|
|
|
`];
|
|
|
|
|
|
- /**
|
|
|
- * 近 7 天逐日新增(sparkline 的数据)。按页面时钟算,不落库、不改 schema。
|
|
|
- * by: 'opp' 机会卡 / 'artifact' 成果。
|
|
|
- */
|
|
|
- #weekly(by = 'opp') {
|
|
|
- const DAY = 86400000;
|
|
|
- const now = Date.now();
|
|
|
- const src = by === 'opp'
|
|
|
- ? this.db.opportunities.map((o) => o.upload?.uploadedAt || o.createdAt)
|
|
|
- : this.db.artifacts.map((a) => a.createdAt);
|
|
|
- const days = Array.from({ length: 7 }, (_, i) => {
|
|
|
- const start = now - (6 - i) * DAY;
|
|
|
- return src.filter((t) => { const v = Date.parse(t || ''); return v > start - DAY && v <= start && v > now - 7 * DAY; }).length;
|
|
|
- });
|
|
|
- // 首日补齐:把更早的都算进第 1 天,形状才不撒谎
|
|
|
- const oldest = now - 7 * DAY;
|
|
|
- days[0] = src.filter((t) => { const v = Date.parse(t || ''); return v > 0 && v <= now - 6 * DAY; }).length;
|
|
|
- const max = Math.max(1, ...days);
|
|
|
- void oldest;
|
|
|
- return { days, max, recent: days.reduce((a, b) => a + b, 0) };
|
|
|
- }
|
|
|
-
|
|
|
render() {
|
|
|
const db = this.db;
|
|
|
if (!db) return html`<kf-empty kind="loading" title="工作台"></kf-empty>`;
|
|
|
@@ -172,26 +189,20 @@ export class ViewWorkbench extends LitElement {
|
|
|
const latest = sortByNewest(pool.latest3);
|
|
|
return html`
|
|
|
${head('我的推进', '五阶段进度与现场状态', html`<a class="go" href=${href('/pool')} data-link>机会卡池 →</a>`)}
|
|
|
- <div class="grid2 kf-enter" style="margin-top:0; --i:0">
|
|
|
- <kf-glass-card surface="2">${this.#mine(me, pool, live)}</kf-glass-card>
|
|
|
- <kf-glass-card surface="2" tone=${live.stalled.length ? 'danger' : 'ok'}>
|
|
|
- <div class="who"><h3>${live.stalled.length ? t(`${live.stalled.length} 人卡住`) : '现场正常'}</h3>
|
|
|
- <a class="go" style="margin-left:auto" href=${href('/pool')} data-link aria-label="去机会卡池">${icon('arrow', 14)}</a></div>
|
|
|
- ${live.stalled.length ? html`<ul class="stalled">
|
|
|
- ${live.stalled.slice(0, 4).map((s) => html`<li data-stalled=${s.name}>
|
|
|
- <span class="nm">${s.name}</span><span class="meta">${t(`${s.stage} · ${s.msgCount} 条`)}</span>
|
|
|
- <span class="h">${s.stuckHours}h</span></li>`)}
|
|
|
- </ul>` : html`<p class="ok">${icon('check', 16)} 大家都在动</p>`}
|
|
|
- </kf-glass-card>
|
|
|
- </div>
|
|
|
+ <div class="bento">
|
|
|
+ <div class="row ab kf-enter" style="--i:0">
|
|
|
+ <kf-glass-card data-role="a">${this.#mine(me, pool, live)}</kf-glass-card>
|
|
|
+ <kf-glass-card data-role="b" dark>${this.#live(live, pool)}</kf-glass-card>
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="hd"><h3>机会卡池</h3><a class="go" href=${href('/pool')} data-link aria-label="全部机会卡">${pool.total} ${icon('arrow', 14)}</a></div>
|
|
|
- ${this.#pool(pool)}
|
|
|
+ <div class="row cde kf-enter" style="--i:1">
|
|
|
+ <kf-glass-card data-role="c">${this.#stat('open', pool)}</kf-glass-card>
|
|
|
+ <kf-glass-card data-role="d">${this.#stat('top', pool)}</kf-glass-card>
|
|
|
+ <kf-glass-card data-role="e">${this.#stat('decided', pool)}</kf-glass-card>
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="latest" style="--i:2">
|
|
|
- ${latest.map((o) => html`<kf-opportunity-card .opp=${o} base="/pool" @card-open=${(e) => this.#open(e.detail.id)}></kf-opportunity-card>`)}
|
|
|
+ <kf-glass-card data-role="f" class="kf-enter" style="--i:2">${this.#feed(latest)}</kf-glass-card>
|
|
|
</div>
|
|
|
- ${/* v12 P2 (b):底部收尾 —— 本页概况的一句收口 + 回顶部,吃掉页尾空白 */ ''}
|
|
|
${cardFooter({ shown: latest.length, all: pool.total, filtered: false, label: '张机会卡,最近 3 张列在上方', onTop: () => window.scrollTo({ top: 0, behavior: 'smooth' }) })}
|
|
|
<!-- 只有手机显示:证据台账从底部 Tab 移到工作台页脚(真实链接,部署前缀同源) -->
|
|
|
<nav class="foot-links" aria-label="更多入口">
|
|
|
@@ -200,85 +211,110 @@ export class ViewWorkbench extends LitElement {
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
+ /** A 卡:选了「我是」→ 我的进度;没选 → 全员进度(v12 定稿:空态也放真实内容,不摆空框) */
|
|
|
#mine(me, pool, live) {
|
|
|
if (!this.me) {
|
|
|
- // 空态(板块八):说清为什么空 + 给一个动作 + 卡片框不消失。
|
|
|
- // v12 P3:「未选」不再是一张空框 —— 左卡改放全员的真实进度(五阶段 × 在阶段人数 / 已交人数),
|
|
|
- // 选人动作在卡片右上角;选完之后同一张卡换成「我的进度」。左右两卡等高(栅格 stretch)。
|
|
|
- return html`<div class="who"><div class="avatar">?</div><h3>还没选「我是」</h3>
|
|
|
- <a class="go" style="margin-left:auto" href=${href('/members')} data-link>${icon('user', 14)} 选我</a></div>
|
|
|
- <div class="recent-h" style="margin-top:12px"><span>${t(`先看全员:${live.memberCount} 人`)}</span><span style="margin-left:auto;color:var(--kf-ink)">${t(`已交 ${live.submittedPeople} 人`)}</span></div>
|
|
|
+ const maxSub = Math.max(1, ...live.stages.map((r) => r.submitted));
|
|
|
+ return html`<div class="me" data-empty="no-me">
|
|
|
+ <div class="me-id"><div class="avatar q">?</div>
|
|
|
+ <div><h3>还没选「我是」</h3><span class="sub">先看全员:${live.memberCount} 人</span></div>
|
|
|
+ <a class="go" href=${href('/members')} data-link>${icon('user', 14)} 选我</a></div>
|
|
|
<div class="stagebar" data-allstages>
|
|
|
${live.stages.map((r, i) => html`<div class="stg ${TONE[r.stage.code]}" style=${`--i:${i + 1}`} data-stage=${r.stage.code}>
|
|
|
<span class="b ${TONE[r.stage.code]}">${r.stage.code}</span>
|
|
|
<span class="n">${r.submitted || '—'}</span>
|
|
|
- <kf-ring size=${40} .pct=${(r.submitted / Math.max(1, live.memberCount)) * 100} label=${String(r.submitted)}></kf-ring>
|
|
|
- <span class="f" aria-hidden="true"><i style=${`--v:${(r.submitted / Math.max(1, live.memberCount)).toFixed(2)}`}></i></span></div>`)}
|
|
|
+ <span class="f" aria-hidden="true"><i style=${`--v:${(r.submitted / maxSub).toFixed(2)}`}></i></span></div>`)}
|
|
|
</div>
|
|
|
- <div class="empty" style="margin-top:12px"><span>选了自己,这张卡就换成你的阶段进度与待办</span></div>`;
|
|
|
+ <div class="empty"><span>已交 ${live.submittedPeople} 人 · 选了自己,这里换成你的阶段进度与待办</span></div>
|
|
|
+ </div>`;
|
|
|
}
|
|
|
- const done = STAGES.filter((s) => me.stagesDone.includes(s.code));
|
|
|
+ const done = new Set(me.stagesDone);
|
|
|
const max = Math.max(1, ...STAGES.map((s) => me.mine.filter((a) => a.stageObj?.code === s.code).length));
|
|
|
- // 阶段完成度环:环 = 本阶段份数占「全池该阶段最多份数」的比例(页面上每个阶段都有自己的最大值,这里取本阶段 5 格里的最大值)
|
|
|
- const stageMax = max;
|
|
|
- const sp = this.#weekly('artifact');
|
|
|
- return html`
|
|
|
- <div class="who">
|
|
|
+ const pending = me.pendingScores || [];
|
|
|
+ return html`<div class="me" data-empty=${me.count === 0 ? 'no-result' : 'none'}>
|
|
|
+ <div class="me-id">
|
|
|
<div class="avatar">${Array.from(me.me).slice(-1).join('')}</div>
|
|
|
- <h3>${me.me}</h3>
|
|
|
- <div class="n">
|
|
|
+ <div><h3>${me.me}</h3><span class="sub">已完成 ${me.stagesDone.length} / 5 个阶段</span></div>
|
|
|
+ <div class="me-nums">
|
|
|
<div><b>${me.count}</b><span>份成果</span></div>
|
|
|
<div><b>${me.opps.length}</b><span>张卡</span></div>
|
|
|
- <div><b>${me.pendingScores.length}</b><span>待评分</span></div>
|
|
|
+ <div><b>${pending.length}</b><span>待评分</span></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- ${me.stagesDone.length || me.stagesMissing.length ? html`<div class="recent-h" data-done>已完成 ${me.stagesDone.length} / 5 个阶段</div>` : ''}
|
|
|
<div class="stagebar">
|
|
|
${STAGES.map((s, i) => {
|
|
|
const n = me.mine.filter((a) => a.stageObj?.code === s.code).length;
|
|
|
- const ok = done.includes(s.code);
|
|
|
- // 阶段完成度环(v11b 2.2):环 40px / 描边 3px / 环心印读数;旁边的 n 是「份」的精确数,环只说形状
|
|
|
+ const ok = done.has(s.code);
|
|
|
return html`<div class="stg kf-enter ${TONE[s.code]} ${ok ? '' : 'todo'}" style=${`--i:${i + 1}`} data-stage=${s.code}>
|
|
|
<span class="b ${TONE[s.code]}">${s.code}</span>
|
|
|
<span class="n">${n || '—'}</span>
|
|
|
- <kf-ring size=${40} .pct=${(n / stageMax) * 100} label=${String(n)}></kf-ring>
|
|
|
<span class="f" aria-hidden="true"><i style=${`--v:${(n / max).toFixed(2)}`}></i></span></div>`;
|
|
|
})}
|
|
|
</div>
|
|
|
- ${me.count === 0 ? html`<div class="empty" style="margin-top:12px"><span>本月还没有你的成果</span>
|
|
|
+ ${me.count === 0 ? html`<div class="empty"><span>本月还没有你的成果</span>
|
|
|
<a class="go" href=${href('/stages')} data-link>看五阶段产出 →</a></div>` : ''}
|
|
|
- <div class="recent">${this.#recent(pool)}</div>
|
|
|
- ${me.pendingScores.length ? html`<div class="recent-h" style="margin-top:12px"><span>待评分 ${me.pendingScores.length}</span>
|
|
|
- <a class="go" href=${href('/pool')} data-link aria-label="待评分卡">${icon('arrow', 14)}</a></div>
|
|
|
- <ul class="todo">${me.pendingScores.slice(0, 3).map((o) => html`<li>
|
|
|
- <a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a><span class="s">${o.stats.avg != null ? Math.round(o.stats.avg) : '—'}</span></li>`)}</ul>`
|
|
|
- : html`<div class="recent-h" style="margin-top:12px"><span>近 7 天成果</span><kf-spark small .values=${sp.days.map((d) => d / sp.max)}></kf-spark><span style="margin-left:auto;color:var(--kf-ink)">${sp.recent}</span></div>`}`;
|
|
|
+ ${pending.length ? html`<div>
|
|
|
+ <div class="lbl">待评分 ${pending.length}</div>
|
|
|
+ <ul class="pending">${pending.slice(0, 3).map((o) => html`<li>
|
|
|
+ <a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a><span class="s">${o.stats.avg != null ? Math.round(o.stats.avg) : '—'}</span></li>`)}</ul>
|
|
|
+ </div>` : ''}
|
|
|
+ </div>`;
|
|
|
}
|
|
|
|
|
|
- /** 「最近动过」:标签 ···· 值(2.5)——三行讲清池子的状态构成,不写一句话 */
|
|
|
- #recent(pool) {
|
|
|
- const s = pool.byStatus;
|
|
|
- const total = Math.max(1, pool.total);
|
|
|
- return html`<div class="recent-h"><span>最近动过</span></div>
|
|
|
- <div class="kv"><span class="k">候选</span><span class="v"><b>${s.candidate}</b> <span class="u">张</span>${Math.round((s.candidate / total) * 100)}<span class="u">%</span></span></div>
|
|
|
- <div class="kv"><span class="k">已立项</span><span class="v"><b>${s.approved}</b> <span class="u">张</span>${Math.round((s.approved / total) * 100)}<span class="u">%</span></span></div>
|
|
|
- <div class="kv"><span class="k">已否决</span><span class="v"><b>${s.rejected}</b> <span class="u">张</span>${Math.round((s.rejected / total) * 100)}<span class="u">%</span></span></div>`;
|
|
|
+ /** B 卡:现场。深色底 + 反白字;有卡住的人 → 橙点 + 人数 + 名单;没人卡住 → 绿点 + 正向文案。 */
|
|
|
+ #live(live, pool) {
|
|
|
+ const stalled = live.stalled;
|
|
|
+ return html`<div class="live" data-stalled-count=${stalled.length}>
|
|
|
+ <div class="live-head"><span class="lbl">${stalled.length
|
|
|
+ ? html`<i class="dot" style="background:#F59E0B"></i>现场`
|
|
|
+ : html`<i class="dot" style="background:#4ADE80"></i>现场`}</span>
|
|
|
+ <a class="go" href=${href('/pool')} data-link aria-label="去机会卡池">${icon('arrow', 14)}</a></div>
|
|
|
+ ${stalled.length ? html`
|
|
|
+ <div class="live-num"><b>${stalled.length}</b><span>人卡住</span></div>
|
|
|
+ <ul class="stalled">${stalled.slice(0, 4).map((s) => html`<li data-stalled=${s.name}>
|
|
|
+ <span class="nm">${s.name}</span><span class="meta">${t(`${s.stage} · ${s.msgCount} 条`)}</span>
|
|
|
+ <span class="h">${s.stuckHours}h</span></li>`)}</ul>`
|
|
|
+ : html`
|
|
|
+ <div class="live-title">大家都在动</div>
|
|
|
+ <p class="live-ok">${icon('check', 16)} 没有超过 8 小时没交成果的人</p>`}
|
|
|
+ <div class="live-foot">${t(`${live.memberCount} 人 · ${pool.total} 张卡在推进`)}</div>
|
|
|
+ </div>`;
|
|
|
}
|
|
|
|
|
|
- #pool(pool) {
|
|
|
- const sp = this.#weekly('opp');
|
|
|
- const spark = html`<kf-spark small .values=${sp.days.map((d) => d / sp.max)}></kf-spark>`;
|
|
|
- const avg = pool.top?.stats?.avg;
|
|
|
- return html`<div class="grid4">
|
|
|
- <kf-glass-card surface="2"><div class="kpi" data-kpi="total"><b>${pool.total}<span class="u">张</span></b>${spark}<span class="lbl">机会卡</span></div></kf-glass-card>
|
|
|
- <kf-glass-card surface="2"><div class="kpi" data-kpi="avg"><b class=${avg == null ? 'dim' : ''}>${avg != null ? Math.round(avg) : '—'}<span class="u">分</span></b>${spark}<span class="lbl">${pool.top?.title || '暂无评分'}</span></div></kf-glass-card>
|
|
|
- <kf-glass-card surface="2"><div class="kpi" data-kpi="people"><b>${pool.uploaders}<span class="u">人</span></b>${spark}<span class="lbl">${t(`${pool.agents} Agent 参与`)}</span></div></kf-glass-card>
|
|
|
- <kf-glass-card surface="2"><div class="kpi" data-kpi="decided"><b>${pool.byStatus.approved + pool.byStatus.rejected}<span class="u">张</span></b>${spark}<span class="lbl">已决策</span></div></kf-glass-card>
|
|
|
- </div>`;
|
|
|
+ /** C/D/E:标签 → 大数字 → 一行说明 */
|
|
|
+ #stat(kind, pool) {
|
|
|
+ if (kind === 'open') {
|
|
|
+ return html`<div class="stat" data-kpi="total">
|
|
|
+ <div class="top"><span class="lbl">卡池</span><a class="btn-key" href=${href('/pool')} data-link>全部 ${icon('arrow', 14)}</a></div>
|
|
|
+ <b>${pool.total}<span class="u">张</span></b>
|
|
|
+ <span class="note">${pool.uploaders} 人提交 · ${pool.agents} 个 Agent</span></div>`;
|
|
|
+ }
|
|
|
+ if (kind === 'top') {
|
|
|
+ const top = pool.top;
|
|
|
+ const avg = top?.stats?.avg;
|
|
|
+ return html`<div class="stat" data-kpi="avg">
|
|
|
+ <div class="top"><span class="lbl">最高分</span></div>
|
|
|
+ <b class=${avg == null ? 'dim' : ''}>${avg != null ? Math.round(avg) : '—'}<span class="u">分</span></b>
|
|
|
+ <span class="note" title=${top?.title || ''}>${top?.title || '暂无评分'}</span></div>`;
|
|
|
+ }
|
|
|
+ const dec = pool.byStatus.approved + pool.byStatus.rejected;
|
|
|
+ return html`<div class="stat" data-kpi="decided">
|
|
|
+ <div class="top"><span class="lbl">已决策</span></div>
|
|
|
+ <b>${dec}<span class="u">张</span></b>
|
|
|
+ <span class="note">立项 ${pool.byStatus.approved} · 否决 ${pool.byStatus.rejected}</span></div>`;
|
|
|
}
|
|
|
|
|
|
- #open(id) {
|
|
|
- this.dispatchEvent(new CustomEvent('card-open', { detail: { id }, bubbles: true, composed: true }));
|
|
|
+ /** F:最近动过(最多 3 条,右对齐时间) */
|
|
|
+ #feed(latest) {
|
|
|
+ return html`<div data-feed>
|
|
|
+ <div class="feed-head"><span class="lbl">最近动过</span>
|
|
|
+ <a class="go" href=${href('/pool')} data-link aria-label="全部机会卡">全部 →</a></div>
|
|
|
+ ${latest.length ? html`<ul class="feed" style="margin-top:8px">${latest.map((o) => html`<li>
|
|
|
+ <a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a>
|
|
|
+ <span class="st">${o.stageObj ? o.stageObj.code + ' ' + o.stageObj.name : ''}</span>
|
|
|
+ <span class="tm">${ago(o.upload?.uploadedAt || o.createdAt)}</span></li>`)}</ul>`
|
|
|
+ : html`<p class="feed-empty">还没有机会卡</p>`}
|
|
|
+ </div>`;
|
|
|
}
|
|
|
}
|
|
|
|