| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- /**
- * pages/view-pool.js —— <view-pool> 机会卡池(组织资产的正面)。
- *
- * - 卡片形态,一屏读完五要素(<kf-opportunity-card>)
- * - 五维筛选(状态 / 优先级 / 阶段 / 品类 / 人)+ 搜索,**多选同维 OR、跨维 AND**(core/ledger)
- * - 排序切换:优先级 / 评分 / 证据 / 最新 / 分歧 —— URL ?sort= 保留
- * - 筛选条件写进 URL(?status=&priority=…),刷新不丢,可整段复制给别人
- * - /pool/:id 深链接进来时,由 app 打开抽屉;列表同时高亮那张卡
- */
- import { LitElement, css, html } from 'lit';
- import { useTokens } from '../components/base.js';
- import { criteriaOf, queryOf, applyFilter, facetCounts, collectValues, toggleFacet, SORTS, sortOpportunities } from '../core/index.js';
- import { navigate } from '../utils/router.js';
- import { ref } from 'lit/directives/ref.js';
- import { t, head, section, VIEW_CSS } from './shared.js';
- import { icon } from '../components/icons.js';
- import './filter-sheet.js';
- /* 手机筛选弹层:机会卡池的五维;「状态」是单选维度(同一时间只能是候选 / 已立项 / 已否决之一) */
- const SINGLE_DIM = 'status';
- const DIMS = [
- { key: 'status', label: '状态', values: () => [{ value: 'candidate', label: '候选' }, { value: 'approved', label: '已立项' }, { value: 'rejected', label: '已否决' }] },
- { key: 'priority', label: '优先级', values: () => ['S', 'A', 'B'].map((v) => ({ value: v, label: t(`${v} 级`) })) },
- { key: 'stage', label: '阶段', values: () => ['S1', 'S2', 'S3', 'S4', 'S5'].map((v) => ({ value: v, label: v })) },
- { key: 'category', label: '品类', values: (rows) => collectValues(rows, 'category') },
- { key: 'member', label: '上传人', values: (rows) => collectValues(rows, 'member') },
- ];
- export class ViewPool extends LitElement {
- static properties = {
- db: { type: Object },
- routeQuery: { type: Object },
- focusId: { type: String },
- me: { type: String },
- };
- constructor() {
- super();
- this.db = null;
- this.routeQuery = {};
- this.focusId = '';
- this.me = '';
- }
- connectedCallback() {
- super.connectedCallback();
- useTokens(this);
- }
- static styles = [VIEW_CSS, css`
- .filters { padding: 12px 16px 16px; display: grid; gap: 8px; align-content: start; position: sticky; top: 72px; z-index: 3; }
- .frow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
- .frow .lbl { width: 56px; font-size: 11px; color: var(--kf-ink-3); flex: none; }
- .chips { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
- .kf-chip[data-zero="1"] { opacity: 0.42; }
- /* 筛选卡与卡片流放在同一个块容器里:sticky 才有参照,行盒也不会把块级子元素叠在一起 */
- .pool-body { display: block; }
- .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
- .toolbar input[type="search"] { height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--kf-line-strong); font: 13px var(--kf-font); min-width: 240px; background: rgba(255,255,255,0.5); transition: var(--kf-t); }
- .toolbar input[type="search"]:focus { outline: none; border-color: var(--kf-focus); box-shadow: 0 0 0 2px rgba(91, 91, 214, 0.22); background: rgba(255,255,255,0.75); }
- .seg { display: inline-flex; border: 1px solid var(--kf-line-strong); border-radius: 8px; overflow: hidden; background: rgba(255,255,255,0.5); }
- .seg button { border: 0; background: transparent; padding: 0 12px; height: 30px; font: 500 11px var(--kf-font); color: var(--kf-ink-2); cursor: pointer; transition: background var(--kf-dur) var(--kf-ease); }
- .seg button[aria-pressed="true"] { background: var(--kf-ink); color: #fff; }
- .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 16px; }
- .cards kf-opportunity-card.is-focus { outline: 2px solid var(--kf-accent); outline-offset: 3px; border-radius: var(--kf-radius); }
- .count { font-size: 13px; color: var(--kf-ink-3); }
- .count b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
- .reset { border: 0; background: transparent; color: var(--kf-accent-ink); cursor: pointer; font: 500 11px var(--kf-font); text-decoration: underline; text-underline-offset: 3px; }
- /* 手机工具条:桌面隐藏 */
- .pbar { display: none; }
- @media (max-width: 767px) {
- .pbar { display: grid; grid-template-columns: 96px minmax(0, 1fr); grid-template-areas: "f m" "s s"; gap: 8px; padding: 8px; margin-bottom: 12px; border-radius: 16px; align-items: center; }
- .pbar-btn { grid-area: f; appearance: none; -webkit-appearance: none; box-sizing: border-box; width: 100%; height: 44px; padding: 0; margin: 0; border: 1px solid transparent; border-radius: 12px; cursor: pointer;
- background: linear-gradient(rgba(255,255,255,0.72), rgba(255,255,255,0.72)) padding-box, var(--kf-edge-chip) border-box; color: var(--kf-ink);
- display: grid; grid-auto-flow: column; justify-content: center; align-items: center; gap: 4px; font: 600 16px/1 var(--kf-font); letter-spacing: var(--ls-cn-small); }
- .pbar input[type="search"] { grid-area: m; min-width: 0; width: 100%; height: 44px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--kf-line-strong); font: 400 14px var(--kf-font); background: rgba(255, 255, 255, 0.5); }
- .pbar-more { grid-area: s; border-radius: 12px; background: rgba(255, 255, 255, 0.32); }
- .pbar-more > summary { list-style: none; display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 12px; font: 600 14px var(--kf-font); color: var(--kf-ink-2); cursor: pointer; }
- .pbar-more > summary::-webkit-details-marker { display: none; }
- .pbar-sorts { display: flex; gap: 6px; padding: 0 8px 8px; }
- .pbar-sorts button { all: unset; box-sizing: border-box; flex: 1; height: 44px; display: grid; place-items: center; border-radius: 10px; font: 500 13px var(--kf-font); color: var(--kf-ink-2); background: rgba(255, 255, 255, 0.45); cursor: pointer; }
- .pbar-sorts button[aria-pressed="true"] { background: var(--kf-ink); color: #fff; }
- /* 手机上原生筛选卡收起(桌面档里已隐藏的 .pbar 不在此列) */
- .filters { display: none; }
- }
- @media (min-width: 768px) and (max-width: 1023px) { .filters { position: static; } .cards { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
- /* 手机:筛选卡不吸顶、搜索框占满一行,卡片单列 */
- @media (max-width: 767px) {
- .filters { position: static; padding: 12px; border-radius: 16px; gap: 8px; }
- .frow .lbl { width: 44px; }
- .chips { gap: 8px; }
- .kf-chip { height: 36px; padding: 0 12px; font-size: 13px; }
- .toolbar input[type="search"] { min-width: 0; width: 100%; height: 44px; font-size: 14px; }
- .seg { height: 44px; overflow-x: auto; }
- .seg button { height: 44px; padding: 0 14px; font-size: 13px; }
- .cards { grid-template-columns: minmax(0, 1fr); gap: 12px; }
- }
- `];
- /** 已选条件数(五个维度都有值就算) */
- #openSheet = () => {
- const s = this.renderRoot.querySelector('kf-filter-sheet');
- if (!s) return;
- const c = this.#criteria;
- s.groups = DIMS.map((d) => {
- const counts = facetCounts(this.db.opportunities, c, d.key);
- return {
- key: d.key, label: d.label, single: d.key === SINGLE_DIM,
- options: d.values(this.db.opportunities).map((v) => ({ ...v, value: v.value, label: v.label, on: (c[d.key] || []).includes(v.value), count: counts.get(v.value) || 0 })),
- };
- });
- s.mode = 'panel'; s.title = '筛选 · 机会卡';
- s.open = true;
- };
- #sheetAction = (ev) => {
- const a = ev.detail?.action;
- if (a === 'clear') { navigate('/pool', { replace: true }); return; }
- const [dim, v] = String(a).split(/:(.*)/s);
- if (DIMS.some((d) => d.key === dim)) this.#toggle(dim, v);
- };
- #mobileSheet(c, rows, shown) {
- return html`<kf-filter-sheet .shown=${shown} .all=${rows.length} @sheet-action=${this.#sheetAction} @sheet-close=${(ev) => { ev.target.open = false; }}></kf-filter-sheet>`;
- }
- get #criteria() {
- return criteriaOf(this.routeQuery || {});
- }
- #apply(c, { sort } = {}) {
- const q = queryOf({ ...c, sort: sort ?? c.sort });
- const qs = new URLSearchParams(q).toString();
- navigate(`/pool${qs ? `?${qs}` : ''}`, { replace: true });
- }
- #toggle(dim, value) {
- this.#apply(toggleFacet(this.#criteria, dim, value));
- }
- #setSort(key) {
- const c = this.#criteria;
- this.#apply(c, { sort: c.sort === key ? '' : key });
- }
- #search(e) {
- const q = e.target.value.trim();
- const c = { ...this.#criteria, q };
- // 搜索输入用 replace 节流:每敲一个字不新增历史
- this.#apply(c);
- }
- #changeStatus(e, id) {
- this.dispatchEvent(new CustomEvent('status-change', { detail: { id, status: e.detail.status }, bubbles: true, composed: true }));
- }
- render() {
- const db = this.db;
- if (!db) return html`<kf-empty kind="loading" title="正在读取机会卡池…"></kf-empty>`;
- const c = this.#criteria;
- const cnt = DIMS.reduce((n, d) => n + (c[d.key] || []).length, 0);
- const rows = db.opportunities;
- const filtered = sortOpportunities(applyFilter(rows, c), c.sort || 'priority');
- const active = DIMS.some((d) => (c[d.key] || []).length) || c.q;
- return html`
- ${head('机会卡池', '全员机会卡汇总入库,带评分与优先级 —— 这是研发中心的机会储备。',
- html`<span class="count"><b>${filtered.length}</b> / ${rows.length} 张</span>
- ${active ? html`<button class="reset" @click=${() => navigate('/pool', { replace: true })}>清除全部条件</button>` : ''}`)}
- <div class="pool-body">
- <!-- 手机工具条:与类型页同一套(筛选按钮带已选数 / 结果数 / 排序收起 / 搜索),筛选面板 ⤢ 按钮锚定到这里 -->
- <div class="pbar tb kf-glass is-panel" data-toolbar ${ref((el) => { this._pbar = el; })}>
- <button class="pbar-btn ${active ? 'on' : ''}" data-open-sheet @click=${this.#openSheet} aria-label=${`筛选${cnt ? `(已选 ${cnt} 项)` : ''}`}>
- ${icon('filter', 18)}筛选${cnt ? html`<span class="pbar-nb">${cnt}</span>` : ''}
- </button>
- <input type="search" placeholder="搜索标题 / 一句话 / 上传人" .value=${c.q || ''} @input=${this.#search} aria-label="搜索机会卡" />
- <details class="pbar-more">
- <summary aria-label="排序">${icon('sort', 16)}<span>${SORTS.find((s) => s.key === (c.sort || 'priority')).label}</span></summary>
- <div class="pbar-sorts">${SORTS.map((s) => html`<button aria-pressed=${(c.sort || 'priority') === s.key ? 'true' : 'false'} @click=${() => { this.#setSort(s.key); this.renderRoot.querySelector('.pbar-more')?.removeAttribute('open'); }}>${s.label}</button>`)}</div>
- </details>
- </div>
- ${this.#mobileSheet(c, rows, filtered.length)}
- <kf-glass-card surface="1" class="filters" data-no-activate>
- ${DIMS.map((d) => {
- const counts = facetCounts(rows, c, d.key);
- const vals = d.values(rows);
- return html`<div class="frow">
- <span class="lbl">${d.label}</span>
- <div class="chips" role="group" aria-label=${`按${d.label}筛选`}>
- ${vals.map((v) => {
- const on = (c[d.key] || []).includes(v.value);
- const n = counts.get(v.value) || 0;
- return html`<button class="kf-chip" aria-pressed=${on ? 'true' : 'false'} data-zero=${n ? '0' : '1'} data-facet=${`${d.key}:${v.value}`}
- @click=${() => this.#toggle(d.key, v.value)}>${v.label}<span class="n">${n}</span></button>`;
- })}
- </div>
- </div>`;
- })}
- <div class="frow toolbar">
- <span class="lbl">排序</span>
- <div class="seg" role="group" aria-label="排序方式">
- ${SORTS.map((s) => html`<button aria-pressed=${(c.sort || 'priority') === s.key ? 'true' : 'false'} @click=${() => this.#setSort(s.key)}>${s.label}</button>`)}
- </div>
- <input type="search" placeholder="搜索卡片标题 / 一句话 / 上传人" .value=${c.q || ''} @input=${this.#search} aria-label="搜索机会卡" />
- </div>
- </kf-glass-card>
- ${section('池子', active ? '符合条件的卡片' : '全部卡片', html`<span>${SORTS.find((s) => s.key === (c.sort || 'priority')).label}</span>`)}
- ${filtered.length ? html`<div class="cards kf-enter" data-list-ids=${filtered.map((o) => o.id).join(',')}>
- ${filtered.map((o) => html`<kf-opportunity-card key=${o.id} class=${o.id === this.focusId ? 'is-focus' : ''} .opp=${o} base="/pool"
- @card-open=${(e) => this.dispatchEvent(new CustomEvent('card-open', { detail: e.detail, bubbles: true, composed: true }))}
- @status-change=${this.#changeStatus}></kf-opportunity-card>`)}
- </div>` : html`<kf-glass-card surface="2"><kf-empty title="没有符合条件的卡" hint="试试点上面的「清除全部条件」,或换一个排序看看。" action-label="清除全部条件" @empty-action=${() => navigate('/pool', { replace: true })}></kf-empty></kf-glass-card>`}
- </div>
- `;
- }
- }
- if (!customElements.get('view-pool')) customElements.define('view-pool', ViewPool);
|