← 文档库
📄 文档docs/plans/2026-09-13-武将必选可选筛选.md
🌱 创建于2026-09-13 11:57
✏️ 修改于2026-09-13 11:57
👁️ 浏览量—
🔗 短链接/~learn/s/418d405c
🔐 可见性

三国杀武将筛选「必选 / 可选」分组 Implementation Plan

> 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。

---

背景与约束

验收标准

---

Task 1:后端 _build_where / query_goods 支持 req/opt 分组(TDD 先红)

Files:

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。

---

Task 2:后端 API 透传新参数(TDD 先红)

Files:

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。

---

Task 3:前端 CSS + 标记(子页签 / 必选可选面板)

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 花括号写单层即可。

---

Task 4:前端 JS 逻辑

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);

---

Task 5:验证与提交

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"

---

手工验收清单(浏览器)

▶ 📜 变更历史(1 次提交)

07ada002026-09-13 11:57feat(game_trade): 武将/皮肤筛选支持必选(AND)·可选(OR)分组查询
🍃quant 知识库
by 伊甸园和枫

🔊 播放设置

勾选音源后调节配比,总和自动归一化
已选 0 个 · 合计:0%
合成中...
00:00/00:00
1.0x

分享图片

生成中...