/~learn/s/418d405c
> For Claude: Use ${SUPERPOWERS_SKILLS_ROOT}/skills/collaboration/executing-plans/SKILL.md to implement this plan task-by-task.
Goal: 让三国杀账号页的武将/皮肤筛选支持「子页签『全部 / 已选』+ 已选内再分『必选组 / 可选组』」,查询语义变为 (必选组全部命中) AND (可选组命中任一)。
Architecture: 前端把「已选集合」拆成 sel*(全部已选)+ sel*Req(其中被标为必选的子集),已选页签按这两个集合渲染必选组/可选组;提交时拆成 chars_req / chars_opt(皮肤同理)两组参数。后端复用已有 _build_where._multi(),必选组用 mode='and'、可选组用 mode='or' 各追加一个条件,靠 query_goods 的 " AND ".join(where) 天然组合成 (必选 AND) AND (可选 OR)。不改数据库、不改表结构,老参数 chars/chars_mode/skins/skins_mode 保留做兼容。
Tech Stack: Python 3.12 + stdlib http.server、SQLite(quant.db)、原生 JS(无框架)、unittest。
---
characters TEXT(JSON 数组)/ skins TEXT 列 + chars/chars_mode 参数。_multi(column, values, mode) → (col LIKE ? OR/AND col LIKE ?),参数 %"名"%(双引号锚定 JSON 数组元素,避免子串误命中)。_PAGE_SANGUOSHA(web_handler.py:2196 起)。绝区零页 _PAGE 是另一套脚本,不在本次范围。gt_filters_sg(三国杀专属),字段 chars/charsMode 需平滑迁移:老 charsMode==='and' → 全必选;否则 → 全可选。
× 从已选中移除;有「全部设必选 / 全部设可选 / 清空」批量操作。(必选组 全含 AND) AND (可选组 含任一 OR),空组不参与。chars/chars_mode(皮肤同理)后端仍兼容。---
_build_where / query_goods 支持 req/opt 分组(TDD 先红)Files:
tests/adata_test/game_trade/test_chars_req_opt.py(新建)adata/aohan/quant/db/game_trade.py
Step 1.1(红) — 新建测试,覆盖三条语义。测试只测 SQL 组装(_build_where),不碰 DB,保证快而稳:
import os, sys, unittest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
sys.path.insert(0, PROJECT_ROOT)
from adata.aohan.quant.db.game_trade import _build_where
class TestCharsReqOpt(unittest.TestCase):
def test_req_group_uses_and(self):
where, args = _build_where(game='sanguosha_yijiang',
chars_req=['A', 'B'])
sql = " AND ".join(where)
self.assertIn('characters LIKE ? AND characters LIKE ?', sql)
self.assertEqual(args.count('%"A"%'), 1)
self.assertEqual(args.count('%"B"%'), 1)
def test_opt_group_uses_or(self):
where, args = _build_where(game='sanguosha_yijiang',
chars_opt=['C', 'D'])
sql = " AND ".join(where)
self.assertIn('characters LIKE ? OR characters LIKE ?', sql)
def test_req_and_opt_combine_with_and(self):
where, _ = _build_where(game='sanguosha_yijiang',
chars_req=['A'], chars_opt=['C', 'D'])
sql = " AND ".join(where)
self.assertIn('(characters LIKE ?)', sql) # 必选组
self.assertIn('(characters LIKE ? OR characters LIKE ?)', sql) # 可选组
self.assertEqual(sql.count('characters LIKE'), 3)
def test_empty_groups_do_not_participate(self):
where, args = _build_where(game='sanguosha_yijiang',
chars_req=[], chars_opt=None)
self.assertNotIn('characters LIKE', " AND ".join(where))
def test_legacy_chars_mode_still_works(self):
where, _ = _build_where(game='sanguosha_yijiang',
chars=['A', 'B'], chars_mode='and')
self.assertIn('characters LIKE ? AND characters LIKE ?', " AND ".join(where))
def test_skins_req_opt(self):
where, _ = _build_where(game='sanguosha_yijiang',
skins_req=['S1'], skins_opt=['S2', 'S3'])
sql = " AND ".join(where)
self.assertIn('skins LIKE ?', sql)
self.assertIn('skins LIKE ? OR skins LIKE ?', sql)
if __name__ == '__main__':
unittest.main()
Step 1.2(验证红):
export PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:$PATH
cd /Users/aohan/PycharmProjects/adata
env -u PYTHONHOME -u PYTHONPATH .venv/bin/python -m unittest \
tests.adata_test.game_trade.test_chars_req_opt -v
预期:TypeError: _build_where() got an unexpected keyword argument 'chars_req'(红)。
Step 1.3(绿) — 改 game_trade.py。
_build_where 签名(688-690 行)在 skins_mode='or', 后加一行:
skins=None, skins_mode='or',
chars_req=None, chars_opt=None, skins_req=None, skins_opt=None,
_build_where 函数体(715-716 行)在两条 legacy 调用后追加:
_multi("characters", chars, chars_mode)
_multi("skins", skins, skins_mode)
# 必选组全部命中(AND) 且 可选组命中任一(OR);空组不参与
_multi("characters", chars_req, 'and')
_multi("characters", chars_opt, 'or')
_multi("skins", skins_req, 'and')
_multi("skins", skins_opt, 'or')
query_goods 签名(833-834 行):在 skins=None, skins_mode='or', 后加 chars_req=None, chars_opt=None, skins_req=None, skins_opt=None,。
query_goods 内 _build_where(...) 调用(861-872 行)尾部追加 keyword(已用 keyword 传参,安全):
col_filters=col_filters, ranks=ranks, account_types=account_types,
chars_req=chars_req, chars_opt=chars_opt,
skins_req=skins_req, skins_opt=skins_opt)
query_goods docstring(851-852 行)补一句:
- chars_req/chars_opt(skins 同理):必选组全含 AND、可选组含任一 OR;
与 legacy chars/chars_mode 可并存,条件间 AND 组合
Step 1.4(验证绿):重跑 Step 1.2 命令,预期 6 个用例全 ok。
---
Files:
tests/adata_test/game_trade/test_chars_req_opt.pyadata/aohan/quant/game_trade/web_handler.py (handle_goods_api, 149-220 行)
Step 2.1(红) — 追加一个用 unittest.mock 拦截 query_goods、断言透传的用例:
from unittest import mock
from urllib.parse import urlparse
class TestGoodsApiPassthrough(unittest.TestCase):
def _call(self, query):
from adata.aohan.quant.game_trade import web_handler as wh
with mock.patch.object(wh, 'query_goods', return_value=([], 0)) as m, \
mock.patch.object(wh, 'stats', return_value={}):
wh.handle_goods_api(urlparse('/api/game-trade/goods?' + query))
return m.call_args.kwargs
def test_req_opt_parsed(self):
kw = self._call('game=sanguosha_yijiang&chars_req=A,B&chars_opt=C')
self.assertEqual(kw['chars_req'], ['A', 'B'])
self.assertEqual(kw['chars_opt'], ['C'])
def test_skins_req_opt_parsed(self):
kw = self._call('game=sanguosha_yijiang&skins_req=S1&skins_opt=S2,S3')
self.assertEqual(kw['skins_req'], ['S1'])
self.assertEqual(kw['skins_opt'], ['S2', 'S3'])
def test_absent_gives_none(self):
kw = self._call('game=sanguosha_yijiang')
self.assertIsNone(kw['chars_req'])
self.assertIsNone(kw['chars_opt'])
> 注意:handle_goods_api 返回前会调 _decorate_sanguosha / _json_resp,rows=[] 时安全;stats 已 mock。若 _json_resp 需要真实 HTTP handler,则改为直接断言 m.call_args.kwargs 并忽略返回值(已按此写法)。
Step 2.2(验证红):重跑 unittest,预期 KeyError: 'chars_req'(透传 kwargs 里没有)。
Step 2.3(绿) — 改 web_handler.py:
解析(在 160-163 行 legacy chars/skins 解析之后插入):
chars_req_raw = (qs.get("chars_req", [""])[0] or "").strip()
chars_req = [c for c in chars_req_raw.split(",") if c] if chars_req_raw else None
chars_opt_raw = (qs.get("chars_opt", [""])[0] or "").strip()
chars_opt = [c for c in chars_opt_raw.split(",") if c] if chars_opt_raw else None
skins_req_raw = (qs.get("skins_req", [""])[0] or "").strip()
skins_req = [s for s in skins_req_raw.split(",") if s] if skins_req_raw else None
skins_opt_raw = (qs.get("skins_opt", [""])[0] or "").strip()
skins_opt = [s for s in skins_opt_raw.split(",") if s] if skins_opt_raw else None
透传(192-193 行后插入):
chars=chars, chars_mode=qs.get("chars_mode", ["or"])[0],
skins=skins, skins_mode=qs.get("skins_mode", ["or"])[0],
chars_req=chars_req, chars_opt=chars_opt,
skins_req=skins_req, skins_opt=skins_opt,
Step 2.4(验证绿):重跑 unittest,全 ok。
---
Files: adata/aohan/quant/game_trade/web_handler.py
Step 3.1 — 在三国杀 CSS 块 .mode-btn.on{...}(2239 行)之后插入:
.sub-tabs{display:inline-flex;background:#0d1420;border:1px solid #2a3441;border-radius:6px;overflow:hidden;margin-left:6px;}
.sub-tab{background:none;border:none;color:var(--muted);font-size:11px;padding:3px 10px;cursor:pointer;}
.sub-tab.on{background:rgba(45,212,191,.15);color:var(--teal);font-weight:600;}
.sel-summary{font-size:11px;color:var(--muted);margin-bottom:6px;}
.sel-summary b{color:var(--teal);}
.sel-actions{display:flex;gap:6px;margin:6px 0;}
.sel-actions button{background:#1f2937;border:1px solid #2a3441;color:var(--muted);border-radius:6px;font-size:11px;padding:4px 9px;cursor:pointer;}
.sel-actions button:hover{color:var(--teal);border-color:var(--teal);}
.sel-group{margin-top:8px;}
.sel-group-title{font-size:11px;font-weight:700;color:var(--text);margin-bottom:5px;}
.sel-group-title .n{color:var(--muted);font-weight:400;}
.sel-group .chars{max-height:140px;}
.chip .x{margin-left:5px;opacity:.6;font-weight:700;}
.chip .x:hover{opacity:1;}
.chip.req{background:rgba(245,194,17,.15);border-color:#f5c211;color:#f5c211;}
.chip.opt{background:rgba(45,212,191,.15);border-color:var(--teal);color:var(--teal);}
Step 3.2 — 替换三国杀页「武将筛选」f-group(2342-2353 行):
<div class="f-group">
<div class="f-group-title">🎭 武将筛选
<span class="sub-tabs" id="chars_tabs">
<button type="button" class="sub-tab on" data-tab="all" onclick="setTab('chars','all')">全部武将</button>
<button type="button" class="sub-tab" data-tab="sel" onclick="setTab('chars','sel')">已选 <b id="chars_sel_cnt">0</b></button>
</span>
</div>
<div id="chars_pane_all">
<div class="char-picker">
<input type="text" class="char-search" id="char_search" placeholder="🔍 搜索武将名…">
<div class="chars" id="chars_box"><span class="chip" style="cursor:default;opacity:.6">加载武将中…</span></div>
</div>
</div>
<div id="chars_pane_sel" style="display:none">
<div class="sel-summary" id="chars_sel_sum">尚未选择任何武将</div>
<div class="sel-actions">
<button type="button" onclick="selSetAll('chars',true)">全部设必选</button>
<button type="button" onclick="selSetAll('chars',false)">全部设可选</button>
<button type="button" onclick="selClear('chars')">清空</button>
</div>
<div class="sel-group">
<div class="sel-group-title">必选(必须全有)<span class="n" id="chars_req_cnt">0</span></div>
<div class="chars" id="chars_req_box"></div>
</div>
<div class="sel-group">
<div class="sel-group-title">可选(有一个就行)<span class="n" id="chars_opt_cnt">0</span></div>
<div class="chars" id="chars_opt_box"></div>
</div>
</div>
</div>
Step 3.3 — 替换三国杀页「皮肤筛选」f-group(2354-2365 行),结构同上,把 chars→skins、武将→皮肤、char_search→skin_search、chars_box→skins_box:
<div class="f-group">
<div class="f-group-title">👗 皮肤筛选
<span class="sub-tabs" id="skins_tabs">
<button type="button" class="sub-tab on" data-tab="all" onclick="setTab('skins','all')">全部皮肤</button>
<button type="button" class="sub-tab" data-tab="sel" onclick="setTab('skins','sel')">已选 <b id="skins_sel_cnt">0</b></button>
</span>
</div>
<div id="skins_pane_all">
<div class="char-picker">
<input type="text" class="char-search" id="skin_search" placeholder="🔍 搜索皮肤名…">
<div class="chars" id="skins_box"><span class="chip" style="cursor:default;opacity:.6">加载皮肤中…</span></div>
</div>
</div>
<div id="skins_pane_sel" style="display:none">
<div class="sel-summary" id="skins_sel_sum">尚未选择任何皮肤</div>
<div class="sel-actions">
<button type="button" onclick="selSetAll('skins',true)">全部设必选</button>
<button type="button" onclick="selSetAll('skins',false)">全部设可选</button>
<button type="button" onclick="selClear('skins')">清空</button>
</div>
<div class="sel-group">
<div class="sel-group-title">必选(必须全有)<span class="n" id="skins_req_cnt">0</span></div>
<div class="chars" id="skins_req_box"></div>
</div>
<div class="sel-group">
<div class="sel-group-title">可选(有一个就行)<span class="n" id="skins_opt_cnt">0</span></div>
<div class="chars" id="skins_opt_box"></div>
</div>
</div>
</div>
> ⚠️ 该页面用 .replace() 渲染(web_handler.py:431-434),不是 .format(),所以 CSS 花括号写单层即可。
---
Files: adata/aohan/quant/game_trade/web_handler.py
Step 4.1 — 替换 state 行(2436):
var state = {page:1, pageSize:50, total:0, sortCol:null, sortDir:'desc', selChars:new Set(), selSkins:new Set(), selCharsReq:new Set(), selSkinsReq:new Set(), charsTab:'all', skinsTab:'all', reqSeq:0, cf:{}};
Step 4.2 — 用新逻辑整体替换 SKIN_CHIP_LIMIT … setSkinsMode(2491-2519 行)为:
var SKIN_CHIP_LIMIT=400;
var PICKERS={
chars:{cn:'武将',sel:'selChars',req:'selCharsReq',tab:'charsTab',all:'allChars',
box:'chars_box',search:'char_search',tabsEl:'chars_tabs',paneAll:'chars_pane_all',
paneSel:'chars_pane_sel',reqBox:'chars_req_box',optBox:'chars_opt_box',
sumEl:'chars_sel_sum',reqCnt:'chars_req_cnt',optCnt:'chars_opt_cnt',tabCnt:'chars_sel_cnt'},
skins:{cn:'皮肤',sel:'selSkins',req:'selSkinsReq',tab:'skinsTab',all:'allSkins',
box:'skins_box',search:'skin_search',tabsEl:'skins_tabs',paneAll:'skins_pane_all',
paneSel:'skins_pane_sel',reqBox:'skins_req_box',optBox:'skins_opt_box',
sumEl:'skins_sel_sum',reqCnt:'skins_req_cnt',optCnt:'skins_opt_cnt',tabCnt:'skins_sel_cnt'}
};
function renderChips(kind){
var cfg=PICKERS[kind];
var box=document.getElementById(cfg.box);box.innerHTML='';
var k=(document.getElementById(cfg.search).value||'').trim();
var items=state[cfg.all]||[];
if(k)items=items.filter(function(c){return c.name.indexOf(k)!==-1;});
if(kind==='skins')items=items.slice(0,SKIN_CHIP_LIMIT);
var frag=document.createDocumentFragment();
items.forEach(function(c){
var el=document.createElement('span');
el.className='chip'+(state[cfg.sel].has(c.name)?' on':'');
el.dataset.name=c.name;
el.innerHTML=c.name+(c.count?' <span class="cnt">'+c.count+'</span>':'');
el.onclick=function(){
if(state[cfg.sel].has(c.name)){state[cfg.sel].delete(c.name);state[cfg.req].delete(c.name);}
else{state[cfg.sel].add(c.name);state[cfg.req].add(c.name);}
el.classList.toggle('on');
renderSelPanels(kind);applyFilters();
};
frag.appendChild(el);
});
box.appendChild(frag);
}
function renderGroupChips(kind,group){
var cfg=PICKERS[kind];
var box=document.getElementById(group==='req'?cfg.reqBox:cfg.optBox);box.innerHTML='';
var names=[];
state[cfg.sel].forEach(function(n){
if((group==='req')===state[cfg.req].has(n))names.push(n);
});
if(!names.length){box.innerHTML='<span class="chip" style="cursor:default;opacity:.45">无</span>';return;}
var frag=document.createDocumentFragment();
names.forEach(function(n){
var el=document.createElement('span');
el.className='chip '+(group==='req'?'req':'opt');
el.title=group==='req'?'点击移入【可选】':'点击移入【必选】';
el.innerHTML=esc(n)+'<span class="x">×</span>';
el.onclick=function(ev){
if(ev.target&&ev.target.className==='x'){state[cfg.sel].delete(n);state[cfg.req].delete(n);}
else if(group==='req'){state[cfg.req].delete(n);}
else{state[cfg.req].add(n);}
renderChips(kind);renderSelPanels(kind);applyFilters();
};
frag.appendChild(el);
});
box.appendChild(frag);
}
function renderSelPanels(kind){
var cfg=PICKERS[kind];var nReq=0,nOpt=0;
state[cfg.sel].forEach(function(n){if(state[cfg.req].has(n))nReq++;else nOpt++;});
document.getElementById(cfg.reqCnt).textContent=nReq;
document.getElementById(cfg.optCnt).textContent=nOpt;
document.getElementById(cfg.tabCnt).textContent=state[cfg.sel].size;
var parts=[];
if(nReq)parts.push('<b>'+nReq+'</b> 个必选(须全含)');
if(nOpt)parts.push('<b>'+nOpt+'</b> 个可选(含任一)');
document.getElementById(cfg.sumEl).innerHTML=parts.length?parts.join(' + '):('尚未选择任何'+cfg.cn);
renderGroupChips(kind,'req');renderGroupChips(kind,'opt');
}
function setTab(kind,tab){
var cfg=PICKERS[kind];state[cfg.tab]=tab;
document.getElementById(cfg.paneAll).style.display=tab==='all'?'':'none';
document.getElementById(cfg.paneSel).style.display=tab==='sel'?'':'none';
document.querySelectorAll('#'+cfg.tabsEl+' .sub-tab').forEach(function(b){
b.classList.toggle('on',b.dataset.tab===tab);
});
if(tab==='sel')renderSelPanels(kind);
}
function selSetAll(kind,toReq){
var cfg=PICKERS[kind];
state[cfg.sel].forEach(function(n){if(toReq)state[cfg.req].add(n);else state[cfg.req].delete(n);});
renderChips(kind);renderSelPanels(kind);applyFilters();
}
function selClear(kind){
var cfg=PICKERS[kind];state[cfg.sel].clear();state[cfg.req].clear();
renderChips(kind);renderSelPanels(kind);applyFilters();
}
Step 4.3 — captureFilters(2539-2542 行)替换为:
f.chars=Array.from(state.selChars);
f.charsReq=Array.from(state.selCharsReq);
f.skins=Array.from(state.selSkins);
f.skinsReq=Array.from(state.selSkinsReq);
f.charsTab=state.charsTab;
f.skinsTab=state.skinsTab;
(删除 f.charsMode/f.skinsMode 两行)
Step 4.4 — restoreFilters(2561-2566 行)替换为:
state.selChars=new Set(f.chars||[]);
state.selSkins=new Set(f.skins||[]);
state.selCharsReq=new Set((f.charsReq||[]).filter(function(n){return state.selChars.has(n);}));
state.selSkinsReq=new Set((f.skinsReq||[]).filter(function(n){return state.selSkins.has(n);}));
if(!f.charsReq&&f.charsMode==='and')state.selCharsReq=new Set(state.selChars);
if(!f.skinsReq&&f.skinsMode==='and')state.selSkinsReq=new Set(state.selSkins);
state.charsTab=f.charsTab==='sel'?'sel':'all';
state.skinsTab=f.skinsTab==='sel'?'sel':'all';
Step 4.5 — restoreSession(2573-2578 行)替换为:
function restoreSession(){
try{var raw=localStorage.getItem(LS_FILTERS);if(raw)restoreFilters(JSON.parse(raw));}catch(e){}
renderChips('chars');renderChips('skins');
renderSelPanels('chars');renderSelPanels('skins');
setTab('chars',state.charsTab);setTab('skins',state.skinsTab);
fetchData(1);
}
Step 4.6 — 搜索监听(2579-2580 行)替换为:
document.getElementById('char_search').addEventListener('input',function(){renderChips('chars');});
document.getElementById('skin_search').addEventListener('input',function(){renderChips('skins');});
Step 4.7 — buildQuery(2592-2593 行)替换为:
var cr=[],co=[];state.selChars.forEach(function(n){(state.selCharsReq.has(n)?cr:co).push(n);});
if(cr.length)q.set('chars_req',cr.join(','));
if(co.length)q.set('chars_opt',co.join(','));
var sr=[],so=[];state.selSkins.forEach(function(n){(state.selSkinsReq.has(n)?sr:so).push(n);});
if(sr.length)q.set('skins_req',sr.join(','));
if(so.length)q.set('skins_opt',so.join(','));
Step 4.8 — clearFilters(2781-2788 行)替换为:
document.getElementById('char_search').value='';document.getElementById('skin_search').value='';
state.selChars.clear();state.selSkins.clear();
state.selCharsReq.clear();state.selSkinsReq.clear();
state.cf={};
renderChips('chars');renderChips('skins');
renderSelPanels('chars');renderSelPanels('skins');
setTab('chars','all');setTab('skins','all');
fetchData(1);
---
Step 5.1 语法检查:
export PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:$PATH
cd /Users/aohan/PycharmProjects/adata
env -u PYTHONHOME -u PYTHONPATH .venv/bin/python -m py_compile \
adata/aohan/quant/game_trade/web_handler.py adata/aohan/quant/db/game_trade.py
Step 5.2 跑全部相关测试:
env -u PYTHONHOME -u PYTHONPATH .venv/bin/python -m unittest \
tests.adata_test.game_trade.test_chars_req_opt \
tests.adata_test.game_trade.test_monitor_push -v
Step 5.3 重启服务并端到端验证:
env -u PYTHONHOME -u PYTHONPATH bash restart.sh
curl -s 'http://127.0.0.1:8765/api/game-trade/goods?game=sanguosha_yijiang&chars_req=曹操&chars_opt=刘备,孙权&page_size=1' | head -c 300
预期:ok:true 且 total 合理(≥0),无 500。
Step 5.4 提交(按模块两个 commit):
git add tests/adata_test/game_trade/test_chars_req_opt.py adata/aohan/quant/db/game_trade.py
git commit -m "feat(game_trade): 武将/皮肤筛选支持必选(AND)/可选(OR)分组查询"
git add adata/aohan/quant/game_trade/web_handler.py docs/plans/2026-09-13-武将必选可选筛选.md
git commit -m "feat(game_trade): 三国杀武将筛选子页签+必选/可选分组 UI"
---
× 能取消。gt_filters_sg)。charsMode:'and')加载后自动落到「必选」组。