打开/关闭菜单
8
231
3
1270
夜幕之下 Wiki - Reign of Nightfall 中文资料站
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

模块:AtlasData

来自夜幕之下 Wiki - Reign of Nightfall 中文资料站

此模块的文档可以在模块:AtlasData/doc创建

-- Module:AtlasData
-- Sandbox atlas renderer backed by Directus cards.

local p = {}

local function trim(value)
    if value == nil then return "" end
    return mw.text.trim(tostring(value))
end

local function parseJson(str)
    if not str or str == "" then return nil end
    local ok, result = pcall(mw.text.jsonDecode, str)
    if ok then return result else return nil end
end

local function normalizeTags(value)
    if not value then return {} end
    if type(value) == "table" then return value end
    if type(value) == "string" then
        local parsed = parseJson(value)
        if type(parsed) == "table" then return parsed end
    end
    return {}
end

local function contains(list, value)
    for _, item in ipairs(list) do
        if trim(item) == value then return true end
    end
    return false
end

local function tagSetFromCards(cards)
    local set = {}
    for _, card in ipairs(cards) do
        for _, tag in ipairs(normalizeTags(card.tags)) do
            local name = trim(tag)
            if name ~= "" then set[name] = true end
        end
    end
    return set
end

local function uniqueCharacterOptions(cards)
    local seen, rows = {}, {}
    for _, card in ipairs(cards) do
        local name = trim(card.character and card.character.name)
        if name ~= "" and not seen[name] then
            seen[name] = true
            table.insert(rows, name)
        end
    end
    return rows
end

local function acquisitionKey(card)
    local key = trim(card.acquisition_type)
    if key == "limited" then return "limited" end
    return "permanent"
end

local function firstText(...)
    local values = {...}
    for _, value in ipairs(values) do
        local text = trim(value)
        if text ~= "" and text ~= "—" then return text end
    end
    return ""
end

local function fileIcon(name, size)
    local text = trim(name)
    if text == "" then return "" end
    return "[[文件:图标_" .. text .. ".png|" .. (size or "24x24px") .. "|link=]]"
end

local function slug(text)
    text = trim(text)
    if text == "" then return "" end
    return text
end

local professionOptions = { "特攻", "先锋", "歼灭", "支援" }
local desireOptions = { "恨欲", "恶欲", "惰欲", "爱欲", "私欲", "贪欲", "食欲" }
local tagOptions = {
    "群攻", "单体", "持续伤害", "治疗", "护盾", "增益", "减益", "控制",
    "驱散", "召唤", "反击", "斩杀", "欲火", "生存", "行动条", "限定",
    "击破", "位移", "自恢复", "减攻", "暴击", "击退"
}


local function fetchCards(frame)
    local url = "https://data.saltedkiss.org/items/cards"
        .. "?fields=id,stylename,rarity,tags,acquisition_type,release_date,character.name,profession.name,desire.name"
        .. ",intel,supply,execute,strategy"
        .. ",attribute_growths.status,attribute_growths.sort,attribute_growths.attr_rank"
        .. ",attribute_growths.base_hp,attribute_growths.base_atk,attribute_growths.base_def"
        .. ",skill_normal_attack.name,skill_normal_attack.type,skill_normal_attack.weapon,skill_normal_attack.description,skill_normal_attack.description_template,skill_normal_attack.levels.*"
        .. ",skill_passive.name,skill_passive.type,skill_passive.trigger_type,skill_passive.trigger_value,skill_passive.tags,skill_passive.description,skill_passive.description_template,skill_passive.levels.*"
        .. ",skill_ultimate.name,skill_ultimate.type,skill_ultimate.desire_cost,skill_ultimate.tags,skill_ultimate.description,skill_ultimate.description_template,skill_ultimate.levels.*"
        .. ",collection_refine_pool"
        .. ",collection_refine_pools.status,collection_refine_pools.sort"
        .. ",collection_refine_pools.pool_id,collection_refine_pools.name"
        .. ",collection_refine_pools.terms.status,collection_refine_pools.terms.sort"
        .. ",collection_refine_pools.terms.effect_id,collection_refine_pools.terms.name"
        .. ",collection_refine_pools.terms.effect_type,collection_refine_pools.terms.attr_id"
        .. ",collection_refine_pools.terms.attr_value,collection_refine_pools.terms.description"
        .. ",collection_refine_pools.terms.quality,collection_refine_pools.terms.normal_weight"
        .. "&limit=-1&sort=-rarity,stylename"
        .. "&deep[attribute_growths][_filter][status][_eq]=published"
        .. "&deep[attribute_growths][_sort]=sort"
        .. "&deep[collection_refine_pools][_filter][status][_eq]=published"
        .. "&deep[collection_refine_pools][_sort]=sort"
        .. "&deep[collection_refine_pools][terms][_filter][status][_eq]=published"
        .. "&deep[collection_refine_pools][terms][_sort]=effect_id"
    local raw = frame:preprocess(
        "{{#get_web_data:url=" .. url
        .. "|format=text|data=responseText=__text}}"
        .. "{{#external_value:responseText}}"
    )
    raw = trim(raw)
    local parsed = parseJson(raw)
    if parsed and type(parsed.data) == "table" then return parsed.data end
    return {}
end

local function cardStatus(card)
    local releaseDate = trim(card.release_date)
    if releaseDate ~= "" then return "published" end
    return "draft"
end

local function rarityClass(rarity, styleName)
    local n = tonumber(rarity) or 0
    if n >= 6 then return "atlas_card-proto--r6-crop-edge-3" end
    if n == 5 then return "atlas_card-proto--r5-crop-edge-4" end
    return "atlas_card-proto--r4-keep-edge-4"
end

local function atlasStyleFile(styleName, rarity)
    local suffix = (tonumber(rarity) or 0) >= 6 and "_2" or ""
    return "图鉴_风格_" .. trim(styleName) .. suffix .. ".png"
end


local function cardFaceFile(styleName, face)
    local suffix = tostring(face or "1") == "2" and "_2" or ""
    return "卡面_" .. trim(styleName) .. suffix .. ".png"
end

local function makePreviewThumbCache(node, styleName, rarity)
    local cache = node:tag("div"):addClass("atlas_card-preview-cache")
    cache:tag("div")
        :attr("data-atlas-preview-thumb", "1")
        :attr("data-atlas-preview-face-source", "1")
        :wikitext("[[文件:" .. cardFaceFile(styleName, "1") .. "|640x329px|link=]]")
    if (tonumber(rarity) or 0) >= 6 then
        cache:tag("div")
            :attr("data-atlas-preview-thumb", "1")
            :attr("data-atlas-preview-face-source", "2")
            :wikitext("[[文件:" .. cardFaceFile(styleName, "2") .. "|640x329px|link=]]")
    end
end

local function makeFilterOption(parent, key, label, iconName)
    local opt = parent:tag("span")
        :addClass("atlas_frame-filter-option")
        :attr("role", "button")
        :attr("tabindex", "0")
        :attr("data-atlas-filter", key)
    if iconName then
        opt:tag("span"):addClass("atlas_frame-filter-icon"):wikitext(fileIcon(iconName, "26x26px"))
    end
    opt:tag("span"):wikitext(label)
    return opt
end

local function makePanelFilterOption(parent, key, label)
    return makeFilterOption(parent, key, label):addClass("atlas_frame-panel-option")
end

local function makePanelSection(panel, key, label)
    local section = panel:tag("div")
        :addClass("atlas_frame-tag-section")
        :addClass("atlas_frame-tag-section--" .. key)
    section:tag("span"):addClass("atlas_frame-tag-section-label"):wikitext(label)
    return section
end


local function firstGrowth(card)
    if not card or type(card.attribute_growths) ~= "table" then return {} end
    return card.attribute_growths[1] or {}
end

local function iconFileName(name)
    local text = trim(name)
    if text == "" or text == "—" then return "" end
    return "图标_" .. text .. ".png"
end

local function skillSummary(skill)
    if type(skill) ~= "table" then return "" end
    local text = tostring(skill.description_template or skill.description or "")
    text = mw.text.trim(text:gsub("<.->", ""):gsub("%s+", " "))
    if text == "" then return "" end
    if mw.ustring.len(text) > 86 then
        text = mw.ustring.sub(text, 1, 84) .. "…"
    end
    return text
end

local function skillTagsText(skill)
    if type(skill) ~= "table" or not skill.tags then return "" end
    if type(skill.tags) == "string" then return skill.tags end
    if type(skill.tags) ~= "table" then return "" end
    local out = {}
    for _, tag in ipairs(skill.tags) do
        if tag ~= nil and tostring(tag) ~= "" then table.insert(out, tostring(tag)) end
    end
    return table.concat(out, "、")
end

local function atlasTriggerLabel(triggerType, triggerValue)
    local t = trim(triggerType)
    local v = trim(triggerValue)
    if t == "normal_attack" then
        return "每进行" .. (v ~= "" and v or "?") .. "次普攻"
    end
    return t ~= "" and t or v
end

local function skillMetaText(skill, skillType)
    if type(skill) ~= "table" then return "" end
    if skillType == "normal_attack" then
        local weapon = trim(skill.weapon)
        return weapon ~= "" and ("使用武器 " .. weapon) or ""
    elseif skillType == "passive" then
        local label = atlasTriggerLabel(skill.trigger_type, skill.trigger_value)
        return label ~= "" and ("触发条件 " .. label) or ""
    elseif skillType == "ultimate" then
        local cost = trim(skill.desire_cost)
        return cost ~= "" and ("欲火消耗 " .. cost) or ""
    end
    return ""
end


local function asJsonArray(value)
    if value == nil then return "[]" end
    if type(value) == "string" then return value end
    if type(value) == "table" then return mw.text.jsonEncode(value) end
    return "[]"
end

local function skillParamRows(levelsValue)
    local rows = levelsValue
    if type(rows) == "string" then rows = parseJson(rows) end
    if type(rows) ~= "table" then return {}, {} end

    local ordered, byIndex = {}, {}
    for idx, row in ipairs(rows) do
        if type(row) == "table" then
            local paramIndex = tonumber(row.source_param_index or row.sort or idx) or idx
            row.__param_index = paramIndex
            table.insert(ordered, row)
            byIndex[paramIndex] = row
        end
    end
    table.sort(ordered, function(a, b)
        local ai = tonumber(a.__param_index) or 9999
        local bi = tonumber(b.__param_index) or 9999
        if ai ~= bi then return ai < bi end
        return tostring(a.name or "") < tostring(b.name or "")
    end)
    return ordered, byIndex
end

local function skillParamValue(paramRow, level)
    if not paramRow or type(paramRow.levels) ~= "table" then return "—" end
    local lv = tonumber(level) or 1
    local value = paramRow.levels[lv]
    if value == nil or value == "" then value = paramRow.levels[1] end
    if value == nil or value == "" then return "—" end
    return tostring(value)
end

local function addSkillDescriptionText(node, text)
    if text and text ~= "" then node:wikitext(text) end
end

local function makeAtlasSkillDescription(skillData, displayLevel)
    local wrap = mw.html.create("div"):addClass("atlas_frame-preview-skill-effect card_content_skill_effect")
    local template = ""
    if type(skillData) == "table" then
        template = mw.text.trim(tostring(skillData.description_template or ""))
        if template == "" then template = mw.text.trim(tostring(skillData.description or "")) end
    end
    if template == "" then
        wrap:wikitext("—")
        return wrap
    end

    local _, paramsByIndex = skillParamRows(skillData.levels)
    local pos = 1
    while true do
        local pStart, pEnd, pIndex = string.find(template, "{p(%d+)}", pos)
        local fStart, fEnd, effectName = string.find(template, "{fx:([^}]+)}", pos)
        if not pStart and not fStart then break end

        local useParam = pStart and (not fStart or pStart < fStart)
        local startPos = useParam and pStart or fStart
        local endPos = useParam and pEnd or fEnd
        addSkillDescriptionText(wrap, string.sub(template, pos, startPos - 1))

        if useParam then
            local index = tonumber(pIndex)
            local value = skillParamValue(paramsByIndex[index], displayLevel or 1)
            local span = wrap:tag("span")
                :addClass("card_content_skill-param")
                :attr("data-skill-param-index", tostring(index))
                :attr("data-skill-param-level", tostring(displayLevel or 1))
            span:wikitext(value)
            span:tag("sup")
                :addClass("card_content_skill-param-index")
                :wikitext(tostring(index))
        else
            local label = mw.text.trim(effectName or "")
            wrap:tag("span")
                :addClass("card_content_skill-effect-term")
                :attr("data-skill-effect", label)
                :wikitext("[" .. label .. "]")
        end
        pos = endPos + 1
    end
    addSkillDescriptionText(wrap, string.sub(template, pos))
    return wrap
end


local collectionQualityMeta = {
    [1] = { code = "C", class = "style_collection-quality--c" },
    [2] = { code = "B", class = "style_collection-quality--b" },
    [3] = { code = "A", class = "style_collection-quality--a" },
    [4] = { code = "S", class = "style_collection-quality--s" },
}

local collectionPercentAttrs = {
    [8] = true, [9] = true, [11] = true, [12] = true,
    [13] = true, [14] = true, [21] = true, [22] = true,
    [23] = true, [24] = true, [58] = true, [59] = true, [60] = true,
}

local function collectionRows(input)
    if type(input) ~= "table" then return {} end
    return input
end

local function collectionFormatPercent(raw)
    local n = (tonumber(raw) or 0) / 100
    local text
    if math.floor(n) == n then
        text = string.format("%.0f", n)
    elseif math.floor(n * 10) == n * 10 then
        text = string.format("%.1f", n)
    else
        text = string.format("%.2f", n)
    end
    return "+" .. text .. "%"
end

local function collectionFormatAttr(attrId, raw)
    local n = tonumber(raw)
    if not n then return "—" end
    if collectionPercentAttrs[tonumber(attrId)] then
        return collectionFormatPercent(n)
    end
    return "+" .. mw.getContentLanguage():formatNum(math.floor(n))
end

local function collectionTermDescription(term)
    if not term then return "—" end
    if tonumber(term.effect_type) == 1 and term.attr_id and term.attr_value then
        return firstText(term.name, "属性") .. " " .. collectionFormatAttr(term.attr_id, term.attr_value)
    end
    return firstText(term.description, "效果描述待补充")
end

local function collectionPreviewTerms(card)
    local terms = {}
    local pools = collectionRows(card.collection_refine_pools)
    for _, pool in ipairs(pools) do
        for _, term in ipairs(collectionRows(pool.terms)) do
            if type(term) == "table" and (tonumber(term.quality) or 0) >= 3 then
                table.insert(terms, term)
            end
        end
    end
    table.sort(terms, function(a, b)
        local aq = tonumber(a.quality) or 0
        local bq = tonumber(b.quality) or 0
        if aq ~= bq then return aq > bq end
        local aw = tonumber(a.normal_weight) or 0
        local bw = tonumber(b.normal_weight) or 0
        if aw ~= bw then return aw > bw end
        local ae = tonumber(a.effect_id) or 999
        local be = tonumber(b.effect_id) or 999
        if ae ~= be then return ae < be end
        return tostring(a.name or "") < tostring(b.name or "")
    end)
    return terms
end

local function makeCollectionPreviewCache(node, card)
    local cache = node:tag("div"):addClass("atlas_card-collection-preview-cache")
    for _, term in ipairs(collectionPreviewTerms(card or {})) do
        local quality = collectionQualityMeta[tonumber(term.quality)] or collectionQualityMeta[1]
        local item = cache:tag("div")
            :addClass("atlas_frame-preview-term")
            :attr("data-atlas-collection-term-source", "1")
        item:tag("span")
            :addClass("style_collection-quality " .. quality.class)
            :wikitext(quality.code)
        local body = item:tag("span"):addClass("atlas_frame-preview-term-body")
        body:tag("span"):addClass("atlas_frame-preview-term-name"):wikitext(firstText(term.name, "词条"))
        body:tag("span"):addClass("atlas_frame-preview-term-desc"):wikitext(collectionTermDescription(term))
    end
end

local function makeSkillPreviewCache(node, skills)
    local cache = node:tag("div"):addClass("atlas_card-skill-preview-cache")
    for _, row in ipairs(skills) do
        cache:tag("div")
            :attr("data-atlas-skill-desc-source", row[1])
            :node(makeAtlasSkillDescription(row[2], 1))
    end
end

local function makeCard(card)
    local rarity = tonumber(card.rarity) or 0
    local styleName = firstText(card.stylename, "未知风格")
    local charName = firstText(card.character and card.character.name, "—")
    local profName = firstText(card.profession and card.profession.name, "—")
    local desireName = firstText(card.desire and card.desire.name, "—")
    local tags = normalizeTags(card.tags)
    local status = cardStatus(card)
    local tagText = table.concat(tags, " ")
    local searchText = table.concat({ styleName, charName, profName, desireName, tagText }, " ")
    local imageFile = atlasStyleFile(styleName, rarity)
    local acquisition = acquisitionKey(card)
    local acquisitionLabel = acquisition == "limited" and "限定" or "常驻"
    local skillNA = card.skill_normal_attack or {}
    local skillPA = card.skill_passive or {}
    local skillUL = card.skill_ultimate or {}
    skillNA.levels = asJsonArray(skillNA.levels)
    skillPA.levels = asJsonArray(skillPA.levels)
    skillUL.levels = asJsonArray(skillUL.levels)

    local node = mw.html.create("div")
        :addClass("atlas_card-proto")
        :addClass(rarityClass(rarity, styleName))
        :addClass("atlas_card-item")
        :attr("role", "button")
        :attr("tabindex", "0")
        :attr("data-atlas-card", "1")
        :attr("data-atlas-card-id", trim(card.id))
        :attr("data-atlas-status", status)
        :attr("data-atlas-danger", tostring(rarity))
        :attr("data-atlas-profession", slug(profName))
        :attr("data-atlas-desire", slug(desireName))
        :attr("data-atlas-character", slug(charName))
        :attr("data-atlas-acquisition", acquisition)
        :attr("data-atlas-page-title", styleName)
        :attr("data-atlas-style-name", styleName)
        :attr("data-atlas-character-name", charName)
        :attr("data-atlas-profession-name", profName)
        :attr("data-atlas-desire-name", desireName)
        :attr("data-atlas-profession-icon", iconFileName(profName))
        :attr("data-atlas-desire-icon", iconFileName(desireName))
        :attr("data-atlas-acquisition-label", acquisitionLabel)
        :attr("data-atlas-info-intel", firstText(card.intel, "—"))
        :attr("data-atlas-info-supply", firstText(card.supply, "—"))
        :attr("data-atlas-info-execute", firstText(card.execute, "—"))
        :attr("data-atlas-info-strategy", firstText(card.strategy, "—"))
        :attr("data-atlas-skill-normal-name", firstText(skillNA.name, "—"))
        :attr("data-atlas-skill-normal-type", firstText(skillNA.type, "—"))
        :attr("data-atlas-skill-normal-meta", skillMetaText(skillNA, "normal_attack"))
        :attr("data-atlas-skill-normal-desc", skillSummary(skillNA))
        :attr("data-atlas-skill-passive-name", firstText(skillPA.name, "—"))
        :attr("data-atlas-skill-passive-type", firstText(skillPA.type, "—"))
        :attr("data-atlas-skill-passive-meta", skillMetaText(skillPA, "passive"))
        :attr("data-atlas-skill-passive-tags", skillTagsText(skillPA))
        :attr("data-atlas-skill-passive-desc", skillSummary(skillPA))
        :attr("data-atlas-skill-ultimate-name", firstText(skillUL.name, "—"))
        :attr("data-atlas-skill-ultimate-type", firstText(skillUL.type, "—"))
        :attr("data-atlas-skill-ultimate-meta", skillMetaText(skillUL, "ultimate"))
        :attr("data-atlas-skill-ultimate-tags", skillTagsText(skillUL))
        :attr("data-atlas-skill-ultimate-desc", skillSummary(skillUL))
        :attr("data-atlas-tag-text", tagText)
        :attr("data-atlas-tags", table.concat(tags, "|"))
        :attr("data-atlas-search-text", searchText)
    node:tag("div")
        :addClass("atlas_card-proto-img")
        :wikitext("[[文件:" .. imageFile .. "|420px|link=]]")
    makePreviewThumbCache(node, styleName, rarity)
    makeSkillPreviewCache(node, {
        { "normal", skillNA },
        { "passive", skillPA },
        { "ultimate", skillUL },
    })
    makeCollectionPreviewCache(node, card)

    local info = node:tag("div"):addClass("atlas_card-proto-info")
    info:tag("div")
        :addClass("atlas_card-proto-meta")
        :tag("span")
        :addClass("atlas_card-proto-character")
        :wikitext(charName)
    local titleRow = info:tag("div"):addClass("atlas_card-proto-title-row")
    local prof = titleRow:tag("span"):addClass("atlas_card-proto-prof")
    prof:tag("span"):addClass("atlas_card-proto-prof-icon"):wikitext(fileIcon(profName, "24x24px"))
    prof:tag("span"):wikitext(profName)
    titleRow:tag("span")
        :addClass("atlas_card-proto-desire")
        :tag("span")
        :addClass("atlas_card-proto-desire-icon")
        :wikitext(fileIcon(desireName, "24x24px"))
    titleRow:tag("span")
        :addClass("atlas_card-proto-title")
        :wikitext(styleName)
    return node
end

function p.render(frame)
    local cards = fetchCards(frame)
    local availableTags = tagSetFromCards(cards)
    local characterOptions = uniqueCharacterOptions(cards)
    local total, published, draft = #cards, 0, 0
    for _, card in ipairs(cards) do
        if cardStatus(card) == "published" then published = published + 1 else draft = draft + 1 end
    end

    local root = mw.html.create("div")
        :addClass("atlas_frame-root")
        :attr("data-atlas-frame-sandbox", "1")

    local hero = root:tag("div"):addClass("atlas_frame-hero")
    hero:tag("div"):addClass("atlas_frame-hero-art card_fullscreen-img")
        :wikitext("[[文件:卡面_复仇童谣.png|2400px|link=]]")
    local heroInner = hero:tag("div"):addClass("atlas_frame-hero-inner")
    heroInner:tag("div"):addClass("atlas_frame-kicker"):wikitext("Wiki Atlas Prototype")
    heroInner:tag("div"):addClass("atlas_frame-title")
        :wikitext("风格图鉴")
        :tag("span"):addClass("atlas_frame-title-sub"):wikitext("STYLE")
    local status = hero:tag("div")
        :addClass("atlas_frame-status")
        :attr("role", "group")
        :attr("aria-label", "图鉴状态筛选")
    local statusRows = {
        { "all", "全部角色", total, true },
        { "published", "已实装", published, false },
        { "draft", "未实装", draft, false },
    }
    for _, item in ipairs(statusRows) do
        local row = status:tag("div")
            :addClass("atlas_frame-status-item")
            :attr("role", "button")
            :attr("tabindex", "0")
            :attr("data-atlas-status", item[1])
        if item[4] then row:addClass("is-active") end
        row:tag("span"):wikitext(item[2])
        row:tag("span"):addClass("atlas_frame-status-num"):wikitext(tostring(item[3]))
    end

    local surface = root:tag("div"):addClass("atlas_frame-surface-wrap")
        :tag("div"):addClass("atlas_frame-surface")
        :tag("div"):addClass("atlas_frame-shell")
    local toolbar = surface:tag("div"):addClass("atlas_frame-toolbar")
    local primary = toolbar:tag("div"):addClass("atlas_frame-filter-row atlas_frame-filter-row--primary")
    local left = primary:tag("div")
        :addClass("atlas_frame-filter-left")
        :attr("role", "group")
        :attr("aria-label", "危险度和职业筛选")
    local danger = left:tag("div")
        :addClass("atlas_frame-filter-group atlas_frame-filter-group--danger")
        :attr("data-atlas-filter-group", "danger")
    danger:tag("span"):addClass("atlas_frame-filter-label"):wikitext("危险度")
    makeFilterOption(danger, "danger-all", "全部"):addClass("is-active")
    makeFilterOption(danger, "danger-6", "6")
    makeFilterOption(danger, "danger-5", "5")
    makeFilterOption(danger, "danger-4", "4")

    local profGroup = left:tag("div")
        :addClass("atlas_frame-filter-group atlas_frame-filter-group--profession")
        :attr("data-atlas-filter-group", "profession")
    profGroup:tag("span"):addClass("atlas_frame-filter-label"):wikitext("职业")
    makeFilterOption(profGroup, "profession-all", "全部"):addClass("is-active")
    for _, name in ipairs(professionOptions) do
        makeFilterOption(profGroup, "profession-" .. name, name, name)
    end

    local previewToggle = primary:tag("div")
        :addClass("atlas_frame-preview-toggle")
        :attr("role", "switch")
        :attr("tabindex", "0")
        :attr("aria-checked", "false")
        :attr("data-atlas-preview-toggle", "1")
    previewToggle:tag("span"):addClass("atlas_frame-preview-toggle-track")
        :tag("span"):addClass("atlas_frame-preview-toggle-thumb")
    previewToggle:tag("span"):addClass("atlas_frame-preview-toggle-text"):wikitext("预览模式")

    local search = primary:tag("div"):addClass("atlas_frame-search"):attr("role", "search")
    search:tag("span"):addClass("atlas_frame-search-mark")
    search:tag("div")
        :addClass("atlas_frame-search-input")
        :attr("role", "textbox")
        :attr("tabindex", "0")
        :attr("contenteditable", "true")
        :attr("spellcheck", "false")
        :attr("aria-label", "搜索角色名或风格名")
        :attr("data-atlas-placeholder", "搜索角色名或风格名")
        :attr("data-atlas-search", "1")

    local desire = toolbar:tag("div")
        :addClass("atlas_frame-filter-row atlas_frame-filter-row--desire")
        :attr("role", "group")
        :attr("aria-label", "欲望筛选")
        :attr("data-atlas-filter-group", "desire")
    desire:tag("span"):addClass("atlas_frame-filter-label"):wikitext("欲望")
    makeFilterOption(desire, "desire-all", "全部"):addClass("is-active")
    for _, name in ipairs(desireOptions) do
        makeFilterOption(desire, "desire-" .. name, name, name)
    end

    local tagsRow = toolbar:tag("div"):addClass("atlas_frame-filter-row atlas_frame-filter-row--tags")
    local toggle = tagsRow:tag("div")
        :addClass("atlas_frame-tag-toggle")
        :attr("role", "button")
        :attr("tabindex", "0")
        :attr("data-atlas-tag-toggle", "1")
        :attr("aria-expanded", "false")
    toggle:tag("span"):wikitext("展开标签筛选")
    toggle:tag("span"):addClass("atlas_frame-tag-toggle-line")
    toggle:tag("span"):addClass("atlas_frame-tag-toggle-mark"):wikitext("+")
    local panel = tagsRow:tag("div"):addClass("atlas_frame-tag-panel"):attr("data-atlas-tag-panel", "1")

    local characterPanel = makePanelSection(panel, "character", "角色")
        :attr("data-atlas-filter-group", "character")
    for _, name in ipairs(characterOptions) do
        makePanelFilterOption(characterPanel, "character-" .. name, name)
    end

    local acquisitionPanel = makePanelSection(panel, "acquisition", "获取途径")
        :attr("data-atlas-filter-group", "acquisition")
    makePanelFilterOption(acquisitionPanel, "acquisition-permanent", "常驻")
    makePanelFilterOption(acquisitionPanel, "acquisition-limited", "限定")

    local positionPanel = makePanelSection(panel, "position", "定位")
    for _, name in ipairs(tagOptions) do
        local opt = positionPanel:tag("span")
            :addClass("atlas_frame-tag-option")
            :attr("role", "button")
            :attr("tabindex", "0")
            :attr("data-atlas-tag", name)
        if not availableTags[name] then opt:addClass("is-disabled") end
        opt:wikitext(name)
    end

    local layout = surface:tag("div"):addClass("atlas_frame-layout")
    local grid = layout:tag("div")
        :addClass("atlas_frame-grid atlas_frame-grid--card-protos")
        :attr("aria-label", "图鉴卡片列表")
    for _, card in ipairs(cards) do
        grid:node(makeCard(card))
    end
    grid:tag("div")
        :addClass("atlas_frame-empty")
        :attr("data-atlas-empty", "1")
        :attr("hidden", "hidden")
        :wikitext("没有符合条件的风格")

    local preview = layout:tag("div"):addClass("atlas_frame-preview"):attr("data-atlas-preview-panel", "1")
    preview:tag("div")
        :addClass("atlas_frame-preview-empty")
        :attr("data-atlas-preview-empty", "1")
        :wikitext("请选择风格预览")
    local previewHead = preview:tag("div"):addClass("atlas_frame-preview-head")
    previewHead:tag("div"):addClass("atlas_frame-preview-title"):attr("data-atlas-preview-title", "1"):wikitext("详情预览")
    previewHead:tag("div")
        :addClass("atlas_frame-preview-open")
        :attr("role", "button")
        :attr("tabindex", "0")
        :attr("aria-disabled", "true")
        :attr("data-atlas-preview-open", "1")
        :wikitext("打开风格页")
    previewHead:tag("div"):addClass("atlas_frame-preview-mode"):attr("data-atlas-preview-mode-label", "1"):wikitext("默认点击会在新窗口打开风格页")
    local artWrap = preview:tag("div"):addClass("atlas_frame-preview-art-wrap")
    artWrap:tag("div"):addClass("atlas_frame-preview-art"):attr("data-atlas-preview-art", "1")
    local faceSwitch = artWrap:tag("div"):addClass("atlas_frame-preview-face-switch"):attr("data-atlas-preview-face-switch", "1")
    faceSwitch:tag("div"):addClass("atlas_frame-preview-face")
        :attr("role", "button"):attr("tabindex", "0"):attr("data-atlas-preview-face", "1"):wikitext("Ⅰ")
    faceSwitch:tag("div"):addClass("atlas_frame-preview-face is-active")
        :attr("role", "button"):attr("tabindex", "0"):attr("data-atlas-preview-face", "2"):wikitext("Ⅱ")
    local previewMeta = preview:tag("div"):addClass("atlas_frame-preview-meta")
    local metaTop = previewMeta:tag("div"):addClass("atlas_frame-preview-meta-top")
    metaTop:tag("div"):addClass("atlas_frame-preview-name"):attr("data-atlas-preview-style", "1"):wikitext("未选择")
    local metaTax = metaTop:tag("div"):addClass("atlas_frame-preview-taxonomy")
    local metaProf = metaTax:tag("div"):addClass("atlas_frame-preview-taxonomy-item")
    metaProf:tag("span"):addClass("atlas_frame-preview-stat-icon"):attr("data-atlas-preview-profession-icon", "1")
    metaProf:tag("span"):addClass("atlas_frame-preview-stat-text"):attr("data-atlas-preview-profession-text", "1"):wikitext("—")
    local metaDesire = metaTax:tag("div"):addClass("atlas_frame-preview-taxonomy-item")
    metaDesire:tag("span"):addClass("atlas_frame-preview-stat-icon"):attr("data-atlas-preview-desire-icon", "1")
    metaDesire:tag("span"):addClass("atlas_frame-preview-stat-text"):attr("data-atlas-preview-desire-text", "1"):wikitext("—")
    local metaSub = previewMeta:tag("div"):addClass("atlas_frame-preview-meta-sub")
    metaSub:tag("div"):addClass("atlas_frame-preview-sub"):attr("data-atlas-preview-sub", "1"):wikitext("开启预览模式后,点击卡片会在这里显示横屏卡面和简单信息。")
    metaSub:tag("div"):addClass("atlas_frame-preview-tags card_content_style-tags card_content_skill-tags")
        :attr("data-atlas-preview-tags", "1"):wikitext("—")
    metaSub:tag("div"):addClass("atlas_frame-preview-acquisition"):attr("data-atlas-preview-acquisition", "1"):wikitext("—")
    local infoLine = preview:tag("div"):addClass("atlas_frame-preview-info-line card_content_info")
    local dangerItem = infoLine:tag("div"):addClass("atlas_frame-preview-info-item atlas_frame-preview-info-item--danger card_content_info-item")
    dangerItem:tag("div"):addClass("atlas_frame-preview-danger-label"):wikitext("")
    dangerItem:tag("div"):addClass("atlas_frame-preview-stars"):attr("data-atlas-preview-danger", "1"):wikitext("—")
    local attrRows = {
        { "情报", "intel" },
        { "物资", "supply" },
        { "执行", "execute" },
        { "策略", "strategy" },
    }
    for _, row in ipairs(attrRows) do
        local item = infoLine:tag("div"):addClass("atlas_frame-preview-info-item card_content_info-item")
        item:tag("div"):wikitext(row[1])
        item:tag("div"):attr("data-atlas-preview-attr-" .. row[2], "1"):wikitext("—")
    end
    local skills = preview:tag("div"):addClass("atlas_frame-preview-skills"):attr("data-atlas-preview-skills", "1")
    local skillRows = {
        { "普攻", "normal" },
        { "被动", "passive" },
        { "终结", "ultimate" },
    }
    for _, row in ipairs(skillRows) do
        local skill = skills:tag("div"):addClass("atlas_frame-preview-skill"):attr("data-atlas-preview-skill", row[2])
        local head = skill:tag("div"):addClass("atlas_frame-preview-skill-head")
        head:tag("b"):addClass("atlas_frame-preview-skill-name"):attr("data-atlas-preview-skill-" .. row[2] .. "-name", "1"):wikitext("—")
        head:tag("span"):addClass("atlas_frame-preview-skill-type card_accent--type"):attr("data-atlas-preview-skill-" .. row[2] .. "-type", "1"):wikitext(row[1])
        head:tag("span"):addClass("atlas_frame-preview-skill-meta"):attr("data-atlas-preview-skill-" .. row[2] .. "-meta", "1"):wikitext("—")
        skill:tag("div"):addClass("atlas_frame-preview-skill-desc"):attr("data-atlas-preview-skill-" .. row[2] .. "-desc", "1"):wikitext("—")
    end
    local collectionPreview = preview:tag("div")
        :addClass("atlas_frame-preview-collection")
        :attr("data-atlas-preview-collection", "1")
    collectionPreview:tag("div"):addClass("atlas_frame-preview-collection-title"):wikitext("映像词条")
    collectionPreview:tag("div")
        :addClass("atlas_frame-preview-collection-list")
        :attr("data-atlas-preview-collection-list", "1")
        :wikitext("—")
    preview:tag("div"):addClass("atlas_frame-preview-copy"):attr("data-atlas-preview-copy", "1")
        :wikitext("默认状态下点击卡片会在新窗口打开对应风格页面。")

    return tostring(root)
end

return p