search.html 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310
  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. /* ── 工序步骤表(复用自工序解构的 .steps 表格)─────────────────── */
  319. .steps-wrap { overflow-x: auto; margin-top: 6px; border: 1px solid var(--border); border-radius: 6px; }
  320. .steps { width: 100%; min-width: 1500px; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
  321. .steps th { padding: 6px 8px; font-size: 11px; font-weight: 700; letter-spacing: 1px; color: #fff; text-align: left; }
  322. .steps thead tr:first-child th { text-align: center; font-size: 12px; letter-spacing: 4px; padding: 7px 4px; }
  323. .steps .h-req { background: #2b4a72; } .steps .h-req2 { background: #33547a; }
  324. .steps .h-in { background: #c2761f; } .steps .h-in2 { background: #cd7522; }
  325. .steps .h-im { background: #2f9c8a; } .steps .h-im2 { background: #2d8273; }
  326. .steps .h-out { background: #3a8757; } .steps .h-out2 { background: #2e6b45; }
  327. .steps td { padding: 8px 9px; border: 1px solid var(--border); vertical-align: top; line-height: 1.6; color: var(--text); }
  328. .steps tbody tr:nth-child(odd) td { background: #fdfdf9; }
  329. .steps td.c-in { background: #FFF7ED !important; }
  330. .steps td.c-out { background: #F0FDF4 !important; }
  331. .steps .sid { font-family: 'Courier New', monospace; font-weight: 700; color: #2b4a72; white-space: nowrap; }
  332. .steps .vtxt { color: var(--text-sub); font-size: 11.5px; word-break: break-all; }
  333. .steps .anchor { font-family: 'Courier New', monospace; font-size: 10.5px; color: var(--text-muted); word-break: break-all; }
  334. .steps .pill { display: inline-block; padding: 1px 7px; border-radius: 4px; font-size: 11px; font-weight: 600; }
  335. .steps .pill.navy { background: #e7edf5; color: #2b4a72; }
  336. .steps .pill.amber { background: #fdebd3; color: #9a560f; }
  337. .steps .pill.teal { background: #d8f0ea; color: #1f6f62; }
  338. .steps .inf { background: #fdf6e3 !important; position: relative; outline: 1px dashed #c9a227; outline-offset: -2px; }
  339. .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; }
  340. .steps-empty { padding: 12px; color: var(--text-muted); font-size: 12px; }
  341. /* 输入/输出「值」单元格:超过 4 行加蒙版,点击展开/收起 */
  342. .clamp-val { position: relative; }
  343. .clamp-val.clampable { max-height: 6.6em; overflow: hidden; cursor: zoom-in; }
  344. .clamp-val.clampable::after {
  345. content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2.4em; pointer-events: none;
  346. }
  347. .steps td.c-in .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(255,247,237,0), rgba(255,247,237,1)); }
  348. .steps td.c-out .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(240,253,244,0), rgba(240,253,244,1)); }
  349. .clamp-val.open { max-height: none; overflow: visible; cursor: zoom-out; }
  350. .clamp-val.open::after { display: none; }
  351. </style>
  352. </head>
  353. <body>
  354. <div class="search-section">
  355. <div class="search-inner">
  356. <div class="search-input-wrap">
  357. <span class="search-icon">
  358. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
  359. <circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
  360. </svg>
  361. </span>
  362. <input id="search-input" class="search-input" type="text" placeholder="搜索知识标题、正文内容…">
  363. </div>
  364. <button id="search-btn" class="btn-primary">搜索</button>
  365. </div>
  366. </div>
  367. <div class="layout">
  368. <aside class="sidebar">
  369. <div class="filter-card" id="sidebar-card">
  370. <div class="sidebar-loading">
  371. <div class="skel" style="width:60%"></div>
  372. <div class="skel" style="width:90%"></div>
  373. <div class="skel" style="width:75%"></div>
  374. <div class="skel" style="width:80%"></div>
  375. <div class="skel" style="width:55%"></div>
  376. </div>
  377. </div>
  378. </aside>
  379. <div class="results-area">
  380. <div class="chips-bar" id="chips-bar" style="display:none"></div>
  381. <div class="results-bar">
  382. <div class="results-count" id="results-count">加载中…</div>
  383. <div class="sort-label" id="sort-label"></div>
  384. </div>
  385. <div id="results-list">
  386. <div class="state-box"><div class="state-icon">🔍</div><p>正在加载…</p></div>
  387. </div>
  388. <div class="pagination" id="pagination"></div>
  389. </div>
  390. </div>
  391. <script>
  392. const API = '/api/v1/knowledge';
  393. const SCOPE_TYPES = ['substance','form','intent','effect','feeling'];
  394. const SCOPE_LABELS = {substance:'实质', form:'形式', intent:'意图', effect:'作用', feeling:'感受'};
  395. const SCOPE_TAGS = {substance:'s', form:'fo', intent:'i', effect:'e', feeling:'fe'};
  396. // ── 转义工具 ──────────────────────────────────────────────────────────
  397. const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  398. function debounce(fn, ms) {
  399. let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
  400. }
  401. // ── 全局状态 ──────────────────────────────────────────────────────────
  402. let dims = null;
  403. let _portalDropdown = null;
  404. function clearPortalDropdown() {
  405. if (_portalDropdown) { _portalDropdown.remove(); _portalDropdown = null; }
  406. }
  407. document.addEventListener('mousedown', e => {
  408. if (_portalDropdown && !_portalDropdown.contains(e.target)) clearPortalDropdown();
  409. });
  410. const sel = {
  411. query: '',
  412. scopes: {substance: new Set(), form: new Set(), intent: new Set(), effect: new Set(), feeling: new Set()},
  413. scopeSemantic: {substance: '', form: '', intent: '', effect: '', feeling: ''},
  414. dimAttr: new Set(),
  415. dimCreate: new Set(),
  416. customValues: {}, // key → Set<string>
  417. customSemantics:{}, // key → string
  418. customRanges: {}, // key → {gte, lte}
  419. page: 1,
  420. };
  421. let _chips = [];
  422. // ── 初始化 ────────────────────────────────────────────────────────────
  423. async function init() {
  424. document.getElementById('search-btn').addEventListener('click', triggerSearch);
  425. document.getElementById('search-input').addEventListener('keydown', e => {
  426. if (e.key === 'Enter') triggerSearch();
  427. });
  428. try {
  429. const r = await fetch(API + '/dimensions');
  430. if (r.ok) {
  431. dims = await r.json();
  432. renderSidebar();
  433. }
  434. } catch(e) {
  435. console.warn('Failed to load dimensions', e);
  436. document.getElementById('sidebar-card').innerHTML =
  437. '<div class="sidebar-loading" style="color:var(--text-muted);font-size:12px;padding:16px 13px">筛选项加载失败</div>';
  438. }
  439. doSearch();
  440. }
  441. function triggerSearch() {
  442. sel.query = document.getElementById('search-input').value.trim();
  443. sel.page = 1;
  444. doSearch();
  445. }
  446. // ── 侧栏渲染 ──────────────────────────────────────────────────────────
  447. function renderSidebar() {
  448. const card = document.getElementById('sidebar-card');
  449. let html = '';
  450. // 作用域
  451. html += `<div class="filter-section">
  452. <div class="section-hd open" onclick="toggleSection(this)">作用域 <span class="chevron">▼</span></div>
  453. <div class="section-bd open" id="scope-bd">`;
  454. for (const sg of (dims?.scope_groups || [])) {
  455. html += renderScopeGroup(sg);
  456. }
  457. html += `</div></div>`;
  458. // 属性维度(从 facets 动态注入)
  459. html += `<div class="filter-section" id="dim-attr-section" style="display:none">
  460. <div class="section-hd open" onclick="toggleSection(this)">属性维度 <span class="chevron">▼</span></div>
  461. <div class="section-bd open" id="dim-attr-bd"></div>
  462. </div>`;
  463. // 创作维度(从 facets 动态注入)
  464. html += `<div class="filter-section" id="dim-create-section" style="display:none">
  465. <div class="section-hd open" onclick="toggleSection(this)">创作维度 <span class="chevron">▼</span></div>
  466. <div class="section-bd open" id="dim-create-bd"></div>
  467. </div>`;
  468. // 自定义字段(section 默认隐藏,有 facets 数据时才显示)
  469. if (dims?.custom_fields?.length) {
  470. html += `<div class="filter-section" id="custom-section" style="display:none">
  471. <div class="section-hd open" onclick="toggleSection(this)">自定义索引 <span class="chevron">▼</span></div>
  472. <div class="section-bd open" id="custom-bd">`;
  473. for (const cf of dims.custom_fields) {
  474. html += renderCustomField(cf);
  475. }
  476. html += `</div></div>`;
  477. }
  478. card.innerHTML = html;
  479. bindSidebarEvents();
  480. }
  481. function renderScopeGroup(sg) {
  482. const st = sg.scope_type;
  483. const label = SCOPE_LABELS[st] || st;
  484. return `<div class="sub-group" id="sg-${st}">
  485. <div class="sub-label">${label}</div>
  486. <div class="sem-wrap">
  487. <input class="semantic-input" type="text" id="sem-${st}" placeholder="语义搜索${label}标签…">
  488. <span class="sem-spinner" id="sem-${st}-sp"></span>
  489. </div>
  490. <div class="suggest-list" id="sug-${st}"></div>
  491. </div>`;
  492. }
  493. function renderCustomField(cf) {
  494. const label = esc(cf.description || cf.key);
  495. let html = `<div class="sub-group" id="cf-${esc(cf.key)}"><div class="sub-label">${label}</div>`;
  496. if (cf.value_type === 'str') {
  497. if (cf.semantic_enabled) {
  498. html += `<div class="sem-wrap">
  499. <input class="semantic-input" type="text" id="csem-${esc(cf.key)}" placeholder="语义搜索${label}…">
  500. <span class="sem-spinner" id="csem-${esc(cf.key)}-sp"></span>
  501. </div>`;
  502. }
  503. html += `<div class="suggest-list" id="csug-${esc(cf.key)}"></div>`;
  504. } else if (cf.value_type === 'num') {
  505. html += `<div class="range-row">
  506. <input class="range-inp" type="number" id="cgte-${esc(cf.key)}" placeholder="最小">
  507. <span class="range-sep">—</span>
  508. <input class="range-inp" type="number" id="clte-${esc(cf.key)}" placeholder="最大">
  509. </div>`;
  510. } else if (cf.value_type === 'date') {
  511. html += `<div class="range-row">
  512. <input class="range-inp" type="date" id="cgte-${esc(cf.key)}">
  513. <span class="range-sep">—</span>
  514. <input class="range-inp" type="date" id="clte-${esc(cf.key)}">
  515. </div>`;
  516. }
  517. html += `</div>`;
  518. return html;
  519. }
  520. function bindSidebarEvents() {
  521. // 作用域复选框
  522. document.querySelectorAll('input[data-scope]').forEach(cb => {
  523. cb.addEventListener('change', () => {
  524. const st = cb.dataset.scope, val = cb.dataset.val;
  525. if (cb.checked) sel.scopes[st].add(val); else sel.scopes[st].delete(val);
  526. sel.page = 1; doSearch();
  527. });
  528. });
  529. // 作用域语义输入
  530. const debouncedSuggestScope = debounce((st, q) => {
  531. if (q) suggestScope(st, q);
  532. else clearPortalDropdown();
  533. }, 300);
  534. for (const sg of (dims?.scope_groups || [])) {
  535. const st = sg.scope_type;
  536. const inp = document.getElementById('sem-' + st);
  537. if (!inp) continue;
  538. inp.addEventListener('input', () => {
  539. sel.scopeSemantic[st] = inp.value.trim();
  540. debouncedSuggestScope(st, inp.value.trim());
  541. });
  542. inp.addEventListener('keydown', e => { if (e.key === 'Enter') { sel.page = 1; doSearch(); } });
  543. inp.addEventListener('blur', clearPortalDropdown);
  544. }
  545. // 自定义字段复选框
  546. document.querySelectorAll('input[data-ckey]').forEach(cb => {
  547. cb.addEventListener('change', () => {
  548. const key = cb.dataset.ckey, val = cb.dataset.val;
  549. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  550. if (cb.checked) sel.customValues[key].add(val); else sel.customValues[key].delete(val);
  551. sel.page = 1; doSearch();
  552. });
  553. });
  554. // 自定义语义输入
  555. const debouncedSuggestCustom = debounce((key, q) => {
  556. if (q) suggestCustom(key, q);
  557. else clearPortalDropdown();
  558. }, 300);
  559. for (const cf of (dims?.custom_fields || [])) {
  560. if (cf.value_type !== 'str' || !cf.semantic_enabled) continue;
  561. const inp = document.getElementById('csem-' + cf.key);
  562. if (!inp) continue;
  563. inp.addEventListener('input', () => {
  564. sel.customSemantics[cf.key] = inp.value.trim();
  565. debouncedSuggestCustom(cf.key, inp.value.trim());
  566. });
  567. inp.addEventListener('keydown', e => { if (e.key === 'Enter') { sel.page = 1; doSearch(); } });
  568. inp.addEventListener('blur', clearPortalDropdown);
  569. }
  570. // 自定义范围输入
  571. for (const cf of (dims?.custom_fields || [])) {
  572. if (cf.value_type !== 'num' && cf.value_type !== 'date') continue;
  573. const gteEl = document.getElementById('cgte-' + cf.key);
  574. const lteEl = document.getElementById('clte-' + cf.key);
  575. const onChange = debounce(() => {
  576. if (!sel.customRanges[cf.key]) sel.customRanges[cf.key] = {};
  577. if (cf.value_type === 'num') {
  578. sel.customRanges[cf.key].gte = gteEl?.value !== '' ? parseFloat(gteEl.value) : null;
  579. sel.customRanges[cf.key].lte = lteEl?.value !== '' ? parseFloat(lteEl.value) : null;
  580. } else {
  581. sel.customRanges[cf.key].gte = gteEl?.value ? new Date(gteEl.value).getTime() : null;
  582. sel.customRanges[cf.key].lte = lteEl?.value ? new Date(lteEl.value + 'T23:59:59').getTime() : null;
  583. }
  584. sel.page = 1; doSearch();
  585. }, 500);
  586. gteEl?.addEventListener('input', onChange);
  587. lteEl?.addEventListener('input', onChange);
  588. }
  589. }
  590. // ── 建议 API ──────────────────────────────────────────────────────────
  591. async function suggestScope(scopeType, q) {
  592. const inputEl = document.getElementById('sem-' + scopeType);
  593. const sp = document.getElementById('sem-' + scopeType + '-sp');
  594. if (!inputEl) return;
  595. if (sp) sp.classList.add('show');
  596. try {
  597. const r = await fetch(`${API}/scope-tags/suggest?scope_type=${encodeURIComponent(scopeType)}&q=${encodeURIComponent(q)}`);
  598. if (!r.ok) return;
  599. const data = await r.json();
  600. renderSuggestDropdown(inputEl, data.tags || [], val => {
  601. sel.scopes[scopeType].add(val);
  602. inputEl.value = '';
  603. sel.scopeSemantic[scopeType] = '';
  604. addInlineScopeTag(scopeType, val);
  605. sel.page = 1; doSearch();
  606. });
  607. } catch(e) {
  608. } finally {
  609. if (sp) sp.classList.remove('show');
  610. }
  611. }
  612. async function suggestCustom(key, q) {
  613. const inputEl = document.getElementById('csem-' + key);
  614. const sp = document.getElementById('csem-' + key + '-sp');
  615. if (!inputEl) return;
  616. if (sp) sp.classList.add('show');
  617. try {
  618. const r = await fetch(`${API}/custom-values/suggest?key=${encodeURIComponent(key)}&q=${encodeURIComponent(q)}`);
  619. if (!r.ok) return;
  620. const data = await r.json();
  621. renderSuggestDropdown(inputEl, data.values || [], val => {
  622. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  623. sel.customValues[key].add(val);
  624. inputEl.value = '';
  625. sel.customSemantics[key] = '';
  626. addInlineCustomValue(key, val);
  627. sel.page = 1; doSearch();
  628. });
  629. } catch(e) {
  630. } finally {
  631. if (sp) sp.classList.remove('show');
  632. }
  633. }
  634. function renderSuggestDropdown(inputEl, items, onSelect) {
  635. clearPortalDropdown();
  636. if (!items.length) return;
  637. const rect = inputEl.getBoundingClientRect();
  638. const div = document.createElement('div');
  639. div.className = 'suggest-dropdown';
  640. div.style.cssText = `position:fixed;z-index:9999;top:${rect.bottom + 2}px;left:${rect.left}px;width:${rect.width}px;`;
  641. items.forEach(item => {
  642. const el = document.createElement('div');
  643. el.className = 'suggest-item';
  644. el.textContent = item;
  645. el.addEventListener('mousedown', e => e.preventDefault());
  646. el.addEventListener('click', () => { onSelect(item); clearPortalDropdown(); });
  647. div.appendChild(el);
  648. });
  649. document.body.appendChild(div);
  650. _portalDropdown = div;
  651. }
  652. function addInlineScopeTag(scopeType, val) {
  653. const container = document.getElementById('sg-' + scopeType);
  654. if (!container) return;
  655. const existing = container.querySelector(`input[data-val="${CSS.escape(val)}"]`);
  656. if (existing) { existing.checked = true; return; }
  657. const label = document.createElement('label');
  658. label.className = 'opt';
  659. label.innerHTML = `<input type="checkbox" checked data-scope="${esc(scopeType)}" data-val="${esc(val)}"><span class="opt-text">${esc(val)}</span>`;
  660. label.querySelector('input').addEventListener('change', function() {
  661. if (this.checked) sel.scopes[scopeType].add(val); else sel.scopes[scopeType].delete(val);
  662. sel.page = 1; doSearch();
  663. });
  664. const sugEl = container.querySelector('.suggest-list');
  665. if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
  666. }
  667. function addInlineCustomValue(key, val) {
  668. const container = document.getElementById('cf-' + key);
  669. if (!container) return;
  670. const existing = container.querySelector(`input[data-val="${CSS.escape(val)}"]`);
  671. if (existing) { existing.checked = true; return; }
  672. const label = document.createElement('label');
  673. label.className = 'opt';
  674. label.innerHTML = `<input type="checkbox" checked data-ckey="${esc(key)}" data-val="${esc(val)}"><span class="opt-text">${esc(val)}</span>`;
  675. label.querySelector('input').addEventListener('change', function() {
  676. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  677. if (this.checked) sel.customValues[key].add(val); else sel.customValues[key].delete(val);
  678. sel.page = 1; doSearch();
  679. });
  680. const sugEl = container.querySelector('.suggest-list');
  681. if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
  682. }
  683. // ── 搜索请求 ──────────────────────────────────────────────────────────
  684. function buildRequest() {
  685. const req = {
  686. page: sel.page,
  687. page_size: 20,
  688. include_facets: true,
  689. highlight: !!sel.query,
  690. include_content: true,
  691. include_matched_conditions: true,
  692. };
  693. if (sel.query) req.query = sel.query;
  694. const scopes = [];
  695. for (const st of SCOPE_TYPES) {
  696. const values = [...sel.scopes[st]];
  697. const semantic = sel.scopeSemantic[st];
  698. if (values.length || semantic) {
  699. const sf = {scope_type: st};
  700. if (values.length) sf.values = values;
  701. if (semantic) sf.semantic_query = semantic;
  702. scopes.push(sf);
  703. }
  704. }
  705. if (scopes.length) req.scopes = scopes;
  706. if (sel.dimAttr.size) req.dim_attributes = [...sel.dimAttr];
  707. if (sel.dimCreate.size) req.dim_creations = [...sel.dimCreate];
  708. const customFilters = [];
  709. const allKeys = new Set([
  710. ...Object.keys(sel.customValues),
  711. ...Object.keys(sel.customSemantics),
  712. ...Object.keys(sel.customRanges),
  713. ]);
  714. for (const key of allKeys) {
  715. const cf = {key};
  716. const vals = [...(sel.customValues[key] || [])];
  717. const sem = sel.customSemantics[key];
  718. const rng = sel.customRanges[key];
  719. if (vals.length) cf.values = vals;
  720. if (sem) cf.semantic_query = sem;
  721. if (rng?.gte != null) cf.gte = rng.gte;
  722. if (rng?.lte != null) cf.lte = rng.lte;
  723. if (Object.keys(cf).length > 1) customFilters.push(cf);
  724. }
  725. if (customFilters.length) req.custom_filters = customFilters;
  726. return req;
  727. }
  728. async function doSearch() {
  729. renderChips();
  730. document.getElementById('results-list').innerHTML =
  731. '<div class="state-box"><div class="state-icon">⏳</div><p>搜索中…</p></div>';
  732. let resp;
  733. try {
  734. const r = await fetch(API + '/search', {
  735. method: 'POST',
  736. headers: {'Content-Type': 'application/json'},
  737. body: JSON.stringify(buildRequest()),
  738. });
  739. if (!r.ok) {
  740. const msg = (await r.json().catch(() => ({}))).detail || r.statusText;
  741. throw new Error(msg);
  742. }
  743. resp = await r.json();
  744. } catch(e) {
  745. document.getElementById('results-count').textContent = '';
  746. document.getElementById('results-list').innerHTML =
  747. `<div class="state-box error"><div class="state-icon">⚠️</div><p>搜索出错:${esc(e.message)}</p></div>`;
  748. return;
  749. }
  750. renderResults(resp);
  751. if (resp.facets) updateSidebarFacets(resp.facets);
  752. }
  753. // ── 渲染结果 ──────────────────────────────────────────────────────────
  754. function renderResults(data) {
  755. document.getElementById('results-count').innerHTML =
  756. `找到 <strong>${data.total.toLocaleString()}</strong> 条相关知识`;
  757. document.getElementById('sort-label').textContent =
  758. sel.query ? '按相关度排序' : '按最新更新排序';
  759. const list = document.getElementById('results-list');
  760. if (!data.hits.length) {
  761. list.innerHTML = '<div class="state-box"><div class="state-icon">📭</div><p>未找到匹配的知识条目,请调整搜索条件</p></div>';
  762. } else {
  763. list.innerHTML = data.hits.map(renderCard).join('');
  764. requestAnimationFrame(markStepClamps);
  765. }
  766. renderPagination(data.total, data.page, data.page_size);
  767. }
  768. // 点击「值」单元格展开/收起(仅在内容溢出、已标记 clampable 时生效)
  769. function toggleClampVal(el) {
  770. if (!el.classList.contains('clampable') && !el.classList.contains('open')) return;
  771. el.classList.toggle('open');
  772. }
  773. // 渲染后标记真正溢出 4 行的「值」单元格,才显示蒙版 + 可点击
  774. // (先套上限高再测量:溢出则保留 clampable,放得下则移除)
  775. function markStepClamps() {
  776. document.querySelectorAll('.steps .clamp-val').forEach((el) => {
  777. if (el.classList.contains('open')) return;
  778. el.classList.add('clampable');
  779. if (el.scrollHeight <= el.clientHeight + 2) el.classList.remove('clampable');
  780. });
  781. }
  782. // ── 工序步骤表(自 index.html 工序解构复用:content 即一条 procedure)──────
  783. function fmtSF(v) {
  784. return v == null ? '' : Array.isArray(v) ? v.join('、') : v;
  785. }
  786. function ioCell(x, kind) {
  787. const cls = kind === 'in' ? 'c-in' : 'c-out';
  788. if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
  789. const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || '模型推断补全')}` : '';
  790. const badge = x.inferred ? '<span class="ib">推</span>' : '';
  791. return `<td class="${cls}"><span class="pill ${kind === 'in' ? 'amber' : 'teal'}">${esc(x.type || '')}</span></td>
  792. <td class="${cls}${inf}">${badge}<div class="clamp-val" onclick="toggleClampVal(this)"><span class="vtxt">${esc(x.value || '')}</span></div></td>
  793. <td class="${cls}"><span class="anchor">${esc(x.anchor || '')}</span></td>`;
  794. }
  795. function renderSteps(steps) {
  796. if (!steps || !steps.length) return '<div class="steps-empty">无步骤</div>';
  797. let rows = '';
  798. for (const s of steps) {
  799. const ins = s.inputs && s.inputs.length ? s.inputs : [null];
  800. const outs = s.outputs && s.outputs.length ? s.outputs : [null];
  801. const n = Math.max(ins.length, outs.length);
  802. for (let i = 0; i < n; i++) {
  803. rows += '<tr>';
  804. if (i === 0) {
  805. rows += `<td rowspan="${n}" class="sid">${esc(s.id || '')}</td>
  806. <td rowspan="${n}">${esc(s.directive || s.intent || '')}</td>
  807. <td rowspan="${n}">${s.effect ? `<span class="pill navy">${esc(s.effect)}</span>` : ''}</td>
  808. <td rowspan="${n}">${esc(fmtSF(s.substance))}</td>
  809. <td rowspan="${n}">${esc(fmtSF(s.form))}</td>`;
  810. }
  811. rows += ioCell(ins[i], 'in');
  812. if (i === 0) {
  813. rows += `<td rowspan="${n}">${s.via ? `<span class="pill teal">${esc(s.via)}</span>` : ''}</td>
  814. <td rowspan="${n}" class="vtxt">${esc(s.action || '')}</td>`;
  815. }
  816. rows += ioCell(outs[i], 'out');
  817. rows += '</tr>';
  818. }
  819. }
  820. return `<div class="steps-wrap"><table class="steps">
  821. <colgroup>
  822. <col style="width:44px"><col style="width:200px"><col style="width:92px">
  823. <col style="width:112px"><col style="width:100px">
  824. <col style="width:112px"><col style="width:330px"><col style="width:92px">
  825. <col style="width:118px"><col style="width:130px">
  826. <col style="width:112px"><col style="width:360px"><col style="width:110px">
  827. </colgroup>
  828. <thead>
  829. <tr><th class="h-req" colspan="5">需 求</th><th class="h-in" colspan="3">输 入</th><th class="h-im" colspan="2">实 现</th><th class="h-out" colspan="3">输 出</th></tr>
  830. <tr>
  831. <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>
  832. <th class="h-in2">类型</th><th class="h-in2">值</th><th class="h-in2">来源</th>
  833. <th class="h-im2">外部工具</th><th class="h-im2">动作</th>
  834. <th class="h-out2">类型</th><th class="h-out2">值</th><th class="h-out2">去处</th>
  835. </tr>
  836. </thead><tbody>${rows}</tbody></table></div>`;
  837. }
  838. function renderCard(hit) {
  839. let titleHtml = esc(hit.title || '(无标题)');
  840. if (hit.highlight?.title?.[0]) titleHtml = hit.highlight.title[0];
  841. const score = hit.score != null
  842. ? `<span class="score-badge">相关度 ${hit.score.toFixed(2)}</span>` : '';
  843. let meta = '';
  844. if (hit.source_type) meta += `<span class="source-badge">${esc(hit.source_type)}</span>`;
  845. if (hit.author) meta += `<span>${esc(hit.author)}</span>`;
  846. if (hit.author && hit.source_title) meta += '<span class="meta-dot">·</span>';
  847. if (hit.source_title) meta += `<span>${esc(hit.source_title)}</span>`;
  848. // 从 matched_conditions 构建命中查找表
  849. const mc = hit.matched_conditions;
  850. const hitScopes = {}; // scope_type → Set<string>
  851. const hitCustom = {}; // key → Set<string>(str 类型)
  852. const hitRange = {}; // key → bool(num/date 类型)
  853. const hitDimAttr = new Set(mc?.dim_attributes || []);
  854. const hitDimCreate = new Set(mc?.dim_creations || []);
  855. for (const ms of (mc?.scopes || [])) hitScopes[ms.scope_type] = new Set(ms.matched_values);
  856. for (const cm of (mc?.custom || [])) {
  857. if (cm.matched_values != null) hitCustom[cm.key] = new Set(cm.matched_values);
  858. if (cm.in_range != null) hitRange[cm.key] = cm.in_range;
  859. }
  860. let tags = '';
  861. const scopeDefs = [
  862. ['s','scope_substance','substance','实质'],
  863. ['fo','scope_form','form','形式'],
  864. ['i','scope_intent','intent','意图'],
  865. ['e','scope_effect','effect','作用'],
  866. ['fe','scope_feeling','feeling','感受'],
  867. ];
  868. for (const [cls, field, st, label] of scopeDefs) {
  869. const hitSet = hitScopes[st] || new Set();
  870. for (const t of (hit[field] || [])) {
  871. const h = hitSet.has(t) ? ' hit' : '';
  872. tags += `<span class="tag ${cls}${h}" title="${h ? '命中筛选条件' : ''}"><span class="tag-pre">${label}</span>${esc(t)}</span>`;
  873. }
  874. }
  875. for (const d of (hit.dim_attributes || [])) {
  876. const h = hitDimAttr.has(d) ? ' hit' : '';
  877. tags += `<span class="tag d${h}" title="${h ? '命中筛选条件' : ''}">${esc(d)}</span>`;
  878. }
  879. for (const d of (hit.dim_creations || [])) {
  880. const h = hitDimCreate.has(d) ? ' hit' : '';
  881. tags += `<span class="tag d${h}" title="${h ? '命中筛选条件' : ''}">${esc(d)}</span>`;
  882. }
  883. let extHtml = '';
  884. for (const item of (hit.custom_ext || [])) {
  885. let val = item.value;
  886. if (item.type === 'date' && typeof val === 'number') {
  887. val = new Date(val).toLocaleDateString('zh-CN');
  888. }
  889. const isHit = item.type === 'str'
  890. ? hitCustom[item.key]?.has(String(item.value ?? ''))
  891. : hitRange[item.key] === true;
  892. const h = isHit ? ' hit' : '';
  893. 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>`;
  894. }
  895. const updatedAt = hit.updated_at
  896. ? new Date(hit.updated_at).toLocaleDateString('zh-CN') : '';
  897. // 正文展开区:content 是一条工序解构(procedure),复用工序步骤表渲染;
  898. // 解析失败或无步骤时回退为格式化 JSON。
  899. let contentSection = '';
  900. if (hit.content) {
  901. let inner = '';
  902. try {
  903. const proc = JSON.parse(hit.content);
  904. if (proc && Array.isArray(proc.steps) && proc.steps.length) inner = renderSteps(proc.steps);
  905. } catch {}
  906. if (!inner) {
  907. let display = hit.content;
  908. try { display = JSON.stringify(JSON.parse(hit.content), null, 2); } catch {}
  909. inner = `<pre class="content-pre">${esc(display)}</pre>`;
  910. }
  911. // 去掉「正文」标题,默认直接展开
  912. contentSection = `<div class="content-body open">${inner}</div>`;
  913. }
  914. return `<div class="card">
  915. <div class="card-top"><div class="card-title">${titleHtml}</div>${score}</div>
  916. ${meta ? `<div class="card-meta">${meta}</div>` : ''}
  917. ${tags ? `<div class="tag-row">${tags}</div>` : ''}
  918. <div class="card-foot">
  919. <div class="ext-row">${extHtml}</div>
  920. <span class="card-time">${updatedAt ? updatedAt + ' 更新' : ''}</span>
  921. </div>
  922. ${contentSection}
  923. </div>`;
  924. }
  925. // ── 分页 ──────────────────────────────────────────────────────────────
  926. function renderPagination(total, page, pageSize) {
  927. const totalPages = Math.ceil(total / pageSize);
  928. const pg = document.getElementById('pagination');
  929. if (totalPages <= 1) { pg.innerHTML = ''; return; }
  930. let html = `<button class="pg-btn" onclick="goPage(${page-1})" ${page<=1?'disabled':''}>‹</button>`;
  931. const pages = buildPageList(page, totalPages);
  932. let prev = null;
  933. for (const p of pages) {
  934. if (prev !== null && p - prev > 1) html += '<span class="pg-dots">…</span>';
  935. html += `<button class="pg-btn ${p===page?'active':''}" onclick="goPage(${p})">${p}</button>`;
  936. prev = p;
  937. }
  938. html += `<button class="pg-btn" onclick="goPage(${page+1})" ${page>=totalPages?'disabled':''}>›</button>`;
  939. pg.innerHTML = html;
  940. }
  941. function buildPageList(cur, total) {
  942. const s = new Set([1, total]);
  943. for (let p = Math.max(1, cur-1); p <= Math.min(total, cur+1); p++) s.add(p);
  944. return [...s].sort((a,b) => a-b);
  945. }
  946. function goPage(p) {
  947. sel.page = p;
  948. doSearch();
  949. window.scrollTo({top: 0, behavior: 'smooth'});
  950. }
  951. // ── Facets → 侧栏 ─────────────────────────────────────────────────────
  952. function updateSidebarFacets(facets) {
  953. // 属性维度 / 创作维度:整体替换
  954. updateFacetSection(
  955. 'dim-attr-section', 'dim-attr-bd',
  956. facets.dim_attributes || [],
  957. sel.dimAttr, 'data-dim-attr',
  958. cb => { const v = cb.dataset.dimAttr; cb.checked ? sel.dimAttr.add(v) : sel.dimAttr.delete(v); sel.page=1; doSearch(); }
  959. );
  960. updateFacetSection(
  961. 'dim-create-section', 'dim-create-bd',
  962. facets.dim_creations || [],
  963. sel.dimCreate, 'data-dim-create',
  964. cb => { const v = cb.dataset.dimCreate; cb.checked ? sel.dimCreate.add(v) : sel.dimCreate.delete(v); sel.page=1; doSearch(); }
  965. );
  966. // 作用域标签:完全由 facets 驱动,移除消失的项、补充新出现的项
  967. const scopeMap = Object.fromEntries((facets.scopes || []).map(s => [s.scope_type, s.buckets || []]));
  968. for (const st of SCOPE_TYPES) {
  969. const buckets = scopeMap[st] || [];
  970. const bucketMap = Object.fromEntries(buckets.map(b => [b.value, b.count]));
  971. const sgContainer = document.getElementById('sg-' + st);
  972. if (!sgContainer) continue;
  973. // 移除不在当前 facets 且未被选中的项
  974. sgContainer.querySelectorAll('label[data-facet-src]').forEach(label => {
  975. const val = label.querySelector('input')?.dataset.val;
  976. if (val && bucketMap[val] == null && !(sel.scopes[st]?.has(val))) label.remove();
  977. });
  978. // 更新已存在项的计数,补充 facets 中新出现的项
  979. const sugEl = sgContainer.querySelector('.suggest-list');
  980. for (const b of buckets) {
  981. const existing = sgContainer.querySelector(`input[data-val="${CSS.escape(b.value)}"]`);
  982. if (existing) {
  983. syncCountBadge(existing.closest('.opt'), b.count);
  984. continue;
  985. }
  986. const label = document.createElement('label');
  987. label.className = 'opt';
  988. label.dataset.facetSrc = 'true';
  989. const chk = sel.scopes[st]?.has(b.value) ? 'checked' : '';
  990. 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>`;
  991. label.querySelector('input').addEventListener('change', function() {
  992. if (this.checked) sel.scopes[st].add(b.value); else sel.scopes[st].delete(b.value);
  993. sel.page = 1; doSearch();
  994. });
  995. if (sugEl) sgContainer.insertBefore(label, sugEl); else sgContainer.appendChild(label);
  996. }
  997. }
  998. // 自定义字段
  999. updateCustomExtFacets(facets.custom_ext || []);
  1000. }
  1001. function updateFacetSection(sectionId, bdId, buckets, selSet, dataAttr, onChange) {
  1002. const section = document.getElementById(sectionId);
  1003. const bd = document.getElementById(bdId);
  1004. if (!section || !bd) return;
  1005. if (!buckets.length) { section.style.display = 'none'; return; }
  1006. section.style.display = '';
  1007. let html = '';
  1008. for (const b of buckets) {
  1009. const checked = selSet.has(b.value) ? 'checked' : '';
  1010. html += `<label class="opt">
  1011. <input type="checkbox" ${checked} ${dataAttr}="${esc(b.value)}">
  1012. <span class="opt-text">${esc(b.value)}</span>
  1013. <span class="opt-count">${b.count}</span>
  1014. </label>`;
  1015. }
  1016. bd.innerHTML = html;
  1017. bd.querySelectorAll(`input[${dataAttr}]`).forEach(cb => {
  1018. cb.addEventListener('change', () => onChange(cb));
  1019. });
  1020. }
  1021. // 自定义字段 facets 处理:key 本身的显隐也由 facets 驱动
  1022. function updateCustomExtFacets(customExtFacets) {
  1023. if (!dims?.custom_fields?.length) return;
  1024. const facetMap = Object.fromEntries(customExtFacets.map(f => [f.key, f]));
  1025. let anyVisible = false;
  1026. for (const cf of dims.custom_fields) {
  1027. const container = document.getElementById('cf-' + cf.key);
  1028. if (!container) continue;
  1029. const facet = facetMap[cf.key];
  1030. // 有活跃筛选时即使 facets 无数据也保留显示
  1031. const hasActiveFilter =
  1032. (cf.value_type === 'str' &&
  1033. (sel.customValues[cf.key]?.size > 0 || !!sel.customSemantics[cf.key])) ||
  1034. ((cf.value_type === 'num' || cf.value_type === 'date') &&
  1035. (sel.customRanges[cf.key]?.gte != null || sel.customRanges[cf.key]?.lte != null));
  1036. const hasFacetData =
  1037. (cf.value_type === 'str' && facet?.buckets?.length > 0) ||
  1038. ((cf.value_type === 'num' || cf.value_type === 'date') && facet?.stats?.count > 0);
  1039. if (!hasFacetData && !hasActiveFilter) {
  1040. container.style.display = 'none';
  1041. continue;
  1042. }
  1043. container.style.display = '';
  1044. anyVisible = true;
  1045. if (cf.value_type === 'str') {
  1046. updateCustomStrCounts(cf.key, container, facet?.buckets || []);
  1047. } else if (cf.value_type === 'num' && facet?.stats?.count > 0) {
  1048. const s = facet.stats;
  1049. const gteEl = document.getElementById('cgte-' + cf.key);
  1050. const lteEl = document.getElementById('clte-' + cf.key);
  1051. if (gteEl && s.min != null) gteEl.placeholder = `最小 (${parseFloat(s.min.toFixed(2))})`;
  1052. if (lteEl && s.max != null) lteEl.placeholder = `最大 (${parseFloat(s.max.toFixed(2))})`;
  1053. }
  1054. // date 类型:range input 不显示 placeholder,仅控制可见性
  1055. }
  1056. // 整个"自定义索引"区块:有可见 key 才展示
  1057. const section = document.getElementById('custom-section');
  1058. if (section) section.style.display = anyVisible ? '' : 'none';
  1059. }
  1060. function updateCustomStrCounts(key, container, buckets) {
  1061. const bucketMap = Object.fromEntries(buckets.map(b => [b.value, b.count]));
  1062. // 移除不在当前 facets 且未被选中的项
  1063. container.querySelectorAll('label[data-facet-src]').forEach(label => {
  1064. const val = label.querySelector('input')?.dataset.val;
  1065. if (val && bucketMap[val] == null && !(sel.customValues[key]?.has(val))) label.remove();
  1066. });
  1067. // 更新已存在项的计数,补充 facets 中新出现的项
  1068. const sugEl = container.querySelector('.suggest-list');
  1069. for (const b of buckets) {
  1070. const existing = container.querySelector(`input[data-val="${CSS.escape(b.value)}"]`);
  1071. if (existing) {
  1072. syncCountBadge(existing.closest('.opt'), b.count);
  1073. continue;
  1074. }
  1075. const label = document.createElement('label');
  1076. label.className = 'opt';
  1077. label.dataset.facetSrc = 'true';
  1078. const chk = sel.customValues[key]?.has(b.value) ? 'checked' : '';
  1079. 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>`;
  1080. label.querySelector('input').addEventListener('change', function() {
  1081. if (!sel.customValues[key]) sel.customValues[key] = new Set();
  1082. if (this.checked) sel.customValues[key].add(b.value); else sel.customValues[key].delete(b.value);
  1083. sel.page = 1; doSearch();
  1084. });
  1085. if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
  1086. }
  1087. }
  1088. // 在 .opt 元素上同步 .opt-count 徽章;count 为 undefined 时清除徽章
  1089. function syncCountBadge(optEl, count) {
  1090. if (!optEl) return;
  1091. let el = optEl.querySelector('.opt-count');
  1092. if (count != null) {
  1093. if (!el) { el = document.createElement('span'); el.className = 'opt-count'; optEl.appendChild(el); }
  1094. el.textContent = count;
  1095. } else if (el) {
  1096. el.remove();
  1097. }
  1098. }
  1099. // ── Chips 栏 ──────────────────────────────────────────────────────────
  1100. function renderChips() {
  1101. _chips = [];
  1102. for (const st of SCOPE_TYPES) {
  1103. for (const val of sel.scopes[st]) {
  1104. _chips.push({
  1105. label: `${SCOPE_LABELS[st]}:${val}`,
  1106. remove: () => { sel.scopes[st].delete(val); uncheckInput(`input[data-scope="${CSS.escape(st)}"][data-val="${CSS.escape(val)}"]`); sel.page=1; doSearch(); }
  1107. });
  1108. }
  1109. if (sel.scopeSemantic[st]) {
  1110. _chips.push({
  1111. label: `${SCOPE_LABELS[st]} 语义:${sel.scopeSemantic[st]}`,
  1112. remove: () => {
  1113. sel.scopeSemantic[st] = '';
  1114. const inp = document.getElementById('sem-' + st);
  1115. if (inp) inp.value = '';
  1116. sel.page = 1; doSearch();
  1117. }
  1118. });
  1119. }
  1120. }
  1121. 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();}});
  1122. 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();}});
  1123. for (const [key, vals] of Object.entries(sel.customValues)) {
  1124. for (const val of vals) {
  1125. _chips.push({
  1126. label: `${key}:${val}`,
  1127. remove: () => { sel.customValues[key].delete(val); uncheckInput(`input[data-ckey="${CSS.escape(key)}"][data-val="${CSS.escape(val)}"]`); sel.page=1; doSearch(); }
  1128. });
  1129. }
  1130. }
  1131. for (const [key, sem] of Object.entries(sel.customSemantics)) {
  1132. if (!sem) continue;
  1133. _chips.push({
  1134. label: `${key} 语义:${sem}`,
  1135. remove: () => { sel.customSemantics[key]=''; const i=document.getElementById('csem-'+key); if(i)i.value=''; sel.page=1; doSearch(); }
  1136. });
  1137. }
  1138. for (const [key, rng] of Object.entries(sel.customRanges)) {
  1139. if (rng.gte == null && rng.lte == null) continue;
  1140. const parts = [];
  1141. if (rng.gte != null) parts.push(`≥ ${rng.gte}`);
  1142. if (rng.lte != null) parts.push(`≤ ${rng.lte}`);
  1143. _chips.push({
  1144. label: `${key}:${parts.join(' ')}`,
  1145. remove: () => {
  1146. sel.customRanges[key] = {};
  1147. const g = document.getElementById('cgte-'+key), l = document.getElementById('clte-'+key);
  1148. if(g) g.value=''; if(l) l.value='';
  1149. sel.page=1; doSearch();
  1150. }
  1151. });
  1152. }
  1153. const bar = document.getElementById('chips-bar');
  1154. if (!_chips.length) { bar.style.display = 'none'; return; }
  1155. bar.style.display = '';
  1156. let html = '<span class="chips-label">已选筛选:</span>';
  1157. _chips.forEach((c, i) => {
  1158. html += `<span class="chip" data-chip="${i}">${esc(c.label)}<span class="chip-x">×</span></span>`;
  1159. });
  1160. html += '<span class="chip-clear" id="chip-clear-all">清空全部</span>';
  1161. bar.innerHTML = html;
  1162. bar.querySelectorAll('[data-chip]').forEach(el => {
  1163. el.addEventListener('click', () => { _chips[+el.dataset.chip].remove(); });
  1164. });
  1165. document.getElementById('chip-clear-all')?.addEventListener('click', clearAllFilters);
  1166. }
  1167. function uncheckInput(selector) {
  1168. const el = document.querySelector(selector);
  1169. if (el) el.checked = false;
  1170. }
  1171. function clearAllFilters() {
  1172. SCOPE_TYPES.forEach(st => { sel.scopes[st].clear(); sel.scopeSemantic[st] = ''; const i=document.getElementById('sem-'+st); if(i)i.value=''; });
  1173. sel.dimAttr.clear();
  1174. sel.dimCreate.clear();
  1175. sel.customValues = {};
  1176. sel.customSemantics= {};
  1177. sel.customRanges = {};
  1178. document.querySelectorAll('input[data-scope],input[data-ckey],input[data-dim-attr],input[data-dim-create]').forEach(cb => cb.checked = false);
  1179. document.querySelectorAll('.range-inp').forEach(inp => inp.value = '');
  1180. sel.page = 1;
  1181. doSearch();
  1182. }
  1183. // ── 折叠展开 ──────────────────────────────────────────────────────────
  1184. function toggleSection(hd) {
  1185. hd.classList.toggle('open');
  1186. hd.nextElementSibling.classList.toggle('open');
  1187. }
  1188. document.addEventListener('DOMContentLoaded', init);
  1189. </script>
  1190. </body>
  1191. </html>