Przeglądaj źródła

feat(library): streamline product details and add review and price sorting

pengzhikang 6 godzin temu
rodzic
commit
0ba3c0a07a

+ 13 - 1
docs/industry-library-20261105.md

@@ -17,7 +17,7 @@
 - 资料结果前置:压缩全库概览,平台/品牌/品类与查看口径优先显示;店铺、日期等收在“更多筛选”,当前值始终展示为可单独移除的标签。移除一个条件会保留其他条件并回到第一页,不自动放宽请求。
 - 卡片/紧凑列表切换;覆盖统计按需展开,点击分布项仍使用原有精确过滤。视图切换与统计展开不额外发起查询。
 - 商品列表展示已入库评价、规格和价格数量;点入商品,在“商品概况/包装规格/价格记录/消费者评价/榜单线索”间切换。价格直接展示人民币观察价及口径,规格仅展示已有结构化字段,不从标题推算克重。政策、社媒保留各自的阅读形式。
-- 评价在当前商品内按日期及查看范围筛选,并完整游标翻页,长评价与追加评价可展开。关联查询始终固定精确商品ID,不按标题或同名商品归并;未知日期不会用采集日补齐。
+- 评价在当前商品内按查看范围筛选,并完整游标翻页,长评价与追加评价可展开。评价不提供日期区间筛选,正文上仍展示实际评价日期。关联查询始终固定精确商品ID,不按标题或同名商品归并;未知日期不会用采集日补齐。
 - 商品栏目、栏目筛选和分页均可保存为深链接,刷新仍停留原商品。返回商品列表保留原列表筛选和页游标,移除栏目内部参数。资料版本更新时清理旧游标,同时保持当前商品和栏目。旧的按商品查看评价/价格等链接会转入商品内对应栏目。
 - 阅读页优先展示原文、文件全文、摘录与应用提示;侧栏支持章节定位和资料信息。平台、店铺、时间等字段统一放在“资料信息”;“资料来源”只保留原始页面与资料阅读链接,不重复字段或关联商品标题。引用复制为标题、业务信息及阅读链接。
 - 游标分页增加已访问页的“上一页”,刷新后的深链接仍可回到第一页;详情返回保留原筛选与游标。无结果时提供一键清空,连接失败继续明确区分。
@@ -32,8 +32,20 @@
 - 平台、品牌、品类和查看口径集中在紧凑的一行;更多筛选收纳店铺和日期,已选条件仍可单独移除。研究成果改用阶段与成员筛选,成员按现有稳定成员ID匹配。
 - 商品卡片按可用宽度自动排布,常见桌面三至四列、平板两列、手机单列。缩小卡片、筛选和详情间距,保留正文可读性和分页;不变更数据库或生产配置。
 
+## 商品评论与价格排序(2026-11-06)
+
+- 移除“更多筛选”折叠区。主栏按平台、品牌、品类筛选;评论数量与价格仅用于排序,不提供区间、不减少当前条件命中的商品。商品目录不再显示对商品本身无过滤作用的“查看口径”,评价详情仍可切换推荐分析与全部评价。
+- 评论数量支持从多到少、从少到多,依据精确商品ID下已收录的全部评价(含默认和待确认),不使用平台显示评论总量。无记录的商品标注“暂未收录评价”,不声称平台没有评论。
+- 价格支持从低到高、从高到低,统一按卡片价格区间的最低历史观察价排序,无有效价格的商品始终排在最后;不当作当前售价。两项排序互斥,选择后一项即替换前一项,可恢复默认排序。
+- 后端先对完整筛选结果排序再分页,同值按稳定记录顺序排列;游标绑定排序及数据版本。切换排序回第一页,刷新与商品详情往返保留排序。Agent检索合同支持相同 `sort` 参数:`reviews_asc`、`reviews_desc`、`price_asc`、`price_desc`。
+- 旧区间链接自动移除 `reviewCount`、`price` 与旧游标,保留平台、品牌等明确条件。商品内评价和价格仍按精确商品ID关联,列表排序不传入商品内部的评价查询。
+- 商品评价与价格记录去掉日期起止筛选,旧链接自动清理日期条件及对应游标,保留商品、查看范围和列表排序;每条记录的评价日期或价格观察时间继续展示。
+- 本次核查:4,639个商品中43个有评价,共2,360条;京东28个、天猫15个。多数商品只采到了商品信息;采集覆盖记录还包含服务受阻、尝试失败或部分获取状态。默认分析规则过滤后有771条评价,不能把其余记录当作不存在。
+
 ## 阅读界面信息边界
 
+商品详情采用紧凑阅读布局:返回与刷新同排,标题和引用操作合并;概况内商品信息与关联资料并列,手机自动纵排。评论合并为连续分隔列表,正文优先,日期、评分和购买规格作为辅助信息;长评价与追加评价仍可展开全文。栏目、条数与查看范围合并为紧凑工具栏,价格和规格按可用宽度并排。商品来源保留为底部横栏,政策、条款等长文阅读页继续采用原有正文布局。1440、1024、390和320宽度已验证,无横向溢出。
+
 - 页面按资料类型选择业务字段,不直接遍历数据库全部列:商品展示品牌、品类、店铺;评价展示原文、购买规格、评分与日期;价格展示人民币元金额、价格口径和观察时间;政策展示状态与生效日期。未知字段不会自动进入页面。
 - 分类依据、规则代码、问题代码、技术编号、JSONL行号、校验指纹、处理版本及压缩包名称留给后台核查,不在列表、详情、更新记录或复制引用中展示。校验指纹用于判断文件内容是否改变,属于运维核验信息。
 - 保留影响研究判断的业务说明:政策状态截至日期、摘要与法规原文的区别、价格口径、资料覆盖与时间未知。移除内部字段不代表对资料完整性或真实性作额外承诺。

+ 66 - 21
scripts/verify-library-browser.py

@@ -74,7 +74,8 @@ try:
     assert action('return Array.from(v.renderRoot.querySelectorAll(".tree-link")).map(a=>new URL(a.href).searchParams.get("type"));')==['products','social','documents','reports']
     assert action('return Array.from(v.renderRoot.querySelectorAll(".stat")).map(a=>new URL(a.href).searchParams.get("type"));')==['products','social','documents','reports']
     reader_surface()
-    assert action('return v.renderRoot.getElementById("advanced-filters").hidden;')
+    assert not action('return !!v.renderRoot.querySelector(".advanced-toggle,#advanced-filters");')
+    assert action('return !!v.renderRoot.querySelector("select[aria-label=评论数量]") && !!v.renderRoot.querySelector("select[aria-label=价格]");')
     assert action('return v.renderRoot.getElementById("coverage-insights").hidden;')
     assert action('return v.renderRoot.querySelector("a[data-record-id]").getBoundingClientRect().top;')<350
     assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='none'
@@ -89,20 +90,52 @@ try:
     wait('!'+V+'.renderRoot.getElementById("coverage-insights").hidden')
     assert action('return v.renderRoot.querySelectorAll(".bar").length;')>0
     assert evaluate('performance.getEntriesByType("resource").length')==resources
-    action('v.renderRoot.querySelector(".insights-toggle").click();v.renderRoot.querySelector(".advanced-toggle").click();')
-    wait('!'+V+'.renderRoot.getElementById("advanced-filters").hidden')
-    action('const i=v.renderRoot.querySelector("input[aria-label=日期起]");i.value="2026-11-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
-    wait(V+'.routeQuery.from==="2026-11-01" && !'+V+'._loading')
-    action('v.renderRoot.querySelector(".advanced-toggle").click();')
-    wait(V+'.renderRoot.getElementById("advanced-filters").hidden')
-    assert action('return !!v.renderRoot.querySelector("button[aria-label^=移除日期起]");')
-    action('v.renderRoot.querySelector("button[aria-label^=移除日期起]").click();')
-    wait('!'+V+'.routeQuery.from && !'+V+'._loading')
+    action('v.renderRoot.querySelector(".insights-toggle").click();')
     def select(label,value):
         selector=json.dumps('select[aria-label="'+label+'"]',ensure_ascii=False)
         action('const s=v.renderRoot.querySelector('+selector+');s.value='+json.dumps(value)+';s.dispatchEvent(new Event("change",{bubbles:true}));')
-        wait(V+'._loading===true || '+V+'._data?.effectiveQuery.filters['+json.dumps({'平台':'platform','品牌':'brand','品类':'category'}.get(label,label))+']')
+        key={'平台':'platform','品牌':'brand','品类':'category','评论数量':'sort','价格':'sort'}.get(label,label)
+        wait('('+V+'.routeQuery['+json.dumps(key)+'] || "")==='+json.dumps(value)+' && !'+V+'._loading && '+V+'._data')
         ready()
+    select('评论数量','reviews_desc')
+    assert action('return v._data.totalMatched;')==4639
+    assert action('return v._data.records[0].relatedCounts.reviews;')==1000
+    assert action('return !v._data.effectiveQuery.filters.reviewCount && !v._data.effectiveQuery.filters.observedPriceMinor;')
+    first_counts=action('return v._data.records.map(r=>r.relatedCounts.reviews || 0);')
+    first_ids=action('return v._data.records.map(r=>r.record_id);')
+    action('Array.from(v.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("下一页")).click();')
+    wait(V+'.routeQuery.cursor && !'+V+'._loading')
+    second_counts=action('return v._data.records.map(r=>r.relatedCounts.reviews || 0);')
+    assert first_counts+second_counts==sorted(first_counts+second_counts,reverse=True)
+    assert not set(first_ids)&set(action('return v._data.records.map(r=>r.record_id);'))
+    select('价格','price_asc')
+    assert not action('return !!v.routeQuery.cursor;')
+    assert action('return v._data.totalMatched;')==4639
+    assert action('return v.renderRoot.querySelector("select[aria-label=评论数量]").value;')==''
+    amounts=action('return v._data.records.map(r=>r.priceRange.minMinor);')
+    assert amounts==sorted(amounts)
+    select('价格','price_desc')
+    amounts=action('return v._data.records.map(r=>r.priceRange.minMinor);')
+    assert amounts==sorted(amounts,reverse=True)
+    assert action('return Array.from(v.renderRoot.querySelectorAll(".product-price")).every(el=>el.textContent.includes("¥") && el.textContent.includes("历史观察价"));')
+    sorted_url=evaluate('location.href');call('Page.reload');ready()
+    assert evaluate('location.href')==sorted_url
+    assert action('return v.routeQuery.sort;')=='price_desc'
+    action('v.renderRoot.querySelector("a[data-record-id]").click();');ready();product_ready()
+    product_tab('reviews')
+    assert product_action('return Object.keys(p._data.effectiveQuery.filters).join(",");')=='productId'
+    assert product_action('return p._data.effectiveQuery.sort;') is None
+    action('v.renderRoot.querySelector(".breadcrumb a").click();');ready()
+    assert action('return v.routeQuery.sort;')=='price_desc'
+    select('评论数量','reviews_asc')
+    assert action('return v._data.totalMatched;')==4639
+    assert action('return v.renderRoot.querySelector("select[aria-label=价格]").value;')==''
+    assert action('return v._data.records.every(r=>!r.relatedCounts.reviews);')
+    action('v.renderRoot.querySelector("a[data-record-id]").click();');ready();product_ready();product_tab('reviews')
+    assert product_action('return p.renderRoot.querySelector(".empty").textContent.includes("不代表平台上没有评论");')
+    call('Page.navigate',dict(url=args.url+'?type=products&brand=ksf&reviewCount=has&price=10-20&cursor=retired-range-cursor'));ready()
+    assert action('return v.routeQuery.brand==="ksf" && !v.routeQuery.reviewCount && !v.routeQuery.price && !v.routeQuery.cursor && !v._error;')
+    call('Page.navigate',dict(url=args.url));ready()
     select('平台','jd')
     wait(V+'.routeQuery.platform==="jd" && !'+V+'._loading')
     select('品牌','ksf')
@@ -125,6 +158,7 @@ try:
     product_ready()
     assert product_action('return p.product.id;')==action('return v._detail.record.id;')
     assert product_action('return p.renderRoot.querySelectorAll(".product-resources button").length;')==4
+    assert action('return v.renderRoot.querySelector("#source").getBoundingClientRect().bottom;')<700
     product_tab('prices')
     assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
     assert action('return v.routeQuery.brand;')=='ksf'
@@ -157,6 +191,9 @@ try:
     assert product_action('return p._data.totalMatched;')==1000
     assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
     assert product_action('return p.renderRoot.querySelectorAll(".review").length;')==20
+    density=product_action('const rows=Array.from(p.renderRoot.querySelectorAll(".review"));return {firstTop:rows[0].getBoundingClientRect().top,firstHeight:rows[0].getBoundingClientRect().height,visible:rows.filter(r=>r.getBoundingClientRect().bottom<innerHeight).length};')
+    assert density['firstTop']<320 and density['firstHeight']<110 and density['visible']>=7,density
+    assert product_action('return p.renderRoot.querySelector(".review .full-review").textContent===p._data.records[0].content;')
     assert product_action('return !/SHA256|分类依据|rule_title_|source_attribute/.test(p.renderRoot.textContent);')
     shot('library-product-reviews')
     review_first=product_action('return p._data.records[0].record_id;')
@@ -172,14 +209,25 @@ try:
     wait('!'+P+'.query.relatedCursor && !'+P+'._loading && '+P+'._data')
     assert product_action('return p._data.records[0].record_id;')==review_first
     assert product_action('return !p.renderRoot.querySelector("input:not([type=date]),form");')
-    product_action('const i=p.renderRoot.querySelector("input[aria-label=商品资料日期起]");i.value="2099-01-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
-    wait(P+'.query.relatedFrom==="2099-01-01" && !'+P+'._loading && '+P+'._data?.totalMatched===0')
-    assert product_action('return p.product.id;')==product_id
-    product_action('Array.from(p.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("清空当前筛选")).click();')
-    wait('!'+P+'.query.relatedFrom && !'+P+'._loading && '+P+'._data')
+    assert product_action('return !p.renderRoot.querySelector("input[type=date],.date-filters");')
+    call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&brand=ksf&sort=reviews_desc&panel=reviews&relatedQuality=audit&relatedFrom=2099-01-01&relatedTo=2099-12-31&relatedCursor=old-date-cursor'))
+    ready();product_ready()
+    assert product_action('return !p.query.relatedFrom && !p.query.relatedTo && !p.query.relatedCursor && p._data.totalMatched===1000;')
+    assert action('return v.routeQuery.brand=== "ksf" && v.routeQuery.sort=== "reviews_desc";')
+    assert product_action('return !!p.renderRoot.querySelector(".review-meta") && !p.renderRoot.querySelector("input[type=date]");')
+    shot('library-product-reviews')
     product_tab('prices')
     assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
     assert product_action('return !!p.renderRoot.querySelector(".price-heading strong");')
+    assert product_action('return !p.renderRoot.querySelector("input[type=date],.date-filters");')
+    price_total=product_action('return p._data.totalMatched;')
+    call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&brand=ksf&sort=price_asc&panel=prices&relatedQuality=audit&relatedFrom=2099-01-01&relatedTo=2099-12-31&relatedCursor=old-price-date-cursor'))
+    ready();product_ready()
+    assert product_action('return !p.query.relatedFrom && !p.query.relatedTo && !p.query.relatedCursor;')
+    assert product_action('return p._data.totalMatched;')==price_total
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id) && !p._data.effectiveQuery.filters.sourceDate;')
+    assert product_action('return p.renderRoot.querySelector(".price").textContent.includes("观察时间") && !p.renderRoot.querySelector("input[type=date],.date-filters");')
+    assert action('return v.routeQuery.brand=== "ksf" && v.routeQuery.sort=== "price_asc";')
     shot('library-product-prices')
     product_tab('specs')
     assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
@@ -275,10 +323,7 @@ try:
             action('v.renderRoot.querySelector(".breadcrumb a").click();')
             wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
     go_type('products')
-    action('v.renderRoot.querySelector(".advanced-toggle").click();')
-    wait('!'+V+'.renderRoot.getElementById("advanced-filters").hidden')
-    action('const i=v.renderRoot.querySelector("input[aria-label=日期起]");i.value="2099-01-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
-    wait(V+'.routeQuery.from==="2099-01-01" && !'+V+'._loading')
+    call('Page.navigate',dict(url=args.url+'?type=products&from=2099-01-01'));ready()
     assert action('return v._data.totalMatched;')==0
     action('v.renderRoot.querySelector(".empty button").click();')
     wait('!'+V+'.routeQuery.from && !'+V+'._loading && '+V+'._data?.totalMatched===4639')
@@ -334,7 +379,7 @@ try:
     shot('library-mobile-detail')
     failures=[x for x in events if x.get('method')=='Runtime.exceptionThrown']
     assert not failures,failures
-    result=dict(ok=True,scope=scope,layouts=layouts,tests=['product-catalog-navigation','real-data-counts','exact-filters','active-filter-removal','advanced-filters','display-mode-no-refetch','coverage-toggle','product-inline-panels','product-review-date-filter','no-search-controls','legacy-search-link-normalization','report-stage-filter','compact-layout','product-review-pagination-refresh','product-isolation','product-panel-error-recovery','legacy-product-link','deep-refresh','back-preserves-filters','review-quality','policy-prose-first','reader-source-without-internal-fields','reader-citation','clause-original-source','previous-next-pagination','zero-result-reset','connection-error-recovery','typed-price-spec-coverage-cards','legacy-category-details','mobile-overview','mobile-product-tabs','mobile-navigation','mobile-facts'],productionWrites=0,consoleExceptions=0)
+    result=dict(ok=True,scope=scope,layouts=layouts,productReviewDensity=density,tests=['product-catalog-navigation','real-data-counts','exact-filters','active-filter-removal','review-count-sorting','historical-price-sorting','sort-keeps-all-results','uncaptured-reviews','sorted-pagination-refresh','retired-range-link-normalization','display-mode-no-refetch','coverage-toggle','product-inline-panels','compact-product-overview','compact-review-density','review-original-text-preserved','review-date-filter-removed','legacy-review-date-link-normalization','no-search-controls','legacy-search-link-normalization','report-stage-filter','compact-layout','product-review-pagination-refresh','product-isolation','product-panel-error-recovery','legacy-product-link','deep-refresh','back-preserves-filters','review-quality','policy-prose-first','reader-source-without-internal-fields','reader-citation','clause-original-source','previous-next-pagination','zero-result-reset','connection-error-recovery','typed-price-spec-coverage-cards','legacy-category-details','mobile-overview','mobile-product-tabs','mobile-navigation','mobile-facts'],productionWrites=0,consoleExceptions=0)
     (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
     print(json.dumps(result,ensure_ascii=False,indent=2))
 finally:

+ 7 - 2
server/voc-library/board_library.py

@@ -26,6 +26,7 @@ def query_request(p):
     q = {'entities':[entity], 'filters':filters, 'quality':p.get('quality','analysis'), 'limit':p.get('limit',20)}
     if p.get('keyword'): q['text'] = {'all':[p['keyword']]}
     if p.get('cursor'): q['cursor'] = p['cursor']
+    if p.get('sort'): q['sort'] = p['sort']
     return q
 
 def summary(c, pointer):
@@ -86,7 +87,11 @@ def browse(c,p):
         counts={pid:{} for pid in ids}
         for row in c.execute('SELECT product_id,entity,count(*) AS n FROM retrieval_documents WHERE product_id IN ('+','.join('?' for _ in ids)+') GROUP BY product_id,entity',ids):
             counts[row['product_id']][row['entity']]=row['n']
-        for row in result['records']:row['relatedCounts']=counts[row['record_id']]
+        prices={row['product_id']:{'minMinor':row['low'],'maxMinor':row['high'],'recordCount':row['n']}
+                for row in c.execute('SELECT product_id,min(amount_minor) low,max(amount_minor) high,count(*) n FROM prices WHERE amount_minor IS NOT NULL AND product_id IN ('+','.join('?' for _ in ids)+') GROUP BY product_id',ids)}
+        for row in result['records']:
+            row['relatedCounts']=counts[row['record_id']]
+            row['priceRange']=prices.get(row['record_id'])
     fields=[f for f in ('platform','brand','category','shopId','policyStatus','contentScope','specVerification','priceBasis') if f in retrieval.ENTITY_FIELDS[p.get('entity','products')]]
     facets={}
     for field in fields:
@@ -155,7 +160,7 @@ def detail(c,p):
 
 def run(config,p):
     voc_db.require(isinstance(p,dict) and p.get('op') in ('summary','browse','detail','version'),'READ_COMMAND_REQUIRED')
-    voc_db.require(not set(p)-{'op','entity','filters','quality','limit','keyword','cursor','id','generation'},'UNKNOWN_REQUEST_FIELD')
+    voc_db.require(not set(p)-{'op','entity','filters','quality','limit','keyword','cursor','id','generation','sort'},'UNKNOWN_REQUEST_FIELD')
     db,pointer=active_database(config)
     with voc_db.readonly(db) as c:
         generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]

+ 32 - 6
server/voc-library/retrieval.py

@@ -164,6 +164,8 @@ def catalog(c):
     return {'ok':True,'retrievalContract':VERSION,'readOnly':True,'databaseWrites':0,
       'entities':{e:{'count':c.execute('SELECT count(*) FROM '+t).fetchone()[0],'fields':sorted(ENTITY_FIELDS[e]|COMMON_FIELDS)} for e,t in ENTITIES.items()},
       'fields':{k:{'type':typ,'operators':['eq','in','exists']+(['min','max'] if typ in ('number','date') else []),'unit':'CNY分' if k=='priceMinor' else 'g' if k=='netWeightG' else '原文日历日期;时区未统一' if typ=='date' else None} for k,(_,typ) in FIELDS.items()},
+      'productSorts':{'reviews_asc':'已收录评论从少到多(含默认及待核)','reviews_desc':'已收录评论从多到少(含默认及待核)',
+                      'price_asc':'最低历史观察价从低到高,未知置后','price_desc':'最低历史观察价从高到低,未知置后'},
       'taxonomy':tax,'concepts':CONCEPTS,
       'relationships':['products.id → reviews/prices/specs/rankings/coverage.product_id','documents.id → clauses.document_id','all.source_record_id → source_records.id → source_links/assets/collection_requests','social品牌仅为提及关系,不能推为商品归属'],
       'questionRoutes':[
@@ -221,7 +223,7 @@ def text_groups(request):
 
 def prepare(c,request):
     require(isinstance(request,dict),'INVALID_SEARCH_REQUEST')
-    allowed={'entities','filters','text','quality','limit','cursor','groupBy'}
+    allowed={'entities','filters','text','quality','limit','cursor','groupBy','sort'}
     require(not set(request)-allowed,'UNKNOWN_SEARCH_FIELD')
     entities=request.get('entities',DEFAULT_ENTITIES)
     require(isinstance(entities,list) and 0<len(entities)<=len(ENTITIES) and all(isinstance(e,str) and e in ENTITIES for e in entities) and len(entities)==len(set(entities)),'UNKNOWN_OR_DUPLICATE_ENTITY')
@@ -304,6 +306,17 @@ def search(c,request,explain=False):
     where,values,effective=prepare(c,request)
     limit=request.get('limit',20)
     require(type(limit) is int and 1<=limit<=100,'INVALID_LIMIT')
+    sort=request.get('sort')
+    sorts={
+        'reviews_asc':('(SELECT count(*) FROM reviews r WHERE r.product_id=d.record_id)','ASC'),
+        'reviews_desc':('(SELECT count(*) FROM reviews r WHERE r.product_id=d.record_id)','DESC'),
+        'price_asc':('(SELECT min(p.amount_minor) FROM prices p WHERE p.product_id=d.record_id)','ASC'),
+        'price_desc':('(SELECT min(p.amount_minor) FROM prices p WHERE p.product_id=d.record_id)','DESC'),
+    }
+    require(sort is None or isinstance(sort,str) and sort in sorts,'INVALID_SORT')
+    require(sort is None or effective['entities']==['products'],'SORT_NOT_APPLICABLE')
+    expression,direction=sorts[sort] if sort else ('d.doc_id','ASC')
+    if sort:effective={**effective,'sort':sort}
     generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
     context=hashlib.sha256(canonical([generation,effective]).encode()).hexdigest()
     total=c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+where,values).fetchone()[0]
@@ -313,17 +326,30 @@ def search(c,request,explain=False):
     if request.get('cursor'):
         try:
             cursor=json.loads(base64.urlsafe_b64decode(request['cursor']))
-            require(set(cursor)=={'context','after'} and cursor['context']==context and type(cursor['after']) is int,'CURSOR_SCOPE_MISMATCH')
+            require(set(cursor)==({'context','after','value'} if sort else {'context','after'}) and cursor['context']==context and type(cursor['after']) is int,'CURSOR_SCOPE_MISMATCH')
+            if sort:require(cursor['value'] is None or type(cursor['value']) in (int,float) and math.isfinite(cursor['value']),'CURSOR_SCOPE_MISMATCH')
         except (ValueError,TypeError,KeyError):
             require(False,'CURSOR_SCOPE_MISMATCH')
-        where+=' AND d.doc_id>?';values.append(cursor['after'])
-    sql='SELECT d.* FROM retrieval_documents d WHERE '+where+' ORDER BY d.doc_id LIMIT ?'
+        if not sort:
+            where+=' AND d.doc_id>?';values.append(cursor['after'])
+        elif cursor['value'] is None:
+            where+=' AND ('+expression+' IS NULL AND d.doc_id>?)';values.append(cursor['after'])
+        else:
+            comparison='>' if direction=='ASC' else '<'
+            where+=' AND ('+expression+' IS NULL OR '+expression+comparison+'? OR ('+expression+'=? AND d.doc_id>?))'
+            values += [cursor['value'],cursor['value'],cursor['after']]
+    order=expression+' IS NULL ASC,'+expression+' '+direction+',d.doc_id ASC' if sort else 'd.doc_id'
+    sql='SELECT d.*'+(','+expression+' AS _sort_value' if sort else '')+' FROM retrieval_documents d WHERE '+where+' ORDER BY '+order+' LIMIT ?'
     rows=c.execute(sql,values+[limit+1]).fetchall();has_more=len(rows)>limit;rows=rows[:limit]
-    next_cursor=base64.urlsafe_b64encode(canonical(dict(context=context,after=rows[-1]['doc_id'])).encode()).decode() if has_more else None
+    next_cursor=None
+    if has_more:
+        last=dict(context=context,after=rows[-1]['doc_id'])
+        if sort:last['value']=rows[-1]['_sort_value']
+        next_cursor=base64.urlsafe_b64encode(canonical(last).encode()).decode()
     from voc_db import citation
     records=[]
     for row in rows:
-        r=dict(row);r.pop('tokens');r.pop('normalized_text');body=r.pop('body')
+        r=dict(row);r.pop('tokens');r.pop('normalized_text');r.pop('_sort_value',None);body=r.pop('body')
         r['match']=snippet(body if r['entity']=='reviews' else r['title']+'\n'+body,effective['text']['groupsAll'])
         if r['entity']=='clauses':
             raw=c.execute('SELECT excerpt,application_note,excerpt_type FROM policy_clauses WHERE id=?',(r['record_id'],)).fetchone()

+ 20 - 0
server/voc-library/test_board_library.py

@@ -61,6 +61,26 @@ class BoardLibraryTests(unittest.TestCase):
         for relation in d['relations']:
             rows=board.run(self.config,{'op':'browse','entity':relation['entity'],'filters':{'productId':r['record_id']},'quality':'audit'})
             self.assertEqual(relation['count'],rows['totalMatched'])
+    def test_product_sort_matches_full_database_across_page_boundary(self):
+        before=updater.digest(DB)
+        with voc_db.readonly(DB) as c:
+            counts=dict(c.execute('SELECT product_id,count(*) FROM reviews GROUP BY product_id'))
+            prices=dict(c.execute('SELECT product_id,min(amount_minor) FROM prices GROUP BY product_id'))
+            products=[(r['record_id'],r['doc_id']) for r in c.execute("SELECT record_id,doc_id FROM retrieval_documents WHERE entity='products'")]
+        for sort in ('reviews_asc','reviews_desc','price_asc','price_desc'):
+            metric=counts if sort.startswith('reviews_') else prices
+            def key(row):
+                val=metric.get(row[0],0 if sort.startswith('reviews_') else None)
+                return (val is None,(-val if sort.endswith('desc') else val) if val is not None else 0,row[1])
+            expected=[x[0] for x in sorted(products,key=key)][:40]
+            request={'op':'browse','entity':'products','sort':sort}
+            first=board.run(self.config,request)
+            second=board.run(self.config,{**request,'cursor':first['nextCursor']})
+            self.assertEqual(first['totalMatched'],4639);self.assertEqual(second['totalMatched'],4639)
+            self.assertEqual([r['record_id'] for r in first['records']+second['records']],expected)
+        scoped=board.run(self.config,{'op':'browse','entity':'products','sort':'reviews_desc','filters':{'platform':'jd','brand':'ksf','category':'banmian'}})
+        self.assertTrue(all((r['platform'],r['brand'],r['category'])==('jd','ksf','banmian') for r in scoped['records']))
+        self.assertEqual(updater.digest(DB),before)
     def test_product_cards_counts_and_inline_details_keep_exact_product_scope(self):
         before=updater.digest(DB)
         cards=board.run(self.config,{'op':'browse','entity':'products','filters':{'brand':'ksf','category':'banmian'}})

+ 45 - 0
server/voc-library/test_retrieval.py

@@ -1,4 +1,9 @@
+import contextlib
 import json
+from pathlib import Path
+import shutil
+import sqlite3
+import tempfile
 import unittest
 import test_voc_db as fixture
 import retrieval as r
@@ -37,6 +42,46 @@ class RetrievalTests(unittest.TestCase):
         result=self.search(entities=['products'],filters={'platform':'京东','brand':'白象','category':'拌面'})
         self.assertEqual([x['record_id'] for x in result['records']],['jd:other-brand'])
 
+    def test_review_sort_keeps_all_products_and_paginates_equal_counts(self):
+        for sort in ('reviews_desc','reviews_asc'):
+            query={'entities':['products'],'sort':sort,'limit':2}
+            rows=[];cursor=None
+            while True:
+                page=self.search(**query,**({'cursor':cursor} if cursor else {}))
+                self.assertEqual(page['totalMatched'],9);rows+=page['records'];cursor=page['nextCursor']
+                if not cursor:break
+            self.assertEqual(len(rows),9);self.assertEqual(len({x['record_id'] for x in rows}),9)
+            self.assertEqual(rows[0 if sort=='reviews_desc' else -1]['record_id'],'jd:same')
+        first=self.search(entities=['products'],sort='reviews_desc',limit=2)
+        with self.assertRaises(ValueError):self.search(entities=['products'],sort='reviews_asc',cursor=first['nextCursor'])
+        with self.assertRaises(ValueError):self.search(entities=['products'],cursor=first['nextCursor'])
+        with self.assertRaises(ValueError):self.search(entities=['reviews'],sort='reviews_desc')
+        with self.assertRaises(ValueError):self.search(entities=['products'],sort='price; DROP TABLE products')
+
+    def test_price_sort_uses_lowest_observation_and_keeps_unknown_prices_last(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            db=Path(tmp)/'prices.sqlite';shutil.copyfile(self.db,db)
+            with contextlib.closing(sqlite3.connect(db)) as c:
+                c.row_factory=sqlite3.Row
+                price=dict(c.execute("SELECT * FROM prices WHERE product_id='jd:same' LIMIT 1").fetchone())
+                price.update(id='second-observation',amount_minor=9990,raw_amount='99.90')
+                c.execute('INSERT INTO prices('+','.join(price)+') VALUES('+','.join('?' for _ in price)+')',list(price.values()))
+                c.execute("UPDATE prices SET amount_minor=NULL WHERE product_id IN ('jd:other-brand','jd:sauce')")
+                c.execute("UPDATE prices SET amount_minor=4900 WHERE product_id='jd:soup'")
+                c.commit()
+            with fixture.v.readonly(db) as c:
+                for sort in ('price_asc','price_desc'):
+                    ids=[];cursor=None
+                    while True:
+                        page=r.search(c,{'entities':['products'],'sort':sort,'limit':1,**({'cursor':cursor} if cursor else {})})
+                        self.assertEqual(page['totalMatched'],9)
+                        ids += [x['record_id'] for x in page['records']];cursor=page['nextCursor']
+                        if not cursor:break
+                    self.assertEqual(len(ids),9);self.assertEqual(len(set(ids)),9)
+                    self.assertEqual(set(ids[-2:]),{'jd:other-brand','jd:sauce'})
+                    self.assertEqual(ids[6 if sort=='price_asc' else 0],'jd:soup')
+                    self.assertEqual(ids.index('jd:same')<ids.index('jd:soup'),sort=='price_asc')
+
     def test_literal_review_body_not_product_title(self):
         result=self.search(entities=['reviews'],text={'all':['面量']})
         self.assertGreater(result['totalMatched'],0)

+ 4 - 4
src/core/library.js

@@ -8,13 +8,13 @@ export function libraryProductRequest(productId,query={}){
   const entity=Object.hasOwn(PRODUCT_PANELS,query.panel)?query.panel:'overview';
   if(entity==='overview')return null;
   const filters={productId};
-  const dateField=entity==='reviews'?'reviewDate':entity==='prices'?'sourceDate':null;
-  if(dateField&&(query.relatedFrom||query.relatedTo))filters[dateField]={...(query.relatedFrom?{min:query.relatedFrom}:{}),...(query.relatedTo?{max:query.relatedTo}:{})};
+  const retiredDates=['reviews','prices'].includes(entity)&&['relatedFrom','relatedTo'].some(key=>Object.hasOwn(query,key));
   return {op:'browse',entity,filters,quality:query.relatedQuality==='audit'?'audit':'analysis',limit:20,
-    ...(query.relatedQ?{keyword:query.relatedQ}:{}),...(query.relatedCursor?{cursor:query.relatedCursor}:{})};
+    ...(query.relatedQ?{keyword:query.relatedQ}:{}),...(query.relatedCursor&&!retiredDates?{cursor:query.relatedCursor}:{})};
 }
 export const LIBRARY_FILTERS={platform:'平台',brand:'品牌',category:'品类',shopId:'店铺',policyStatus:'政策状态',contentScope:'内容范围',specVerification:'规格依据',priceBasis:'价格口径'};
 export const FACET_COLUMNS={platform:'platform',brand:'brand',category:'category',shopId:'shop_id',policyStatus:'policy_status',contentScope:'content_scope',specVerification:'spec_verification',priceBasis:'price_basis'};
+export const PRODUCT_SORTS={reviews_desc:'评论从多到少',reviews_asc:'评论从少到多',price_asc:'价格从低到高',price_desc:'价格从高到低'};
 export const LIBRARY_LABELS={jd:'京东',tmall:'天猫',taobao_unverified:'淘宝/渠道待核',xiaohongshu:'小红书',douyin:'抖音',bilibili:'哔哩哔哩',current:'现行(采集时声明)',future:'待实施',draft:'征求意见',source_attribute:'来源属性',title_candidate:'标题声明,待核对',unknown:'未知',matched:'商品关联一致',unverified:'关联待核对',mismatch:'关联不符',orphan:'未关联商品',consistent:'购买规格符合',listing_only:'仅关联商品链接',conflict:'规格冲突',supported:'有分类依据',pending:'待核对',unavailable:'无法计算',verified:'已核对',search_listing_snapshot:'搜索展示价',detail_snapshot:'详情观察价',detail_page_signal:'详情页榜单标识'};
 Object.assign(LIBRARY_LABELS,{announcement:'公告',dietary_guidance:'膳食指导',draft_consultation:'征求意见',future_standard_explanation:'待实施标准解读',historical_call_closed:'历史征集(已结束)',policy_guidance:'政策指导',adjacent_noodle_food:'面食相关内容',packaged_noodles:'包装面食内容',partial_blocked:'部分采集,后续受阻',attempted_failed:'已尝试,采集失败',not_attempted_service_blocked:'服务受阻,未采集',api_terminated_history_unverified:'接口终止,历史覆盖未核实',detail_page_badge_not_complete_ranking:'商品详情页标识,非完整榜单',rule_title_prefix:'标题首部品牌规则',rule_title_explicit:'标题明确分类规则',paraphrase_or_transcription:'摘要或转写',unverified_excerpt:'摘录待逐字核对'});
 Object.assign(LIBRARY_LABELS,{'priceFloor.price':'商品详情价格字段','priceFloor.ext.jdPrice':'京东参考价字段',price:'上游价格(口径待核对)',reservePrice:'上游参考价(口径待核对)'});
@@ -25,7 +25,7 @@ export function libraryRequest(query={}){
   const filters={};for(const key of [...Object.keys(LIBRARY_FILTERS),'productId','documentId'])if(query[key])filters[key]=query[key]==='__unknown'?{exists:false}:query[key];
   const timeField=entity==='reviews'?'reviewDate':entity==='social'?'publishedDate':['products','prices','specs','rankings','documents','clauses'].includes(entity)?'sourceDate':null;
   if(timeField&&(query.from||query.to))filters[timeField]={...(query.from?{min:query.from}:{}),...(query.to?{max:query.to}:{})};
-  return {op:'browse',entity,filters,quality:query.quality==='audit'?'audit':'analysis',limit:20,...(query.q?{keyword:query.q}:{}),...(query.cursor?{cursor:query.cursor}:{})};
+  return {op:'browse',entity,filters,quality:query.quality==='audit'?'audit':'analysis',limit:20,...(entity==='products'&&Object.hasOwn(PRODUCT_SORTS,query.sort)?{sort:query.sort}:{}),...(query.q?{keyword:query.q}:{}),...(query.cursor?{cursor:query.cursor}:{})};
 }
 export function libraryPath(query={},detail=null){const q=new URLSearchParams(Object.entries(query).filter(([,v])=>v!==null&&v!==undefined&&v!==''));return (detail?`/library/${encodeURIComponent(detail.entity)}/${encodeURIComponent(detail.id)}`:'/library')+(q.size?'?'+q:'');}
 export const LIBRARY_FIELDS={title:'标题',content:'原文',full_text:'文件全文',clause:'条款定位',excerpt:'条款摘录',application_note:'应用提示(非官方原话)',excerpt_type:'摘录口径',platform_family:'平台接口族',marketplace:'销售平台',platform:'内容平台',item_id:'平台商品编号',shop_name:'店铺',shop_id:'店铺编号',brand_code:'品牌',category_code:'品类',classification_state:'分类状态',brand_basis:'品牌依据',category_basis:'品类依据',spec_text:'规格原文',net_weight_g:'净含量(克)',pack_count:'件数',total_weight_g:'总重量(克)',verification:'规格核对依据',price_basis:'价格口径',raw_amount:'原始价格',amount_minor:'金额(人民币分)',price_per_100g:'每100克价格(元)',unit_price_verification:'单位价格依据',source_time_raw:'来源观察时间',collected_at_raw:'采集时间',review_date_raw:'评价日期',purchased_sku_text:'购买规格',rating_raw:'原始评分',rating_scale:'评分体系',identity_state:'商品关联状态',category_state:'购买规格状态',is_default:'是否默认评价',append_content_json:'追加评价',published_at_raw:'发表时间',content_scope:'内容覆盖范围',fulltext_available:'是否有正文',declared_status:'政策状态(采集时声明)',status_as_of:'政策状态截至日期',issued_date:'发布日期',effective_date:'实施日期',coverage:'内容覆盖',rank_number:'排名标识',rank_id:'榜单编号',scope:'榜单覆盖口径',available_reviews:'可采评价数',reported_total:'上游报告评价数',historical_total_complete:'历史评价是否采全',status:'覆盖状态'};

Plik diff jest za duży
+ 17 - 18
src/pages/library-product-detail.js


+ 32 - 6
src/pages/library-styles.js

@@ -51,14 +51,14 @@ export const LIBRARY_CSS = css`
 
   .filter{padding:12px 14px}
   .filter-main{display:flex;align-items:flex-end;gap:12px}
-  .filter-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:8px;min-width:0}
+  .filter-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;min-width:0}
   .primary-filters{flex:1}
   .field{display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--kf-ink-3);min-width:0}
   .field small{font-size:10px}
   select,input[type=date]{appearance:auto;-webkit-appearance:auto;padding:6px 8px;min-width:0;width:100%;border:1px solid var(--kf-line);border-radius:6px;background:var(--kf-surface);font-size:12px;color:var(--kf-ink-2);min-height:32px}
-  .advanced-toggle{white-space:nowrap;font-size:11px;min-height:32px}
-  .advanced-toggle b{font-size:10px;border-radius:4px;padding:0 4px;background:var(--kf-accent-soft);line-height:1.6}
-  .advanced-filters{border-top:1px solid var(--kf-line);margin-top:10px;padding-top:10px}
+  .product-price{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin:5px 0}
+  .product-price strong{font-family:var(--kf-font-num);font-size:16px;font-weight:600;color:var(--kf-ink);font-variant-numeric:tabular-nums}
+  .product-price span{font-size:10px;color:var(--kf-ink-3)}
   .active-filters{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:9px;padding-top:9px;border-top:1px solid var(--kf-line-soft)}
   .selected-label{font-size:11px;color:var(--kf-ink-3);margin-right:1px}
   .filter-chip{display:inline-flex;align-items:center;gap:5px;max-width:100%;border:1px solid color-mix(in srgb,var(--kf-accent) 15%,transparent);background:var(--kf-accent-soft);border-radius:6px;padding:5px 7px;color:var(--kf-accent-ink);font-size:10px;line-height:1.5}
@@ -105,6 +105,10 @@ export const LIBRARY_CSS = css`
   .record:hover h4{color:var(--kf-accent-ink)}
   .record p{font-size:12px;line-height:1.8;color:var(--kf-ink-3);margin-top:7px;margin-bottom:12px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
   .record .muted{color:var(--kf-ink-3)}
+  .product-record p{margin:4px 0}
+  .product-record .tags{margin-bottom:6px}
+  .product-record .record-meta{padding-top:8px}
+  .product-record .product-summary{margin-bottom:6px}
   .product-summary{display:flex;gap:8px 12px;flex-wrap:wrap;margin:0 0 8px;color:var(--kf-ink-3);font-size:10px;line-height:1.8}
   .product-summary>span{display:flex;align-items:center;gap:4px}
   .price-fact{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;margin:12px 0}
@@ -183,6 +187,19 @@ export const LIBRARY_CSS = css`
   .inline-link:hover{text-decoration:underline}
   .source-links{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:16px}
   .source-links .scope-note{width:100%;margin:0}
+  /* Product details use a compact header; long-form policy readers keep their own spacing. */
+  .product-detail{gap:10px;margin-top:0}
+  .product-detail .breadcrumb{font-size:11px}
+  .product-detail .breadcrumb .text-button{font-size:11px;min-height:28px}
+  .product-detail .detail-title{display:flex;align-items:center;gap:16px;padding:14px 16px}
+  .product-detail .detail-title h2{flex:1;min-width:0;margin:0;font-size:18px;line-height:1.55}
+  .product-detail .detail-actions{flex:none;gap:7px;align-items:center}
+  .product-detail .detail-actions .button{font-size:11px;padding:6px 9px;min-height:30px}
+  .product-detail .detail-actions .text-button{font-size:11px}
+  .product-detail>.source{display:flex;align-items:center;gap:16px;padding:10px 14px}
+  .product-detail>.source h3{display:flex;align-items:center;gap:5px;font-size:12px;white-space:nowrap}
+  .product-detail>.source .source-links{margin:0;gap:8px;flex:1}
+  .product-detail>.source .source-links .scope-note{border:0;padding:0;flex:1;font-size:11px}
   .report-filters .filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
   .report-filters>.text-button{margin-top:6px}
   .report-results{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
@@ -204,14 +221,17 @@ export const LIBRARY_CSS = css`
     .facts-toggle{display:flex;align-items:center;justify-content:space-between;border:0;background:none;width:100%;padding:0;min-height:28px;font-size:12px;color:var(--kf-ink);text-align:left}
     .facts-toggle span{display:flex;gap:5px;align-items:center;color:var(--kf-accent-ink);font-size:11px}
     .structured-aside>h3{display:none}.structured-aside #record-facts{display:none}.structured-aside.expanded #record-facts{display:block}
+    .product-detail .detail-title{display:block}
+    .product-detail .detail-actions{margin-top:8px}
   }
   @media(max-width:767px){
     .p-head{gap:8px;padding:8px 0 10px}.p-head h2{font-size:19px}.p-actions{flex:none;gap:5px}.p-actions .button{padding:7px 8px;font-size:10px;min-height:34px}.p-actions .button>svg{width:12px;height:12px}
     .stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-bottom:10px}.stat{padding:10px;gap:4px}.stat-label{font-size:10px;gap:4px}.stat-label svg{width:11px;height:11px}.stat strong{font-size:22px}.stat small{font-size:9px;line-height:1.7}
     .directory{padding:7px 10px;margin-bottom:10px}.directory-links{display:none}.mobile-type{display:flex;align-items:center;gap:12px;font-size:11px;color:var(--kf-ink-3);white-space:nowrap}.mobile-type select{flex:1;font-size:12px;min-width:0}
-    .content{gap:10px}.panel{padding:14px}.filter{padding:11px 12px}.filter-main{display:block}.filter-main>.advanced-toggle{display:flex;margin:5px 0 0 auto;padding:4px 0 0 8px;min-height:28px}
+    .content{gap:10px}.panel{padding:14px}.filter{padding:11px 12px}.filter-main{display:block}
     .filter-grid{gap:9px;grid-template-columns:repeat(2,minmax(0,1fr))}.field{font-size:10px}select,input[type=date]{font-size:12px;min-height:34px}
-    .advanced-toggle{font-size:10px}.filter-chip{font-size:10px;padding:5px}.filter-chip b{max-width:170px}.active-filters{gap:5px;padding-top:8px;margin-top:7px}
+    .product-filters .field:last-child{grid-column:1/-1}
+    .filter-chip{font-size:10px;padding:5px}.filter-chip b{max-width:170px}.active-filters{gap:5px;padding-top:8px;margin-top:7px}
     .result-head{gap:8px;margin-bottom:9px;align-items:flex-start}.result-head h3{font-size:13px;gap:5px}.result-head h3 strong{font-size:20px}.result-head p{font-size:9px;max-width:22em}
     .result-tools{gap:6px;align-items:center}.insights-toggle{font-size:10px;padding:6px 7px;min-height:32px}.view-switch{padding:3px}.view-switch button{width:26px;height:24px}
     .record-list.cards{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:8px}.cards .record-symbol{display:none}.record,.list .record{padding:12px;gap:8px}.record h4{font-size:13px}.record p{font-size:12px;margin-bottom:8px}.record-meta{font-size:10px;padding-top:8px}.record-number{max-width:47%}
@@ -220,6 +240,12 @@ export const LIBRARY_CSS = css`
     .detail-page{gap:10px;margin-top:8px}.breadcrumb{font-size:11px}.detail-title h2{font-size:18px;margin:8px 0 12px;line-height:1.7}.detail-actions{gap:7px}.detail-actions .button{font-size:11px;padding:7px 9px}.detail-actions .text-button{font-size:11px}
     .detail-grid{gap:14px}.detail-main,.detail-aside{gap:14px}.prose{padding:20px 18px}.full-text{font-size:13px;line-height:2.1;margin-top:16px}.prose h3{font-size:14px}.metadata{gap:0 16px}.metadata dt{font-size:10px}.metadata dd{font-size:11px}.relations{grid-template-columns:1fr}.relations a{font-size:11px}.notice{font-size:11px}.source .metadata{grid-template-columns:1fr}.structured-aside .metadata{grid-template-columns:1fr 1fr}
     .report-results{grid-template-columns:1fr}.report-record{padding:12px}.report-record h4{font-size:12px}
+    .product-detail{gap:8px}
+    .product-detail .detail-title{padding:12px}
+    .product-detail .detail-title h2{font-size:16px;line-height:1.55}
+    .product-detail .detail-actions{gap:6px}
+    .product-detail .detail-actions .button{min-height:32px}
+    .product-detail>.source{align-items:flex-start;flex-wrap:wrap;gap:6px 12px;padding:10px 12px}
   }
   @media(max-width:360px){
     .p-head h2{font-size:17px}.p-actions .button{font-size:10px;padding:6px}.p-actions .button>svg{display:none}.stat-label{font-size:9px}.stat-label svg{display:none}.stat strong{font-size:21px}.stat small{font-size:8px}.result-head{flex-wrap:wrap}.result-tools{margin-left:auto}.filter-chip b{max-width:130px}.structured-aside .metadata{grid-template-columns:1fr}.record-number{max-width:42%}.record-meta{gap:6px}.pagination>div{gap:4px}

+ 30 - 29
src/pages/view-library.js

@@ -6,7 +6,7 @@ import { LIBRARY_CSS } from './library-styles.js';
 import './library-product-detail.js';
 import { href, navigate } from '../utils/router.js';
 import { readLibrary } from '../services/library-service.js';
-import { LIBRARY_ENTITIES, LIBRARY_GROUPS, LIBRARY_FILTERS, FACET_COLUMNS, LIBRARY_FIELDS, libraryLabel, libraryRequest, libraryPath, libraryDate, libraryRecordInfo, libraryCatalogQuery } from '../core/library.js';
+import { LIBRARY_ENTITIES, LIBRARY_GROUPS, LIBRARY_FILTERS, FACET_COLUMNS, LIBRARY_FIELDS, PRODUCT_SORTS, libraryLabel, libraryRequest, libraryPath, libraryDate, libraryRecordInfo, libraryCatalogQuery } from '../core/library.js';
 
 const number = n => Number(n || 0).toLocaleString('zh-CN');
 const safeUrl = value => {
@@ -38,7 +38,7 @@ export class ViewLibrary extends LitElement {
     db: { type: Object }, routeQuery: { type: Object }, recordId: { type: String }, recordEntity: { type: String },
     _summary: { state: true }, _data: { state: true }, _detail: { state: true }, _loading: { state: true },
     _error: { state: true }, _notice: { state: true },
-    _advanced: { state: true }, _insights: { state: true }, _layout: { state: true },
+    _insights: { state: true }, _layout: { state: true },
     _overview: { state: true }, _factsExpanded: { state: true },
   };
   constructor() {
@@ -46,7 +46,7 @@ export class ViewLibrary extends LitElement {
     this.db = null; this.routeQuery = {}; this.recordId = ''; this.recordEntity = '';
     this._summary = null; this._data = null; this._detail = null; this._loading = true;
     this._error = ''; this._notice = ''; this._ticket = 0;
-    this._advanced = false; this._insights = false; this._layout = 'cards';
+    this._insights = false; this._layout = 'cards';
     this._overview = false; this._factsExpanded = false;
     this._previousCursors = new Map();
   }
@@ -57,7 +57,7 @@ export class ViewLibrary extends LitElement {
       const sameProduct = this.recordEntity === 'products' && this._detail?.record.id === this.recordId
         && !changed.has('recordId') && !changed.has('recordEntity')
         && JSON.stringify(libraryCatalogQuery(changed.get('routeQuery') || {})) === JSON.stringify(libraryCatalogQuery(this.routeQuery));
-      if (!sameProduct || ['q', 'relatedQ'].some(key => Object.hasOwn(this.routeQuery, key))) this._load();
+      if (!sameProduct || ['q', 'relatedQ', 'reviewCount', 'price'].some(key => Object.hasOwn(this.routeQuery, key))) this._load();
     }
     if (changed.has('_loading') && !this._loading && this._scrollResults) {
       this._scrollResults = false;
@@ -78,12 +78,12 @@ export class ViewLibrary extends LitElement {
     const ticket = ++this._ticket;
     this._loading = true; this._error = ''; this._detail = null; this._data = null;
     try {
-      // Saved search links become filter-only links; their cursors belong to a different query.
-      if (['q', 'relatedQ'].some(key => Object.hasOwn(this.routeQuery, key))) {
+      // Remove retired keyword and interval controls, including their old cursors.
+      if (['q', 'relatedQ', 'reviewCount', 'price'].some(key => Object.hasOwn(this.routeQuery, key))) {
         const query = { ...this.routeQuery };
-        if (Object.hasOwn(query, 'q')) delete query.cursor;
+        if (['q', 'reviewCount', 'price'].some(key => Object.hasOwn(query, key))) delete query.cursor;
         if (Object.hasOwn(query, 'relatedQ')) delete query.relatedCursor;
-        delete query.q; delete query.relatedQ;
+        delete query.q; delete query.relatedQ; delete query.reviewCount; delete query.price;
         navigate(libraryPath(query, this.recordId ? { entity: this.recordEntity, id: this.recordId } : null), { replace: true });
         return;
       }
@@ -198,25 +198,21 @@ export class ViewLibrary extends LitElement {
   }
   _filter() {
     const d = this._data, q = this.routeQuery;
-    const primary = ['platform', 'brand', 'category', 'policyStatus'].filter(k => d.facets[k]);
-    const more = Object.keys(d.facets).filter(k => !primary.includes(k) && k !== 'specVerification');
-    const advancedCount = more.filter(k => q[k]).length + (q.from ? 1 : 0) + (q.to ? 1 : 0);
-    return html`<section class="panel filter" aria-label="资料筛选">
+    const primary = ['platform', 'brand', 'category', 'policyStatus', 'contentScope'].filter(k => d.facets[k]);
+    return html`<section class=${this._type === 'products' ? 'panel filter product-filters' : 'panel filter'} aria-label="资料筛选">
       <div class="filter-main"><div class="filter-grid primary-filters">
         ${primary.map(k => this._facet(k, d.facets[k]))}
-        <label class="field">查看口径<select aria-label="查看口径" .value=${q.quality || 'analysis'} @change=${e => this._patch({ quality: e.target.value })}>
+        ${this._type === 'products' ? html`
+          <label class="field">评论数量<select aria-label="评论数量" title="按已收录评论数量排序" .value=${q.sort?.startsWith('reviews_') ? q.sort : ''} @change=${e => this._patch({ sort: e.target.value })}>
+            <option value="">默认排序</option>${['reviews_desc', 'reviews_asc'].map(value => html`<option value=${value} ?selected=${q.sort === value}>${PRODUCT_SORTS[value]}</option>`)}
+          </select></label>
+          <label class="field">价格<select aria-label="价格" title="按最低历史观察价排序,暂无价格的商品排在最后" .value=${q.sort?.startsWith('price_') ? q.sort : ''} @change=${e => this._patch({ sort: e.target.value })}>
+            <option value="">默认排序</option>${['price_asc', 'price_desc'].map(value => html`<option value=${value} ?selected=${q.sort === value}>${PRODUCT_SORTS[value]}</option>`)}
+          </select></label>` : ''}
+        ${this._type !== 'products' ? html`<label class="field">查看口径<select aria-label="查看口径" .value=${q.quality || 'analysis'} @change=${e => this._patch({ quality: e.target.value })}>
           <option value="analysis">推荐用于分析的资料</option><option value="audit">全部资料(含待确认)</option>
-        </select></label>
-      </div>
-        <button class=${this._advanced ? 'text-button advanced-toggle active' : 'text-button advanced-toggle'} aria-expanded=${this._advanced} aria-controls="advanced-filters" @click=${() => this._advanced = !this._advanced}>
-          ${icon('filter', 14)}${this._advanced ? '收起筛选' : '更多筛选'}${advancedCount ? html`<b>${advancedCount}</b>` : ''}${icon(this._advanced ? 'up' : 'down', 11)}
-        </button>
-      </div>
-      <div id="advanced-filters" class="advanced-filters filter-grid" ?hidden=${!this._advanced}>
-        ${more.map(k => this._facet(k, d.facets[k]))}
-        ${this._type !== 'coverage' ? html`<label class="field">${this._type === 'reviews' ? '评价' : this._type === 'social' ? '发表' : '来源'}日期起<input aria-label="日期起" type="date" .value=${q.from || ''} @change=${e => this._patch({ from: e.target.value })}></label>
-          <label class="field">日期止<input aria-label="日期止" type="date" .value=${q.to || ''} @change=${e => this._patch({ to: e.target.value })}></label>` : ''}
-      </div>${this._chips()}
+        </select></label>` : ''}
+      </div></div>${this._chips()}
     </section>`;
   }
   _distribution() {
@@ -244,7 +240,7 @@ export class ViewLibrary extends LitElement {
     else if (r.entity === 'coverage') preview = preview ? this._label(preview) : '';
     else if (r.entity === 'documents') preview = preview.replace(/--- PDF page \d+ ---/g, '').replace(/\s+/g, ' ').trim();
     const recordDate = r.review_day ? `评价 ${r.review_day}` : r.published_day ? `发表 ${r.published_day}` : r.source_day ? `观察 ${r.source_day}` : r.policyStatusAsOf ? `状态截至 ${r.policyStatusAsOf}` : '来源日期未知';
-    return html`<a class="record" href=${href(libraryPath(this.routeQuery, { entity: r.entity, id: r.record_id }))} data-link data-record-id=${r.record_id}>
+    return html`<a class=${r.entity === 'products' ? 'record product-record' : 'record'} href=${href(libraryPath(this.routeQuery, { entity: r.entity, id: r.record_id }))} data-link data-record-id=${r.record_id}>
       <div class="record-symbol" aria-hidden="true">${icon(TYPE_INFO[r.entity]?.[0] || 'doc', 18)}</div>
       <div class="record-main"><div class="tags">
         ${r.platform || r.policy_status ? html`<span class="platform-tag">${this._label(r.platform || r.policy_status)}</span>` : ''}
@@ -258,7 +254,9 @@ export class ViewLibrary extends LitElement {
           ${r.net_weight_g == null && r.pack_count == null ? html`<span>暂无详细规格</span>` : ''}
         </div>` : ''}
         ${preview ? html`<p>${preview}</p>` : this._type === 'social' && !r.fulltext_available ? html`<p class="muted">仅有标题,暂无正文</p>` : ''}
-        ${r.entity === 'products' ? html`<div class="product-summary"><span>${icon('chat', 11)}评价 ${number(r.relatedCounts?.reviews)}</span><span>${icon('bars', 11)}价格 ${number(r.relatedCounts?.prices)}</span><span>${icon('table', 11)}规格 ${number(r.relatedCounts?.specs)}</span></div>` : ''}
+        ${r.entity === 'products' ? html`
+          <div class="product-price">${r.priceRange ? html`<strong>¥${(r.priceRange.minMinor / 100).toFixed(2)}${r.priceRange.maxMinor !== r.priceRange.minMinor ? `–${(r.priceRange.maxMinor / 100).toFixed(2)}` : ''}</strong><span>历史观察价</span>` : html`<span>暂无价格</span>`}</div>
+          <div class="product-summary"><span title="已收录评价,含默认及待确认记录">${icon('chat', 11)}${r.relatedCounts?.reviews ? `已收录评价 ${number(r.relatedCounts.reviews)}` : '暂未收录评价'}</span><span>${icon('table', 11)}规格 ${number(r.relatedCounts?.specs)}</span></div>` : ''}
         <div class="record-meta"><span>${icon('clock', 11)}${recordDate}</span><span>查看详情 ${icon('right', 11)}</span></div>
       </div><span class="open-arrow">${icon('right', 14)}</span>
     </a>`;
@@ -307,8 +305,11 @@ export class ViewLibrary extends LitElement {
     const d = this._detail;
     if (!d) return '';
     const r = d.record, prose = ['content', 'full_text', 'excerpt', 'application_note'].filter(k => r[k]);
-    return html`<div class="detail-page"><div class="breadcrumb"><a href=${href(libraryPath(libraryCatalogQuery(this.routeQuery)))} data-link>${icon('left', 14)}返回筛选结果</a><span>${LIBRARY_ENTITIES[this.recordEntity]}</span></div>
-      <section class="panel detail-title"><p class="eyebrow">${icon(TYPE_INFO[this.recordEntity]?.[0] || 'doc', 14)}${LIBRARY_ENTITIES[this.recordEntity]}</p>
+    const product = this.recordEntity === 'products';
+    return html`<div class=${product ? 'detail-page product-detail' : 'detail-page'}><div class="breadcrumb"><a href=${href(libraryPath(libraryCatalogQuery(this.routeQuery)))} data-link>${icon('left', 14)}返回筛选结果</a>
+        ${product ? html`<button class="text-button refresh" ?disabled=${this._loading} @click=${() => this._load(true)}>${icon('clock', 12)}刷新资料</button>` : html`<span>${LIBRARY_ENTITIES[this.recordEntity]}</span>`}
+      </div>
+      <section class="panel detail-title">${!product ? html`<p class="eyebrow">${icon(TYPE_INFO[this.recordEntity]?.[0] || 'doc', 14)}${LIBRARY_ENTITIES[this.recordEntity]}</p>` : ''}
         <h2>${r.title || r.parentDocument?.title || r.parentProduct?.title || r.clause || LIBRARY_ENTITIES[this.recordEntity]}</h2>
         <div class="detail-actions"><button class="button primary" @click=${() => this._copy(r)}>${icon('quote', 14)}复制资料引用</button>
           ${this._originalLink(r) ? html`<a class="button" href=${this._originalLink(r)} target="_blank" rel="noopener noreferrer">原始链接 ${icon('link', 14)}</a>` : ''}
@@ -356,7 +357,7 @@ export class ViewLibrary extends LitElement {
   }
   _skeleton() { return html`<section class="panel loading" aria-busy="true" role="status"><span class="loading-dot"></span><span>${this.recordId ? '正在读取资料原文与来源…' : '正在读取资料…'}</span></section>`; }
   render() {
-    return html`${head('行业资料库', '', html`${!this.recordId ? html`<button class="button overview-toggle" aria-expanded=${this._overview} aria-controls="library-overview" @click=${() => this._overview = !this._overview}>${icon('bars', 14)}资料概览${icon(this._overview ? 'up' : 'down', 11)}</button>` : ''}<button class="button refresh" ?disabled=${this._loading} @click=${() => this._load(true)}>${icon('clock', 14)}刷新资料</button>`)}
+    return html`${this.recordId && this.recordEntity === 'products' ? '' : head('行业资料库', '', html`${!this.recordId ? html`<button class="button overview-toggle" aria-expanded=${this._overview} aria-controls="library-overview" @click=${() => this._overview = !this._overview}>${icon('bars', 14)}资料概览${icon(this._overview ? 'up' : 'down', 11)}</button>` : ''}<button class="button refresh" ?disabled=${this._loading} @click=${() => this._load(true)}>${icon('clock', 14)}刷新资料</button>`)}
       ${this._notice ? html`<div class="notice" role="status"><span>${icon('check', 14)}${this._notice}</span><button class="text-button" aria-label="关闭提示" @click=${() => this._notice = ''}>${icon('x', 14)}</button></div>` : ''}
       ${this._error ? html`<div class="panel error" role="alert">${icon('alert', 22)}<div><h3>${this._error}</h3><p>资料暂时无法读取,请稍后重试。</p><button class="button" @click=${() => this._load(true)}>重试</button></div></div>` : ''}
       ${this.recordId ? (this._loading ? this._skeleton() : this._detailView()) : html`${this._stats()}${this._tree()}<div class="library-layout"><div class="content">

+ 17 - 1
test/library.test.js

@@ -13,6 +13,19 @@ test('详情深链接保留查询,标签使用真实字典',()=>{
   assert.equal(libraryPath({type:'products',brand:'ksf'},{entity:'products',id:'jd:123'}),'/library/products/jd%3A123?type=products&brand=ksf');
   assert.equal(libraryLabel(null),'未知/待核对');assert.equal(libraryLabel('ksf',{taxonomy:{brands:[{code:'ksf',name:'康师傅'}],categories:[]}}),'康师傅');
 });
+test('商品评论和价格只排序,不缩小结果范围;URL保留排序且不影响商品内评论',()=>{
+  const query={type:'products',platform:'jd',brand:'ksf',category:'banmian',sort:'reviews_desc',cursor:'page-2'};
+  assert.deepEqual(libraryRequest(query).filters,{platform:'jd',brand:'ksf',category:'banmian'});
+  assert.equal(libraryRequest(query).sort,'reviews_desc');
+  assert.equal(libraryRequest({...query,sort:'price_asc'}).sort,'price_asc');
+  assert.equal(libraryRequest({...query,sort:'price_desc'}).sort,'price_desc');
+  assert.ok(libraryPath(query).includes('sort=reviews_desc'));
+  assert.deepEqual(libraryProductRequest('jd:123',{...query,panel:'reviews'}).filters,{productId:'jd:123'});
+  assert.equal(libraryProductRequest('jd:123',{...query,panel:'reviews'}).sort,undefined);
+  assert.equal(libraryRequest({...query,type:'social'}).sort,undefined);
+  assert.equal(libraryRequest({...query,sort:'not-supported'}).sort,undefined);
+  assert.deepEqual(libraryRequest({type:'products',reviewCount:'has',price:'10-20'}).filters,{});
+});
 test('阅读信息按资料类型选择业务事实,内部规则、编号和指纹不进入展示',()=>{
   const record={marketplace:'jd',shop_name:'康师傅旗舰店',category_basis:'rule_title_explicit',brand_basis:'rule_title_prefix',
     classification_state:'supported',source_record_id:'internal-source',item_id:'123',shop_id:'456',
@@ -28,7 +41,10 @@ test('阅读价格使用元且不暴露分单位和技术字段,单位价格
 test('商品是浏览入口,详情内查询固定商品编号并隔离列表条件和分页',()=>{
   assert.deepEqual(LIBRARY_GROUPS.flatMap(group=>group.entities),['products','social','documents','reports']);
   const query={type:'products',platform:'tmall',brand:'other-brand',category:'other-category',q:'旧列表词',cursor:'list-cursor',panel:'reviews',relatedQ:'包装',relatedQuality:'audit',relatedFrom:'2026-11-01',relatedTo:'2026-11-05',relatedCursor:'review-cursor'};
-  assert.deepEqual(libraryProductRequest('jd:123',query),{op:'browse',entity:'reviews',filters:{productId:'jd:123',reviewDate:{min:'2026-11-01',max:'2026-11-05'}},quality:'audit',limit:20,keyword:'包装',cursor:'review-cursor'});
+  assert.deepEqual(libraryProductRequest('jd:123',query),{op:'browse',entity:'reviews',filters:{productId:'jd:123'},quality:'audit',limit:20,keyword:'包装'});
+  assert.equal(libraryProductRequest('jd:123',{panel:'reviews',relatedCursor:'review-cursor'}).cursor,'review-cursor');
+  assert.deepEqual(libraryProductRequest('jd:123',{...query,panel:'prices'}),{op:'browse',entity:'prices',filters:{productId:'jd:123'},quality:'audit',limit:20,keyword:'包装'});
+  assert.equal(libraryProductRequest('jd:123',{panel:'prices',relatedCursor:'price-cursor'}).cursor,'price-cursor');
   assert.deepEqual(libraryCatalogQuery(query),{type:'products',platform:'tmall',brand:'other-brand',category:'other-category',q:'旧列表词',cursor:'list-cursor'});
   assert.equal(libraryProductRequest('jd:123',{panel:'overview'}),null);
   assert.equal(libraryProductRequest('jd:123',{panel:'unknown'}),null);

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików