| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>知识检索 — 知识检索中心</title>
- <style>
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- :root {
- --primary: #2563EB;
- --primary-light: #EFF6FF;
- --primary-hover: #1D4ED8;
- --bg: #F1F5F9;
- --surface: #FFFFFF;
- --border: #E2E8F0;
- --border-focus: #A5B4FC;
- --text: #0F172A;
- --text-sub: #475569;
- --text-muted: #94A3B8;
- --radius: 8px;
- --substance-bg: #EFF6FF; --substance: #1D4ED8;
- --form-bg: #F5F3FF; --form: #6D28D9;
- --intent-bg: #F0FDF4; --intent: #15803D;
- --effect-bg: #FFF7ED; --effect: #C2410C;
- --feeling-bg: #FDF2F8; --feeling: #BE185D;
- --dim-bg: #F8FAFC; --dim: #64748B;
- }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
- background: var(--bg);
- color: var(--text);
- font-size: 14px;
- line-height: 1.5;
- }
- /* ── 搜索栏 ───────────────────────────────────────────────────── */
- .search-section {
- position: sticky; top: 0; z-index: 199;
- background: var(--surface);
- border-bottom: 1px solid var(--border);
- padding: 11px 16px;
- }
- .search-inner { max-width: 1400px; margin: 0 auto; display: flex; gap: 8px; }
- .search-input-wrap { flex: 0 1 680px; min-width: 240px; position: relative; }
- .search-icon {
- position: absolute; left: 10px; top: 50%;
- transform: translateY(-50%);
- color: var(--text-muted); pointer-events: none;
- display: flex; align-items: center;
- }
- .search-input {
- width: 100%; height: 38px;
- padding: 0 12px 0 34px;
- border: 1.5px solid var(--border); border-radius: 6px;
- font-size: 14px; outline: none;
- background: var(--bg); color: var(--text);
- transition: border-color .15s, background .15s;
- }
- .search-input:focus { border-color: var(--border-focus); background: #fff; }
- .btn-primary {
- height: 38px; padding: 0 18px;
- background: var(--primary); color: #fff;
- border: none; border-radius: 6px;
- font-size: 14px; font-weight: 500; cursor: pointer;
- transition: background .15s; white-space: nowrap;
- }
- .btn-primary:hover { background: var(--primary-hover); }
- /* ── 布局 ─────────────────────────────────────────────────────── */
- .layout {
- display: flex;
- max-width: 1400px; margin: 0 auto;
- padding: 16px; gap: 14px;
- align-items: flex-start;
- }
- /* ── 侧栏 ─────────────────────────────────────────────────────── */
- .sidebar {
- width: 232px; flex-shrink: 0;
- position: sticky; top: 126px;
- max-height: calc(100vh - 132px);
- overflow-y: auto;
- }
- .sidebar::-webkit-scrollbar { width: 3px; }
- .sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
- .filter-card {
- background: var(--surface);
- border: 1px solid var(--border); border-radius: var(--radius);
- overflow: hidden;
- }
- .filter-section { border-bottom: 1px solid var(--border); }
- .filter-section:last-child { border-bottom: none; }
- .section-hd {
- display: flex; align-items: center; justify-content: space-between;
- padding: 10px 13px; cursor: pointer; user-select: none;
- font-size: 13px; font-weight: 600; color: var(--text);
- transition: background .1s;
- }
- .section-hd:hover { background: var(--bg); }
- .section-hd .chevron { color: var(--text-muted); font-size: 10px; transition: transform .2s; }
- .section-hd.open .chevron { transform: rotate(180deg); }
- .section-bd { display: none; padding: 2px 13px 10px; }
- .section-bd.open { display: block; }
- .sub-group { margin-bottom: 10px; }
- .sub-label {
- font-size: 10px; font-weight: 700; letter-spacing: .6px;
- text-transform: uppercase; color: var(--text-muted);
- padding: 5px 0 3px;
- }
- .semantic-input {
- width: 100%; height: 26px; padding: 0 8px;
- border: 1px solid var(--border); border-radius: 4px;
- font-size: 12px; outline: none; color: var(--text);
- background: var(--bg); margin-bottom: 5px;
- transition: border-color .15s;
- }
- .semantic-input:focus { border-color: var(--border-focus); background: #fff; }
- .semantic-input::placeholder { color: var(--text-muted); }
- .opt {
- display: flex; align-items: center; gap: 6px;
- padding: 3px 0; cursor: pointer;
- font-size: 13px; color: var(--text-sub);
- }
- .opt:hover { color: var(--primary); }
- .opt input[type="checkbox"] {
- accent-color: var(--primary);
- width: 13px; height: 13px; cursor: pointer; flex-shrink: 0;
- }
- .opt-text { flex: 1; }
- .opt-count {
- font-size: 11px; color: var(--text-muted);
- background: var(--bg); border-radius: 8px;
- padding: 0 5px; min-width: 20px; text-align: center;
- }
- .range-row { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
- .range-inp {
- flex: 1; height: 28px; padding: 0 7px;
- border: 1px solid var(--border); border-radius: 4px;
- font-size: 12px; outline: none; color: var(--text); min-width: 0;
- }
- .range-inp:focus { border-color: var(--border-focus); }
- .range-sep { color: var(--text-muted); font-size: 12px; }
- /* ── 建议下拉(portal,fixed 定位到 body)──────────────────────── */
- .suggest-dropdown {
- background: var(--surface);
- border: 1px solid var(--border); border-radius: 4px;
- overflow: hidden;
- box-shadow: 0 6px 16px rgba(0,0,0,.12);
- max-height: 220px; overflow-y: auto;
- }
- .suggest-item {
- padding: 6px 10px; font-size: 12px;
- color: var(--text-sub); cursor: pointer;
- transition: background .1s;
- }
- .suggest-item:hover { background: var(--primary-light); color: var(--primary); }
- /* ── 结果区域 ─────────────────────────────────────────────────── */
- .results-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
- .chips-bar {
- display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
- padding: 9px 13px;
- background: var(--surface);
- border: 1px solid var(--border); border-radius: var(--radius);
- }
- .chips-label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
- .chip {
- display: inline-flex; align-items: center; gap: 3px;
- padding: 2px 8px; border-radius: 4px;
- background: var(--primary-light); color: var(--primary);
- font-size: 12px; font-weight: 500; cursor: pointer;
- }
- .chip-x { font-size: 13px; opacity: .55; margin-left: 1px; }
- .chip:hover .chip-x { opacity: 1; }
- .chip-clear { margin-left: auto; font-size: 12px; color: var(--text-muted); cursor: pointer; }
- .chip-clear:hover { color: #EF4444; }
- .results-bar {
- display: flex; align-items: center; justify-content: space-between;
- }
- .results-count { font-size: 13px; color: var(--text-sub); }
- .results-count strong { color: var(--text); font-weight: 600; }
- .sort-label { font-size: 12px; color: var(--text-muted); }
- /* ── 卡片 ─────────────────────────────────────────────────────── */
- .card {
- background: var(--surface);
- border: 1px solid var(--border); border-radius: var(--radius);
- padding: 15px 16px; cursor: pointer;
- transition: border-color .15s, box-shadow .15s;
- }
- .card:hover { border-color: #C7D2FE; box-shadow: 0 3px 12px rgba(79,70,229,.08); }
- .card-top {
- display: flex; align-items: flex-start;
- justify-content: space-between; gap: 10px; margin-bottom: 6px;
- }
- .card-title {
- font-size: 15px; font-weight: 600; color: var(--text);
- line-height: 1.4; flex: 1;
- }
- .card-title em {
- font-style: normal; color: var(--primary);
- background: var(--primary-light); padding: 0 2px; border-radius: 2px;
- }
- .score-badge {
- flex-shrink: 0; font-size: 11px; font-weight: 600;
- color: #15803D; background: #F0FDF4;
- padding: 2px 7px; border-radius: 10px; white-space: nowrap;
- }
- .card-meta {
- display: flex; align-items: center; flex-wrap: wrap;
- gap: 3px 10px; font-size: 12px; color: var(--text-muted); margin-bottom: 9px;
- }
- .source-badge {
- display: inline-flex; align-items: center;
- padding: 1px 6px;
- background: var(--bg); border: 1px solid var(--border);
- border-radius: 4px; font-size: 11px; color: var(--text-sub);
- }
- .meta-dot { color: var(--border); }
- .tag-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
- .tag {
- display: inline-flex; align-items: center; gap: 2px;
- padding: 2px 7px; border-radius: 4px;
- font-size: 11px; font-weight: 500; line-height: 1.6;
- }
- .tag-pre { opacity: .65; font-size: 10px; }
- .tag.s { background: var(--substance-bg); color: var(--substance); }
- .tag.fo { background: var(--form-bg); color: var(--form); }
- .tag.i { background: var(--intent-bg); color: var(--intent); }
- .tag.e { background: var(--effect-bg); color: var(--effect); }
- .tag.fe { background: var(--feeling-bg); color: var(--feeling); }
- .tag.d { background: var(--dim-bg); color: var(--dim); border: 1px solid var(--border); }
- .tag.hit { box-shadow: 0 0 0 1.5px currentColor; font-weight: 700; }
- .tag.d.hit { border-color: var(--dim); }
- .ext-item.hit .ext-key { color: var(--text-sub); }
- .ext-item.hit .ext-val { color: var(--primary); font-weight: 700; }
- .card-foot {
- display: flex; align-items: center; justify-content: space-between;
- padding-top: 9px; border-top: 1px solid var(--bg);
- }
- .ext-row { display: flex; flex-wrap: wrap; gap: 10px; }
- .ext-item { font-size: 11px; }
- .ext-key { color: var(--text-muted); }
- .ext-val { color: var(--text-sub); font-weight: 500; }
- .card-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
- /* ── 分页 ─────────────────────────────────────────────────────── */
- .pagination {
- display: flex; justify-content: center; align-items: center;
- gap: 3px; padding: 6px 0 4px;
- }
- .pg-btn {
- min-width: 32px; height: 32px; padding: 0 8px;
- display: inline-flex; align-items: center; justify-content: center;
- border: 1px solid var(--border); border-radius: 4px;
- background: var(--surface); color: var(--text-sub);
- font-size: 13px; cursor: pointer; transition: all .15s; user-select: none;
- }
- .pg-btn:hover:not(.active):not([disabled]) { border-color: var(--primary); color: var(--primary); }
- .pg-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600; }
- .pg-btn[disabled] { color: var(--text-muted); cursor: default; }
- .pg-dots { font-size: 13px; color: var(--text-muted); padding: 0 2px; }
- /* ── 空/加载/错误状态 ─────────────────────────────────────────── */
- .state-box {
- text-align: center; padding: 60px 20px;
- color: var(--text-muted); font-size: 14px;
- background: var(--surface);
- border: 1px solid var(--border); border-radius: var(--radius);
- }
- .state-box.error { color: #EF4444; }
- .state-box .state-icon { font-size: 32px; margin-bottom: 10px; }
- .state-box p { margin-top: 6px; font-size: 13px; }
- /* ── 侧栏骨架 ─────────────────────────────────────────────────── */
- .sidebar-loading {
- padding: 20px 13px;
- display: flex; flex-direction: column; gap: 8px;
- }
- .skel {
- height: 12px; border-radius: 4px;
- background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
- background-size: 200% 100%;
- animation: shimmer 1.4s infinite;
- }
- @keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
- /* ── 语义输入框 loading 圈 ────────────────────────────────────────── */
- .sem-wrap { position: relative; }
- .sem-wrap .semantic-input { padding-right: 26px; }
- .sem-spinner {
- position: absolute; right: 7px; top: 50%;
- transform: translateY(-50%);
- width: 11px; height: 11px;
- border: 2px solid var(--border);
- border-top-color: var(--primary);
- border-radius: 50%;
- animation: sem-spin .65s linear infinite;
- display: none; pointer-events: none;
- }
- .sem-spinner.show { display: block; }
- @keyframes sem-spin { to { transform: translateY(-50%) rotate(360deg); } }
- /* ── 正文展开 ─────────────────────────────────────────────────── */
- .content-toggle {
- display: flex; align-items: center; gap: 5px;
- padding: 7px 0 2px;
- font-size: 12px; color: var(--text-muted);
- cursor: pointer; user-select: none;
- border-top: 1px solid var(--bg); margin-top: 8px;
- }
- .content-toggle:hover { color: var(--primary); }
- .content-caret {
- font-size: 9px; display: inline-block;
- transition: transform .2s; margin-left: auto;
- }
- .content-toggle.open .content-caret { transform: rotate(180deg); }
- .content-body { display: none; }
- .content-body.open { display: block; padding-top: 6px; }
- .content-pre {
- margin: 0; padding: 10px 12px;
- background: var(--bg); border: 1px solid var(--border); border-radius: 5px;
- font-family: 'Courier New', Consolas, monospace;
- font-size: 11px; line-height: 1.65; color: var(--text-sub);
- max-height: 300px; overflow-y: auto;
- white-space: pre-wrap; word-break: break-all;
- }
- /* ── 工序步骤表(复用自工序解构的 .steps 表格)─────────────────── */
- .steps-wrap { overflow-x: auto; margin-top: 6px; border: 1px solid var(--border); border-radius: 6px; }
- .steps { width: 100%; min-width: 1500px; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
- .steps th { padding: 6px 8px; font-size: 11px; font-weight: 700; letter-spacing: 1px; color: #fff; text-align: left; }
- .steps thead tr:first-child th { text-align: center; font-size: 12px; letter-spacing: 4px; padding: 7px 4px; }
- .steps .h-req { background: #2b4a72; } .steps .h-req2 { background: #33547a; }
- .steps .h-in { background: #c2761f; } .steps .h-in2 { background: #cd7522; }
- .steps .h-im { background: #2f9c8a; } .steps .h-im2 { background: #2d8273; }
- .steps .h-out { background: #2563eb; } .steps .h-out2 { background: #4f7fe6; }
- .steps td { padding: 8px 9px; border: 1px solid var(--border); vertical-align: top; line-height: 1.6; color: var(--text); }
- .steps tbody tr:nth-child(odd) td { background: #fdfdf9; }
- .steps td.c-in { background: #FFF7ED !important; }
- .steps td.c-out { background: #eef3fe !important; }
- .steps .sid { font-family: 'Courier New', monospace; font-weight: 700; color: #2b4a72; white-space: nowrap; }
- .steps .vtxt { color: var(--text-sub); font-size: 11.5px; word-break: break-all; }
- .steps .anchor { font-family: 'Courier New', monospace; font-size: 10.5px; color: var(--text-muted); word-break: break-all; }
- .steps .pill { display: inline-block; padding: 1px 7px; border-radius: 4px; font-size: 11px; font-weight: 600; }
- .steps .pill.navy { background: #e7edf5; color: #2b4a72; }
- .steps .pill.amber { background: #fdebd3; color: #9a560f; }
- .steps .pill.teal { background: #d8f0ea; color: #1f6f62; }
- .steps .pill.green { background: #e3f3e8; color: #2e6b45; }
- .steps .pill.blue { background: #eef3fe; color: #2563eb; }
- /* 目的列 intent 胶囊: 底色 = 所引用列的分组色, 与表头/列 chip 一致
- (需求=navy / 输入=amber / 实现=teal / 输出=green; 口径同 procedure-dsl「token 色对应来源列」) */
- .intent-text { color: #1f2937; line-height: 1.6; }
- .intent-tok { display: inline-block; padding: 1px 6px; border-radius: 4px; margin: 0 1px; font-size: 11.5px; font-weight: 600; }
- .intent-tok.ik-effect { background: #e7edf5; color: #2b4a72; } /* 作用列 (需求组) */
- .intent-tok.ik-via { background: #d8f0ea; color: #1f6f62; font-family: ui-monospace, "SF Mono", monospace; } /* 外部工具列 (实现组) */
- .intent-tok.ik-act { background: #d8f0ea; color: #1f6f62; } /* 动作列 (实现组) */
- .intent-tok.ik-in-type { background: #fdebd3; color: #9a560f; border-radius: 99px; padding: 1px 8px; } /* 输入·类型 */
- .intent-tok.ik-out-type { background: #eef3fe; color: #2563eb; border: 1px solid #b6cdf7; border-radius: 99px; padding: 1px 8px; } /* 输出·类型 (蓝色,避免与实现组绿色混淆) */
- .intent-tok.ik-other { background: #fbeae5; color: #b3341d; text-decoration: line-through; } /* 非法类别(lint 警告) */
- .steps .inf { background: #fdf6e3 !important; position: relative; outline: 1px dashed #c9a227; outline-offset: -2px; }
- .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; }
- .steps-empty { padding: 12px; color: var(--text-muted); font-size: 12px; }
- /* 输入/输出「值」单元格:超过 4 行加蒙版,点击展开/收起 */
- .clamp-val { position: relative; }
- .clamp-val.clampable { max-height: 6.6em; overflow: hidden; cursor: zoom-in; }
- .clamp-val.clampable::after {
- content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2.4em; pointer-events: none;
- }
- .steps td.c-in .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(255,247,237,0), rgba(255,247,237,1)); }
- .steps td.c-out .clamp-val.clampable::after { background: linear-gradient(180deg, rgba(238,243,254,0), rgba(238,243,254,1)); }
- .clamp-val.open { max-height: none; overflow: visible; cursor: zoom-out; }
- .clamp-val.open::after { display: none; }
- </style>
- </head>
- <body>
- <div class="search-section">
- <div class="search-inner">
- <div class="search-input-wrap">
- <span class="search-icon">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
- <circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
- </svg>
- </span>
- <input id="search-input" class="search-input" type="text" placeholder="搜索知识标题、正文内容…">
- </div>
- <button id="search-btn" class="btn-primary">搜索</button>
- </div>
- </div>
- <div class="layout">
- <aside class="sidebar">
- <div class="filter-card" id="sidebar-card">
- <div class="sidebar-loading">
- <div class="skel" style="width:60%"></div>
- <div class="skel" style="width:90%"></div>
- <div class="skel" style="width:75%"></div>
- <div class="skel" style="width:80%"></div>
- <div class="skel" style="width:55%"></div>
- </div>
- </div>
- </aside>
- <div class="results-area">
- <div class="chips-bar" id="chips-bar" style="display:none"></div>
- <div class="results-bar">
- <div class="results-count" id="results-count">加载中…</div>
- <div class="sort-label" id="sort-label"></div>
- </div>
- <div id="results-list">
- <div class="state-box"><div class="state-icon">🔍</div><p>正在加载…</p></div>
- </div>
- <div class="pagination" id="pagination"></div>
- </div>
- </div>
- <script>
- const API = '/api/v1/knowledge';
- const SCOPE_TYPES = ['substance','form','intent','effect','feeling'];
- const SCOPE_LABELS = {substance:'实质', form:'形式', intent:'意图', effect:'作用', feeling:'感受'};
- const SCOPE_TAGS = {substance:'s', form:'fo', intent:'i', effect:'e', feeling:'fe'};
- // ── 转义工具 ──────────────────────────────────────────────────────────
- const esc = s => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
- // 目的列: 把 intent 里的 {类别:值} 标记渲染成彩色胶囊 (口径同 procedure-dsl renderer.py:render_intent)。
- // 合法类别 5 个: effect/via/act/in-type/out-type; 其余落 ik-other(红删除线)。标记外字面与值都转义。
- const INTENT_KIND = { effect:'ik-effect', via:'ik-via', act:'ik-act', 'in-type':'ik-in-type', 'out-type':'ik-out-type' };
- function renderIntent(text) {
- const s = String(text ?? ''), re = /\{([\w-]+):([^}]+)\}/g;
- let out = '', last = 0, m;
- while ((m = re.exec(s))) {
- out += esc(s.slice(last, m.index).replace(/`/g, ''));
- out += `<span class="intent-tok ${INTENT_KIND[m[1]] || 'ik-other'}">${esc(m[2])}</span>`;
- last = m.index + m[0].length;
- }
- return out + esc(s.slice(last).replace(/`/g, ''));
- }
- function debounce(fn, ms) {
- let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
- }
- // ── 全局状态 ──────────────────────────────────────────────────────────
- let dims = null;
- let _portalDropdown = null;
- function clearPortalDropdown() {
- if (_portalDropdown) { _portalDropdown.remove(); _portalDropdown = null; }
- }
- document.addEventListener('mousedown', e => {
- if (_portalDropdown && !_portalDropdown.contains(e.target)) clearPortalDropdown();
- });
- const sel = {
- query: '',
- scopes: {substance: new Set(), form: new Set(), intent: new Set(), effect: new Set(), feeling: new Set()},
- scopeSemantic: {substance: '', form: '', intent: '', effect: '', feeling: ''},
- dimAttr: new Set(),
- dimCreate: new Set(),
- customValues: {}, // key → Set<string>
- customSemantics:{}, // key → string
- customRanges: {}, // key → {gte, lte}
- page: 1,
- };
- let _chips = [];
- // ── 初始化 ────────────────────────────────────────────────────────────
- async function init() {
- document.getElementById('search-btn').addEventListener('click', triggerSearch);
- document.getElementById('search-input').addEventListener('keydown', e => {
- if (e.key === 'Enter') triggerSearch();
- });
- try {
- const r = await fetch(API + '/dimensions');
- if (r.ok) {
- dims = await r.json();
- renderSidebar();
- }
- } catch(e) {
- console.warn('Failed to load dimensions', e);
- document.getElementById('sidebar-card').innerHTML =
- '<div class="sidebar-loading" style="color:var(--text-muted);font-size:12px;padding:16px 13px">筛选项加载失败</div>';
- }
- doSearch();
- }
- function triggerSearch() {
- sel.query = document.getElementById('search-input').value.trim();
- sel.page = 1;
- doSearch();
- }
- // ── 侧栏渲染 ──────────────────────────────────────────────────────────
- function renderSidebar() {
- const card = document.getElementById('sidebar-card');
- let html = '';
- // 作用域
- html += `<div class="filter-section">
- <div class="section-hd open" onclick="toggleSection(this)">作用域 <span class="chevron">▼</span></div>
- <div class="section-bd open" id="scope-bd">`;
- for (const sg of (dims?.scope_groups || [])) {
- html += renderScopeGroup(sg);
- }
- html += `</div></div>`;
- // 属性维度(从 facets 动态注入)
- html += `<div class="filter-section" id="dim-attr-section" style="display:none">
- <div class="section-hd open" onclick="toggleSection(this)">属性维度 <span class="chevron">▼</span></div>
- <div class="section-bd open" id="dim-attr-bd"></div>
- </div>`;
- // 创作维度(从 facets 动态注入)
- html += `<div class="filter-section" id="dim-create-section" style="display:none">
- <div class="section-hd open" onclick="toggleSection(this)">创作维度 <span class="chevron">▼</span></div>
- <div class="section-bd open" id="dim-create-bd"></div>
- </div>`;
- // 自定义字段(section 默认隐藏,有 facets 数据时才显示)
- if (dims?.custom_fields?.length) {
- html += `<div class="filter-section" id="custom-section" style="display:none">
- <div class="section-hd open" onclick="toggleSection(this)">自定义索引 <span class="chevron">▼</span></div>
- <div class="section-bd open" id="custom-bd">`;
- for (const cf of dims.custom_fields) {
- html += renderCustomField(cf);
- }
- html += `</div></div>`;
- }
- card.innerHTML = html;
- bindSidebarEvents();
- }
- function renderScopeGroup(sg) {
- const st = sg.scope_type;
- const label = SCOPE_LABELS[st] || st;
- return `<div class="sub-group" id="sg-${st}">
- <div class="sub-label">${label}</div>
- <div class="sem-wrap">
- <input class="semantic-input" type="text" id="sem-${st}" placeholder="语义搜索${label}标签…">
- <span class="sem-spinner" id="sem-${st}-sp"></span>
- </div>
- <div class="suggest-list" id="sug-${st}"></div>
- </div>`;
- }
- function renderCustomField(cf) {
- const label = esc(cf.description || cf.key);
- let html = `<div class="sub-group" id="cf-${esc(cf.key)}"><div class="sub-label">${label}</div>`;
- if (cf.value_type === 'str') {
- if (cf.semantic_enabled) {
- html += `<div class="sem-wrap">
- <input class="semantic-input" type="text" id="csem-${esc(cf.key)}" placeholder="语义搜索${label}…">
- <span class="sem-spinner" id="csem-${esc(cf.key)}-sp"></span>
- </div>`;
- }
- html += `<div class="suggest-list" id="csug-${esc(cf.key)}"></div>`;
- } else if (cf.value_type === 'num') {
- html += `<div class="range-row">
- <input class="range-inp" type="number" id="cgte-${esc(cf.key)}" placeholder="最小">
- <span class="range-sep">—</span>
- <input class="range-inp" type="number" id="clte-${esc(cf.key)}" placeholder="最大">
- </div>`;
- } else if (cf.value_type === 'date') {
- html += `<div class="range-row">
- <input class="range-inp" type="date" id="cgte-${esc(cf.key)}">
- <span class="range-sep">—</span>
- <input class="range-inp" type="date" id="clte-${esc(cf.key)}">
- </div>`;
- }
- html += `</div>`;
- return html;
- }
- function bindSidebarEvents() {
- // 作用域复选框
- document.querySelectorAll('input[data-scope]').forEach(cb => {
- cb.addEventListener('change', () => {
- const st = cb.dataset.scope, val = cb.dataset.val;
- if (cb.checked) sel.scopes[st].add(val); else sel.scopes[st].delete(val);
- sel.page = 1; doSearch();
- });
- });
- // 作用域语义输入
- const debouncedSuggestScope = debounce((st, q) => {
- if (q) suggestScope(st, q);
- else clearPortalDropdown();
- }, 300);
- for (const sg of (dims?.scope_groups || [])) {
- const st = sg.scope_type;
- const inp = document.getElementById('sem-' + st);
- if (!inp) continue;
- inp.addEventListener('input', () => {
- sel.scopeSemantic[st] = inp.value.trim();
- debouncedSuggestScope(st, inp.value.trim());
- });
- inp.addEventListener('keydown', e => { if (e.key === 'Enter') { sel.page = 1; doSearch(); } });
- inp.addEventListener('blur', clearPortalDropdown);
- }
- // 自定义字段复选框
- document.querySelectorAll('input[data-ckey]').forEach(cb => {
- cb.addEventListener('change', () => {
- const key = cb.dataset.ckey, val = cb.dataset.val;
- if (!sel.customValues[key]) sel.customValues[key] = new Set();
- if (cb.checked) sel.customValues[key].add(val); else sel.customValues[key].delete(val);
- sel.page = 1; doSearch();
- });
- });
- // 自定义语义输入
- const debouncedSuggestCustom = debounce((key, q) => {
- if (q) suggestCustom(key, q);
- else clearPortalDropdown();
- }, 300);
- for (const cf of (dims?.custom_fields || [])) {
- if (cf.value_type !== 'str' || !cf.semantic_enabled) continue;
- const inp = document.getElementById('csem-' + cf.key);
- if (!inp) continue;
- inp.addEventListener('input', () => {
- sel.customSemantics[cf.key] = inp.value.trim();
- debouncedSuggestCustom(cf.key, inp.value.trim());
- });
- inp.addEventListener('keydown', e => { if (e.key === 'Enter') { sel.page = 1; doSearch(); } });
- inp.addEventListener('blur', clearPortalDropdown);
- }
- // 自定义范围输入
- for (const cf of (dims?.custom_fields || [])) {
- if (cf.value_type !== 'num' && cf.value_type !== 'date') continue;
- const gteEl = document.getElementById('cgte-' + cf.key);
- const lteEl = document.getElementById('clte-' + cf.key);
- const onChange = debounce(() => {
- if (!sel.customRanges[cf.key]) sel.customRanges[cf.key] = {};
- if (cf.value_type === 'num') {
- sel.customRanges[cf.key].gte = gteEl?.value !== '' ? parseFloat(gteEl.value) : null;
- sel.customRanges[cf.key].lte = lteEl?.value !== '' ? parseFloat(lteEl.value) : null;
- } else {
- sel.customRanges[cf.key].gte = gteEl?.value ? new Date(gteEl.value).getTime() : null;
- sel.customRanges[cf.key].lte = lteEl?.value ? new Date(lteEl.value + 'T23:59:59').getTime() : null;
- }
- sel.page = 1; doSearch();
- }, 500);
- gteEl?.addEventListener('input', onChange);
- lteEl?.addEventListener('input', onChange);
- }
- }
- // ── 建议 API ──────────────────────────────────────────────────────────
- async function suggestScope(scopeType, q) {
- const inputEl = document.getElementById('sem-' + scopeType);
- const sp = document.getElementById('sem-' + scopeType + '-sp');
- if (!inputEl) return;
- if (sp) sp.classList.add('show');
- try {
- const r = await fetch(`${API}/scope-tags/suggest?scope_type=${encodeURIComponent(scopeType)}&q=${encodeURIComponent(q)}`);
- if (!r.ok) return;
- const data = await r.json();
- renderSuggestDropdown(inputEl, data.tags || [], val => {
- sel.scopes[scopeType].add(val);
- inputEl.value = '';
- sel.scopeSemantic[scopeType] = '';
- addInlineScopeTag(scopeType, val);
- sel.page = 1; doSearch();
- });
- } catch(e) {
- } finally {
- if (sp) sp.classList.remove('show');
- }
- }
- async function suggestCustom(key, q) {
- const inputEl = document.getElementById('csem-' + key);
- const sp = document.getElementById('csem-' + key + '-sp');
- if (!inputEl) return;
- if (sp) sp.classList.add('show');
- try {
- const r = await fetch(`${API}/custom-values/suggest?key=${encodeURIComponent(key)}&q=${encodeURIComponent(q)}`);
- if (!r.ok) return;
- const data = await r.json();
- renderSuggestDropdown(inputEl, data.values || [], val => {
- if (!sel.customValues[key]) sel.customValues[key] = new Set();
- sel.customValues[key].add(val);
- inputEl.value = '';
- sel.customSemantics[key] = '';
- addInlineCustomValue(key, val);
- sel.page = 1; doSearch();
- });
- } catch(e) {
- } finally {
- if (sp) sp.classList.remove('show');
- }
- }
- function renderSuggestDropdown(inputEl, items, onSelect) {
- clearPortalDropdown();
- if (!items.length) return;
- const rect = inputEl.getBoundingClientRect();
- const div = document.createElement('div');
- div.className = 'suggest-dropdown';
- div.style.cssText = `position:fixed;z-index:9999;top:${rect.bottom + 2}px;left:${rect.left}px;width:${rect.width}px;`;
- items.forEach(item => {
- const el = document.createElement('div');
- el.className = 'suggest-item';
- el.textContent = item;
- el.addEventListener('mousedown', e => e.preventDefault());
- el.addEventListener('click', () => { onSelect(item); clearPortalDropdown(); });
- div.appendChild(el);
- });
- document.body.appendChild(div);
- _portalDropdown = div;
- }
- function addInlineScopeTag(scopeType, val) {
- const container = document.getElementById('sg-' + scopeType);
- if (!container) return;
- const existing = container.querySelector(`input[data-val="${CSS.escape(val)}"]`);
- if (existing) { existing.checked = true; return; }
- const label = document.createElement('label');
- label.className = 'opt';
- label.innerHTML = `<input type="checkbox" checked data-scope="${esc(scopeType)}" data-val="${esc(val)}"><span class="opt-text">${esc(val)}</span>`;
- label.querySelector('input').addEventListener('change', function() {
- if (this.checked) sel.scopes[scopeType].add(val); else sel.scopes[scopeType].delete(val);
- sel.page = 1; doSearch();
- });
- const sugEl = container.querySelector('.suggest-list');
- if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
- }
- function addInlineCustomValue(key, val) {
- const container = document.getElementById('cf-' + key);
- if (!container) return;
- const existing = container.querySelector(`input[data-val="${CSS.escape(val)}"]`);
- if (existing) { existing.checked = true; return; }
- const label = document.createElement('label');
- label.className = 'opt';
- label.innerHTML = `<input type="checkbox" checked data-ckey="${esc(key)}" data-val="${esc(val)}"><span class="opt-text">${esc(val)}</span>`;
- label.querySelector('input').addEventListener('change', function() {
- if (!sel.customValues[key]) sel.customValues[key] = new Set();
- if (this.checked) sel.customValues[key].add(val); else sel.customValues[key].delete(val);
- sel.page = 1; doSearch();
- });
- const sugEl = container.querySelector('.suggest-list');
- if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
- }
- // ── 搜索请求 ──────────────────────────────────────────────────────────
- function buildRequest() {
- const req = {
- page: sel.page,
- page_size: 20,
- include_facets: true,
- highlight: !!sel.query,
- include_content: true,
- include_matched_conditions: true,
- };
- if (sel.query) req.query = sel.query;
- const scopes = [];
- for (const st of SCOPE_TYPES) {
- const values = [...sel.scopes[st]];
- const semantic = sel.scopeSemantic[st];
- if (values.length || semantic) {
- const sf = {scope_type: st};
- if (values.length) sf.values = values;
- if (semantic) sf.semantic_query = semantic;
- scopes.push(sf);
- }
- }
- if (scopes.length) req.scopes = scopes;
- if (sel.dimAttr.size) req.dim_attributes = [...sel.dimAttr];
- if (sel.dimCreate.size) req.dim_creations = [...sel.dimCreate];
- const customFilters = [];
- const allKeys = new Set([
- ...Object.keys(sel.customValues),
- ...Object.keys(sel.customSemantics),
- ...Object.keys(sel.customRanges),
- ]);
- for (const key of allKeys) {
- const cf = {key};
- const vals = [...(sel.customValues[key] || [])];
- const sem = sel.customSemantics[key];
- const rng = sel.customRanges[key];
- if (vals.length) cf.values = vals;
- if (sem) cf.semantic_query = sem;
- if (rng?.gte != null) cf.gte = rng.gte;
- if (rng?.lte != null) cf.lte = rng.lte;
- if (Object.keys(cf).length > 1) customFilters.push(cf);
- }
- if (customFilters.length) req.custom_filters = customFilters;
- return req;
- }
- async function doSearch() {
- renderChips();
- document.getElementById('results-list').innerHTML =
- '<div class="state-box"><div class="state-icon">⏳</div><p>搜索中…</p></div>';
- let resp;
- try {
- const r = await fetch(API + '/search', {
- method: 'POST',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify(buildRequest()),
- });
- if (!r.ok) {
- const msg = (await r.json().catch(() => ({}))).detail || r.statusText;
- throw new Error(msg);
- }
- resp = await r.json();
- } catch(e) {
- document.getElementById('results-count').textContent = '';
- document.getElementById('results-list').innerHTML =
- `<div class="state-box error"><div class="state-icon">⚠️</div><p>搜索出错:${esc(e.message)}</p></div>`;
- return;
- }
- renderResults(resp);
- if (resp.facets) updateSidebarFacets(resp.facets);
- }
- // ── 渲染结果 ──────────────────────────────────────────────────────────
- function renderResults(data) {
- document.getElementById('results-count').innerHTML =
- `找到 <strong>${data.total.toLocaleString()}</strong> 条相关知识`;
- document.getElementById('sort-label').textContent =
- sel.query ? '按相关度排序' : '按最新更新排序';
- const list = document.getElementById('results-list');
- if (!data.hits.length) {
- list.innerHTML = '<div class="state-box"><div class="state-icon">📭</div><p>未找到匹配的知识条目,请调整搜索条件</p></div>';
- } else {
- list.innerHTML = data.hits.map(renderCard).join('');
- requestAnimationFrame(markStepClamps);
- }
- renderPagination(data.total, data.page, data.page_size);
- }
- // 点击「值」单元格展开/收起(仅在内容溢出、已标记 clampable 时生效)
- function toggleClampVal(el) {
- if (!el.classList.contains('clampable') && !el.classList.contains('open')) return;
- el.classList.toggle('open');
- }
- // 渲染后标记真正溢出 4 行的「值」单元格,才显示蒙版 + 可点击
- // (先套上限高再测量:溢出则保留 clampable,放得下则移除)
- function markStepClamps() {
- document.querySelectorAll('.steps .clamp-val').forEach((el) => {
- if (el.classList.contains('open')) return;
- el.classList.add('clampable');
- if (el.scrollHeight <= el.clientHeight + 2) el.classList.remove('clampable');
- });
- }
- // ── 工序步骤表(自 index.html 工序解构复用:content 即一条 procedure)──────
- function fmtSF(v) {
- return v == null ? '' : Array.isArray(v) ? v.join('、') : v;
- }
- function ioCell(x, kind) {
- const cls = kind === 'in' ? 'c-in' : 'c-out';
- if (!x) return `<td class="${cls}"></td><td class="${cls}"></td><td class="${cls}"></td>`;
- const inf = x.inferred ? ` inf" title="推断理由:${esc(x.inferred_reason || '模型推断补全')}` : '';
- const badge = x.inferred ? '<span class="ib">推</span>' : '';
- return `<td class="${cls}"><span class="pill ${kind === 'in' ? 'amber' : 'blue'}">${esc(x.type || '')}</span></td>
- <td class="${cls}${inf}">${badge}<div class="clamp-val" onclick="toggleClampVal(this)"><span class="vtxt">${esc(x.value || '')}</span></div></td>
- <td class="${cls}"><span class="anchor">${esc(x.anchor || '')}</span></td>`;
- }
- function renderSteps(steps) {
- if (!steps || !steps.length) return '<div class="steps-empty">无步骤</div>';
- let rows = '';
- for (const s of steps) {
- const ins = s.inputs && s.inputs.length ? s.inputs : [null];
- const outs = s.outputs && s.outputs.length ? s.outputs : [null];
- const n = Math.max(ins.length, outs.length);
- for (let i = 0; i < n; i++) {
- rows += '<tr>';
- if (i === 0) {
- rows += `<td rowspan="${n}" class="sid">${esc(s.id || '')}</td>
- <td rowspan="${n}"><div class="intent-text">${renderIntent(s.intent || s.directive || '')}</div></td>
- <td rowspan="${n}">${s.effect ? `<span class="pill navy">${esc(s.effect)}</span>` : ''}</td>
- <td rowspan="${n}">${esc(fmtSF(s.substance))}</td>
- <td rowspan="${n}">${esc(fmtSF(s.form))}</td>`;
- }
- rows += ioCell(ins[i], 'in');
- if (i === 0) {
- rows += `<td rowspan="${n}">${s.via ? `<span class="pill teal">${esc(s.via)}</span>` : ''}</td>
- <td rowspan="${n}" class="vtxt">${esc(s.action || '')}</td>`;
- }
- rows += ioCell(outs[i], 'out');
- rows += '</tr>';
- }
- }
- return `<div class="steps-wrap"><table class="steps">
- <colgroup>
- <col style="width:44px"><col style="width:200px"><col style="width:92px">
- <col style="width:112px"><col style="width:100px">
- <col style="width:112px"><col style="width:330px"><col style="width:92px">
- <col style="width:118px"><col style="width:130px">
- <col style="width:112px"><col style="width:360px"><col style="width:110px">
- </colgroup>
- <thead>
- <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>
- <tr>
- <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>
- <th class="h-in2">类型</th><th class="h-in2">值</th><th class="h-in2">来源</th>
- <th class="h-im2">外部工具</th><th class="h-im2">动作</th>
- <th class="h-out2">类型</th><th class="h-out2">值</th><th class="h-out2">去处</th>
- </tr>
- </thead><tbody>${rows}</tbody></table></div>`;
- }
- function renderCard(hit) {
- let titleHtml = esc(hit.title || '(无标题)');
- if (hit.highlight?.title?.[0]) titleHtml = hit.highlight.title[0];
- const score = hit.score != null
- ? `<span class="score-badge">相关度 ${hit.score.toFixed(2)}</span>` : '';
- let meta = '';
- if (hit.source_type) meta += `<span class="source-badge">${esc(hit.source_type)}</span>`;
- if (hit.author) meta += `<span>${esc(hit.author)}</span>`;
- if (hit.author && hit.source_title) meta += '<span class="meta-dot">·</span>';
- if (hit.source_title) meta += `<span>${esc(hit.source_title)}</span>`;
- // 从 matched_conditions 构建命中查找表
- const mc = hit.matched_conditions;
- const hitScopes = {}; // scope_type → Set<string>
- const hitCustom = {}; // key → Set<string>(str 类型)
- const hitRange = {}; // key → bool(num/date 类型)
- const hitDimAttr = new Set(mc?.dim_attributes || []);
- const hitDimCreate = new Set(mc?.dim_creations || []);
- for (const ms of (mc?.scopes || [])) hitScopes[ms.scope_type] = new Set(ms.matched_values);
- for (const cm of (mc?.custom || [])) {
- if (cm.matched_values != null) hitCustom[cm.key] = new Set(cm.matched_values);
- if (cm.in_range != null) hitRange[cm.key] = cm.in_range;
- }
- let tags = '';
- const scopeDefs = [
- ['s','scope_substance','substance','实质'],
- ['fo','scope_form','form','形式'],
- ['i','scope_intent','intent','意图'],
- ['e','scope_effect','effect','作用'],
- ['fe','scope_feeling','feeling','感受'],
- ];
- for (const [cls, field, st, label] of scopeDefs) {
- const hitSet = hitScopes[st] || new Set();
- for (const t of (hit[field] || [])) {
- const h = hitSet.has(t) ? ' hit' : '';
- tags += `<span class="tag ${cls}${h}" title="${h ? '命中筛选条件' : ''}"><span class="tag-pre">${label}</span>${esc(t)}</span>`;
- }
- }
- for (const d of (hit.dim_attributes || [])) {
- const h = hitDimAttr.has(d) ? ' hit' : '';
- tags += `<span class="tag d${h}" title="${h ? '命中筛选条件' : ''}">${esc(d)}</span>`;
- }
- for (const d of (hit.dim_creations || [])) {
- const h = hitDimCreate.has(d) ? ' hit' : '';
- tags += `<span class="tag d${h}" title="${h ? '命中筛选条件' : ''}">${esc(d)}</span>`;
- }
- let extHtml = '';
- for (const item of (hit.custom_ext || [])) {
- let val = item.value;
- if (item.type === 'date' && typeof val === 'number') {
- val = new Date(val).toLocaleDateString('zh-CN');
- }
- const isHit = item.type === 'str'
- ? hitCustom[item.key]?.has(String(item.value ?? ''))
- : hitRange[item.key] === true;
- const h = isHit ? ' hit' : '';
- 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>`;
- }
- const updatedAt = hit.updated_at
- ? new Date(hit.updated_at).toLocaleDateString('zh-CN') : '';
- // 正文展开区:content 是一条工序解构(procedure),复用工序步骤表渲染;
- // 解析失败或无步骤时回退为格式化 JSON。
- let contentSection = '';
- if (hit.content) {
- let inner = '';
- try {
- const proc = JSON.parse(hit.content);
- if (proc && Array.isArray(proc.steps) && proc.steps.length) inner = renderSteps(proc.steps);
- } catch {}
- if (!inner) {
- let display = hit.content;
- try { display = JSON.stringify(JSON.parse(hit.content), null, 2); } catch {}
- inner = `<pre class="content-pre">${esc(display)}</pre>`;
- }
- // 去掉「正文」标题,默认直接展开
- contentSection = `<div class="content-body open">${inner}</div>`;
- }
- return `<div class="card">
- <div class="card-top"><div class="card-title">${titleHtml}</div>${score}</div>
- ${meta ? `<div class="card-meta">${meta}</div>` : ''}
- ${tags ? `<div class="tag-row">${tags}</div>` : ''}
- <div class="card-foot">
- <div class="ext-row">${extHtml}</div>
- <span class="card-time">${updatedAt ? updatedAt + ' 更新' : ''}</span>
- </div>
- ${contentSection}
- </div>`;
- }
- // ── 分页 ──────────────────────────────────────────────────────────────
- function renderPagination(total, page, pageSize) {
- const totalPages = Math.ceil(total / pageSize);
- const pg = document.getElementById('pagination');
- if (totalPages <= 1) { pg.innerHTML = ''; return; }
- let html = `<button class="pg-btn" onclick="goPage(${page-1})" ${page<=1?'disabled':''}>‹</button>`;
- const pages = buildPageList(page, totalPages);
- let prev = null;
- for (const p of pages) {
- if (prev !== null && p - prev > 1) html += '<span class="pg-dots">…</span>';
- html += `<button class="pg-btn ${p===page?'active':''}" onclick="goPage(${p})">${p}</button>`;
- prev = p;
- }
- html += `<button class="pg-btn" onclick="goPage(${page+1})" ${page>=totalPages?'disabled':''}>›</button>`;
- pg.innerHTML = html;
- }
- function buildPageList(cur, total) {
- const s = new Set([1, total]);
- for (let p = Math.max(1, cur-1); p <= Math.min(total, cur+1); p++) s.add(p);
- return [...s].sort((a,b) => a-b);
- }
- function goPage(p) {
- sel.page = p;
- doSearch();
- window.scrollTo({top: 0, behavior: 'smooth'});
- }
- // ── Facets → 侧栏 ─────────────────────────────────────────────────────
- function updateSidebarFacets(facets) {
- // 属性维度 / 创作维度:整体替换
- updateFacetSection(
- 'dim-attr-section', 'dim-attr-bd',
- facets.dim_attributes || [],
- sel.dimAttr, 'data-dim-attr',
- cb => { const v = cb.dataset.dimAttr; cb.checked ? sel.dimAttr.add(v) : sel.dimAttr.delete(v); sel.page=1; doSearch(); }
- );
- updateFacetSection(
- 'dim-create-section', 'dim-create-bd',
- facets.dim_creations || [],
- sel.dimCreate, 'data-dim-create',
- cb => { const v = cb.dataset.dimCreate; cb.checked ? sel.dimCreate.add(v) : sel.dimCreate.delete(v); sel.page=1; doSearch(); }
- );
- // 作用域标签:完全由 facets 驱动,移除消失的项、补充新出现的项
- const scopeMap = Object.fromEntries((facets.scopes || []).map(s => [s.scope_type, s.buckets || []]));
- for (const st of SCOPE_TYPES) {
- const buckets = scopeMap[st] || [];
- const bucketMap = Object.fromEntries(buckets.map(b => [b.value, b.count]));
- const sgContainer = document.getElementById('sg-' + st);
- if (!sgContainer) continue;
- // 移除不在当前 facets 且未被选中的项
- sgContainer.querySelectorAll('label[data-facet-src]').forEach(label => {
- const val = label.querySelector('input')?.dataset.val;
- if (val && bucketMap[val] == null && !(sel.scopes[st]?.has(val))) label.remove();
- });
- // 更新已存在项的计数,补充 facets 中新出现的项
- const sugEl = sgContainer.querySelector('.suggest-list');
- for (const b of buckets) {
- const existing = sgContainer.querySelector(`input[data-val="${CSS.escape(b.value)}"]`);
- if (existing) {
- syncCountBadge(existing.closest('.opt'), b.count);
- continue;
- }
- const label = document.createElement('label');
- label.className = 'opt';
- label.dataset.facetSrc = 'true';
- const chk = sel.scopes[st]?.has(b.value) ? 'checked' : '';
- 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>`;
- label.querySelector('input').addEventListener('change', function() {
- if (this.checked) sel.scopes[st].add(b.value); else sel.scopes[st].delete(b.value);
- sel.page = 1; doSearch();
- });
- if (sugEl) sgContainer.insertBefore(label, sugEl); else sgContainer.appendChild(label);
- }
- }
- // 自定义字段
- updateCustomExtFacets(facets.custom_ext || []);
- }
- function updateFacetSection(sectionId, bdId, buckets, selSet, dataAttr, onChange) {
- const section = document.getElementById(sectionId);
- const bd = document.getElementById(bdId);
- if (!section || !bd) return;
- if (!buckets.length) { section.style.display = 'none'; return; }
- section.style.display = '';
- let html = '';
- for (const b of buckets) {
- const checked = selSet.has(b.value) ? 'checked' : '';
- html += `<label class="opt">
- <input type="checkbox" ${checked} ${dataAttr}="${esc(b.value)}">
- <span class="opt-text">${esc(b.value)}</span>
- <span class="opt-count">${b.count}</span>
- </label>`;
- }
- bd.innerHTML = html;
- bd.querySelectorAll(`input[${dataAttr}]`).forEach(cb => {
- cb.addEventListener('change', () => onChange(cb));
- });
- }
- // 自定义字段 facets 处理:key 本身的显隐也由 facets 驱动
- function updateCustomExtFacets(customExtFacets) {
- if (!dims?.custom_fields?.length) return;
- const facetMap = Object.fromEntries(customExtFacets.map(f => [f.key, f]));
- let anyVisible = false;
- for (const cf of dims.custom_fields) {
- const container = document.getElementById('cf-' + cf.key);
- if (!container) continue;
- const facet = facetMap[cf.key];
- // 有活跃筛选时即使 facets 无数据也保留显示
- const hasActiveFilter =
- (cf.value_type === 'str' &&
- (sel.customValues[cf.key]?.size > 0 || !!sel.customSemantics[cf.key])) ||
- ((cf.value_type === 'num' || cf.value_type === 'date') &&
- (sel.customRanges[cf.key]?.gte != null || sel.customRanges[cf.key]?.lte != null));
- const hasFacetData =
- (cf.value_type === 'str' && facet?.buckets?.length > 0) ||
- ((cf.value_type === 'num' || cf.value_type === 'date') && facet?.stats?.count > 0);
- if (!hasFacetData && !hasActiveFilter) {
- container.style.display = 'none';
- continue;
- }
- container.style.display = '';
- anyVisible = true;
- if (cf.value_type === 'str') {
- updateCustomStrCounts(cf.key, container, facet?.buckets || []);
- } else if (cf.value_type === 'num' && facet?.stats?.count > 0) {
- const s = facet.stats;
- const gteEl = document.getElementById('cgte-' + cf.key);
- const lteEl = document.getElementById('clte-' + cf.key);
- if (gteEl && s.min != null) gteEl.placeholder = `最小 (${parseFloat(s.min.toFixed(2))})`;
- if (lteEl && s.max != null) lteEl.placeholder = `最大 (${parseFloat(s.max.toFixed(2))})`;
- }
- // date 类型:range input 不显示 placeholder,仅控制可见性
- }
- // 整个"自定义索引"区块:有可见 key 才展示
- const section = document.getElementById('custom-section');
- if (section) section.style.display = anyVisible ? '' : 'none';
- }
- function updateCustomStrCounts(key, container, buckets) {
- const bucketMap = Object.fromEntries(buckets.map(b => [b.value, b.count]));
- // 移除不在当前 facets 且未被选中的项
- container.querySelectorAll('label[data-facet-src]').forEach(label => {
- const val = label.querySelector('input')?.dataset.val;
- if (val && bucketMap[val] == null && !(sel.customValues[key]?.has(val))) label.remove();
- });
- // 更新已存在项的计数,补充 facets 中新出现的项
- const sugEl = container.querySelector('.suggest-list');
- for (const b of buckets) {
- const existing = container.querySelector(`input[data-val="${CSS.escape(b.value)}"]`);
- if (existing) {
- syncCountBadge(existing.closest('.opt'), b.count);
- continue;
- }
- const label = document.createElement('label');
- label.className = 'opt';
- label.dataset.facetSrc = 'true';
- const chk = sel.customValues[key]?.has(b.value) ? 'checked' : '';
- 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>`;
- label.querySelector('input').addEventListener('change', function() {
- if (!sel.customValues[key]) sel.customValues[key] = new Set();
- if (this.checked) sel.customValues[key].add(b.value); else sel.customValues[key].delete(b.value);
- sel.page = 1; doSearch();
- });
- if (sugEl) container.insertBefore(label, sugEl); else container.appendChild(label);
- }
- }
- // 在 .opt 元素上同步 .opt-count 徽章;count 为 undefined 时清除徽章
- function syncCountBadge(optEl, count) {
- if (!optEl) return;
- let el = optEl.querySelector('.opt-count');
- if (count != null) {
- if (!el) { el = document.createElement('span'); el.className = 'opt-count'; optEl.appendChild(el); }
- el.textContent = count;
- } else if (el) {
- el.remove();
- }
- }
- // ── Chips 栏 ──────────────────────────────────────────────────────────
- function renderChips() {
- _chips = [];
- for (const st of SCOPE_TYPES) {
- for (const val of sel.scopes[st]) {
- _chips.push({
- label: `${SCOPE_LABELS[st]}:${val}`,
- remove: () => { sel.scopes[st].delete(val); uncheckInput(`input[data-scope="${CSS.escape(st)}"][data-val="${CSS.escape(val)}"]`); sel.page=1; doSearch(); }
- });
- }
- if (sel.scopeSemantic[st]) {
- _chips.push({
- label: `${SCOPE_LABELS[st]} 语义:${sel.scopeSemantic[st]}`,
- remove: () => {
- sel.scopeSemantic[st] = '';
- const inp = document.getElementById('sem-' + st);
- if (inp) inp.value = '';
- sel.page = 1; doSearch();
- }
- });
- }
- }
- 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();}});
- 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();}});
- for (const [key, vals] of Object.entries(sel.customValues)) {
- for (const val of vals) {
- _chips.push({
- label: `${key}:${val}`,
- remove: () => { sel.customValues[key].delete(val); uncheckInput(`input[data-ckey="${CSS.escape(key)}"][data-val="${CSS.escape(val)}"]`); sel.page=1; doSearch(); }
- });
- }
- }
- for (const [key, sem] of Object.entries(sel.customSemantics)) {
- if (!sem) continue;
- _chips.push({
- label: `${key} 语义:${sem}`,
- remove: () => { sel.customSemantics[key]=''; const i=document.getElementById('csem-'+key); if(i)i.value=''; sel.page=1; doSearch(); }
- });
- }
- for (const [key, rng] of Object.entries(sel.customRanges)) {
- if (rng.gte == null && rng.lte == null) continue;
- const parts = [];
- if (rng.gte != null) parts.push(`≥ ${rng.gte}`);
- if (rng.lte != null) parts.push(`≤ ${rng.lte}`);
- _chips.push({
- label: `${key}:${parts.join(' ')}`,
- remove: () => {
- sel.customRanges[key] = {};
- const g = document.getElementById('cgte-'+key), l = document.getElementById('clte-'+key);
- if(g) g.value=''; if(l) l.value='';
- sel.page=1; doSearch();
- }
- });
- }
- const bar = document.getElementById('chips-bar');
- if (!_chips.length) { bar.style.display = 'none'; return; }
- bar.style.display = '';
- let html = '<span class="chips-label">已选筛选:</span>';
- _chips.forEach((c, i) => {
- html += `<span class="chip" data-chip="${i}">${esc(c.label)}<span class="chip-x">×</span></span>`;
- });
- html += '<span class="chip-clear" id="chip-clear-all">清空全部</span>';
- bar.innerHTML = html;
- bar.querySelectorAll('[data-chip]').forEach(el => {
- el.addEventListener('click', () => { _chips[+el.dataset.chip].remove(); });
- });
- document.getElementById('chip-clear-all')?.addEventListener('click', clearAllFilters);
- }
- function uncheckInput(selector) {
- const el = document.querySelector(selector);
- if (el) el.checked = false;
- }
- function clearAllFilters() {
- SCOPE_TYPES.forEach(st => { sel.scopes[st].clear(); sel.scopeSemantic[st] = ''; const i=document.getElementById('sem-'+st); if(i)i.value=''; });
- sel.dimAttr.clear();
- sel.dimCreate.clear();
- sel.customValues = {};
- sel.customSemantics= {};
- sel.customRanges = {};
- document.querySelectorAll('input[data-scope],input[data-ckey],input[data-dim-attr],input[data-dim-create]').forEach(cb => cb.checked = false);
- document.querySelectorAll('.range-inp').forEach(inp => inp.value = '');
- sel.page = 1;
- doSearch();
- }
- // ── 折叠展开 ──────────────────────────────────────────────────────────
- function toggleSection(hd) {
- hd.classList.toggle('open');
- hd.nextElementSibling.classList.toggle('open');
- }
- document.addEventListener('DOMContentLoaded', init);
- </script>
- </body>
- </html>
|