Browse Source

feat(policy): card-based policy reference page with detail drawer

- 政策与膳食改为紧凑卡片页:统计条(文件/条款/已核/待核,全部来自接口)、
  课题 chip 筛选(沿用 policyApplication,按 clauseIds 精确过滤)、
  按 documentId 聚合的文件卡片(标题两行、编号、状态与日期、摘要、已核/待核与主题标签)
- 右侧详情抽屉:文件信息 → 相关条款 → 已有应用说明 → 原文与来源;
  保留原文分页、来源链接;内链不误触发卡片
- 原文核验与法规现行状态分开呈现,待核条款照常展示不继承核验
- 全库/课题两种口径在统计条与说明行中明确区分
- 新增独立发布清单 deploy/policy-cards-20261112.json 与发布入口脚本,
  仅覆盖本页两个文件,复用 publish-board-local 的校验/备份/回滚能力
- 三档栅格 3/2/1,移动端抽屉近全屏,320–1440 无横向溢出
FmodeCode 6 hours ago
parent
commit
354767c386

+ 32 - 0
deploy/policy-cards-20261112.json

@@ -0,0 +1,32 @@
+{
+ "version": "ksf.board-local.publish.v1",
+ "release": "ksf-policy-cards-20261112-r1",
+ "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+ "files": [
+  {
+   "path": "src/pages/industry-policy-view.js",
+   "sha256": "00e3e9d7904984da10e41417b150bd81809f9eb692ea9da08f6c942ab40f7bd1",
+   "priorNormalizedSha256": [
+    "911367be02770172f88b5cfba9c6cbe5258fd77d29465a5d873e5a253cb26f1a",
+    "738b4be4a4ec5eecc055e4e6e6d9affa3f6bed235fd122b3290dd585db7cd46e",
+    "6b6a7c70a259579a680418c71748aa0fd53961de39f96050608ac3f4b2b5ac04",
+    "f3a87b3f3c8be627e0c8fe5c9e13caa0fc2326e4c6b3d26a15b15073085b5262",
+    "8d73b55c8e04cd0ea2a453eab451e5145e04bd32b55858cb9491cbd1e736da22",
+    "bba1c73e15df2eeedd14bd12ed14057aef1b17ea5f4631550f229893e0004c1d",
+    "6a3b1fa1f8bf0c4c613323a34746da0ad51bc4889837b46a44c25604b9db9a96",
+    "c699987fd0795119da850b2cec4e410dae9ee1849b089b174c75d89cc33aef1f"
+   ],
+   "allowMissing": false,
+   "changed": true
+  },
+  {
+   "path": "src/pages/industry-research-views.js",
+   "sha256": "898276f783fe296e5135bf06652007e866eedad3419a5eca12de5f7f44345e52",
+   "priorNormalizedSha256": [
+    "a40180e68186737ba7a09b4e4449b243a5c88c0d77448ee580e5987800d81415"
+   ],
+   "allowMissing": false,
+   "changed": true
+  }
+ ]
+}

+ 43 - 0
scripts/publish-policy-cards-20261112.mjs

@@ -0,0 +1,43 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { publishFiles } from './publish-board-local.mjs';
+
+/* 2026-11-12 政策卡片页:仅两文件,独立清单,不覆盖其他已上线改动。 */
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+export const MANIFEST_PATH = path.join(root, 'deploy/policy-cards-20261112.json');
+export const RELEASE = 'ksf-policy-cards-20261112-r1';
+export const POLICY_FILES = Object.freeze([
+  'src/pages/industry-policy-view.js',
+  'src/pages/industry-research-views.js',
+]);
+
+export function publishPolicyCards({ targetDir, sourceDir = root, manifestPath = MANIFEST_PATH, apply = false }, dependencies = {}) {
+  const definition = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
+  if (definition.release !== RELEASE
+    || JSON.stringify(definition.files.map(file => file.path)) !== JSON.stringify(POLICY_FILES)) {
+    throw Object.assign(Error('UNEXPECTED_POLICY_RELEASE'), { code: 'UNEXPECTED_POLICY_RELEASE' });
+  }
+  const result = publishFiles({ sourceDir, manifestPath, targetDir, apply, releaseDefinition: definition }, dependencies);
+  return { ...result, businessRecordWrites: 0, databaseWrites: 0, serviceRestarted: false };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  try {
+    const args = process.argv.slice(2);
+    let targetDir, apply = false;
+    for (let index = 0; index < args.length; index++) {
+      if (args[index] === '--target-dir' && !targetDir && args[index + 1] && !args[index + 1].startsWith('--')) targetDir = args[++index];
+      else if (args[index] === '--apply' && !apply) apply = true;
+      else throw Object.assign(Error('USAGE'), { code: 'USAGE' });
+    }
+    if (targetDir !== '/opt/kangshifu-web') throw Error('TARGET_REQUIRED');
+    console.log(JSON.stringify(publishPolicyCards({ targetDir, apply }), null, 2));
+  } catch (error) {
+    console.error(JSON.stringify({ ok: false, error: { code: error.code || 'POLICY_RELEASE_STOPPED',
+      ...(POLICY_FILES.includes(error.file) || error.file === 'index.html' ? { file: error.file } : {}) },
+      ...(error.backup ? { backup: error.backup, rollbackComplete: error.rollbackComplete } : {}),
+      businessRecordWrites: 0, databaseWrites: 0 }, null, 2));
+    process.exitCode = 1;
+  }
+}

+ 262 - 22
src/pages/industry-policy-view.js

@@ -1,36 +1,276 @@
 import { html, css } from 'lit';
-import { reviewedPolicyEvidence, POLICY_REVIEW_CSS } from './policy-reviewed-evidence.js';
+import { POLICY_REVIEW_CSS } from './policy-reviewed-evidence.js';
 
 const STATES = { FETCH_FAILED: '本次访问未核成', OFFICIAL_CONTENT_NOT_IDENTIFIED: '未识别到对应正文', OFFICIAL_METADATA_VERIFIED: '官方状态及日期已核' };
+const HASH_STATE = v => v === 'matched' ? '原件指纹已匹配' : v === 'mismatched' ? '原件指纹不符' : '原件指纹待核';
 
-function publicationProof(p) {
-  return p ? html`<p>发布原文与日期已核:${p.publishedAtRaw}(时区未知)。${p.sourceKind === 'archived_wrapper' ? '复用历史归档,来源观察时间' : '官方页面观察时间'} ${p.sourceObservedAtRaw}。</p><p class="muted">${p.note} 原文页SHA ${p.pageSha256}</p>` : '';
+
+/** 详情开关会触发上游数据重载,页面高度瞬时塌陷导致滚动归零;这里保存并在还原后恢复。 */
+function rememberScroll() {
+  try { window.__ksfPolicyScrollY = window.scrollY || document.documentElement.scrollTop || 0; } catch { /* 无窗口环境忽略 */ }
+}
+function restoreScroll() {
+  const y = window.__ksfPolicyScrollY;
+  if (typeof y !== 'number' || !y) return;
+  /* 上游数据重载为异步,高度回落时刻不定:在 1.2 秒内持续对位,直到高度足够容纳该位置。 */
+  const started = Date.now();
+  const tick = () => {
+    const maxY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
+    if (maxY >= y) window.scrollTo(0, y);
+    if (Date.now() - started < 2500) requestAnimationFrame(tick);
+    else window.scrollTo(0, Math.min(y, maxY));
+  };
+  requestAnimationFrame(tick);
+}
+
+/** 摘要只取自库内已有文本:条款摘录 → 应用说明 → 文件标题。不推导结论。 */
+export function policySummary(row) {
+  const candidates = [row.excerpt, row.applicationNote].filter(x => typeof x === 'string' && x.trim());
+  const text = candidates.join(' ').replace(/\s+/g, ' ').trim();
+  return text ? text.slice(0, 220) : '';
+}
+
+/** 把条款按文件聚合:同一文件不重复出卡。 */
+export function groupClausesByDocument(clauses) {
+  const map = new Map();
+  for (const c of clauses) {
+    const key = c.documentId;
+    if (!map.has(key)) map.set(key, []);
+    map.get(key).push(c);
+  }
+  return map;
+}
+
+function statCell(label, value, tone = '') {
+  return html`<div class="ps-cell ${tone}"><span>${label}</span><strong>${value}</strong></div>`;
 }
 
 export function policyApplicationsView(view, data) {
   if (!data) return '';
-  const q = view.routeQuery, w = data.selectedWorkflow, c = data.calculation;
-  const input = (key, label, type = 'text') => html`<label>${label}<input name=${key} type=${type} .value=${q[key] || (key === 'policyAsOf' ? data.asOf : '')}></label>`;
-  const submit = (e, fields) => { e.preventDefault(); const f = new FormData(e.target); view._set(Object.fromEntries(fields.map(key => [key, f.get(key)]))); };
+  const q = view.routeQuery || {};
+  /* data 为顶层接口响应:课题与核验记录都在 data.applications 下 */
+  const app = data.applications || data;
+  /* 后端在未指定课题时会默认回路第一项;因此「是否选中课题」以 URL 参数为准,
+     避免把默认项误当作用户筛选,从而让「全部依据」可真正回到全库。 */
+  const wfActive = typeof q.policyApplication === 'string' && q.policyApplication !== '';
+  const w = wfActive ? app.selectedWorkflow : null;
+  const review = app.reviewedEvidence || {};
   const labels = { 'nutrition-basis': '营养对照', 'sodium-claims': '低钠与减钠', fiber: '膳食纤维', 'process-quality': '工艺与质量', ingredients: '原料与添加剂', wholegrain: '全谷物方案', 'digital-label': '数字标签', transition: '标签换版' };
-  return html`<section class="panel policy-applications"><div class="policy-section-head"><div><h3>创新方案,先核什么</h3><p class="muted">选一个研发课题,查看核查要点与原文。</p></div><span>${data.workflows.length}个课题</span></div>
-    <div class="policy-workflows">${data.workflows.map(row => html`<button data-policy-application=${row.id} aria-pressed=${String(w?.id === row.id)} @click=${() => view._set({ policyApplication: row.id })}>${labels[row.id] || row.title}</button>`)}</div>
-    ${reviewedPolicyEvidence(view, data, w)}
-    ${w ? html`<article class="policy-readout" data-selected-policy-application=${w.id}><header><h4>${w.title}</h4><span>研究核查</span></header><div class="policy-takeaway"><span>得到什么</span><strong>${w.output}</strong></div><p class="policy-boundary">${w.boundary}</p>
-      <div class="policy-inputs"><span>需要准备</span>${w.inputs.map(x => html`<small>${x}</small>`)}</div>
-      <details class="policy-steps"><summary>核查步骤 · ${w.steps.length}步</summary><ol>${w.steps.map(x => html`<li>${x}</li>`)}</ol></details>
-      <details><summary>文件版本与本次官方核验</summary>${w.sources.map(s => html`<article class="policy-proof"><h4>${view._record('documents', s.documentId, `${s.documentId} · ${s.title}`)}</h4><p>库内声明 ${view._label(s.declaredStatus)} · 生效 ${s.effectiveDate || '未知'}${s.notYetEffectiveOnSelectedDate ? ' · 在选定日期尚未实施' : ''}</p>${s.officialCheck ? html`<p>${STATES[s.officialCheck.state]} · 核验 ${s.officialCheck.checkedAt} · PDF ${s.officialCheck.pdfMatchesArchive ? '与归档SHA相同' : '未核成同原件'}</p><small>${s.officialCheck.statusQuote || s.officialCheck.error}<br>官方页SHA ${s.officialCheck.pageSha256 || '无'}</small>` : html`<p>本次官方状态未独立核实。</p>`}${publicationProof(s.publicationCheck)}</article>`)}</details>
-      <details><summary>原文位置与提取证据(${w.evidence.length}处)</summary>${w.evidence.map(e => html`<article class="policy-proof"><h4>${e.documentId} · ${e.locator}</h4><pre>${e.quote}</pre><small>归档提取文本,可能有排版误差;来源 ${e.sourceRecordId || '未知'}</small><p>${view._record('documents', e.documentId, '完整文件与来源')}</p></article>`)}</details>
-      ${w.missingDocumentIds.length ? html`<p>缺文件:${w.missingDocumentIds.join('、')}</p>` : ''}</article>` : html`<p class="muted">选择使用场景,查看输入、步骤和可验证输出。</p>`}
-    <details class="policy-date-check"><summary>日期与文件状态</summary><form class="filters" @submit=${e => submit(e, ['policyAsOf'])}>${input('policyAsOf', '计划使用日期', 'date')}<button type="submit">检查实施日期</button><button type="button" @click=${() => view._set({ policyApplication: '', policyAsOf: '', policyMg: '', policyGrams: '', policyReference: '' })}>重置</button></form><p class="muted">${data.datePolicy}</p><p>已核官方元数据 ${data.officialChecks.verifiedDocuments} 份;访问未核成 ${data.officialChecks.failedDocuments || 0} 份;发布原文身份另核 ${data.officialChecks.publicationVerifiedDocuments || 0} 份。</p><p class="muted">${data.officialChecks.note || '状态为核查时点观察,使用前需核对应版本。'}</p></details>
-    <details class="policy-calculator"><summary>营养计量换算(仅算术,不判断商品合规)</summary><form class="filters" @submit=${e => submit(e, ['policyMg', 'policyGrams', 'policyReference'])}>${input('policyMg', '固体食品每份钠(mg)')}${input('policyGrams', '同一份净含量(g)')}${input('policyReference', '同口径参照值(mg/100g,可留空)')}<button type="submit">换算</button></form>${c?.state === 'CALCULATED' ? html`<div data-policy-calculation><p>${c.formula} = <strong>${c.mgPer100g} mg/100g</strong></p>${c.reductionPercent !== undefined ? html`<p>${c.comparisonFormula} = ${c.reductionPercent}%</p>` : ''}<p class="muted">${c.note}</p></div>` : c ? html`<p>还需填写每份钠含量和同一份净含量。</p>` : ''}</details></section>`;
+
+  /* 统计全部来自接口,不硬编码 */
+  const docs = data.documents || [];
+  const clauses = data.clauses || [];
+  const docCount = docs.length;
+  const verified = typeof review.verifiedClauses === 'number' ? review.verifiedClauses : 0;
+  const pending = typeof review.pendingClauses === 'number' ? review.pendingClauses : 0;
+  const byDoc = groupClausesByDocument(clauses);
+
+  /* 选中课题时只展示该课题关联的文件(按 clauseIds 精确匹配,不按名称猜) */
+  const wfClauseIds = w ? new Set(w.clauseIds || []) : null;
+  const shown = wfClauseIds
+    ? docs.filter(doc => (byDoc.get(doc.id) || []).some(r => wfClauseIds.has(r.id)))
+    : docs;
+
+  return html`<section class="ps-page" data-policy-page>
+    <header class="ps-head">
+      <div>
+        <h2>政策与膳食</h2>
+        <p class="ps-sub">用于方便面产品创新的政策依据;原文核验与法规现行状态分别记录。</p>
+      </div>
+    </header>
+
+    <div class="ps-stats" data-policy-stats>
+      ${statCell('文件', w ? `${shown.length}/${docs.length}` : docCount)}
+      ${statCell('条款', w ? `${wfClauseIds.size}/${clauses.length}` : clauses.length)}
+      ${statCell('已核条款', verified, 'ok')}
+      ${statCell('待核条款', pending, 'pend')}
+    </div>
+    ${w ? html`<p class="ps-scope">当前按课题「${labels[w.id] || w.title}」筛选:展示 ${shown.length} 份文件 / ${wfClauseIds.size} 条条款;已核与待核总数为全库口径。</p>` : ''}
+
+    <div class="ps-filters" data-policy-workflows>
+      <button class="kf-chip ps-chip" aria-pressed=${String(!w)} data-policy-application=""
+        @click=${() => view._set({ policyApplication: '' })}>
+        全部依据<em class="n">${docs.length}</em>
+      </button>
+      ${(app.workflows || []).map(row => html`<button class="kf-chip ps-chip"
+        aria-pressed=${String(w?.id === row.id)} data-policy-application=${row.id}
+        @click=${() => view._set({ policyApplication: row.id })}>
+        ${labels[row.id] || row.title}<em class="n">${(row.clauseIds || []).length}</em>
+      </button>`)}
+    </div>
+
+    ${review.connected === false ? html`<p class="ps-warn">逐条原件核验记录尚未连接,仅显示库内文件与条款。</p>` : ''}
+
+    <div class="ps-cards" data-policy-cards>
+      ${shown.length ? shown.map(doc => {
+        const rows = (byDoc.get(doc.id) || []).filter(r => !w || (w.clauseIds || []).includes(r.id));
+        const first = rows[0] || {};
+        const summary = policySummary(first);
+        const vCount = rows.filter(r => r.originalVerification?.status === 'verified').length;
+        const pCount = rows.length - vCount;
+        const topics = [...new Set(rows.map(r => r.clause).filter(Boolean))].slice(0, 3);
+        return html`<article class="ps-card" data-policy-card=${doc.id} tabindex="0"
+          @click=${() => { rememberScroll(); view._set({ policyDoc: doc.id, policySegment: '', policyPage: '' }); }}
+          @keydown=${e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); rememberScroll(); view._set({ policyDoc: doc.id, policySegment: '', policyPage: '' }); } }}>
+          <h3 class="ps-card-title">${doc.title}</h3>
+          <p class="ps-card-id">${doc.id}</p>
+          <div class="ps-card-meta">
+            <span>${view._label(doc.status)}</span>
+            ${doc.statusAsOf ? html`<span class="ps-dot">状态截至 ${doc.statusAsOf}</span>` : ''}
+            ${doc.effectiveDate ? html`<span class="ps-dot">生效 ${doc.effectiveDate}</span>` : ''}
+          </div>
+          ${summary ? html`<p class="ps-card-sum" title=${summary}>${summary}</p>` : ''}
+          <footer class="ps-card-foot">
+            ${rows.length ? html`<span class="ps-badge">已核 ${vCount}</span><span class="ps-badge pend">待核 ${pCount}</span>` : html`<span class="ps-badge pend">暂无条款</span>`}
+            ${topics.map(t => html`<span class="ps-tag">${t}</span>`)}
+          </footer>
+        </article>`;
+      }) : html`<p class="ps-empty">当前筛选条件没有文件。</p>`}
+    </div>
+
+    <details class="ps-more"><summary>检索与说明(关键词、状态)</summary>
+      <form class="ps-form" @submit=${e => { e.preventDefault(); view._set({ q: new FormData(e.target).get('q') }); }}>
+        <label>文件全文、条款或应用词<input name="q" .value=${q.q || ''} placeholder="例如:钠、营养标签"></label>
+        <label>声明状态<select .value=${q.policyStatus || ''} @change=${e => view._set({ policyStatus: e.target.value, policyDoc: '', policySegment: '' })}>
+          <option value="">全部状态</option>${(data.statuses || []).map(s => html`<option value=${s}>${view._label(s)}</option>`)}
+        </select></label>
+        <button type="submit">查找</button>
+        <button type="button" @click=${() => view._set({ q: '', policyStatus: '', policyDoc: '', policyPage: '', policySegment: '' })}>重置</button>
+      </form>
+      <p class="ps-note">${data.note}</p>
+    </details>
+  </section>`;
+}
+
+/* ---------------- 右侧详情抽屉:文件信息 → 相关条款 → 应用说明 → 原文与来源 ---------------- */
+export function policyDetailPanel(view, d) {
+  const doc = d.selectedDocument;
+  if (!doc) return '';
+  const clauses = (d.clauses || []).filter(c => c.documentId === doc.id);
+  const wf = (d.applications?.workflows || []).filter(w => (w.sources || []).some(s => s.documentId === doc.id));
+  queueMicrotask(restoreScroll);
+  return html`<aside class="ps-drawer" data-policy-drawer role="dialog" aria-modal="true" aria-label=${doc.title}>
+    <header class="ps-drawer-head">
+      <div><h3>${doc.title}</h3><p class="ps-card-id">${doc.id}</p></div>
+      <button class="ps-close" aria-label="关闭详情" @click=${() => { view._set({ policyDoc: '', policySegment: '', policyPage: '' }); restoreScroll(); }}>×</button>
+    </header>
+    <div class="ps-drawer-body">
+      <section class="ps-block">
+        <h4>文件信息</h4>
+        <dl class="ps-dl">
+          <dt>库内声明状态</dt><dd>${view._label(doc.status)}</dd>
+          ${doc.statusAsOf ? html`<dt>状态截至</dt><dd>${doc.statusAsOf}</dd>` : ''}
+          ${doc.effectiveDate ? html`<dt>生效日期</dt><dd>${doc.effectiveDate}</dd>` : ''}
+          ${doc.issuedDate ? html`<dt>发布日期</dt><dd>${doc.issuedDate}</dd>` : ''}
+          <dt>原件指纹</dt><dd>${HASH_STATE(doc.attachmentHashState)}</dd>
+        </dl>
+        <p class="ps-caveat">库内声明状态不等于今日复核;原文已核也不等于现行有效。</p>
+      </section>
+
+      <section class="ps-block">
+        <h4>相关条款 <small>${clauses.length}条</small></h4>
+        ${clauses.length ? clauses.map(c => {
+          const ver = c.originalVerification?.status === 'verified';
+          return html`<article class="ps-clause">
+            <header><strong>${c.clause}</strong><span class="ps-badge ${ver ? '' : 'pend'}">${ver ? '原文已核' : '待补原文'}</span></header>
+            <p class="ps-muted">${c.page ? `第${c.page}页 · ` : ''}${view._label(c.declaredStatus)}${c.statusAsOf ? ` · 截至 ${c.statusAsOf}` : ''}</p>
+            ${c.excerpt ? html`<p>${c.excerpt}</p>` : ''}
+            <small class="ps-muted">${c.excerptType === 'paraphrase_or_transcription' ? '摘要 / 人工转写,非逐字引文' : '摘录待核对'}</small>
+            ${c.applicationNote ? html`<p class="ps-apply">${c.applicationNote}</p>` : ''}
+          </article>`;
+        }) : html`<p class="ps-muted">本文件尚未录入条款。</p>`}
+      </section>
+
+      ${wf.length ? html`<section class="ps-block">
+        <h4>已有应用说明</h4>
+        ${wf.map(w => html`<article class="ps-clause"><header><strong>${w.title}</strong></header>
+          ${w.output ? html`<p>${w.output}</p>` : ''}
+          ${w.boundary ? html`<p class="ps-caveat">${w.boundary}</p>` : ''}
+        </article>`)}
+      </section>` : ''}
+
+      <section class="ps-block">
+        <h4>原文与来源</h4>
+        ${doc.fullTextAvailable ? html`
+          <p class="ps-muted">提取全文 ${doc.segments || 1} 段 · 第 ${doc.segment || 1}/${doc.segments || 1} 段</p>
+          <pre class="ps-original">${doc.text || '该文件尚无提取全文'}</pre>
+          ${doc.textTruncated ? html`<p class="ps-muted">本段超过展示上限,请沿来源读取完整原件。</p>` : ''}
+          <div class="ps-pager">
+            <button ?disabled=${(doc.segment || 1) <= 1} @click=${() => view._set({ policySegment: String((doc.segment || 1) - 1) })}>上一段</button>
+            <button ?disabled=${(doc.segment || 1) >= (doc.segments || 1)} @click=${() => view._set({ policySegment: String((doc.segment || 1) + 1) })}>下一段</button>
+          </div>` : html`<p class="ps-muted">该文件未提供提取全文,请沿来源读取原件。</p>`}
+        <div class="ps-links" @click=${e => e.stopPropagation()}>
+          ${view._record('documents', doc.id, '文件详情与归档来源')}
+          ${view._safeSourceLink(doc.sourceUrl)}
+        </div>
+        <p class="ps-muted">${d.fullTextPolicy}</p>
+      </section>
+    </div>
+  </aside>`;
 }
 
 export const POLICY_APPLICATION_CSS = css`
 ${POLICY_REVIEW_CSS}
-.policy-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.policy-section-head h3{margin:0!important}.policy-section-head p{font-size:12px;margin:4px 0 0!important}.policy-section-head>span{font-size:11px;color:var(--kf-ink-3);white-space:nowrap}
-.policy-workflows{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin:14px 0}.policy-workflows button{text-align:center;overflow-wrap:anywhere;font-size:12px;min-height:36px;padding:7px}.policy-workflows button[aria-pressed=true]{border-color:#d9d1ee;color:var(--kf-accent-ink);background:var(--kf-accent-soft);font-weight:600}
-.policy-readout{padding:15px;border:1px solid #e8e5f1;border-radius:9px;background:#fcfbfe}.policy-readout>header{display:flex;justify-content:space-between;gap:12px;align-items:baseline}.policy-readout>header h4{font-size:15px;margin:0}.policy-readout>header>span{font-size:10px;white-space:nowrap;color:var(--kf-ink-3)}.policy-takeaway{display:grid;grid-template-columns:70px minmax(0,1fr);gap:10px;margin-top:14px;font-size:13px}.policy-takeaway>span{font-size:11px;color:var(--kf-ink-3);padding-top:2px}.policy-takeaway strong{font-weight:550}.policy-boundary{font-size:12px;padding:9px 11px;background:#f6f3eb;border-radius:6px}.policy-inputs{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;font-size:11px;margin-top:12px}.policy-inputs>span{color:var(--kf-ink-3);margin-right:5px}.policy-inputs small{font-size:11px;padding:3px 6px;background:white;border:1px solid #e9e9ef;border-radius:4px}.policy-readout details{font-size:12px;border-top:1px solid #e9e7f0;padding-top:10px;margin-top:12px}.policy-readout li{font-size:12px;line-height:1.8}.policy-readout ol{margin-top:10px}.policy-proof{margin:12px 0;padding:10px;border:1px solid var(--kf-line);border-radius:7px;background:white}.policy-applications small{overflow-wrap:anywhere}.policy-date-check,.policy-calculator{font-size:12px}.policy-date-check form,.policy-calculator form{margin-top:12px}
-.policy-reference-index>summary,.policy-results>summary,.policy-clauses>summary{font-size:13px}.policy-reference-index[open]>form{margin-top:14px}.policy-results:not([open]),.policy-clauses:not([open]){padding:13px 18px}.policy-clauses .clause{margin-top:10px;padding:11px;background:#fafbfc}.policy-clauses .clause>summary{font-size:12px}.policy-clauses .clause h4{font-size:13px}.policy-clauses .clause p{font-size:12px}
-@media(max-width:767px){.policy-workflows{grid-template-columns:repeat(2,minmax(0,1fr))}.policy-readout{padding:12px}.policy-readout>header{display:block}.policy-readout>header>span{display:none}.policy-takeaway{grid-template-columns:1fr;gap:4px}.policy-inputs>span{width:100%}}
+.ps-head h2{margin:0!important;font-size:20px}
+.ps-sub{font-size:12px;color:var(--kf-ink-3);margin:4px 0 0}
+.ps-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:14px 0}
+.ps-cell{border:1px solid var(--kf-line,#e4e5ed);background:#fbfbfe;border-radius:10px;padding:10px 12px}
+.ps-cell span{display:block;font-size:11px;color:var(--kf-ink-3,#697185)}
+.ps-cell strong{font-size:20px;font-weight:650;font-variant-numeric:tabular-nums}
+.ps-cell.ok strong{color:#2f7a58}.ps-cell.pend strong{color:#8a6b2f}
+.ps-filters{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 16px}
+.ps-chip[aria-pressed=true]{background:var(--kf-accent-soft,#f0f0fb);border-color:#d9d1ee;color:var(--kf-accent-ink,#5353ad);font-weight:600}
+.ps-chip .n{font-style:normal;font-size:11px;background:#f2f3f7;border-radius:999px;padding:1px 7px;margin-left:6px;color:var(--kf-ink-3)}
+.ps-chip[aria-pressed=true] .n{background:#fff}
+.ps-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
+.ps-card{display:flex;flex-direction:column;gap:6px;border:1px solid #e7e8ef;border-radius:12px;background:#fff;padding:14px 15px;box-shadow:0 1px 2px rgba(23,32,55,.04);cursor:pointer;transition:box-shadow .16s,border-color .16s,transform .16s;min-width:0}
+.ps-card:hover{border-color:#cfd2e6;box-shadow:0 4px 14px rgba(23,32,55,.09)}
+.ps-card:focus-visible{outline:2px solid var(--kf-accent,#6666bd);outline-offset:2px}
+.ps-card-title{margin:0;font-size:14px;line-height:1.45;font-weight:600;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
+.ps-card-id{margin:0;font-size:11px;color:var(--kf-ink-3);font-variant-numeric:tabular-nums}
+.ps-card-meta{display:flex;flex-wrap:wrap;align-items:center;gap:4px;font-size:11px;color:var(--kf-ink-2,#3c4457)}
+.ps-dot:before{content:'·';margin-right:4px;color:#c3c6d2}
+.ps-card-sum{margin:2px 0 0;font-size:12px;line-height:1.65;color:var(--kf-ink-2,#3c4457);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
+.ps-card-foot{display:flex;flex-wrap:wrap;gap:5px;margin-top:auto;padding-top:8px}
+.ps-badge{font-size:11px;padding:1px 7px;border-radius:999px;background:#edf5f0;color:#2f7a58;white-space:nowrap}
+.ps-badge.pend{background:#faf5e9;color:#8a6b2f}
+.ps-tag{font-size:10px;padding:1px 7px;border-radius:999px;background:#f3f4f8;color:var(--kf-ink-3);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ps-empty,.ps-warn{padding:16px;border:1px dashed var(--kf-line);border-radius:10px;font-size:12px;color:var(--kf-ink-3)}
+.ps-scope{margin:-6px 0 12px;font-size:11px;color:var(--kf-ink-3)}
+.ps-warn{border-style:solid;background:#faf5e9;color:#8a6b2f}
+.ps-more{margin-top:18px;border-top:1px solid var(--kf-line);font-size:12px}
+.ps-form{display:flex;flex-wrap:wrap;gap:10px;align-items:end;margin:12px 0}
+.ps-form label{display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--kf-ink-3)}
+.ps-form input,.ps-form select{font:inherit;font-size:12px;padding:7px 10px;border:1px solid var(--kf-line);border-radius:8px;background:#fff;color:inherit;min-height:34px}
+.ps-note{font-size:11px;color:var(--kf-ink-3);line-height:1.7}
+.ps-drawer{position:fixed;top:0;right:0;height:100dvh;width:min(520px,100%);background:#fff;border-left:1px solid #e7e8ef;box-shadow:-8px 0 32px rgba(23,32,55,.14);z-index:60;display:flex;flex-direction:column}
+.ps-scrim{position:fixed;inset:0;background:rgba(32,37,56,.34);z-index:55}
+.ps-drawer-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 18px;border-bottom:1px solid var(--kf-line);flex:none}
+.ps-drawer-head h3{margin:0;font-size:15px;line-height:1.5;overflow-wrap:anywhere}
+.ps-close{border:0;background:transparent;font-size:24px;line-height:1;padding:0 4px;cursor:pointer;color:var(--kf-ink-3)}
+.ps-drawer-body{overflow-y:auto;padding:4px 18px 24px;font-size:12px}
+.ps-block{padding:14px 0;border-bottom:1px solid #f0f1f5}
+.ps-block:last-child{border-bottom:0}
+.ps-block h4{margin:0 0 8px;font-size:12px;font-weight:600}
+.ps-block h4 small{font-weight:400;color:var(--kf-ink-3);margin-left:6px}
+.ps-dl{display:grid;grid-template-columns:86px minmax(0,1fr);gap:4px 10px;margin:0}
+.ps-dl dt{color:var(--kf-ink-3);font-size:11px}
+.ps-dl dd{margin:0;font-size:12px;overflow-wrap:anywhere}
+.ps-clause{border:1px solid #eeeff4;border-radius:8px;padding:10px 12px;margin-bottom:8px;background:#fcfcfe}
+.ps-clause header{display:flex;justify-content:space-between;gap:8px;align-items:baseline;margin-bottom:4px}
+.ps-clause header strong{font-size:12px;overflow-wrap:anywhere}
+.ps-clause p{margin:4px 0;line-height:1.7;overflow-wrap:anywhere}
+.ps-apply{background:#f6f8fb;border-radius:6px;padding:7px 9px;font-size:11px}
+.ps-muted{color:var(--kf-ink-3);font-size:11px}
+.ps-caveat{background:#faf5e9;color:#7e6c48;border-radius:6px;padding:7px 9px;font-size:11px;line-height:1.7}
+.ps-original{white-space:pre-wrap;overflow-wrap:anywhere;max-height:22rem;overflow:auto;background:#fafbfc;border:1px solid #eeeff4;border-radius:8px;padding:10px;font-size:11px;line-height:1.8}
+.ps-pager{display:flex;gap:6px;margin:8px 0}
+.ps-links{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
+@media(max-width:1199px){.ps-cards{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:767px){
+  .ps-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .ps-cards{grid-template-columns:1fr}
+  .ps-drawer{width:100%;height:100dvh;border-left:0;border-top:0}
+  .ps-drawer-body{padding-bottom:32px}
+}
 `;

+ 9 - 7
src/pages/industry-research-views.js

@@ -1,5 +1,5 @@
 import { html } from 'lit';
-import { policyApplicationsView } from './industry-policy-view.js';
+import { policyApplicationsView, policyDetailPanel } from './industry-policy-view.js';
 
 const REVIEW_MODES = { readable: '可读正文(含未知状态)', raw: '全部原记录', analysis: '符合库内分析规则' };
 const DEFAULT_STATES = { '': '全部默认状态', unknown: '未知', '0': '明确非默认', '1': '明确默认' };
@@ -22,11 +22,13 @@ export function reviewsView(view, d) {
     </section>${view._monitoringView(d.monitoring, 'reviews')}`;
 }
 
+/**
+ * 政策与膳食:卡片式资料页(统计条 + 课题筛选 + 文件卡片 + 右侧详情抽屉)。
+ * 统计、条款、核验状态全部来自接口;摘要只取库内已有文本。
+ */
 export function policiesView(view, d) {
-  const q = view.routeQuery, matches = d.fullTextMatches, selected = d.selectedDocument;
-  return html`${policyApplicationsView(view, d.applications)}<details class="panel policy-reference-index" ?open=${!!q.q || !!q.policyDoc || !!q.policyStatus}><summary>文件与原文检索 · ${d.documents.length}份文件</summary><form class="policy-query filters" @submit=${e => { e.preventDefault(); view._set({ q: new FormData(e.target).get('q') }); }}><label>文件全文、条款或应用词<input name="q" .value=${q.q || ''} placeholder="例如:钠、营养标签、食品添加剂"></label><label>声明状态<select data-research-filter="policyStatus" .value=${q.policyStatus || ''} @change=${e => view._set({ policyStatus: e.target.value, policyDoc: '', policySegment: '' })}><option value="">全部状态</option>${d.statuses.map(s => html`<option value=${s}>${view._label(s)}</option>`)}</select></label><button type="submit">查找</button><button type="button" @click=${() => view._set({ q: '', policyDoc: '', policyStatus: '', policyPage: '', policySegment: '' })}>重置政策筛选</button></form><p class="muted">${d.note}</p><details><summary>全部文件(${d.documents.length}份)</summary>${d.documents.map(r => html`<p><button @click=${() => view._set({ policyDoc: r.id, policySegment: '', policyStatus: '', policyPage: '' })}>${r.title}</button><br><small>${view._label(r.status)} · 截至 ${r.statusAsOf || '未知'} · ${r.fullTextAvailable ? `${r.segments}段提取全文` : '无全文'}</small></p>`)}</details><details><summary>如何使用条款</summary><ol>${d.usageSteps.map(s => html`<li>${s}</li>`)}</ol></details></details>
-    ${selected ? html`<article class="panel policy-fulltext"><h3>${selected.title}</h3><p class="muted">${view._label(selected.status)} · 状态截至 ${selected.statusAsOf || '未知'} · 生效 ${selected.effectiveDate || '未知'} · 附件核验 ${HASH_STATE(selected.attachmentHashState)}</p><p>${selected.locator} · 第${selected.segment}/${selected.segments || 1}段</p><pre class="policy-original">${selected.text || '该文件尚无提取全文'}</pre>${selected.textTruncated ? html`<p>本段超过展示上限,请沿来源读取完整原件。</p>` : ''}<div class="task-paging"><button data-policy-segment="previous" ?disabled=${selected.segment <= 1} @click=${() => view._set({ policySegment: String(selected.segment - 1) })}>上一段</button><button data-policy-segment="next" ?disabled=${selected.segment >= selected.segments} @click=${() => view._set({ policySegment: String(selected.segment + 1) })}>下一段</button></div><div class="links">${view._record('documents', selected.id, '文件详情与归档来源')}${view._safeSourceLink(selected.sourceUrl)}</div><p class="muted">${d.fullTextPolicy}</p></article>` : ''}
-    <details class="panel policy-fulltext-results policy-results" ?open=${!!q.q || !!q.policyDoc || !!q.policyStatus}><summary>全文定位 · ${matches.total}段</summary><p class="muted">${d.fullTextPolicy}</p>${matches.rows.map(r => html`<article class="policy-hit"><button @click=${() => view._set({ policyDoc: r.documentId, policySegment: String(r.segment), policyPage: '' })}>${r.title} · ${r.locator}</button><p>${r.text}</p><small>${view._label(r.status)} · 状态截至 ${r.statusAsOf || '未知'} · ${r.matchIn === 'title' ? '文件标题命中' : '全文文本命中'}</small></article>`)}<div class="task-paging"><button data-policy-page="previous" ?disabled=${matches.page <= 1} @click=${() => view._set({ policyPage: String(matches.page - 1) })}>上一页</button><span>第${matches.page}/${matches.pages}页</span><button data-policy-page="next" ?disabled=${matches.page >= matches.pages} @click=${() => view._set({ policyPage: String(matches.page + 1) })}>下一页</button></div></details>
-    <details class="panel policy-clauses"><summary>条款与应用 · ${d.clauses.length}条</summary><p class="muted">适用条件与阈值须核原文。</p>
-    ${d.clauses.map(c => html`<details class="clause"><summary>${c.documentTitle} · ${c.clause}</summary><p class="muted">第${c.page || '未知'}页 · ${view._label(c.declaredStatus)} · 截至${c.statusAsOf || '未知'} · 生效${c.effectiveDate || '未知'}</p><h4>摘录 / 转写</h4><p>${c.excerpt}</p><small>${c.excerptType === 'paraphrase_or_transcription' ? '摘要 / 人工转写,非逐字引文' : '摘录待核对'}</small><h4>使用方法与应用提示</h4><p>${c.applicationNote || '本条尚无应用提示,需回读全文判断'}</p><div class="links">${view._record('clauses', c.id, '条款与来源')}${view._record('documents', c.documentId, '所属文件')}${view._safeSourceLink(c.sourceUrl)}</div></details>`)}</details>`;
+  const q = view.routeQuery;
+  return html`${policyApplicationsView(view, d)}
+    ${d.selectedDocument ? html`<div class="ps-scrim" @click=${() => view._set({ policyDoc: '', policySegment: '', policyPage: '' })}></div>` : ''}
+    ${policyDetailPanel(view, { ...d, selectedDocument: d.selectedDocument ? { ...d.selectedDocument, text: d.selectedDocument.text, segment: d.selectedDocument.segment, segments: d.selectedDocument.segments, textTruncated: d.selectedDocument.textTruncated } : null })}`;
 }