search.html 73 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>知识检索 — 知识检索中心</title>
  7. <style>
  8. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  9. :root {
  10. --primary: #2563EB;
  11. --primary-light: #EFF6FF;
  12. --primary-hover: #1D4ED8;
  13. --bg: #F1F5F9;
  14. --surface: #FFFFFF;
  15. --border: #E2E8F0;
  16. --border-focus: #A5B4FC;
  17. --text: #0F172A;
  18. --text-sub: #475569;
  19. --text-muted: #94A3B8;
  20. --radius: 8px;
  21. --substance-bg: #EFF6FF; --substance: #1D4ED8;
  22. --form-bg: #F5F3FF; --form: #6D28D9;
  23. --intent-bg: #F0FDF4; --intent: #15803D;
  24. --effect-bg: #FFF7ED; --effect: #C2410C;
  25. --feeling-bg: #FDF2F8; --feeling: #BE185D;
  26. --dim-bg: #F8FAFC; --dim: #64748B;
  27. }
  28. body {
  29. font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
  30. background: var(--bg);
  31. color: var(--text);
  32. font-size: 14px;
  33. line-height: 1.5;
  34. }
  35. /* ── 搜索栏 ───────────────────────────────────────────────────── */
  36. .search-section {
  37. position: sticky; top: 0; z-index: 199;
  38. background: var(--surface);
  39. border-bottom: 1px solid var(--border);
  40. padding: 11px 16px;
  41. }
  42. .search-inner { max-width: 1400px; margin: 0 auto; display: flex; gap: 8px; }
  43. .search-input-wrap { flex: 0 1 680px; min-width: 240px; position: relative; }
  44. .search-icon {
  45. position: absolute; left: 10px; top: 50%;
  46. transform: translateY(-50%);
  47. color: var(--text-muted); pointer-events: none;
  48. display: flex; align-items: center;
  49. }
  50. .search-input {
  51. width: 100%; height: 38px;
  52. padding: 0 12px 0 34px;
  53. border: 1.5px solid var(--border); border-radius: 6px;
  54. font-size: 14px; outline: none;
  55. background: var(--bg); color: var(--text);
  56. transition: border-color .15s, background .15s;
  57. }
  58. .search-input:focus { border-color: var(--border-focus); background: #fff; }
  59. .btn-primary {
  60. height: 38px; padding: 0 18px;
  61. background: var(--primary); color: #fff;
  62. border: none; border-radius: 6px;
  63. font-size: 14px; font-weight: 500; cursor: pointer;
  64. transition: background .15s; white-space: nowrap;
  65. }
  66. .btn-primary:hover { background: var(--primary-hover); }
  67. /* ── 布局 ─────────────────────────────────────────────────────── */
  68. .layout {
  69. display: flex;
  70. max-width: 1400px; margin: 0 auto;
  71. padding: 16px; gap: 14px;
  72. align-items: flex-start;
  73. }
  74. /* ── 侧栏 ─────────────────────────────────────────────────────── */
  75. .sidebar {
  76. width: 232px; flex-shrink: 0;
  77. position: sticky; top: 126px;
  78. max-height: calc(100vh - 132px);
  79. overflow-y: auto;
  80. }
  81. .sidebar::-webkit-scrollbar { width: 3px; }
  82. .sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
  83. .filter-card {
  84. background: var(--surface);
  85. border: 1px solid var(--border); border-radius: var(--radius);
  86. overflow: hidden;
  87. }
  88. .filter-section { border-bottom: 1px solid var(--border); }
  89. .filter-section:last-child { border-bottom: none; }
  90. .section-hd {
  91. display: flex; align-items: center; justify-content: space-between;
  92. padding: 10px 13px; cursor: pointer; user-select: none;
  93. font-size: 13px; font-weight: 600; color: var(--text);
  94. transition: background .1s;
  95. }
  96. .section-hd:hover { background: var(--bg); }
  97. .section-hd .chevron { color: var(--text-muted); font-size: 10px; transition: transform .2s; }
  98. .section-hd.open .chevron { transform: rotate(180deg); }
  99. .section-bd { display: none; padding: 2px 13px 10px; }
  100. .section-bd.open { display: block; }
  101. .sub-group { margin-bottom: 10px; }
  102. .sub-label {
  103. font-size: 10px; font-weight: 700; letter-spacing: .6px;
  104. text-transform: uppercase; color: var(--text-muted);
  105. padding: 5px 0 3px;
  106. }
  107. .semantic-input {
  108. width: 100%; height: 26px; padding: 0 8px;
  109. border: 1px solid var(--border); border-radius: 4px;
  110. font-size: 12px; outline: none; color: var(--text);
  111. background: var(--bg); margin-bottom: 5px;
  112. transition: border-color .15s;
  113. }
  114. .semantic-input:focus { border-color: var(--border-focus); background: #fff; }
  115. .semantic-input::placeholder { color: var(--text-muted); }
  116. .opt {
  117. display: flex; align-items: center; gap: 6px;
  118. padding: 3px 0; cursor: pointer;
  119. font-size: 13px; color: var(--text-sub);
  120. }
  121. .opt:hover { color: var(--primary); }
  122. .opt input[type="checkbox"] {
  123. accent-color: var(--primary);
  124. width: 13px; height: 13px; cursor: pointer; flex-shrink: 0;
  125. }
  126. .opt-text { flex: 1; }
  127. .opt-count {
  128. font-size: 11px; color: var(--text-muted);
  129. background: var(--bg); border-radius: 8px;
  130. padding: 0 5px; min-width: 20px; text-align: center;
  131. }
  132. .range-row { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
  133. .range-inp {
  134. flex: 1; height: 28px; padding: 0 7px;
  135. border: 1px solid var(--border); border-radius: 4px;
  136. font-size: 12px; outline: none; color: var(--text); min-width: 0;
  137. }
  138. .range-inp:focus { border-color: var(--border-focus); }
  139. .range-sep { color: var(--text-muted); font-size: 12px; }
  140. /* ── 建议下拉(portal,fixed 定位到 body)──────────────────────── */
  141. .suggest-dropdown {
  142. background: var(--surface);
  143. border: 1px solid var(--border); border-radius: 4px;
  144. overflow: hidden;
  145. box-shadow: 0 6px 16px rgba(0,0,0,.12);
  146. max-height: 220px; overflow-y: auto;
  147. }
  148. .suggest-item {
  149. padding: 6px 10px; font-size: 12px;
  150. color: var(--text-sub); cursor: pointer;
  151. transition: background .1s;
  152. }
  153. .suggest-item:hover { background: var(--primary-light); color: var(--primary); }
  154. /* ── 结果区域 ─────────────────────────────────────────────────── */
  155. .results-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
  156. .chips-bar {
  157. display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
  158. padding: 9px 13px;
  159. background: var(--surface);
  160. border: 1px solid var(--border); border-radius: var(--radius);
  161. }
  162. .chips-label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
  163. .chip {
  164. display: inline-flex; align-items: center; gap: 3px;
  165. padding: 2px 8px; border-radius: 4px;
  166. background: var(--primary-light); color: var(--primary);
  167. font-size: 12px; font-weight: 500; cursor: pointer;
  168. }
  169. .chip-x { font-size: 13px; opacity: .55; margin-left: 1px; }
  170. .chip:hover .chip-x { opacity: 1; }
  171. .chip-clear { margin-left: auto; font-size: 12px; color: var(--text-muted); cursor: pointer; }
  172. .chip-clear:hover { color: #EF4444; }
  173. .results-bar {
  174. display: flex; align-items: center; justify-content: space-between;
  175. }
  176. .results-count { font-size: 13px; color: var(--text-sub); }
  177. .results-count strong { color: var(--text); font-weight: 600; }
  178. .sort-label { font-size: 12px; color: var(--text-muted); }
  179. /* ── 卡片 ─────────────────────────────────────────────────────── */
  180. .card {
  181. background: var(--surface);
  182. border: 1px solid var(--border); border-radius: var(--radius);
  183. padding: 15px 16px; cursor: pointer;
  184. transition: border-color .15s, box-shadow .15s;
  185. }
  186. .card:hover { border-color: #C7D2FE; box-shadow: 0 3px 12px rgba(79,70,229,.08); }
  187. .card-top {
  188. display: flex; align-items: flex-start;
  189. justify-content: space-between; gap: 10px; margin-bottom: 6px;
  190. }
  191. .card-title {
  192. font-size: 15px; font-weight: 600; color: var(--text);
  193. line-height: 1.4; flex: 1;
  194. }
  195. .card-title em {
  196. font-style: normal; color: var(--primary);
  197. background: var(--primary-light); padding: 0 2px; border-radius: 2px;
  198. }
  199. .score-badge {
  200. flex-shrink: 0; font-size: 11px; font-weight: 600;
  201. color: #15803D; background: #F0FDF4;
  202. padding: 2px 7px; border-radius: 10px; white-space: nowrap;
  203. }
  204. .card-meta {
  205. display: flex; align-items: center; flex-wrap: wrap;
  206. gap: 3px 10px; font-size: 12px; color: var(--text-muted); margin-bottom: 9px;
  207. }
  208. .source-badge {
  209. display: inline-flex; align-items: center;
  210. padding: 1px 6px;
  211. background: var(--bg); border: 1px solid var(--border);
  212. border-radius: 4px; font-size: 11px; color: var(--text-sub);
  213. }
  214. .meta-dot { color: var(--border); }
  215. .tag-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
  216. .tag {
  217. display: inline-flex; align-items: center; gap: 2px;
  218. padding: 2px 7px; border-radius: 4px;
  219. font-size: 11px; font-weight: 500; line-height: 1.6;
  220. }
  221. .tag-pre { opacity: .65; font-size: 10px; }
  222. .tag.s { background: var(--substance-bg); color: var(--substance); }
  223. .tag.fo { background: var(--form-bg); color: var(--form); }
  224. .tag.i { background: var(--intent-bg); color: var(--intent); }
  225. .tag.e { background: var(--effect-bg); color: var(--effect); }
  226. .tag.fe { background: var(--feeling-bg); color: var(--feeling); }
  227. .tag.d { background: var(--dim-bg); color: var(--dim); border: 1px solid var(--border); }
  228. .tag.hit { box-shadow: 0 0 0 1.5px currentColor; font-weight: 700; }
  229. .tag.d.hit { border-color: var(--dim); }
  230. .ext-item.hit .ext-key { color: var(--text-sub); }
  231. .ext-item.hit .ext-val { color: var(--primary); font-weight: 700; }
  232. .card-foot {
  233. display: flex; align-items: center; justify-content: space-between;
  234. padding-top: 9px; border-top: 1px solid var(--bg);
  235. }
  236. .ext-row { display: flex; flex-wrap: wrap; gap: 10px; }
  237. .ext-item { font-size: 11px; }
  238. .ext-key { color: var(--text-muted); }
  239. .ext-val { color: var(--text-sub); font-weight: 500; }
  240. .card-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
  241. /* ── 分页 ─────────────────────────────────────────────────────── */
  242. .pagination {
  243. display: flex; justify-content: center; align-items: center;
  244. gap: 3px; padding: 6px 0 4px;
  245. }
  246. .pg-btn {
  247. min-width: 32px; height: 32px; padding: 0 8px;
  248. display: inline-flex; align-items: center; justify-content: center;
  249. border: 1px solid var(--border); border-radius: 4px;
  250. background: var(--surface); color: var(--text-sub);
  251. font-size: 13px; cursor: pointer; transition: all .15s; user-select: none;
  252. }
  253. .pg-btn:hover:not(.active):not([disabled]) { border-color: var(--primary); color: var(--primary); }
  254. .pg-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600; }
  255. .pg-btn[disabled] { color: var(--text-muted); cursor: default; }
  256. .pg-dots { font-size: 13px; color: var(--text-muted); padding: 0 2px; }
  257. /* ── 空/加载/错误状态 ─────────────────────────────────────────── */
  258. .state-box {
  259. text-align: center; padding: 60px 20px;
  260. color: var(--text-muted); font-size: 14px;
  261. background: var(--surface);
  262. border: 1px solid var(--border); border-radius: var(--radius);
  263. }
  264. .state-box.error { color: #EF4444; }
  265. .state-box .state-icon { font-size: 32px; margin-bottom: 10px; }
  266. .state-box p { margin-top: 6px; font-size: 13px; }
  267. /* ── 侧栏骨架 ─────────────────────────────────────────────────── */
  268. .sidebar-loading {
  269. padding: 20px 13px;
  270. display: flex; flex-direction: column; gap: 8px;
  271. }
  272. .skel {
  273. height: 12px; border-radius: 4px;
  274. background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
  275. background-size: 200% 100%;
  276. animation: shimmer 1.4s infinite;
  277. }
  278. @keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
  279. /* ── 语义输入框 loading 圈 ────────────────────────────────────────── */
  280. .sem-wrap { position: relative; }
  281. .sem-wrap .semantic-input { padding-right: 26px; }
  282. .sem-spinner {
  283. position: absolute; right: 7px; top: 50%;
  284. transform: translateY(-50%);
  285. width: 11px; height: 11px;
  286. border: 2px solid var(--border);
  287. border-top-color: var(--primary);
  288. border-radius: 50%;
  289. animation: sem-spin .65s linear infinite;
  290. display: none; pointer-events: none;
  291. }
  292. .sem-spinner.show { display: block; }
  293. @keyframes sem-spin { to { transform: translateY(-50%) rotate(360deg); } }
  294. /* ── 正文展开 ─────────────────────────────────────────────────── */
  295. .content-toggle {
  296. display: flex; align-items: center; gap: 5px;
  297. padding: 7px 0 2px;
  298. font-size: 12px; color: var(--text-muted);
  299. cursor: pointer; user-select: none;
  300. border-top: 1px solid var(--bg); margin-top: 8px;
  301. }
  302. .content-toggle:hover { color: var(--primary); }
  303. .content-caret {
  304. font-size: 9px; display: inline-block;
  305. transition: transform .2s; margin-left: auto;
  306. }
  307. .content-toggle.open .content-caret { transform: rotate(180deg); }
  308. .content-body { display: none; }
  309. .content-body.open { display: block; padding-top: 6px; }
  310. .content-pre {
  311. margin: 0; padding: 10px 12px;
  312. background: var(--bg); border: 1px solid var(--border); border-radius: 5px;
  313. font-family: 'Courier New', Consolas, monospace;
  314. font-size: 11px; line-height: 1.65; color: var(--text-sub);
  315. max-height: 300px; overflow-y: auto;
  316. white-space: pre-wrap; word-break: break-all;
  317. }
  318. /* ── 原文卡片(按 source_id=case_id 懒加载;标题+缩略图+正文,可整体展开/收起)── */
  319. .src-block { border: 1px solid var(--border); border-radius: 8px; margin: 10px 0; background: #F8FAFC; overflow: hidden; }
  320. .src-head { display: flex; align-items: center; gap: 8px; padding: 8px 11px; cursor: pointer; user-select: none; }
  321. .src-head:hover { background: #F1F5F9; }
  322. .src-caret { flex: 0 0 auto; color: var(--text-muted); font-size: 11px; transition: transform .15s; }
  323. .src-block.open .src-caret { transform: rotate(90deg); }
  324. .src-label { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-light); padding: 1px 7px; border-radius: 4px; }
  325. .src-title { flex: 1; min-width: 0; font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
  326. .src-link { flex: 0 0 auto; font-size: 12px; color: var(--primary); text-decoration: none; }
  327. .src-case { flex: 0 0 auto; font-size: 11px; font-family: 'Courier New', monospace; color: var(--text-muted); background: var(--bg); border: 1px solid var(--border); padding: 1px 7px; border-radius: 4px; white-space: nowrap; }
  328. .src-body { padding: 0 11px 11px; }
  329. .src-block:not(.open) .src-body { display: none; }
  330. .src-thumbs { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 10px; scrollbar-width: thin; }
  331. .src-thumbs img { flex: 0 0 auto; height: 96px; width: auto; max-width: 150px; border-radius: 8px; border: 1px solid var(--border); object-fit: cover; background: #F0EFE9; cursor: zoom-in; }
  332. .src-text { font-size: 13px; line-height: 1.7; color: var(--text-sub); white-space: pre-wrap; }
  333. .src-text.clamp { max-height: 88px; overflow: hidden; -webkit-mask-image: linear-gradient(180deg,#000 62%,transparent); mask-image: linear-gradient(180deg,#000 62%,transparent); }
  334. .src-text-toggle { margin-top: 4px; background: none; border: none; color: var(--primary); cursor: pointer; font-size: 12px; padding: 2px 0; }
  335. .src-loading, .src-empty { font-size: 12px; color: var(--text-muted); padding: 8px 2px; }
  336. /* ── 工序步骤表(复用自工序解构的 .steps 表格)─────────────────── */
  337. .steps-wrap { overflow-x: auto; margin-top: 6px; border: 1px solid var(--border); border-radius: 6px; }
  338. .steps { width: 100%; min-width: 1500px; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
  339. .steps th { padding: 6px 8px; font-size: 11px; font-weight: 700; letter-spacing: 1px; color: #fff; text-align: left; }
  340. .steps thead tr:first-child th { text-align: center; font-size: 12px; letter-spacing: 4px; padding: 7px 4px; }
  341. .steps .h-req { background: #2b4a72; } .steps .h-req2 { background: #33547a; }
  342. .steps .h-in { background: #c2761f; } .steps .h-in2 { background: #cd7522; }
  343. .steps .h-im { background: #2f9c8a; } .steps .h-im2 { background: #2d8273; }
  344. .steps .h-out { background: #2563eb; } .steps .h-out2 { background: #4f7fe6; }
  345. .steps td { padding: 8px 9px; border: 1px solid var(--border); vertical-align: top; line-height: 1.6; color: var(--text); }
  346. .steps tbody tr:nth-child(odd) td { background: #fdfdf9; }
  347. .steps td.c-in { background: #FFF7ED !important; }
  348. .steps td.c-out { background: #eef3fe !important; }
  349. .steps .sid { font-family: 'Courier New', monospace; font-weight: 700; color: #2b4a72; white-space: nowrap; }
  350. .steps .vtxt { color: var(--text-sub); font-size: 11.5px; word-break: break-all; }
  351. .steps .anchor { font-family: 'Courier New', monospace; font-size: 10.5px; color: var(--text-muted); word-break: break-all; }
  352. .steps .pill { display: inline-block; padding: 1px 7px; border-radius: 4px; font-size: 11px; font-weight: 600; }
  353. .steps .pill.navy { background: #e7edf5; color: #2b4a72; }
  354. .steps .pill.amber { background: #fdebd3; color: #9a560f; }
  355. .steps .pill.teal { background: #d8f0ea; color: #1f6f62; }
  356. .steps .pill.green { background: #e3f3e8; color: #2e6b45; }
  357. .steps .pill.blue { background: #eef3fe; color: #2563eb; }
  358. /* 目的列 intent 胶囊: 底色 = 所引用列的分组色, 与表头/列 chip 一致
  359. (需求=navy / 输入=amber / 实现=teal / 输出=green; 口径同 procedure-dsl「token 色对应来源列」) */
  360. .intent-text { color: #1f2937; line-height: 1.6; }
  361. .intent-tok { display: inline-block; padding: 1px 6px; border-radius: 4px; margin: 0 1px; font-size: 11.5px; font-weight: 600; }
  362. .intent-tok.ik-effect { background: #e7edf5; color: #2b4a72; } /* 作用列 (需求组) */
  363. .intent-tok.ik-via { background: #d8f0ea; color: #1f6f62; font-family: ui-monospace, "SF Mono", monospace; } /* 外部工具列 (实现组) */
  364. .intent-tok.ik-act { background: #d8f0ea; color: #1f6f62; } /* 动作列 (实现组) */
  365. .intent-tok.ik-in-type { background: #fdebd3; color: #9a560f; border-radius: 99px; padding: 1px 8px; } /* 输入·类型 */
  366. .intent-tok.ik-out-type { background: #eef3fe; color: #2563eb; border: 1px solid #b6cdf7; border-radius: 99px; padding: 1px 8px; } /* 输出·类型 (蓝色,避免与实现组绿色混淆) */
  367. .intent-tok.ik-other { background: #fbeae5; color: #b3341d; text-decoration: line-through; } /* 非法类别(lint 警告) */
  368. .steps .inf { background: #fdf6e3 !important; position: relative; outline: 1px dashed #c9a227; outline-offset: -2px; }
  369. .steps .inf .ib { position: absolute; top: -1px; right: -1px; background: #c9a227; color: #fff; font-size: 9px; padding: 0 4px; border-radius: 0 0 0 4px; font-weight: 700; }
  370. .steps-empty { padding: 12px; color: var(--text-muted); font-size: 12px; }
  371. /* 归类命中(实质/形式):原值 → 命中值 逐行配对 (口径同 index.html renderSF) */
  372. .steps .sf-map { display: flex; flex-direction: column; gap: 4px; }
  373. .steps .sf-pair { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; line-height: 1.5; }
  374. .steps .sf-old { color: #9aa0a6; text-decoration: line-through; }
  375. .steps .sf-plain { color: var(--text); }
  376. .steps .sf-arrow { color: #2e9e5b; flex: none; }
  377. .steps .sf-new { display: inline-block; padding: 1px 7px; border-radius: 10px; background: #e3f3e8; color: #2e6b45; border: 1px solid #bfe3cb; font-weight: 600; white-space: nowrap; }
  378. /* 输入/输出「值」单元格:超过 4 行加蒙版,点击展开/收起 */
  379. .clamp-val { position: relative; }
  380. .clamp-val.clampable { max-height: 6.6em; overflow: hidden; cursor: zoom-in; }
  381. .clamp-val.clampable::after {
  382. content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2.4em; pointer-events: none;
  383. }
  384. .steps td.c-in .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(255,247,237,0), rgba(255,247,237,1)); }
  385. .steps td.c-out .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(238,243,254,0), rgba(238,243,254,1)); }
  386. .clamp-val.open { max-height: none; overflow: visible; cursor: zoom-out; }
  387. .clamp-val.open::after { display: none; }
  388. /* ── 工具解构表(移植自 index.html renderTools:案例逐行 rowspan + 限高展开)─ */
  389. .mw-ttwrap { overflow-x: auto; margin-top: 6px; border: 1px solid var(--border); border-radius: 8px; }
  390. .mw-tt { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 1180px; background: #fff; font-size: 12.5px; }
  391. .mw-tt thead th {
  392. text-align: left; white-space: nowrap;
  393. background: linear-gradient(180deg, #2aa79b, #1c8076); color: #fff; font-weight: 700;
  394. padding: 9px 12px; letter-spacing: .3px; border-right: 1px solid rgba(255,255,255,.18);
  395. }
  396. .mw-tt thead th:last-child { border-right: none; }
  397. .mw-tt .th-group { text-align: center; }
  398. .mw-tt .th-sub { background: linear-gradient(180deg, #36bdb0, #23897f); font-weight: 600; }
  399. .mw-tt tbody td {
  400. padding: 9px 12px; vertical-align: top; line-height: 1.6;
  401. border-bottom: 1px solid #f0eee8; border-right: 1px solid #f5f3ee; color: #3a3a3a;
  402. }
  403. .mw-tt tbody td:last-child { border-right: none; }
  404. .mw-tt td.col-case { background: #fafdfc; }
  405. .mw-tt tbody td.col-tool {
  406. font-weight: 700; color: #176d64; white-space: nowrap;
  407. border-left: 3px solid #2aa79b; background: #f3faf8;
  408. }
  409. .mw-tt ul { margin: 0; padding-left: 17px; }
  410. .mw-tt ul li { margin: 3px 0; }
  411. .mw-tt ul li::marker { color: #2aa79b; }
  412. .mw-tt .layer-badge { display: inline-block; font-weight: 700; font-size: 11px; padding: 2px 10px; border-radius: 20px; white-space: nowrap; }
  413. .mw-tt .layer-badge.make { color: #0e7490; background: #d6f0ee; }
  414. .mw-tt .layer-badge.create { color: #b8731a; background: #fef0db; }
  415. .mw-tt .dash { color: #c9c2b6; }
  416. .mw-tt .tcell { position: relative; max-height: 7.8em; overflow: hidden; transition: max-height .15s; }
  417. .mw-tt .tcell.clamped { cursor: zoom-in; }
  418. .mw-tt .tcell.clamped::after {
  419. content: '▾ 展开'; position: absolute; left: 0; right: 0; bottom: 0; height: 2.6em;
  420. display: flex; align-items: flex-end; justify-content: center; padding-bottom: 2px;
  421. font-size: 11px; font-weight: 700; color: #176d64;
  422. background: linear-gradient(rgba(255,255,255,0), #fff 72%); pointer-events: none;
  423. }
  424. .mw-tt td.col-case .tcell.clamped::after { background: linear-gradient(rgba(250,253,252,0), #fafdfc 72%); }
  425. .mw-tt .tcell.open { max-height: none; cursor: zoom-out; }
  426. .mw-tt .tcell.open::after { content: ''; height: 0; }
  427. </style>
  428. </head>
  429. <body>
  430. <div class="search-section">
  431. <div class="search-inner">
  432. <div class="search-input-wrap">
  433. <span class="search-icon">
  434. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
  435. <circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
  436. </svg>
  437. </span>
  438. <input id="search-input" class="search-input" type="text" placeholder="搜索知识标题、正文内容…">
  439. </div>
  440. <button id="search-btn" class="btn-primary">搜索</button>
  441. </div>
  442. </div>
  443. <div class="layout">
  444. <aside class="sidebar">
  445. <div class="filter-card" id="sidebar-card">
  446. <div class="sidebar-loading">
  447. <div class="skel" style="width:60%"></div>
  448. <div class="skel" style="width:90%"></div>
  449. <div class="skel" style="width:75%"></div>
  450. <div class="skel" style="width:80%"></div>
  451. <div class="skel" style="width:55%"></div>
  452. </div>
  453. </div>
  454. </aside>
  455. <div class="results-area">
  456. <div class="chips-bar" id="chips-bar" style="display:none"></div>
  457. <div class="results-bar">
  458. <div class="results-count" id="results-count">加载中…</div>
  459. <div class="sort-label" id="sort-label"></div>
  460. </div>
  461. <div id="results-list">
  462. <div class="state-box"><div class="state-icon">🔍</div><p>正在加载…</p></div>
  463. </div>
  464. <div class="pagination" id="pagination"></div>
  465. </div>
  466. </div>
  467. <script>
  468. const API = '/api/v1/knowledge';
  469. const SCOPE_TYPES = ['substance','form','intent','effect','feeling'];
  470. const SCOPE_LABELS = {substance:'实质', form:'形式', intent:'意图', effect:'作用', feeling:'感受'};
  471. const SCOPE_TAGS = {substance:'s', form:'fo', intent:'i', effect:'e', feeling:'fe'};
  472. // ── 转义工具 ──────────────────────────────────────────────────────────
  473. const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  474. // 目的列: 把 intent 里的 {类别:值} 标记渲染成彩色胶囊 (口径同 procedure-dsl renderer.py:render_intent)。
  475. // 合法类别 5 个: effect/via/act/in-type/out-type; 其余落 ik-other(红删除线)。标记外字面与值都转义。
  476. const INTENT_KIND = { effect:'ik-effect', via:'ik-via', act:'ik-act', 'in-type':'ik-in-type', 'out-type':'ik-out-type' };
  477. function renderIntent(text) {
  478. const s = String(text ?? ''), re = /\{([\w-]+):([^}]+)\}/g;
  479. let out = '', last = 0, m;
  480. while ((m = re.exec(s))) {
  481. out += esc(s.slice(last, m.index).replace(/`/g, ''));
  482. out += `<span class="intent-tok ${INTENT_KIND[m[1]] || 'ik-other'}">${esc(m[2])}</span>`;
  483. last = m.index + m[0].length;
  484. }
  485. return out + esc(s.slice(last).replace(/`/g, ''));
  486. }
  487. function debounce(fn, ms) {
  488. let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
  489. }
  490. // ── 全局状态 ──────────────────────────────────────────────────────────
  491. let dims = null;
  492. let _portalDropdown = null;
  493. function clearPortalDropdown() {
  494. if (_portalDropdown) { _portalDropdown.remove(); _portalDropdown = null; }
  495. }
  496. document.addEventListener('mousedown', e => {
  497. if (_portalDropdown && !_portalDropdown.contains(e.target)) clearPortalDropdown();
  498. });
  499. const sel = {
  500. query: '',
  501. scopes: {substance: new Set(), form: new Set(), intent: new Set(), effect: new Set(), feeling: new Set()},
  502. scopeSemantic: {substance: '', form: '', intent: '', effect: '', feeling: ''},
  503. dimAttr: new Set(),
  504. dimCreate: new Set(),
  505. customValues: {}, // key → Set<string>
  506. customSemantics:{}, // key → string
  507. customRanges: {}, // key → {gte, lte}
  508. page: 1,
  509. };
  510. let _chips = [];
  511. // ── 初始化 ────────────────────────────────────────────────────────────
  512. async function init() {
  513. document.getElementById('search-btn').addEventListener('click', triggerSearch);
  514. document.getElementById('search-input').addEventListener('keydown', e => {
  515. if (e.key === 'Enter') triggerSearch();
  516. });
  517. try {
  518. const r = await fetch(API + '/dimensions');
  519. if (r.ok) {
  520. dims = await r.json();
  521. renderSidebar();
  522. }
  523. } catch(e) {
  524. console.warn('Failed to load dimensions', e);
  525. document.getElementById('sidebar-card').innerHTML =
  526. '<div class="sidebar-loading" style="color:var(--text-muted);font-size:12px;padding:16px 13px">筛选项加载失败</div>';
  527. }
  528. doSearch();
  529. }
  530. function triggerSearch() {
  531. sel.query = document.getElementById('search-input').value.trim();
  532. sel.page = 1;
  533. doSearch();
  534. }
  535. // ── 侧栏渲染 ──────────────────────────────────────────────────────────
  536. function renderSidebar() {
  537. const card = document.getElementById('sidebar-card');
  538. let html = '';
  539. // 作用域
  540. html += `<div class="filter-section">
  541. <div class="section-hd open" onclick="toggleSection(this)">作用域 <span class="chevron">▼</span></div>
  542. <div class="section-bd open" id="scope-bd">`;
  543. for (const sg of (dims?.scope_groups || [])) {
  544. html += renderScopeGroup(sg);
  545. }
  546. html += `</div></div>`;
  547. // 属性维度(从 facets 动态注入)
  548. html += `<div class="filter-section" id="dim-attr-section" style="display:none">
  549. <div class="section-hd open" onclick="toggleSection(this)">属性维度 <span class="chevron">▼</span></div>
  550. <div class="section-bd open" id="dim-attr-bd"></div>
  551. </div>`;
  552. // 创作维度(从 facets 动态注入)
  553. html += `<div class="filter-section" id="dim-create-section" style="display:none">
  554. <div class="section-hd open" onclick="toggleSection(this)">创作维度 <span class="chevron">▼</span></div>
  555. <div class="section-bd open" id="dim-create-bd"></div>
  556. </div>`;
  557. // 自定义字段(section 默认隐藏,有 facets 数据时才显示)
  558. if (dims?.custom_fields?.length) {
  559. html += `<div class="filter-section" id="custom-section" style="display:none">
  560. <div class="section-hd open" onclick="toggleSection(this)">自定义索引 <span class="chevron">▼</span></div>
  561. <div class="section-bd open" id="custom-bd">`;
  562. for (const cf of dims.custom_fields) {
  563. html += renderCustomField(cf);
  564. }
  565. html += `</div></div>`;
  566. }
  567. card.innerHTML = html;
  568. bindSidebarEvents();
  569. }
  570. function renderScopeGroup(sg) {
  571. const st = sg.scope_type;
  572. const label = SCOPE_LABELS[st] || st;
  573. return `<div class="sub-group" id="sg-${st}">
  574. <div class="sub-label">${label}</div>
  575. <div class="sem-wrap">
  576. <input class="semantic-input" type="text" id="sem-${st}" placeholder="语义搜索${label}标签…">
  577. <span class="sem-spinner" id="sem-${st}-sp"></span>
  578. </div>
  579. <div class="suggest-list" id="sug-${st}"></div>
  580. </div>`;
  581. }
  582. function renderCustomField(cf) {
  583. const label = esc(cf.description || cf.key);
  584. let html = `<div class="sub-group" id="cf-${esc(cf.key)}"><div class="sub-label">${label}</div>`;
  585. if (cf.value_type === 'str') {
  586. if (cf.semantic_enabled) {
  587. html += `<div class="sem-wrap">
  588. <input class="semantic-input" type="text" id="csem-${esc(cf.key)}" placeholder="语义搜索${label}…">
  589. <span class="sem-spinner" id="csem-${esc(cf.key)}-sp"></span>
  590. </div>`;
  591. }
  592. html += `<div class="suggest-list" id="csug-${esc(cf.key)}"></div>`;
  593. } else if (cf.value_type === 'num') {
  594. html += `<div class="range-row">
  595. <input class="range-inp" type="number" id="cgte-${esc(cf.key)}" placeholder="最小">
  596. <span class="range-sep">—</span>
  597. <input class="range-inp" type="number" id="clte-${esc(cf.key)}" placeholder="最大">
  598. </div>`;
  599. } else if (cf.value_type === 'date') {
  600. html += `<div class="range-row">
  601. <input class="range-inp" type="date" id="cgte-${esc(cf.key)}">
  602. <span class="range-sep">—</span>
  603. <input class="range-inp" type="date" id="clte-${esc(cf.key)}">
  604. </div>`;
  605. }
  606. html += `</div>`;
  607. return html;
  608. }
  609. function bindSidebarEvents() {
  610. // 作用域复选框
  611. document.querySelectorAll('input[data-scope]').forEach(cb => {
  612. cb.addEventListener('change', () => {
  613. const st = cb.dataset.scope, val = cb.dataset.val;
  614. if (cb.checked) sel.scopes[st].add(val); else sel.scopes[st].delete(val);
  615. sel.page = 1; doSearch();
  616. });
  617. });
  618. // 作用域语义输入
  619. const debouncedSuggestScope = debounce((st, q) => {
  620. if (q) suggestScope(st, q);
  621. else clearPortalDropdown();
  622. }, 300);
  623. for (const sg of (dims?.scope_groups || [])) {
  624. const st = sg.scope_type;
  625. const inp = document.getElementById('sem-' + st);
  626. if (!inp) continue;
  627. inp.addEventListener('input', () => {
  628. sel.scopeSemantic[st] = inp.value.trim();
  629. debouncedSuggestScope(st, inp.value.trim());
  630. });
  631. inp.addEventListener('keydown', e => { if (e.key === 'Enter') { sel.page = 1; doSearch(); } });
  632. inp.addEventListener('blur', clearPortalDropdown);
  633. }
  634. // 自定义字段复选框
  635. document.querySelectorAll('input[data-ckey]').forEach(cb => {
  636. cb.addEventListener('change', () => {
  637. const key = cb.dataset.ckey, val = cb.dataset.val;
  638. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  639. if (cb.checked) sel.customValues[key].add(val); else sel.customValues[key].delete(val);
  640. sel.page = 1; doSearch();
  641. });
  642. });
  643. // 自定义语义输入
  644. const debouncedSuggestCustom = debounce((key, q) => {
  645. if (q) suggestCustom(key, q);
  646. else clearPortalDropdown();
  647. }, 300);
  648. for (const cf of (dims?.custom_fields || [])) {
  649. if (cf.value_type !== 'str' || !cf.semantic_enabled) continue;
  650. const inp = document.getElementById('csem-' + cf.key);
  651. if (!inp) continue;
  652. inp.addEventListener('input', () => {
  653. sel.customSemantics[cf.key] = inp.value.trim();
  654. debouncedSuggestCustom(cf.key, inp.value.trim());
  655. });
  656. inp.addEventListener('keydown', e => { if (e.key === 'Enter') { sel.page = 1; doSearch(); } });
  657. inp.addEventListener('blur', clearPortalDropdown);
  658. }
  659. // 自定义范围输入
  660. for (const cf of (dims?.custom_fields || [])) {
  661. if (cf.value_type !== 'num' && cf.value_type !== 'date') continue;
  662. const gteEl = document.getElementById('cgte-' + cf.key);
  663. const lteEl = document.getElementById('clte-' + cf.key);
  664. const onChange = debounce(() => {
  665. if (!sel.customRanges[cf.key]) sel.customRanges[cf.key] = {};
  666. if (cf.value_type === 'num') {
  667. sel.customRanges[cf.key].gte = gteEl?.value !== '' ? parseFloat(gteEl.value) : null;
  668. sel.customRanges[cf.key].lte = lteEl?.value !== '' ? parseFloat(lteEl.value) : null;
  669. } else {
  670. sel.customRanges[cf.key].gte = gteEl?.value ? new Date(gteEl.value).getTime() : null;
  671. sel.customRanges[cf.key].lte = lteEl?.value ? new Date(lteEl.value + 'T23:59:59').getTime() : null;
  672. }
  673. sel.page = 1; doSearch();
  674. }, 500);
  675. gteEl?.addEventListener('input', onChange);
  676. lteEl?.addEventListener('input', onChange);
  677. }
  678. }
  679. // ── 建议 API ──────────────────────────────────────────────────────────
  680. async function suggestScope(scopeType, q) {
  681. const inputEl = document.getElementById('sem-' + scopeType);
  682. const sp = document.getElementById('sem-' + scopeType + '-sp');
  683. if (!inputEl) return;
  684. if (sp) sp.classList.add('show');
  685. try {
  686. const r = await fetch(`${API}/scope-tags/suggest?scope_type=${encodeURIComponent(scopeType)}&q=${encodeURIComponent(q)}`);
  687. if (!r.ok) return;
  688. const data = await r.json();
  689. renderSuggestDropdown(inputEl, data.tags || [], val => {
  690. sel.scopes[scopeType].add(val);
  691. inputEl.value = '';
  692. sel.scopeSemantic[scopeType] = '';
  693. addInlineScopeTag(scopeType, val);
  694. sel.page = 1; doSearch();
  695. });
  696. } catch(e) {
  697. } finally {
  698. if (sp) sp.classList.remove('show');
  699. }
  700. }
  701. async function suggestCustom(key, q) {
  702. const inputEl = document.getElementById('csem-' + key);
  703. const sp = document.getElementById('csem-' + key + '-sp');
  704. if (!inputEl) return;
  705. if (sp) sp.classList.add('show');
  706. try {
  707. const r = await fetch(`${API}/custom-values/suggest?key=${encodeURIComponent(key)}&q=${encodeURIComponent(q)}`);
  708. if (!r.ok) return;
  709. const data = await r.json();
  710. renderSuggestDropdown(inputEl, data.values || [], val => {
  711. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  712. sel.customValues[key].add(val);
  713. inputEl.value = '';
  714. sel.customSemantics[key] = '';
  715. addInlineCustomValue(key, val);
  716. sel.page = 1; doSearch();
  717. });
  718. } catch(e) {
  719. } finally {
  720. if (sp) sp.classList.remove('show');
  721. }
  722. }
  723. function renderSuggestDropdown(inputEl, items, onSelect) {
  724. clearPortalDropdown();
  725. if (!items.length) return;
  726. const rect = inputEl.getBoundingClientRect();
  727. const div = document.createElement('div');
  728. div.className = 'suggest-dropdown';
  729. div.style.cssText = `position:fixed;z-index:9999;top:${rect.bottom + 2}px;left:${rect.left}px;width:${rect.width}px;`;
  730. items.forEach(item => {
  731. const el = document.createElement('div');
  732. el.className = 'suggest-item';
  733. el.textContent = item;
  734. el.addEventListener('mousedown', e => e.preventDefault());
  735. el.addEventListener('click', () => { onSelect(item); clearPortalDropdown(); });
  736. div.appendChild(el);
  737. });
  738. document.body.appendChild(div);
  739. _portalDropdown = div;
  740. }
  741. function addInlineScopeTag(scopeType, val) {
  742. const container = document.getElementById('sg-' + scopeType);
  743. if (!container) return;
  744. const existing = container.querySelector(`input[data-val="${CSS.escape(val)}"]`);
  745. if (existing) { existing.checked = true; return; }
  746. const label = document.createElement('label');
  747. label.className = 'opt';
  748. label.innerHTML = `<input type="checkbox" checked data-scope="${esc(scopeType)}" data-val="${esc(val)}"><span class="opt-text">${esc(val)}</span>`;
  749. label.querySelector('input').addEventListener('change', function() {
  750. if (this.checked) sel.scopes[scopeType].add(val); else sel.scopes[scopeType].delete(val);
  751. sel.page = 1; doSearch();
  752. });
  753. const sugEl = container.querySelector('.suggest-list');
  754. if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
  755. }
  756. function addInlineCustomValue(key, val) {
  757. const container = document.getElementById('cf-' + key);
  758. if (!container) return;
  759. const existing = container.querySelector(`input[data-val="${CSS.escape(val)}"]`);
  760. if (existing) { existing.checked = true; return; }
  761. const label = document.createElement('label');
  762. label.className = 'opt';
  763. label.innerHTML = `<input type="checkbox" checked data-ckey="${esc(key)}" data-val="${esc(val)}"><span class="opt-text">${esc(val)}</span>`;
  764. label.querySelector('input').addEventListener('change', function() {
  765. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  766. if (this.checked) sel.customValues[key].add(val); else sel.customValues[key].delete(val);
  767. sel.page = 1; doSearch();
  768. });
  769. const sugEl = container.querySelector('.suggest-list');
  770. if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
  771. }
  772. // ── 搜索请求 ──────────────────────────────────────────────────────────
  773. function buildRequest() {
  774. const req = {
  775. page: sel.page,
  776. page_size: 20,
  777. include_facets: true,
  778. highlight: !!sel.query,
  779. include_content: true,
  780. include_matched_conditions: true,
  781. };
  782. if (sel.query) req.query = sel.query;
  783. const scopes = [];
  784. for (const st of SCOPE_TYPES) {
  785. const values = [...sel.scopes[st]];
  786. const semantic = sel.scopeSemantic[st];
  787. if (values.length || semantic) {
  788. const sf = {scope_type: st};
  789. if (values.length) sf.values = values;
  790. if (semantic) sf.semantic_query = semantic;
  791. scopes.push(sf);
  792. }
  793. }
  794. if (scopes.length) req.scopes = scopes;
  795. if (sel.dimAttr.size) req.dim_attributes = [...sel.dimAttr];
  796. if (sel.dimCreate.size) req.dim_creations = [...sel.dimCreate];
  797. const customFilters = [];
  798. const allKeys = new Set([
  799. ...Object.keys(sel.customValues),
  800. ...Object.keys(sel.customSemantics),
  801. ...Object.keys(sel.customRanges),
  802. ]);
  803. for (const key of allKeys) {
  804. const cf = {key};
  805. const vals = [...(sel.customValues[key] || [])];
  806. const sem = sel.customSemantics[key];
  807. const rng = sel.customRanges[key];
  808. if (vals.length) cf.values = vals;
  809. if (sem) cf.semantic_query = sem;
  810. if (rng?.gte != null) cf.gte = rng.gte;
  811. if (rng?.lte != null) cf.lte = rng.lte;
  812. if (Object.keys(cf).length > 1) customFilters.push(cf);
  813. }
  814. if (customFilters.length) req.custom_filters = customFilters;
  815. return req;
  816. }
  817. async function doSearch() {
  818. renderChips();
  819. document.getElementById('results-list').innerHTML =
  820. '<div class="state-box"><div class="state-icon">⏳</div><p>搜索中…</p></div>';
  821. let resp;
  822. try {
  823. const r = await fetch(API + '/search', {
  824. method: 'POST',
  825. headers: {'Content-Type': 'application/json'},
  826. body: JSON.stringify(buildRequest()),
  827. });
  828. if (!r.ok) {
  829. const msg = (await r.json().catch(() => ({}))).detail || r.statusText;
  830. throw new Error(msg);
  831. }
  832. resp = await r.json();
  833. } catch(e) {
  834. document.getElementById('results-count').textContent = '';
  835. document.getElementById('results-list').innerHTML =
  836. `<div class="state-box error"><div class="state-icon">⚠️</div><p>搜索出错:${esc(e.message)}</p></div>`;
  837. return;
  838. }
  839. renderResults(resp);
  840. if (resp.facets) updateSidebarFacets(resp.facets);
  841. }
  842. // ── 渲染结果 ──────────────────────────────────────────────────────────
  843. function renderResults(data) {
  844. document.getElementById('results-count').innerHTML =
  845. `找到 <strong>${data.total.toLocaleString()}</strong> 条相关知识`;
  846. document.getElementById('sort-label').textContent =
  847. sel.query ? '按相关度排序' : '按最新更新排序';
  848. const list = document.getElementById('results-list');
  849. if (!data.hits.length) {
  850. list.innerHTML = '<div class="state-box"><div class="state-icon">📭</div><p>未找到匹配的知识条目,请调整搜索条件</p></div>';
  851. } else {
  852. list.innerHTML = data.hits.map(renderCard).join('');
  853. requestAnimationFrame(markStepClamps);
  854. requestAnimationFrame(markToolClamps);
  855. }
  856. renderPagination(data.total, data.page, data.page_size);
  857. }
  858. // 点击「值」单元格展开/收起(仅在内容溢出、已标记 clampable 时生效)
  859. function toggleClampVal(el) {
  860. if (!el.classList.contains('clampable') && !el.classList.contains('open')) return;
  861. el.classList.toggle('open');
  862. }
  863. // 渲染后标记真正溢出 4 行的「值」单元格,才显示蒙版 + 可点击
  864. // (先套上限高再测量:溢出则保留 clampable,放得下则移除)
  865. function markStepClamps() {
  866. document.querySelectorAll('.steps .clamp-val').forEach((el) => {
  867. if (el.classList.contains('open')) return;
  868. el.classList.add('clampable');
  869. if (el.scrollHeight <= el.clientHeight + 2) el.classList.remove('clampable');
  870. });
  871. }
  872. // ── 工序步骤表(自 index.html 工序解构复用:content 即一条 procedure)──────
  873. function fmtSF(v) {
  874. return v == null ? '' : Array.isArray(v) ? v.join('、') : v;
  875. }
  876. const SF_ARROW =
  877. '<svg class="sf-arrow" viewBox="0 0 24 24" width="13" height="13" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
  878. const SF_NO_MATCH = '无'; // 后端未命中占位符(与 category_match.py NO_MATCH 一致)
  879. /* 原值拆分:与后端 _split_values 一致 —— 括号内的「、」不拆、去重保序,
  880. 保证原值子项与 *Match 子项「等长等序」可按下标配对 */
  881. function _splitParts(raw) {
  882. if (raw == null) return [];
  883. if (Array.isArray(raw)) {
  884. const out = [], seen = new Set();
  885. for (const x of raw) {
  886. const p = String(x).trim();
  887. if (p && !seen.has(p)) { seen.add(p); out.push(p); }
  888. }
  889. return out;
  890. }
  891. const parts = [];
  892. let cur = '', depth = 0;
  893. for (const ch of String(raw)) {
  894. if (ch === '(' || ch === '(') { depth++; cur += ch; }
  895. else if (ch === ')' || ch === ')') { depth--; cur += ch; }
  896. else if (ch === '、' && depth === 0) { const p = cur.trim(); if (p) parts.push(p); cur = ''; }
  897. else { cur += ch; }
  898. }
  899. const last = cur.trim();
  900. if (last) parts.push(last);
  901. const out = [], seen = new Set();
  902. for (const p of parts) if (!seen.has(p)) { seen.add(p); out.push(p); }
  903. return out;
  904. }
  905. /* *Match 拆分:按下标对齐,**不去重不滤空**,保留「无」占位 */
  906. function _matchParts(v) {
  907. return v == null ? [] : String(v).split('、').map((x) => x.trim());
  908. }
  909. /* 实质/形式单元格:逐子项「原值 → 命中值」配对。
  910. 命中 → 原值灰色划除 + 箭头 + 绿色命中值;
  911. 未命中(占位「无」/缺失)→ 只显原值,黑色正常 */
  912. function renderSF(value, match) {
  913. const olds = _splitParts(value);
  914. const news = _matchParts(match);
  915. if (!news.length) return esc(fmtSF(value)); // 整格未归类(旧数据无 *Match)→ 原值黑色
  916. const n = Math.max(olds.length, news.length);
  917. let rows = '';
  918. for (let i = 0; i < n; i++) {
  919. const a = olds[i], b = news[i];
  920. const matched = b != null && b !== '' && b !== SF_NO_MATCH;
  921. if (matched) {
  922. rows += `<div class="sf-pair">${
  923. a != null ? `<span class="sf-old">${esc(a)}</span>${SF_ARROW}` : ''
  924. }<span class="sf-new">${esc(b)}</span></div>`;
  925. } else if (a != null) {
  926. rows += `<div class="sf-pair"><span class="sf-plain">${esc(a)}</span></div>`;
  927. }
  928. }
  929. return `<div class="sf-map">${rows}</div>`;
  930. }
  931. function ioCell(x, kind) {
  932. const cls = kind === 'in' ? 'c-in' : 'c-out';
  933. if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
  934. const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || '模型推断补全')}` : '';
  935. const badge = x.inferred ? '<span class="ib">推</span>' : '';
  936. return `<td class="${cls}"><span class="pill ${kind === 'in' ? 'amber' : 'blue'}">${esc(x.type || '')}</span></td>
  937. <td class="${cls}${inf}">${badge}<div class="clamp-val" onclick="toggleClampVal(this)"><span class="vtxt">${esc(x.value || '')}</span></div></td>
  938. <td class="${cls}"><span class="anchor">${esc(x.anchor || '')}</span></td>`;
  939. }
  940. function renderSteps(steps) {
  941. if (!steps || !steps.length) return '<div class="steps-empty">无步骤</div>';
  942. let rows = '';
  943. for (const s of steps) {
  944. const ins = s.inputs && s.inputs.length ? s.inputs : [null];
  945. const outs = s.outputs && s.outputs.length ? s.outputs : [null];
  946. const n = Math.max(ins.length, outs.length);
  947. for (let i = 0; i < n; i++) {
  948. rows += '<tr>';
  949. if (i === 0) {
  950. rows += `<td rowspan="${n}" class="sid">${esc(s.id || '')}</td>
  951. <td rowspan="${n}"><div class="intent-text">${renderIntent(s.intent || s.directive || '')}</div></td>
  952. <td rowspan="${n}">${s.effect ? `<span class="pill navy">${esc(s.effect)}</span>` : ''}</td>
  953. <td rowspan="${n}">${renderSF(s.substance, s.substanceMatch)}</td>
  954. <td rowspan="${n}">${renderSF(s.form, s.formMatch)}</td>`;
  955. }
  956. rows += ioCell(ins[i], 'in');
  957. if (i === 0) {
  958. rows += `<td rowspan="${n}">${s.via ? `<span class="pill teal">${esc(s.via)}</span>` : ''}</td>
  959. <td rowspan="${n}" class="vtxt">${esc(s.action || '')}</td>
  960. <td rowspan="${n}" class="vtxt">${esc(s.directive || '')}</td>`;
  961. }
  962. rows += ioCell(outs[i], 'out');
  963. rows += '</tr>';
  964. }
  965. }
  966. return `<div class="steps-wrap"><table class="steps">
  967. <colgroup>
  968. <col style="width:44px"><col style="width:200px"><col style="width:92px">
  969. <col style="width:112px"><col style="width:100px">
  970. <col style="width:112px"><col style="width:330px"><col style="width:92px">
  971. <col style="width:118px"><col style="width:130px"><col style="width:130px">
  972. <col style="width:112px"><col style="width:360px"><col style="width:110px">
  973. </colgroup>
  974. <thead>
  975. <tr><th class="h-req" colspan="5">需 求</th><th class="h-in" colspan="3">输 入</th><th class="h-im" colspan="3">实 现</th><th class="h-out" colspan="3">输 出</th></tr>
  976. <tr>
  977. <th class="h-req2">#</th><th class="h-req2">目的</th><th class="h-req2">作用</th><th class="h-req2">实质</th><th class="h-req2">形式</th>
  978. <th class="h-in2">类型</th><th class="h-in2">值</th><th class="h-in2">来源</th>
  979. <th class="h-im2">外部工具</th><th class="h-im2">动作</th><th class="h-im2">指令</th>
  980. <th class="h-out2">类型</th><th class="h-out2">值</th><th class="h-out2">去处</th>
  981. </tr>
  982. </thead><tbody>${rows}</tbody></table></div>`;
  983. }
  984. // ── 工具解构表(自 index.html renderTools 移植:单条 content 即一个 tool 对象)──
  985. const TT_DASH = '<span class="dash">—</span>';
  986. function _ttToolCell(v) {
  987. if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) return TT_DASH;
  988. if (Array.isArray(v)) return '<ul>' + v.map((x) => `<li>${esc(String(x))}</li>`).join('') + '</ul>';
  989. return esc(String(v));
  990. }
  991. function _ttScopeCell(v) {
  992. if (v === null || v === undefined || (Array.isArray(v) && !v.length) || v === '') return TT_DASH;
  993. return esc(Array.isArray(v) ? v.join('、') : String(v));
  994. }
  995. function _ttWrap(inner, clampable) {
  996. return clampable ? `<div class="tcell" onclick="this.classList.toggle('open')">${inner}</div>` : inner;
  997. }
  998. function _ttCellContent(c, t) {
  999. let inner, cls = '', clampable = true, style = '';
  1000. if (c === '工具名称') {
  1001. cls = 'col-tool'; clampable = false;
  1002. inner = `🔧 ${esc(t[c] || '(未命名)')}`;
  1003. } else if (c === '来源链接') {
  1004. clampable = false;
  1005. inner = t[c] ? `<a href="${esc(t[c])}" target="_blank" style="color:#176d64;font-weight:600;">🔗 打开</a>` : TT_DASH;
  1006. } else if (c === '创作层级') {
  1007. clampable = false;
  1008. inner = t[c] ? `<span class="layer-badge ${t[c] === '制作层' ? 'make' : 'create'}">${esc(t[c])}</span>` : TT_DASH;
  1009. } else if (c === '实质作用域' || c === '形式作用域') {
  1010. inner = _ttScopeCell(t[c]);
  1011. } else {
  1012. inner = _ttToolCell(t[c]);
  1013. }
  1014. if (['输入', '输出', '用法', '缺点'].includes(c)) style = 'max-width:240px;';
  1015. else if (c === '实质作用域' || c === '形式作用域') style = 'max-width:170px;';
  1016. else if (!clampable) style = 'white-space:nowrap;';
  1017. return { inner, cls, clampable, style };
  1018. }
  1019. function _ttTd(c, t, rowspan) {
  1020. const { inner, cls, clampable, style } = _ttCellContent(c, t);
  1021. const rs = rowspan > 1 ? ` rowspan="${rowspan}"` : '';
  1022. return `<td class="${cls}" style="${style}"${rs}>${_ttWrap(inner, clampable)}</td>`;
  1023. }
  1024. function _ttCaseTd(cse, key) {
  1025. const v = cse && cse[key] != null && cse[key] !== '' ? esc(String(cse[key])) : TT_DASH;
  1026. return `<td class="col-case" style="max-width:210px;">${_ttWrap(v, true)}</td>`;
  1027. }
  1028. // content 是单个工具对象 → 渲染成一行(案例多条则逐行 rowspan)的工具解构表
  1029. function renderToolTable(tool) {
  1030. const before = ['工具名称', '创作层级', '实质作用域', '形式作用域', '输入', '输出', '用法'];
  1031. const after = ['缺点', '来源链接', '最新更新时间'];
  1032. const thead = `<thead>
  1033. <tr>
  1034. ${before.map((c) => `<th rowspan="2">${c}</th>`).join('')}
  1035. <th colspan="3" class="th-group">案例</th>
  1036. ${after.map((c) => `<th rowspan="2">${c}</th>`).join('')}
  1037. </tr>
  1038. <tr>${['输入', '输出', '效果'].map((c) => `<th class="th-sub">${c}</th>`).join('')}</tr>
  1039. </thead>`;
  1040. const cases = Array.isArray(tool['案例']) && tool['案例'].length ? tool['案例'] : [null];
  1041. const K = cases.length;
  1042. const rows = cases.map((cse, i) => {
  1043. const caseTds = `${_ttCaseTd(cse, '输入')}${_ttCaseTd(cse, '输出')}${_ttCaseTd(cse, '效果')}`;
  1044. if (i === 0) {
  1045. return `<tr>${before.map((c) => _ttTd(c, tool, K)).join('')}${caseTds}${after.map((c) => _ttTd(c, tool, K)).join('')}</tr>`;
  1046. }
  1047. return `<tr>${caseTds}</tr>`;
  1048. }).join('');
  1049. return `<div class="mw-ttwrap"><table class="mw-tt">${thead}<tbody>${rows}</tbody></table></div>`;
  1050. }
  1051. // 渲染后标记真正溢出的工具单元格(才显示蒙版 + 可点击)
  1052. function markToolClamps() {
  1053. document.querySelectorAll('.mw-tt .tcell').forEach((el) => {
  1054. if (el.classList.contains('open')) return;
  1055. if (el.scrollHeight > el.clientHeight + 2) el.classList.add('clamped');
  1056. else el.classList.remove('clamped');
  1057. });
  1058. }
  1059. // ── 原文块(按 source_id=case_id 懒加载,复用 #dataset 的取数/图片策略)──────────
  1060. // 图片:优先浏览器直连 CDN(referrerpolicy=no-referrer 多能绕防盗链),失败回退同源代理 /api/img。
  1061. const _imgProxy = u => (/^https?:\/\//.test(u || '') ? '/api/img?u=' + encodeURIComponent(u) : (u || ''));
  1062. const _imgDirect = u => (/^https?:\/\//.test(u || '') ? u : _imgProxy(u));
  1063. function _imgFallback(el, proxy) {
  1064. el.onerror = null;
  1065. if (!el.dataset.fb) { el.dataset.fb = '1'; el.onerror = () => _imgFallback(el, proxy); el.src = proxy; return; }
  1066. el.style.opacity = 0.25;
  1067. }
  1068. function _srcImg(u) {
  1069. return `<img src="${esc(_imgDirect(u))}" referrerpolicy="no-referrer" loading="lazy" onerror="_imgFallback(this,'${esc(_imgProxy(u))}')">`;
  1070. }
  1071. // 折叠态的原文条:标题先用知识里的 source_title 占位,展开后用真实原文标题覆盖。
  1072. function renderSourceBlock(hit) {
  1073. const caseId = hit.source_id || '';
  1074. if (!caseId) return '';
  1075. const placeholder = esc(hit.source_title || hit.title || '(展开加载原文)');
  1076. return `<div class="src-block" id="src-${esc(hit.id)}">
  1077. <div class="src-head" onclick="toggleKbSrc('${esc(caseId)}','${esc(hit.id)}')">
  1078. <span class="src-caret">▸</span>
  1079. <span class="src-label">原文</span>
  1080. <span class="src-title">${placeholder}</span>
  1081. <a class="src-link" target="_blank" rel="noreferrer" onclick="event.stopPropagation()" hidden>原文 ↗</a>
  1082. <span class="src-case" title="case_id" onclick="event.stopPropagation()">${esc(caseId)}</span>
  1083. </div>
  1084. <div class="src-body"><div class="src-loading">展开加载原文…</div></div>
  1085. </div>`;
  1086. }
  1087. function _srcBodyHtml(p) {
  1088. const imgs = (p.images || []).filter(Boolean);
  1089. const thumbs = imgs.length
  1090. ? `<div class="src-thumbs">${imgs.map(u => `<a href="${esc(u)}" target="_blank" rel="noreferrer">${_srcImg(u)}</a>`).join('')}</div>`
  1091. : '';
  1092. const body = p.body
  1093. ? `<div class="src-text clamp">${esc(p.body)}</div><button class="src-text-toggle" hidden onclick="toggleKbText(this)">展开 ▼</button>`
  1094. : '';
  1095. return (thumbs + body) || '<div class="src-empty">(无正文/配图)</div>';
  1096. }
  1097. async function toggleKbSrc(caseId, hitId) {
  1098. const block = document.getElementById('src-' + hitId);
  1099. if (!block) return;
  1100. const open = block.classList.toggle('open');
  1101. if (!open || block.dataset.loaded) return;
  1102. block.dataset.loaded = '1';
  1103. const body = block.querySelector('.src-body');
  1104. try {
  1105. const r = await fetch('/api/post_by_case?case_id=' + encodeURIComponent(caseId));
  1106. if (!r.ok) throw new Error(r.status);
  1107. const p = await r.json();
  1108. const titleEl = block.querySelector('.src-title');
  1109. if (p.title) titleEl.textContent = p.title;
  1110. const linkEl = block.querySelector('.src-link');
  1111. if (p.url) { linkEl.href = p.url; linkEl.hidden = false; }
  1112. body.innerHTML = _srcBodyHtml(p);
  1113. requestAnimationFrame(() => _markKbSrcClamp(hitId));
  1114. } catch (e) {
  1115. block.dataset.loaded = ''; // 失败允许重试
  1116. body.innerHTML = '<div class="src-empty">原文加载失败,点击标题重试</div>';
  1117. }
  1118. }
  1119. function toggleKbText(btn) {
  1120. const t = btn.previousElementSibling;
  1121. const clamped = t.classList.toggle('clamp');
  1122. btn.textContent = clamped ? '展开 ▼' : '收起 ▲';
  1123. }
  1124. // 仅当正文在定高下溢出时才显示「展开」
  1125. function _markKbSrcClamp(hitId) {
  1126. const block = document.getElementById('src-' + hitId);
  1127. const t = block && block.querySelector('.src-text');
  1128. const btn = block && block.querySelector('.src-text-toggle');
  1129. if (!t || !btn) return;
  1130. btn.hidden = !(t.scrollHeight > t.clientHeight + 2);
  1131. }
  1132. function renderCard(hit) {
  1133. let titleHtml = esc(hit.title || '(无标题)');
  1134. if (hit.highlight?.title?.[0]) titleHtml = hit.highlight.title[0];
  1135. const score = hit.score != null
  1136. ? `<span class="score-badge">相关度 ${hit.score.toFixed(2)}</span>` : '';
  1137. let meta = '';
  1138. if (hit.source_type) meta += `<span class="source-badge">${esc(hit.source_type)}</span>`;
  1139. if (hit.author) meta += `<span>${esc(hit.author)}</span>`;
  1140. if (hit.author && hit.source_title) meta += '<span class="meta-dot">·</span>';
  1141. if (hit.source_title) meta += `<span>${esc(hit.source_title)}</span>`;
  1142. // 从 matched_conditions 构建命中查找表
  1143. const mc = hit.matched_conditions;
  1144. const hitScopes = {}; // scope_type → Set<string>
  1145. const hitCustom = {}; // key → Set<string>(str 类型)
  1146. const hitRange = {}; // key → bool(num/date 类型)
  1147. const hitDimAttr = new Set(mc?.dim_attributes || []);
  1148. const hitDimCreate = new Set(mc?.dim_creations || []);
  1149. for (const ms of (mc?.scopes || [])) hitScopes[ms.scope_type] = new Set(ms.matched_values);
  1150. for (const cm of (mc?.custom || [])) {
  1151. if (cm.matched_values != null) hitCustom[cm.key] = new Set(cm.matched_values);
  1152. if (cm.in_range != null) hitRange[cm.key] = cm.in_range;
  1153. }
  1154. let tags = '';
  1155. const scopeDefs = [
  1156. ['s','scope_substance','substance','实质'],
  1157. ['fo','scope_form','form','形式'],
  1158. ['i','scope_intent','intent','意图'],
  1159. ['e','scope_effect','effect','作用'],
  1160. ['fe','scope_feeling','feeling','感受'],
  1161. ];
  1162. for (const [cls, field, st, label] of scopeDefs) {
  1163. const hitSet = hitScopes[st] || new Set();
  1164. for (const t of (hit[field] || [])) {
  1165. const h = hitSet.has(t) ? ' hit' : '';
  1166. tags += `<span class="tag ${cls}${h}" title="${h ? '命中筛选条件' : ''}"><span class="tag-pre">${label}</span>${esc(t)}</span>`;
  1167. }
  1168. }
  1169. for (const d of (hit.dim_attributes || [])) {
  1170. const h = hitDimAttr.has(d) ? ' hit' : '';
  1171. tags += `<span class="tag d${h}" title="${h ? '命中筛选条件' : ''}">${esc(d)}</span>`;
  1172. }
  1173. for (const d of (hit.dim_creations || [])) {
  1174. const h = hitDimCreate.has(d) ? ' hit' : '';
  1175. tags += `<span class="tag d${h}" title="${h ? '命中筛选条件' : ''}">${esc(d)}</span>`;
  1176. }
  1177. let extHtml = '';
  1178. for (const item of (hit.custom_ext || [])) {
  1179. let val = item.value;
  1180. if (item.type === 'date' && typeof val === 'number') {
  1181. val = new Date(val).toLocaleDateString('zh-CN');
  1182. }
  1183. const isHit = item.type === 'str'
  1184. ? hitCustom[item.key]?.has(String(item.value ?? ''))
  1185. : hitRange[item.key] === true;
  1186. const h = isHit ? ' hit' : '';
  1187. extHtml += `<span class="ext-item${h}" title="${h ? '命中筛选条件' : ''}"><span class="ext-key">${esc(item.key)}:</span><span class="ext-val">${esc(String(val ?? ''))}</span></span>`;
  1188. }
  1189. const updatedAt = hit.updated_at
  1190. ? new Date(hit.updated_at).toLocaleDateString('zh-CN') : '';
  1191. // 正文展开区:content 可能是一条工序解构(procedure,含 steps)或一个工具解构(tool,含工具名称)。
  1192. // 工序 → 工序步骤表;工具 → 直接展示 content 的格式化 JSON(不再渲染工具解构表)。
  1193. let contentSection = '';
  1194. if (hit.content) {
  1195. let inner = '';
  1196. try {
  1197. const obj = JSON.parse(hit.content);
  1198. if (obj && Array.isArray(obj.steps) && obj.steps.length) inner = renderSteps(obj.steps);
  1199. // 工具解构:按需求改为直接显示 content JSON,落到下方 content-pre 回退分支。
  1200. } catch {}
  1201. if (!inner) {
  1202. let display = hit.content;
  1203. try { display = JSON.stringify(JSON.parse(hit.content), null, 2); } catch {}
  1204. inner = `<pre class="content-pre">${esc(display)}</pre>`;
  1205. }
  1206. // 去掉「正文」标题,默认直接展开
  1207. contentSection = `<div class="content-body open">${inner}</div>`;
  1208. }
  1209. return `<div class="card">
  1210. <div class="card-top"><div class="card-title">${titleHtml}</div>${score}</div>
  1211. ${meta ? `<div class="card-meta">${meta}</div>` : ''}
  1212. ${tags ? `<div class="tag-row">${tags}</div>` : ''}
  1213. ${renderSourceBlock(hit)}
  1214. <div class="card-foot">
  1215. <div class="ext-row">${extHtml}</div>
  1216. <span class="card-time">${updatedAt ? updatedAt + ' 更新' : ''}</span>
  1217. </div>
  1218. ${contentSection}
  1219. </div>`;
  1220. }
  1221. // ── 分页 ──────────────────────────────────────────────────────────────
  1222. function renderPagination(total, page, pageSize) {
  1223. const totalPages = Math.ceil(total / pageSize);
  1224. const pg = document.getElementById('pagination');
  1225. if (totalPages <= 1) { pg.innerHTML = ''; return; }
  1226. let html = `<button class="pg-btn" onclick="goPage(${page-1})" ${page<=1?'disabled':''}>‹</button>`;
  1227. const pages = buildPageList(page, totalPages);
  1228. let prev = null;
  1229. for (const p of pages) {
  1230. if (prev !== null && p - prev > 1) html += '<span class="pg-dots">…</span>';
  1231. html += `<button class="pg-btn ${p===page?'active':''}" onclick="goPage(${p})">${p}</button>`;
  1232. prev = p;
  1233. }
  1234. html += `<button class="pg-btn" onclick="goPage(${page+1})" ${page>=totalPages?'disabled':''}>›</button>`;
  1235. pg.innerHTML = html;
  1236. }
  1237. function buildPageList(cur, total) {
  1238. const s = new Set([1, total]);
  1239. for (let p = Math.max(1, cur-1); p <= Math.min(total, cur+1); p++) s.add(p);
  1240. return [...s].sort((a,b) => a-b);
  1241. }
  1242. function goPage(p) {
  1243. sel.page = p;
  1244. doSearch();
  1245. window.scrollTo({top: 0, behavior: 'smooth'});
  1246. }
  1247. // ── Facets → 侧栏 ─────────────────────────────────────────────────────
  1248. function updateSidebarFacets(facets) {
  1249. // 属性维度 / 创作维度:整体替换
  1250. updateFacetSection(
  1251. 'dim-attr-section', 'dim-attr-bd',
  1252. facets.dim_attributes || [],
  1253. sel.dimAttr, 'data-dim-attr',
  1254. cb => { const v = cb.dataset.dimAttr; cb.checked ? sel.dimAttr.add(v) : sel.dimAttr.delete(v); sel.page=1; doSearch(); }
  1255. );
  1256. updateFacetSection(
  1257. 'dim-create-section', 'dim-create-bd',
  1258. facets.dim_creations || [],
  1259. sel.dimCreate, 'data-dim-create',
  1260. cb => { const v = cb.dataset.dimCreate; cb.checked ? sel.dimCreate.add(v) : sel.dimCreate.delete(v); sel.page=1; doSearch(); }
  1261. );
  1262. // 作用域标签:完全由 facets 驱动,移除消失的项、补充新出现的项
  1263. const scopeMap = Object.fromEntries((facets.scopes || []).map(s => [s.scope_type, s.buckets || []]));
  1264. for (const st of SCOPE_TYPES) {
  1265. const buckets = scopeMap[st] || [];
  1266. const bucketMap = Object.fromEntries(buckets.map(b => [b.value, b.count]));
  1267. const sgContainer = document.getElementById('sg-' + st);
  1268. if (!sgContainer) continue;
  1269. // 移除不在当前 facets 且未被选中的项
  1270. sgContainer.querySelectorAll('label[data-facet-src]').forEach(label => {
  1271. const val = label.querySelector('input')?.dataset.val;
  1272. if (val && bucketMap[val] == null && !(sel.scopes[st]?.has(val))) label.remove();
  1273. });
  1274. // 更新已存在项的计数,补充 facets 中新出现的项
  1275. const sugEl = sgContainer.querySelector('.suggest-list');
  1276. for (const b of buckets) {
  1277. const existing = sgContainer.querySelector(`input[data-val="${CSS.escape(b.value)}"]`);
  1278. if (existing) {
  1279. syncCountBadge(existing.closest('.opt'), b.count);
  1280. continue;
  1281. }
  1282. const label = document.createElement('label');
  1283. label.className = 'opt';
  1284. label.dataset.facetSrc = 'true';
  1285. const chk = sel.scopes[st]?.has(b.value) ? 'checked' : '';
  1286. label.innerHTML = `<input type="checkbox" ${chk} data-scope="${esc(st)}" data-val="${esc(b.value)}"><span class="opt-text">${esc(b.value)}</span><span class="opt-count">${b.count}</span>`;
  1287. label.querySelector('input').addEventListener('change', function() {
  1288. if (this.checked) sel.scopes[st].add(b.value); else sel.scopes[st].delete(b.value);
  1289. sel.page = 1; doSearch();
  1290. });
  1291. if (sugEl) sgContainer.insertBefore(label, sugEl); else sgContainer.appendChild(label);
  1292. }
  1293. }
  1294. // 自定义字段
  1295. updateCustomExtFacets(facets.custom_ext || []);
  1296. }
  1297. function updateFacetSection(sectionId, bdId, buckets, selSet, dataAttr, onChange) {
  1298. const section = document.getElementById(sectionId);
  1299. const bd = document.getElementById(bdId);
  1300. if (!section || !bd) return;
  1301. if (!buckets.length) { section.style.display = 'none'; return; }
  1302. section.style.display = '';
  1303. let html = '';
  1304. for (const b of buckets) {
  1305. const checked = selSet.has(b.value) ? 'checked' : '';
  1306. html += `<label class="opt">
  1307. <input type="checkbox" ${checked} ${dataAttr}="${esc(b.value)}">
  1308. <span class="opt-text">${esc(b.value)}</span>
  1309. <span class="opt-count">${b.count}</span>
  1310. </label>`;
  1311. }
  1312. bd.innerHTML = html;
  1313. bd.querySelectorAll(`input[${dataAttr}]`).forEach(cb => {
  1314. cb.addEventListener('change', () => onChange(cb));
  1315. });
  1316. }
  1317. // 自定义字段 facets 处理:key 本身的显隐也由 facets 驱动
  1318. function updateCustomExtFacets(customExtFacets) {
  1319. if (!dims?.custom_fields?.length) return;
  1320. const facetMap = Object.fromEntries(customExtFacets.map(f => [f.key, f]));
  1321. let anyVisible = false;
  1322. for (const cf of dims.custom_fields) {
  1323. const container = document.getElementById('cf-' + cf.key);
  1324. if (!container) continue;
  1325. const facet = facetMap[cf.key];
  1326. // 有活跃筛选时即使 facets 无数据也保留显示
  1327. const hasActiveFilter =
  1328. (cf.value_type === 'str' &&
  1329. (sel.customValues[cf.key]?.size > 0 || !!sel.customSemantics[cf.key])) ||
  1330. ((cf.value_type === 'num' || cf.value_type === 'date') &&
  1331. (sel.customRanges[cf.key]?.gte != null || sel.customRanges[cf.key]?.lte != null));
  1332. const hasFacetData =
  1333. (cf.value_type === 'str' && facet?.buckets?.length > 0) ||
  1334. ((cf.value_type === 'num' || cf.value_type === 'date') && facet?.stats?.count > 0);
  1335. if (!hasFacetData && !hasActiveFilter) {
  1336. container.style.display = 'none';
  1337. continue;
  1338. }
  1339. container.style.display = '';
  1340. anyVisible = true;
  1341. if (cf.value_type === 'str') {
  1342. updateCustomStrCounts(cf.key, container, facet?.buckets || []);
  1343. } else if (cf.value_type === 'num' && facet?.stats?.count > 0) {
  1344. const s = facet.stats;
  1345. const gteEl = document.getElementById('cgte-' + cf.key);
  1346. const lteEl = document.getElementById('clte-' + cf.key);
  1347. if (gteEl && s.min != null) gteEl.placeholder = `最小 (${parseFloat(s.min.toFixed(2))})`;
  1348. if (lteEl && s.max != null) lteEl.placeholder = `最大 (${parseFloat(s.max.toFixed(2))})`;
  1349. }
  1350. // date 类型:range input 不显示 placeholder,仅控制可见性
  1351. }
  1352. // 整个"自定义索引"区块:有可见 key 才展示
  1353. const section = document.getElementById('custom-section');
  1354. if (section) section.style.display = anyVisible ? '' : 'none';
  1355. }
  1356. function updateCustomStrCounts(key, container, buckets) {
  1357. const bucketMap = Object.fromEntries(buckets.map(b => [b.value, b.count]));
  1358. // 移除不在当前 facets 且未被选中的项
  1359. container.querySelectorAll('label[data-facet-src]').forEach(label => {
  1360. const val = label.querySelector('input')?.dataset.val;
  1361. if (val && bucketMap[val] == null && !(sel.customValues[key]?.has(val))) label.remove();
  1362. });
  1363. // 更新已存在项的计数,补充 facets 中新出现的项
  1364. const sugEl = container.querySelector('.suggest-list');
  1365. for (const b of buckets) {
  1366. const existing = container.querySelector(`input[data-val="${CSS.escape(b.value)}"]`);
  1367. if (existing) {
  1368. syncCountBadge(existing.closest('.opt'), b.count);
  1369. continue;
  1370. }
  1371. const label = document.createElement('label');
  1372. label.className = 'opt';
  1373. label.dataset.facetSrc = 'true';
  1374. const chk = sel.customValues[key]?.has(b.value) ? 'checked' : '';
  1375. label.innerHTML = `<input type="checkbox" ${chk} data-ckey="${esc(key)}" data-val="${esc(b.value)}"><span class="opt-text">${esc(b.value)}</span><span class="opt-count">${b.count}</span>`;
  1376. label.querySelector('input').addEventListener('change', function() {
  1377. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  1378. if (this.checked) sel.customValues[key].add(b.value); else sel.customValues[key].delete(b.value);
  1379. sel.page = 1; doSearch();
  1380. });
  1381. if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
  1382. }
  1383. }
  1384. // 在 .opt 元素上同步 .opt-count 徽章;count 为 undefined 时清除徽章
  1385. function syncCountBadge(optEl, count) {
  1386. if (!optEl) return;
  1387. let el = optEl.querySelector('.opt-count');
  1388. if (count != null) {
  1389. if (!el) { el = document.createElement('span'); el.className = 'opt-count'; optEl.appendChild(el); }
  1390. el.textContent = count;
  1391. } else if (el) {
  1392. el.remove();
  1393. }
  1394. }
  1395. // ── Chips 栏 ──────────────────────────────────────────────────────────
  1396. function renderChips() {
  1397. _chips = [];
  1398. for (const st of SCOPE_TYPES) {
  1399. for (const val of sel.scopes[st]) {
  1400. _chips.push({
  1401. label: `${SCOPE_LABELS[st]}:${val}`,
  1402. remove: () => { sel.scopes[st].delete(val); uncheckInput(`input[data-scope="${CSS.escape(st)}"][data-val="${CSS.escape(val)}"]`); sel.page=1; doSearch(); }
  1403. });
  1404. }
  1405. if (sel.scopeSemantic[st]) {
  1406. _chips.push({
  1407. label: `${SCOPE_LABELS[st]} 语义:${sel.scopeSemantic[st]}`,
  1408. remove: () => {
  1409. sel.scopeSemantic[st] = '';
  1410. const inp = document.getElementById('sem-' + st);
  1411. if (inp) inp.value = '';
  1412. sel.page = 1; doSearch();
  1413. }
  1414. });
  1415. }
  1416. }
  1417. for (const val of sel.dimAttr) _chips.push({label:`属性:${val}`, remove:()=>{sel.dimAttr.delete(val); uncheckInput(`input[data-dim-attr="${CSS.escape(val)}"]`); sel.page=1; doSearch();}});
  1418. for (const val of sel.dimCreate) _chips.push({label:`创作:${val}`, remove:()=>{sel.dimCreate.delete(val); uncheckInput(`input[data-dim-create="${CSS.escape(val)}"]`); sel.page=1; doSearch();}});
  1419. for (const [key, vals] of Object.entries(sel.customValues)) {
  1420. for (const val of vals) {
  1421. _chips.push({
  1422. label: `${key}:${val}`,
  1423. remove: () => { sel.customValues[key].delete(val); uncheckInput(`input[data-ckey="${CSS.escape(key)}"][data-val="${CSS.escape(val)}"]`); sel.page=1; doSearch(); }
  1424. });
  1425. }
  1426. }
  1427. for (const [key, sem] of Object.entries(sel.customSemantics)) {
  1428. if (!sem) continue;
  1429. _chips.push({
  1430. label: `${key} 语义:${sem}`,
  1431. remove: () => { sel.customSemantics[key]=''; const i=document.getElementById('csem-'+key); if(i)i.value=''; sel.page=1; doSearch(); }
  1432. });
  1433. }
  1434. for (const [key, rng] of Object.entries(sel.customRanges)) {
  1435. if (rng.gte == null && rng.lte == null) continue;
  1436. const parts = [];
  1437. if (rng.gte != null) parts.push(`≥ ${rng.gte}`);
  1438. if (rng.lte != null) parts.push(`≤ ${rng.lte}`);
  1439. _chips.push({
  1440. label: `${key}:${parts.join(' ')}`,
  1441. remove: () => {
  1442. sel.customRanges[key] = {};
  1443. const g = document.getElementById('cgte-'+key), l = document.getElementById('clte-'+key);
  1444. if(g) g.value=''; if(l) l.value='';
  1445. sel.page=1; doSearch();
  1446. }
  1447. });
  1448. }
  1449. const bar = document.getElementById('chips-bar');
  1450. if (!_chips.length) { bar.style.display = 'none'; return; }
  1451. bar.style.display = '';
  1452. let html = '<span class="chips-label">已选筛选:</span>';
  1453. _chips.forEach((c, i) => {
  1454. html += `<span class="chip" data-chip="${i}">${esc(c.label)}<span class="chip-x">×</span></span>`;
  1455. });
  1456. html += '<span class="chip-clear" id="chip-clear-all">清空全部</span>';
  1457. bar.innerHTML = html;
  1458. bar.querySelectorAll('[data-chip]').forEach(el => {
  1459. el.addEventListener('click', () => { _chips[+el.dataset.chip].remove(); });
  1460. });
  1461. document.getElementById('chip-clear-all')?.addEventListener('click', clearAllFilters);
  1462. }
  1463. function uncheckInput(selector) {
  1464. const el = document.querySelector(selector);
  1465. if (el) el.checked = false;
  1466. }
  1467. function clearAllFilters() {
  1468. SCOPE_TYPES.forEach(st => { sel.scopes[st].clear(); sel.scopeSemantic[st] = ''; const i=document.getElementById('sem-'+st); if(i)i.value=''; });
  1469. sel.dimAttr.clear();
  1470. sel.dimCreate.clear();
  1471. sel.customValues = {};
  1472. sel.customSemantics= {};
  1473. sel.customRanges = {};
  1474. document.querySelectorAll('input[data-scope],input[data-ckey],input[data-dim-attr],input[data-dim-create]').forEach(cb => cb.checked = false);
  1475. document.querySelectorAll('.range-inp').forEach(inp => inp.value = '');
  1476. sel.page = 1;
  1477. doSearch();
  1478. }
  1479. // ── 折叠展开 ──────────────────────────────────────────────────────────
  1480. function toggleSection(hd) {
  1481. hd.classList.toggle('open');
  1482. hd.nextElementSibling.classList.toggle('open');
  1483. }
  1484. document.addEventListener('DOMContentLoaded', init);
  1485. </script>
  1486. </body>
  1487. </html>