view-pool.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. /**
  2. * pages/view-pool.js —— <view-pool> 机会卡池(组织资产的正面)。
  3. *
  4. * - 卡片形态,一屏读完五要素(<kf-opportunity-card>)
  5. * - 五维筛选(状态 / 优先级 / 阶段 / 品类 / 人)+ 搜索,**多选同维 OR、跨维 AND**(core/ledger)
  6. * - 排序切换:优先级 / 评分 / 证据 / 最新 / 分歧 —— URL ?sort= 保留
  7. * - 筛选条件写进 URL(?status=&priority=…),刷新不丢,可整段复制给别人
  8. * - /pool/:id 深链接进来时,由 app 打开抽屉;列表同时高亮那张卡
  9. */
  10. import { LitElement, css, html } from 'lit';
  11. import { useTokens } from '../components/base.js';
  12. import { criteriaOf, queryOf, applyFilter, facetCounts, collectValues, toggleFacet, SORTS, sortOpportunities } from '../core/index.js';
  13. import { navigate } from '../utils/router.js';
  14. import { ref } from 'lit/directives/ref.js';
  15. import { t, head, section, VIEW_CSS } from './shared.js';
  16. import { icon } from '../components/icons.js';
  17. import './filter-sheet.js';
  18. /* 手机筛选弹层:机会卡池的五维;「状态」是单选维度(同一时间只能是候选 / 已立项 / 已否决之一) */
  19. const SINGLE_DIM = 'status';
  20. const DIMS = [
  21. { key: 'status', label: '状态', values: () => [{ value: 'candidate', label: '候选' }, { value: 'approved', label: '已立项' }, { value: 'rejected', label: '已否决' }] },
  22. { key: 'priority', label: '优先级', values: () => ['S', 'A', 'B'].map((v) => ({ value: v, label: t(`${v} 级`) })) },
  23. { key: 'stage', label: '阶段', values: () => ['S1', 'S2', 'S3', 'S4', 'S5'].map((v) => ({ value: v, label: v })) },
  24. { key: 'category', label: '品类', values: (rows) => collectValues(rows, 'category') },
  25. { key: 'member', label: '上传人', values: (rows) => collectValues(rows, 'member') },
  26. ];
  27. export class ViewPool extends LitElement {
  28. static properties = {
  29. db: { type: Object },
  30. routeQuery: { type: Object },
  31. focusId: { type: String },
  32. me: { type: String },
  33. };
  34. constructor() {
  35. super();
  36. this.db = null;
  37. this.routeQuery = {};
  38. this.focusId = '';
  39. this.me = '';
  40. }
  41. connectedCallback() {
  42. super.connectedCallback();
  43. useTokens(this);
  44. }
  45. static styles = [VIEW_CSS, css`
  46. .filters { padding: 12px 16px 16px; display: grid; gap: 8px; align-content: start; position: sticky; top: 72px; z-index: 3; }
  47. .frow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
  48. .frow .lbl { width: 56px; font-size: 11px; color: var(--kf-ink-3); flex: none; }
  49. .chips { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
  50. .kf-chip[data-zero="1"] { opacity: 0.42; }
  51. /* 筛选卡与卡片流放在同一个块容器里:sticky 才有参照,行盒也不会把块级子元素叠在一起 */
  52. .pool-body { display: block; }
  53. .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
  54. .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); }
  55. .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); }
  56. .seg { display: inline-flex; border: 1px solid var(--kf-line-strong); border-radius: 8px; overflow: hidden; background: rgba(255,255,255,0.5); }
  57. .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); }
  58. .seg button[aria-pressed="true"] { background: var(--kf-ink); color: #fff; }
  59. .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 16px; }
  60. .cards kf-opportunity-card.is-focus { outline: 2px solid var(--kf-accent); outline-offset: 3px; border-radius: var(--kf-radius); }
  61. .count { font-size: 13px; color: var(--kf-ink-3); }
  62. .count b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
  63. .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; }
  64. /* 手机工具条:桌面隐藏 */
  65. .pbar { display: none; }
  66. @media (max-width: 767px) {
  67. .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; }
  68. .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;
  69. 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);
  70. 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); }
  71. .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); }
  72. .pbar-more { grid-area: s; border-radius: 12px; background: rgba(255, 255, 255, 0.32); }
  73. .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; }
  74. .pbar-more > summary::-webkit-details-marker { display: none; }
  75. .pbar-sorts { display: flex; gap: 6px; padding: 0 8px 8px; }
  76. .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; }
  77. .pbar-sorts button[aria-pressed="true"] { background: var(--kf-ink); color: #fff; }
  78. /* 手机上原生筛选卡收起(桌面档里已隐藏的 .pbar 不在此列) */
  79. .filters { display: none; }
  80. }
  81. @media (min-width: 768px) and (max-width: 1023px) { .filters { position: static; } .cards { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
  82. /* 手机:筛选卡不吸顶、搜索框占满一行,卡片单列 */
  83. @media (max-width: 767px) {
  84. .filters { position: static; padding: 12px; border-radius: 16px; gap: 8px; }
  85. .frow .lbl { width: 44px; }
  86. .chips { gap: 8px; }
  87. .kf-chip { height: 36px; padding: 0 12px; font-size: 13px; }
  88. .toolbar input[type="search"] { min-width: 0; width: 100%; height: 44px; font-size: 14px; }
  89. .seg { height: 44px; overflow-x: auto; }
  90. .seg button { height: 44px; padding: 0 14px; font-size: 13px; }
  91. .cards { grid-template-columns: minmax(0, 1fr); gap: 12px; }
  92. }
  93. `];
  94. /** 已选条件数(五个维度都有值就算) */
  95. #openSheet = () => {
  96. const s = this.renderRoot.querySelector('kf-filter-sheet');
  97. if (!s) return;
  98. const c = this.#criteria;
  99. s.groups = DIMS.map((d) => {
  100. const counts = facetCounts(this.db.opportunities, c, d.key);
  101. return {
  102. key: d.key, label: d.label, single: d.key === SINGLE_DIM,
  103. 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 })),
  104. };
  105. });
  106. s.mode = 'panel'; s.title = '筛选 · 机会卡';
  107. s.open = true;
  108. };
  109. #sheetAction = (ev) => {
  110. const a = ev.detail?.action;
  111. if (a === 'clear') { navigate('/pool', { replace: true }); return; }
  112. const [dim, v] = String(a).split(/:(.*)/s);
  113. if (DIMS.some((d) => d.key === dim)) this.#toggle(dim, v);
  114. };
  115. #mobileSheet(c, rows, shown) {
  116. return html`<kf-filter-sheet .shown=${shown} .all=${rows.length} @sheet-action=${this.#sheetAction} @sheet-close=${(ev) => { ev.target.open = false; }}></kf-filter-sheet>`;
  117. }
  118. get #criteria() {
  119. return criteriaOf(this.routeQuery || {});
  120. }
  121. #apply(c, { sort } = {}) {
  122. const q = queryOf({ ...c, sort: sort ?? c.sort });
  123. const qs = new URLSearchParams(q).toString();
  124. navigate(`/pool${qs ? `?${qs}` : ''}`, { replace: true });
  125. }
  126. #toggle(dim, value) {
  127. this.#apply(toggleFacet(this.#criteria, dim, value));
  128. }
  129. #setSort(key) {
  130. const c = this.#criteria;
  131. this.#apply(c, { sort: c.sort === key ? '' : key });
  132. }
  133. #search(e) {
  134. const q = e.target.value.trim();
  135. const c = { ...this.#criteria, q };
  136. // 搜索输入用 replace 节流:每敲一个字不新增历史
  137. this.#apply(c);
  138. }
  139. #changeStatus(e, id) {
  140. this.dispatchEvent(new CustomEvent('status-change', { detail: { id, status: e.detail.status }, bubbles: true, composed: true }));
  141. }
  142. render() {
  143. const db = this.db;
  144. if (!db) return html`<kf-empty kind="loading" title="正在读取机会卡池…"></kf-empty>`;
  145. const c = this.#criteria;
  146. const cnt = DIMS.reduce((n, d) => n + (c[d.key] || []).length, 0);
  147. const rows = db.opportunities;
  148. const filtered = sortOpportunities(applyFilter(rows, c), c.sort || 'priority');
  149. const active = DIMS.some((d) => (c[d.key] || []).length) || c.q;
  150. return html`
  151. ${head('机会卡池', '全员机会卡汇总入库,带评分与优先级 —— 这是研发中心的机会储备。',
  152. html`<span class="count"><b>${filtered.length}</b> / ${rows.length} 张</span>
  153. ${active ? html`<button class="reset" @click=${() => navigate('/pool', { replace: true })}>清除全部条件</button>` : ''}`)}
  154. <div class="pool-body">
  155. <!-- 手机工具条:与类型页同一套(筛选按钮带已选数 / 结果数 / 排序收起 / 搜索),筛选面板 ⤢ 按钮锚定到这里 -->
  156. <div class="pbar tb kf-glass is-panel" data-toolbar ${ref((el) => { this._pbar = el; })}>
  157. <button class="pbar-btn ${active ? 'on' : ''}" data-open-sheet @click=${this.#openSheet} aria-label=${`筛选${cnt ? `(已选 ${cnt} 项)` : ''}`}>
  158. ${icon('filter', 18)}筛选${cnt ? html`<span class="pbar-nb">${cnt}</span>` : ''}
  159. </button>
  160. <input type="search" placeholder="搜索标题 / 一句话 / 上传人" .value=${c.q || ''} @input=${this.#search} aria-label="搜索机会卡" />
  161. <details class="pbar-more">
  162. <summary aria-label="排序">${icon('sort', 16)}<span>${SORTS.find((s) => s.key === (c.sort || 'priority')).label}</span></summary>
  163. <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>
  164. </details>
  165. </div>
  166. ${this.#mobileSheet(c, rows, filtered.length)}
  167. <kf-glass-card surface="1" class="filters" data-no-activate>
  168. ${DIMS.map((d) => {
  169. const counts = facetCounts(rows, c, d.key);
  170. const vals = d.values(rows);
  171. return html`<div class="frow">
  172. <span class="lbl">${d.label}</span>
  173. <div class="chips" role="group" aria-label=${`按${d.label}筛选`}>
  174. ${vals.map((v) => {
  175. const on = (c[d.key] || []).includes(v.value);
  176. const n = counts.get(v.value) || 0;
  177. return html`<button class="kf-chip" aria-pressed=${on ? 'true' : 'false'} data-zero=${n ? '0' : '1'} data-facet=${`${d.key}:${v.value}`}
  178. @click=${() => this.#toggle(d.key, v.value)}>${v.label}<span class="n">${n}</span></button>`;
  179. })}
  180. </div>
  181. </div>`;
  182. })}
  183. <div class="frow toolbar">
  184. <span class="lbl">排序</span>
  185. <div class="seg" role="group" aria-label="排序方式">
  186. ${SORTS.map((s) => html`<button aria-pressed=${(c.sort || 'priority') === s.key ? 'true' : 'false'} @click=${() => this.#setSort(s.key)}>${s.label}</button>`)}
  187. </div>
  188. <input type="search" placeholder="搜索卡片标题 / 一句话 / 上传人" .value=${c.q || ''} @input=${this.#search} aria-label="搜索机会卡" />
  189. </div>
  190. </kf-glass-card>
  191. ${section('池子', active ? '符合条件的卡片' : '全部卡片', html`<span>${SORTS.find((s) => s.key === (c.sort || 'priority')).label}</span>`)}
  192. ${filtered.length ? html`<div class="cards kf-enter" data-list-ids=${filtered.map((o) => o.id).join(',')}>
  193. ${filtered.map((o) => html`<kf-opportunity-card key=${o.id} class=${o.id === this.focusId ? 'is-focus' : ''} .opp=${o} base="/pool"
  194. @card-open=${(e) => this.dispatchEvent(new CustomEvent('card-open', { detail: e.detail, bubbles: true, composed: true }))}
  195. @status-change=${this.#changeStatus}></kf-opportunity-card>`)}
  196. </div>` : html`<kf-glass-card surface="2"><kf-empty title="没有符合条件的卡" hint="试试点上面的「清除全部条件」,或换一个排序看看。" action-label="清除全部条件" @empty-action=${() => navigate('/pool', { replace: true })}></kf-empty></kf-glass-card>`}
  197. </div>
  198. `;
  199. }
  200. }
  201. if (!customElements.get('view-pool')) customElements.define('view-pool', ViewPool);