|
|
@@ -16,7 +16,9 @@ import { forMe, poolOverview, liveBoard, STAGES, sortOpportunities } from '../co
|
|
|
const sortByNewest = (rows) => sortOpportunities(rows, 'newest');
|
|
|
import { href } from '../utils/router.js';
|
|
|
import { t, VIEW_CSS, head } from './shared.js';
|
|
|
-import { cardFooter } from './report-card.js';
|
|
|
+import { cardFooter, reportCard, playCard, CARD_CSS } from './report-card.js';
|
|
|
+import { entryOfArtifact } from '../core/index.js';
|
|
|
+import '../components/kf-drawer.js';
|
|
|
import { icon } from '../components/icons.js';
|
|
|
|
|
|
const TONE = { S1: 'blue', S2: 'violet', S3: 'teal', S4: 'amber', S5: 'rose' };
|
|
|
@@ -36,12 +38,17 @@ export class ViewWorkbench extends LitElement {
|
|
|
static properties = {
|
|
|
db: { type: Object },
|
|
|
me: { type: String },
|
|
|
+ /** 抽屉:{ id, title } —— 机会卡全貌(完整报告 + 评分 + 快捷决策) */
|
|
|
+ drawer: { type: Object },
|
|
|
};
|
|
|
|
|
|
+ #mineLatest = null;
|
|
|
+
|
|
|
constructor() {
|
|
|
super();
|
|
|
this.db = null;
|
|
|
this.me = '';
|
|
|
+ this.drawer = null;
|
|
|
}
|
|
|
|
|
|
connectedCallback() {
|
|
|
@@ -49,7 +56,7 @@ export class ViewWorkbench extends LitElement {
|
|
|
useTokens(this);
|
|
|
}
|
|
|
|
|
|
- static styles = [VIEW_CSS, css`
|
|
|
+ static styles = [VIEW_CSS, CARD_CSS, css`
|
|
|
/* 纵向节奏(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; }
|
|
|
@@ -127,6 +134,8 @@ export class ViewWorkbench extends LitElement {
|
|
|
.pending li a:hover { color: var(--kf-accent-ink); }
|
|
|
.pending li .d { font-size: 12px; color: var(--kf-ink-3); flex: none; }
|
|
|
.pending li .s { margin-left: auto; font-weight: 600; color: var(--kf-accent-ink); font-variant-numeric: tabular-nums; flex: none; }
|
|
|
+ .pending li .s.dim { color: var(--kf-ink-3); font-weight: 500; }
|
|
|
+ .pending li .nm { font-weight: 500; }
|
|
|
.me-none { font-size: 14px; color: var(--kf-ink-3); padding-top: 10px; }
|
|
|
.me-note { font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); padding-top: 2px; }
|
|
|
.prog { display: grid; gap: 0; margin-top: 4px; }
|
|
|
@@ -164,7 +173,10 @@ export class ViewWorkbench extends LitElement {
|
|
|
.live-foot { margin-top: auto; padding-top: 8px; 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; }
|
|
|
+ /* 三张短数据卡:近方形(dr6 的 KPI 行),卡内上标签 / 下数字说明 */
|
|
|
+ .stat { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; height: 100%; align-content: start; min-height: 152px; }
|
|
|
+ .stat b { align-self: end; }
|
|
|
+ .stat .note { min-height: 0; }
|
|
|
.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);
|
|
|
@@ -174,22 +186,34 @@ export class ViewWorkbench extends LitElement {
|
|
|
.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:最近动过(通栏) ---------- */
|
|
|
- /* 分区标签:三个区块共用同一种「12px 灰小标 + 右侧箭头」,整页只有一种小标题样式 */
|
|
|
+ /* ---------- 底行:左侧机会卡(3 张,与池子同一张卡)+ 右侧「最近动过」时间线 ---------- */
|
|
|
+ .cards-row { grid-template-columns: repeat(12, minmax(0, 1fr)); align-items: start; }
|
|
|
+ .cards-main { grid-column: span 8; min-width: 0; }
|
|
|
+ .cards-main .feed-head { margin-bottom: 0; }
|
|
|
+ .cards-main .cards { margin-top: 12px; }
|
|
|
+ .cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--kf-space-card-gap); align-items: stretch; }
|
|
|
+ .cards > kf-glass-card { min-width: 0; }
|
|
|
+ .side-stack { grid-column: span 4; min-width: 0; }
|
|
|
+ .side-in { display: flex; flex-direction: column; height: 100%; }
|
|
|
+ .side-in [data-feed] { flex: 1 1 auto; }
|
|
|
.feed-head { display: flex; align-items: center; gap: 8px; min-height: 22px; }
|
|
|
.feed-head .go { margin-left: auto; }
|
|
|
- /* 行:左标题 / 中阶段 / 右时间。网格而不是 flex —— 中间那列 auto 宽,右边永远贴右 */
|
|
|
- .feed { display: grid; }
|
|
|
- .feed li { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) 64px; align-items: baseline; gap: 16px; padding: 11px 0; border-top: 1px solid var(--kf-line); font-size: 14px; }
|
|
|
- .feed li:first-child { border-top: 1px solid var(--kf-line); padding-top: 11px; margin-top: 8px; }
|
|
|
- .feed li:last-child { padding-bottom: 4px; border-bottom: 0; }
|
|
|
- .feed li:last-child a { font-weight: 500; }
|
|
|
- .feed-more { display: inline-flex; align-items: center; gap: 4px; margin-top: 6px; font-size: 14px; }
|
|
|
- .feed li a { color: var(--kf-ink); font-weight: 500; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+ .feed-head .lbl { font-size: 14px; font-weight: 600; color: var(--kf-ink); }
|
|
|
+ /* 时间线:时间在左(12 灰),标题在右;竖向虚线把时间与内容分开 */
|
|
|
+ .feed { display: grid; margin-top: 8px; }
|
|
|
+ .feed li { display: grid; grid-template-columns: 12px minmax(0, 1fr); column-gap: 12px; row-gap: 2px; padding: 10px 0; border-top: 1px solid var(--kf-line); position: relative; }
|
|
|
+ .feed li:first-child { border-top: 0; }
|
|
|
+ .feed li::before { content: ""; grid-row: 1 / 3; grid-column: 1; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid var(--kf-line-strong); background: var(--kf-surface); margin-top: 6px; justify-self: center; }
|
|
|
+ .feed li:first-child::before { background: var(--kf-ink); border-color: var(--kf-ink); }
|
|
|
+ .feed li a { grid-column: 2; color: var(--kf-ink); font-weight: 500; font-size: 14px; text-decoration: none; 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); white-space: nowrap; }
|
|
|
- .feed .tm { font-size: 12px; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
|
|
+ .feed .st { grid-column: 2; font-size: 12px; color: var(--kf-ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+ .feed .tm { position: absolute; right: 0; top: 10px; font-size: 12px; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
|
|
|
.feed-empty { font-size: 14px; color: var(--kf-ink-3); padding: 8px 0 0; }
|
|
|
+ /* 抽屉:机会卡全貌 */
|
|
|
+ .dr { display: grid; gap: 16px; }
|
|
|
+ .dr .act-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
|
+ .dr .act-row .go { margin-left: auto; }
|
|
|
.foot-links { display: none; }
|
|
|
|
|
|
/* 平板:A/B 变 7/5 太挤,直接上下堆;C/D/E 保持一行 */
|
|
|
@@ -228,6 +252,12 @@ export class ViewWorkbench extends LitElement {
|
|
|
.feed li a { grid-column: 1 / -1; white-space: normal; }
|
|
|
.go, .hd .go, .me-id .go { min-height: 44px; min-width: 44px; justify-content: center; }
|
|
|
.feed-more { min-height: 44px; align-items: center; }
|
|
|
+ /* 底行:手机上卡片一列(与卡池同一张卡,可整张点开抽屉),时间线在下面 */
|
|
|
+ .bento > .row.cards-row { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-card); }
|
|
|
+ .cards-main, .side-stack { grid-column: auto; }
|
|
|
+ .cards { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-card); }
|
|
|
+ .cards-main .feed-head { margin-bottom: 8px; }
|
|
|
+ .feed .tm { position: static; grid-column: 2; justify-self: end; grid-row: 1; }
|
|
|
.pending li { min-height: 44px; }
|
|
|
.empty { font-size: 14px; }
|
|
|
.foot-links { display: flex; gap: 8px; margin-top: var(--kf-space-card); }
|
|
|
@@ -242,7 +272,13 @@ export class ViewWorkbench extends LitElement {
|
|
|
const me = forMe(db, this.me);
|
|
|
const pool = poolOverview(db);
|
|
|
const live = liveBoard(db);
|
|
|
- const latest = sortByNewest(pool.latest3);
|
|
|
+ /* 左卡片:我的待评分卡在前,再补最近上传,最多 3 张;右侧时间线取最近 6 条 */
|
|
|
+ const latest = sortByNewest(pool.sorted);
|
|
|
+ const seen = new Set();
|
|
|
+ const play = [...(me.pendingScores || []), ...latest].filter((o) => (seen.has(o.id) ? false : seen.add(o.id))).slice(0, 2);
|
|
|
+ const recent = latest.slice(0, 5);
|
|
|
+ /* C 卡「我的环节」:我最近一条成果 */
|
|
|
+ this.#mineLatest = me.mine?.[0] || null;
|
|
|
return html`
|
|
|
${head('我的推进', '五阶段进度与现场状态', html`<a class="go" href=${href('/pool')} data-link>机会卡池 →</a>`)}
|
|
|
<div class="bento">
|
|
|
@@ -252,14 +288,32 @@ export class ViewWorkbench extends LitElement {
|
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
+ <kf-glass-card data-role="c" fill-to>${this.#stat('open', pool)}</kf-glass-card>
|
|
|
+ <kf-glass-card data-role="d" fill-to>${this.#stat('top', pool)}</kf-glass-card>
|
|
|
+ <kf-glass-card data-role="e" fill-to>${this.#stat('decided', pool)}</kf-glass-card>
|
|
|
</div>
|
|
|
|
|
|
- <kf-glass-card data-role="f" class="kf-enter" style="--i:2">${this.#feed(latest, pool)}</kf-glass-card>
|
|
|
+ <div class="row cards-row kf-enter" style="--i:2">
|
|
|
+ <div class="cards-main">
|
|
|
+ <div class="feed-head"><span class="lbl">${t('最近动过')}</span>
|
|
|
+ <a class="go" href=${href('/pool')} data-link aria-label="全部机会卡">全部 ${pool.total} →</a></div>
|
|
|
+ ${play.length ? html`<div class="cards">${play.map((o) => html`<kf-glass-card lift fill-to @card-activate=${() => this.#openCard(o.id)}>${playCard({ opp: o, status: o.status, onStatus: (st) => this.#setStatus({ id: o.id, status: st }) })}</kf-glass-card>`)}</div>`
|
|
|
+ : html`<p class="feed-empty">还没有机会卡</p>`}
|
|
|
+ </div>
|
|
|
+ <kf-glass-card class="side-stack" data-role="f" fill-to>
|
|
|
+ <div class="side-in">
|
|
|
+ <div class="feed-head"><span class="lbl">${t('最近动过')}</span>
|
|
|
+ <a class="go" href=${href('/pool')} data-link aria-label="全部机会卡">全部 →</a></div>
|
|
|
+ ${this.#feed(recent, pool)}
|
|
|
+ </div>
|
|
|
+ </kf-glass-card>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- ${cardFooter({ shown: latest.length, all: pool.total, filtered: false, label: '张机会卡,最近 3 张列在上方', onTop: () => window.scrollTo({ top: 0, behavior: 'smooth' }) })}
|
|
|
+ <kf-drawer .open=${Boolean(this.drawer)} .heading=${this.drawer?.title || '机会卡'} sub=${t('完整报告 · 评分 · 决策')} .width=${this.drawer ? "min(760px, 96vw)" : ""}
|
|
|
+ @drawer-close=${() => { this.drawer = null; }}>
|
|
|
+ ${this.drawer ? this.#drawerBody() : ''}
|
|
|
+ </kf-drawer>
|
|
|
+ ${cardFooter({ shown: play.length, all: pool.total, filtered: false, label: '张机会卡,最近 3 张列在上方', onTop: () => window.scrollTo({ top: 0, behavior: 'smooth' }) })}
|
|
|
<!-- 只有手机显示:证据台账从底部 Tab 移到工作台页脚(真实链接,部署前缀同源) -->
|
|
|
<nav class="foot-links" aria-label="更多入口">
|
|
|
<a href=${href('/ledger')} data-link>${icon('ledger', 16)} 证据台账</a>
|
|
|
@@ -268,6 +322,36 @@ export class ViewWorkbench extends LitElement {
|
|
|
}
|
|
|
|
|
|
/** A 卡:选了「我是」→ 我的进度;没选 → 全员进度(v12 定稿:空态也放真实内容,不摆空框) */
|
|
|
+ /** 打开一张机会卡的抽屉(全貌:完整报告 + 给卡打分 + 决策) */
|
|
|
+ #openCard(id) {
|
|
|
+ const o = this.db?.opportunityById?.[id];
|
|
|
+ if (!o) return;
|
|
|
+ this.drawer = { id, title: o.title };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 抽屉内容:所有关联成果的报告卡(同一套 .rc 卡片,不另造样式) */
|
|
|
+ #drawerBody() {
|
|
|
+ const o = this.db?.opportunityById?.[this.drawer?.id];
|
|
|
+ if (!o) return '';
|
|
|
+ /* 关联成果 = 机会卡上登记的 linkedArtifacts(正常化的 linkedArtifactIds),不猜别的字段 */
|
|
|
+ const ids = new Set(o.linkedArtifactIds || []);
|
|
|
+ const arts = (this.db.artifacts || []).filter((a) => ids.has(a.id)).slice(0, 4);
|
|
|
+ return html`<div class="dr">
|
|
|
+ <div class="act-row">
|
|
|
+ <span class="lbl">${o.stageObj ? `${o.stageObj.code} · ${o.stageObj.name}` : ''} · ${o.upload?.uploadedBy || '上传人未记录'}</span>
|
|
|
+ <span class="st" style="margin-left:auto">${o.status === 'approved' ? '已立项' : o.status === 'rejected' ? '已否决' : '候选'}</span>
|
|
|
+ <a class="go" href=${href(`/pool/${o.id}`)} data-link>在卡池里打开 →</a>
|
|
|
+ </div>
|
|
|
+ ${arts.length ? arts.map((a) => reportCard({ e: entryOfArtifact(this.db, a, a.typeKey), fit: true }))
|
|
|
+ : html`<div class="empty"><span>这张卡还没有关联成果报告</span></div>`}
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 快捷决策:复用 app 的 status-change(带操作人校验与写入),与卡池同一路径 */
|
|
|
+ #setStatus({ id, status }) {
|
|
|
+ this.dispatchEvent(new CustomEvent('status-change', { detail: { id, status }, bubbles: true, composed: true }));
|
|
|
+ }
|
|
|
+
|
|
|
#mine(me, pool, live) {
|
|
|
if (!this.me) {
|
|
|
const maxSub = Math.max(1, ...live.stages.map((r) => r.submitted));
|
|
|
@@ -288,6 +372,9 @@ export class ViewWorkbench extends LitElement {
|
|
|
const done = new Set(me.stagesDone);
|
|
|
const max = Math.max(1, ...STAGES.map((s) => me.mine.filter((a) => a.stageObj?.code === s.code).length));
|
|
|
const pending = me.pendingScores || [];
|
|
|
+ /* 队友动态:没卡住、但一段时间没动静的人(按空闲时长),3 人以内 */
|
|
|
+ const idle = (this.db?.members || []).filter((m) => !m.stuck && m.lastActiveAt)
|
|
|
+ .sort((a2, b2) => (b2.idleHours || 0) - (a2.idleHours || 0)).slice(0, 3);
|
|
|
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>
|
|
|
@@ -325,6 +412,10 @@ export class ViewWorkbench extends LitElement {
|
|
|
<a class="s" href=${href(`/pool/${o.id}`)} data-link aria-label="去评分">${o.stats.avg != null ? Math.round(o.stats.avg) : '去评'}</a></li>`)}</ul>
|
|
|
${pending.length > 4 ? html`<a class="feed-more go" href=${href('/pool')} data-link>还有 ${pending.length - 4} 张待评分 →</a>` : ''}`
|
|
|
: html`<p class="me-none">没有等你评分的卡</p>`}
|
|
|
+ ${idle.length ? html`<div class="lbl" style="margin-top:14px">${t('队友动态')}</div>
|
|
|
+ <ul class="pending">${idle.map((m) => html`<li><span class="nm">${m.name}</span>
|
|
|
+ <span class="d">${m.stageObj ? `${m.stageObj.code} · ${m.stageObj.name}` : t('未开工')}</span>
|
|
|
+ <span class="s dim">${m.idleHours == null ? '—' : m.idleHours + 'h'}</span></li>`)}</ul>` : ''}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>`;
|
|
|
@@ -384,18 +475,17 @@ export class ViewWorkbench extends LitElement {
|
|
|
<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>`;
|
|
|
+ /* C:我的环节 —— 最近一条成果落在哪个阶段(没选 / 没成果时如实说) */
|
|
|
+ const last = this.#mineLatest;
|
|
|
+ return html`<div class="stat" data-kpi="stage">
|
|
|
+ <div class="top"><span class="lbl">我的环节</span></div>
|
|
|
+ <b class=${last ? '' : 'dim'}>${last ? last.stageObj.code : '—'}</b>
|
|
|
+ <span class="note">${last ? `${last.stageObj.name} · ${ago(last.createdAt)}` : this.me ? '本月还没有你的成果' : '还没选「我是」'}</span></div>`;
|
|
|
}
|
|
|
|
|
|
- /** F:最近动过(最多 3 条,右对齐时间) */
|
|
|
+ /** 右侧「最近动过」:时间线(6 条) */
|
|
|
#feed(latest, pool) {
|
|
|
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">${latest.map((o) => html`<li>
|
|
|
<a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a>
|
|
|
<span class="st">${(o.upload?.uploadedBy || '') + (o.stageObj ? ' · ' + o.stageObj.name : '')}</span>
|