已经满足条件但是没有拿到指导顾问勋章
11037
2026-08-26 18:18
1

看了自己已经有14个解决方案,但是迟迟没有拿到这个勋章,一周前我就已经满10了
最新回复 (9)
-
9527-oo 08-26 18:191楼你从哪看你符合10条被标记为解决方案的话题
-
xun 08-26 18:192楼我记得勋章的计数是统计无权限贴的数据,你要无权限贴的10个解决方案才行 ^-^
-
11037 楼主 08-26 18:203楼
这里 -
Linus Torvalds 08-26 18:204楼这些徽章要无等级的帖子的,而且是部分分区,有的分区不算
还差4个lv0/无等级的,积分乐园、深海、推广分区等等的都不算

-
醉爱天使 08-26 18:205楼有些勋章第二天才发,不要着急,明天看看
-
11037 楼主 08-26 18:216楼原来是这样,请问这个页面在哪里看啊
-
Linus Torvalds 08-26 18:237楼https://linux.do/t/topic/965302?u=torvalds
-
Grogu 08-26 18:558楼只统计无权限帖,无权限帖 = 在无权限的类别中的无等级帖子
-
Waitingfor 08-26 18:589楼效果:

油猴脚本:
// ==UserScript==
// @name linux.do Solution 统计(动态分类版)
// @namespace https://linux.do/
// @version 2.7.1
// @description 全量可见分类、分页加载、排除自解的徽章进度、支持查询其他用户的 Solution 统计
// @match https://linux.do/*
// @match http://linux.do/*
// @exclude https://linux.do/a/*
// @exclude http://linux.do/a/*
// @run-at document-idle
// @noframes
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
const API = location.origin;
const DRAWER_ID = 'ldo-solution-drawer';
const LAUNCHER_ID = 'ldo-solution-launcher';
const PAGE_SIZE = 200;
const MAX_PAGES = 50;
const REQUEST_DELAY = 800;
const LARGE_QUERY_THRESHOLD = 1000;
const CACHE_TTL = 5 * 60 * 1000;
const BADGE_CATEGORY_CACHE_TTL = 6 * 60 * 60 * 1000;
const CREATED_TOPICS_PAGE_SIZE_ESTIMATE = 30;
const SELF_CHECK_CONFIRM_THRESHOLD = 20;
const SELF_CHECK_MAX_REQUESTS = 100;
const SELF_CHECK_REQUEST_DELAY = 1200;
const SELF_CHECK_CACHE_TTL = 6 * 60 * 60 * 1000;
const SELF_CHECK_CACHE_KEY = 'ldo-solution-self-check-v2';
const SELF_CHECK_CACHE_LIMIT = 5000;
const QUICK_ANSWER_TAG_SLUG = '1436-tag';
const BADGES = [
{ threshold: 1, name: '已解决' },
{ threshold: 10, name: '指导顾问' },
{ threshold: 50, name: '无所不知' },
{ threshold: 150, name: '解决方案机构' }
];
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let controller = null;
const resultCache = new Map();
const badgeCategoryCache = new Map();
let selfCheckCache = new Map();
let state = {
username: '',
currentUsername: '',
officialTotal: null,
officialTopicCount: null,
queriedUserId: null,
posts: [],
categories: [],
byId: new Map(),
badgeCategories: new Map(),
badgeMetaError: null,
badgeEligibleCount: null,
badgeUnknownCount: 0,
badgeSelfSolvedCount: 0,
badgeSelfUnknownCount: 0,
selfSolvedByTopic: new Map(),
selfCheckMode: 'none',
selfCheckRequestCount: 0,
stats: [],
unknown: [],
activeTab: 'levels',
detailCategoryId: null,
detailPage: 1,
detailQuery: ''
};
GM_addStyle(`
#${LAUNCHER_ID}{position:fixed;right:18px;bottom:22px;z-index:999998;display:flex;align-items:center;gap:8px;height:40px;padding:0 13px;border:1px solid #3b4654;border-radius:8px;background:#192129;color:#e8edf2;box-shadow:0 8px 24px #0007;cursor:pointer;font:600 13px/1 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
#${LAUNCHER_ID}:hover{border-color:#48b883;background:#202b34} #${LAUNCHER_ID} .mark{display:grid;place-items:center;width:22px;height:22px;border-radius:5px;background:#48b883;color:#0d2017;font-weight:800}
#${DRAWER_ID}{position:fixed;top:0;right:0;bottom:0;z-index:999999;width:min(500px,100vw);display:flex;flex-direction:column;background:#11161c;color:#e8edf2;border-left:1px solid #303944;box-shadow:-18px 0 50px #0009;transform:translateX(105%);transition:transform .22s ease;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
#${DRAWER_ID}.open{transform:translateX(0)} #${DRAWER_ID} *{box-sizing:border-box} #${DRAWER_ID} button,#${DRAWER_ID} input{font:inherit}
#${DRAWER_ID} .top{display:flex;align-items:center;gap:12px;padding:18px 18px 15px;border-bottom:1px solid #2b343e;background:#141a21} #${DRAWER_ID} .brand{display:grid;place-items:center;width:36px;height:36px;border-radius:7px;background:#48b883;color:#0d2017;font-size:17px;font-weight:800} #${DRAWER_ID} .title{flex:1;min-width:0} #${DRAWER_ID} h2{margin:0;font-size:17px;letter-spacing:0} #${DRAWER_ID} .subtitle{margin-top:2px;color:#8f9aa7;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#${DRAWER_ID} .icon-btn{width:34px;height:34px;padding:0;border:1px solid #34404c;border-radius:6px;background:#1b232c;color:#bac4cf;cursor:pointer;font-size:19px} #${DRAWER_ID} .icon-btn:hover{color:#fff;border-color:#637181}
#${DRAWER_ID} .query{padding:14px 18px;border-bottom:1px solid #2b343e;background:#141a21} #${DRAWER_ID} .input-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px} #${DRAWER_ID} input{width:100%;height:40px;padding:0 12px;border:1px solid #37424e;border-radius:7px;outline:none;background:#0f141a;color:#f1f5f8} #${DRAWER_ID} input:focus{border-color:#48b883;box-shadow:0 0 0 2px #48b88324} #${DRAWER_ID} .primary{height:40px;padding:0 16px;border:1px solid #48b883;border-radius:7px;background:#2d8b63;color:#fff;font-weight:650;cursor:pointer} #${DRAWER_ID} .primary:hover{background:#339c70} #${DRAWER_ID} button:disabled{opacity:.5;cursor:not-allowed}
#${DRAWER_ID} .query-tools{display:flex;justify-content:space-between;align-items:center;min-height:25px;margin-top:7px;color:#8f9aa7;font-size:12px} #${DRAWER_ID} .text-btn{padding:0;border:0;background:none;color:#62c997;cursor:pointer} #${DRAWER_ID} .cancel{color:#ef8585}
#${DRAWER_ID} .progress{display:none;height:3px;background:#26313b;overflow:hidden} #${DRAWER_ID}.loading .progress{display:block} #${DRAWER_ID} .progress i{display:block;width:32%;height:100%;background:#48b883;animation:ldo-progress 1.15s ease-in-out infinite} @keyframes ldo-progress{0%{transform:translateX(-110%)}100%{transform:translateX(420%)}}
#${DRAWER_ID} .content{flex:1;min-height:0;overflow:auto;padding:16px 18px 26px} #${DRAWER_ID} .kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px} #${DRAWER_ID} .kpi{min-height:78px;padding:11px 12px;border:1px solid #2e3843;border-radius:7px;background:#181f27} #${DRAWER_ID} .kpi b{display:block;font-size:22px;font-weight:700;font-variant-numeric:tabular-nums} #${DRAWER_ID} .kpi span{color:#8f9aa7;font-size:11px}
#${DRAWER_ID} .badge-box{margin-bottom:16px;padding:13px 14px;border:1px solid #3b4654;border-radius:8px;background:#1a222b} #${DRAWER_ID} .badge-line{display:flex;align-items:baseline;justify-content:space-between;gap:10px} #${DRAWER_ID} .badge-title{color:#f1d487;font-weight:700} #${DRAWER_ID} .badge-next{color:#aab5c0;font-size:12px} #${DRAWER_ID} .badge-track{height:7px;margin-top:9px;border-radius:99px;background:#29333d;overflow:hidden} #${DRAWER_ID} .badge-track i{display:block;height:100%;border-radius:99px;background:#d3a83f} #${DRAWER_ID} .badge-note{margin-top:7px;color:#8f9aa7;font-size:12px}
#${DRAWER_ID} .tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;padding:3px;border-radius:7px;background:#181f27;margin-bottom:14px} #${DRAWER_ID} .tabs button{height:33px;border:0;border-radius:5px;background:transparent;color:#94a0ad;cursor:pointer} #${DRAWER_ID} .tabs button.active{background:#293440;color:#fff;box-shadow:0 1px 4px #0005}
#${DRAWER_ID} .level-row{display:grid;grid-template-columns:52px minmax(0,1fr) 48px;align-items:center;gap:11px;padding:12px 2px;border-bottom:1px solid #29323c} #${DRAWER_ID} .level-tag{display:inline-flex;justify-content:center;padding:3px 7px;border-radius:4px;background:#293440;color:#dfe6ec;font-size:12px;font-weight:700;text-transform:uppercase} #${DRAWER_ID} .track{height:8px;border-radius:99px;background:#242d36;overflow:hidden} #${DRAWER_ID} .track i{display:block;height:100%;border-radius:99px;background:#48b883} #${DRAWER_ID} .value{text-align:right;font-weight:700;font-variant-numeric:tabular-nums}
#${DRAWER_ID} table{width:100%;border-collapse:collapse} #${DRAWER_ID} th,#${DRAWER_ID} td{padding:10px 7px;border-bottom:1px solid #29323c;text-align:left;vertical-align:top;font-size:12px} #${DRAWER_ID} th{position:sticky;top:-16px;z-index:1;background:#11161c;color:#84909c;font-weight:600} #${DRAWER_ID} td:last-child,#${DRAWER_ID} th:last-child{text-align:right} #${DRAWER_ID} code{color:#84909c;font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px} #${DRAWER_ID} .unknown{color:#e7b968}
#${DRAWER_ID} .empty{padding:52px 20px;text-align:center;color:#84909c} #${DRAWER_ID} .empty strong{display:block;margin-bottom:5px;color:#e8edf2;font-size:15px} #${DRAWER_ID} .foot{padding:10px 18px;border-top:1px solid #29323c;color:#6f7b87;background:#141a21;font-size:11px}
@media(max-width:560px){#${LAUNCHER_ID}{right:10px;bottom:12px}#${DRAWER_ID} .top{padding-top:14px}#${DRAWER_ID} .content{padding-left:14px;padding-right:14px}}
/* 暖灰底 + 彩色功能区主题 */
#${LAUNCHER_ID}{background:#f5f2ec;color:#29323b;border-color:#d8d1c5;box-shadow:0 8px 24px #463b2c24}
#${LAUNCHER_ID}:hover{background:#fffdf9;border-color:#50a7d8} #${LAUNCHER_ID} .mark{background:#50a7d8;color:#fff}
#${DRAWER_ID}{background:#f5f2ec;color:#29323b;border-left-color:#ded7cc;box-shadow:-18px 0 50px #463b2c30}
#${DRAWER_ID} .top,#${DRAWER_ID} .query,#${DRAWER_ID} .foot{background:#eeebe4;border-color:#ded7cc}
#${DRAWER_ID} .brand{background:#50a7d8;color:#fff} #${DRAWER_ID} .subtitle,#${DRAWER_ID} .meta{color:#7d8791}
#${DRAWER_ID} .icon-btn,#${DRAWER_ID} button{background:#fffdf9;color:#3b4650;border-color:#d5cec2}
#${DRAWER_ID} .icon-btn:hover,#${DRAWER_ID} button:hover{border-color:#50a7d8;color:#21617e}
#${DRAWER_ID} .primary{background:#3f9b74;color:#fff;border-color:#3f9b74} #${DRAWER_ID} .primary:hover{background:#348765}
#${DRAWER_ID} input{background:#fffdf9;color:#29323b;border-color:#d5cec2} #${DRAWER_ID} input:focus{border-color:#50a7d8;box-shadow:0 0 0 2px #50a7d82b}
#${DRAWER_ID} .content{background:#f5f2ec} #${DRAWER_ID} .kpi{background:#fffdf9;border-color:#d9d2c6;box-shadow:0 2px 8px #463b2c0d} #${DRAWER_ID} .kpi:nth-child(1){border-top:3px solid #50a7d8} #${DRAWER_ID} .kpi:nth-child(2){border-top:3px solid #55b68a} #${DRAWER_ID} .kpi:nth-child(3){border-top:3px solid #e77d6c} #${DRAWER_ID} .kpi span{color:#7d8791}
#${DRAWER_ID} .tabs{background:#e7e2d9} #${DRAWER_ID} .tabs button{color:#747e88} #${DRAWER_ID} .tabs button.active{background:#fffdf9;color:#29323b;box-shadow:0 2px 7px #463b2c1c}
#${DRAWER_ID} .level-row{border-color:#ddd6ca} #${DRAWER_ID} .level-tag{background:#e1e8ef;color:#31536c} #${DRAWER_ID} .level-row:nth-child(2) .level-tag{background:#dfeee5;color:#2f6e4e} #${DRAWER_ID} .level-row:nth-child(3) .level-tag{background:#ebe2f4;color:#694a83} #${DRAWER_ID} .level-row:nth-child(4) .level-tag{background:#f5e0dc;color:#975449} #${DRAWER_ID} .track{background:#e2ddd4} #${DRAWER_ID} .track i{background:#50a7d8}
#${DRAWER_ID} th{background:#f5f2ec;color:#7d8791} #${DRAWER_ID} td{border-color:#ddd6ca} #${DRAWER_ID} code{color:#71808b} #${DRAWER_ID} .unknown{color:#c96857}
#${DRAWER_ID} .badge-box{background:#fff8e8;border-color:#e3c879;box-shadow:0 2px 8px #8a6a1c14} #${DRAWER_ID} .badge-title{color:#9c7413} #${DRAWER_ID} .badge-next,#${DRAWER_ID} .badge-note{color:#85775a} #${DRAWER_ID} .badge-track{background:#eadfc3} #${DRAWER_ID} .badge-track i{background:#d7a933}
#${DRAWER_ID} .empty{color:#7d8791} #${DRAWER_ID} .empty strong{color:#394650}
#${DRAWER_ID} .group{margin-bottom:14px;border:1px solid #d9d2c6;border-radius:8px;background:#fffdf9;overflow:hidden} #${DRAWER_ID} .group-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 13px;background:#eeebe4;border-bottom:1px solid #d9d2c6} #${DRAWER_ID} .group-name{font-weight:700} #${DRAWER_ID} .group-total{color:#7d8791;font-size:12px} #${DRAWER_ID} .cat-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 13px;border-bottom:1px solid #e5ded3;cursor:pointer} #${DRAWER_ID} .cat-row:hover{background:#f1eee8} #${DRAWER_ID} .cat-row:last-child{border-bottom:0} #${DRAWER_ID} .cat-main{display:flex;align-items:center;gap:8px;min-width:0} #${DRAWER_ID} .cat-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #${DRAWER_ID} .cat-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border-radius:99px;color:#fff;font-size:11px;white-space:nowrap} #${DRAWER_ID} .cat-id{color:#83909a;font:11px ui-monospace,SFMono-Regular,monospace} #${DRAWER_ID} .cat-total{min-width:30px;text-align:right;font-weight:700}
#${DRAWER_ID}.dark{background:#11161c;color:#e8edf2;border-left-color:#303944} #${DRAWER_ID}.dark .top,#${DRAWER_ID}.dark .query,#${DRAWER_ID}.dark .foot{background:#141a21;border-color:#2b343e} #${DRAWER_ID}.dark .icon-btn,#${DRAWER_ID}.dark button{background:#1b232c;color:#bac4cf;border-color:#34404c} #${DRAWER_ID}.dark .icon-btn:hover,#${DRAWER_ID}.dark button:hover{border-color:#48b883;color:#fff} #${DRAWER_ID}.dark input{background:#0f141a;color:#f1f5f8;border-color:#37424e} #${DRAWER_ID}.dark .content{background:#11161c} #${DRAWER_ID}.dark .kpi{background:#181f27;border-color:#2e3843;box-shadow:none} #${DRAWER_ID}.dark .kpi span,#${DRAWER_ID}.dark .subtitle,#${DRAWER_ID}.dark .meta{color:#8f9aa7} #${DRAWER_ID}.dark .tabs{background:#181f27} #${DRAWER_ID}.dark .tabs button{color:#94a0ad} #${DRAWER_ID}.dark .tabs button.active{background:#293440;color:#fff} #${DRAWER_ID}.dark .level-row,#${DRAWER_ID}.dark td{border-color:#29323c} #${DRAWER_ID}.dark th{background:#11161c;color:#84909c} #${DRAWER_ID}.dark .level-tag{background:#293440;color:#dfe6ec} #${DRAWER_ID}.dark .track{background:#242d36} #${DRAWER_ID}.dark code{color:#84909c} #${DRAWER_ID}.dark .empty{color:#84909c} #${DRAWER_ID}.dark .empty strong{color:#e8edf2} #${DRAWER_ID}.dark .group{border-color:#303944;background:#181f27} #${DRAWER_ID}.dark .group-head{background:#202934;border-color:#303944} #${DRAWER_ID}.dark .cat-row{border-color:#29323c} #${DRAWER_ID}.dark .cat-row:hover{background:#202934} #${DRAWER_ID}.dark .cat-id{color:#84909c}
#${DRAWER_ID}.dark .badge-box{background:#302a1c;border-color:#775f28} #${DRAWER_ID}.dark .badge-title{color:#f1d487} #${DRAWER_ID}.dark .badge-next,#${DRAWER_ID}.dark .badge-note{color:#b8aa83} #${DRAWER_ID}.dark .badge-track{background:#514524}
#${LAUNCHER_ID}.dark{background:#192129;color:#e8edf2;border-color:#3b4654;box-shadow:0 8px 24px #0007} #${LAUNCHER_ID}.dark:hover{background:#202b34;border-color:#48b883}
#${DRAWER_ID} .group-head{width:100%;border:0;text-align:left;cursor:pointer;color:inherit;font:inherit} #${DRAWER_ID} .group-head:hover{background:#e5dfd5} #${DRAWER_ID}.dark .group-head{background:#202934;color:#e8edf2} #${DRAWER_ID}.dark .group-head:hover{background:#293541}
#${DRAWER_ID} .detail-tools{display:flex;flex-direction:column;gap:10px;margin-bottom:12px} #${DRAWER_ID} .detail-tools strong{font-size:15px} #${DRAWER_ID} .detail-search{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:7px} #${DRAWER_ID} .detail-search input,#${DRAWER_ID} .detail-search button{height:35px} #${DRAWER_ID} .post-list{border:1px solid #d9d2c6;border-radius:8px;overflow:hidden;background:#fffdf9} #${DRAWER_ID} .post-item{display:flex;flex-direction:column;gap:3px;padding:11px 13px;border-bottom:1px solid #e5ded3;color:#29323b;text-decoration:none} #${DRAWER_ID} .post-item:last-child{border-bottom:0} #${DRAWER_ID} .post-item:hover{background:#f1eee8} #${DRAWER_ID} .post-title{font-weight:650} #${DRAWER_ID} .post-meta{color:#84909c;font-size:11px} #${DRAWER_ID} .pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:13px;color:#7d8791;font-size:12px} #${DRAWER_ID} .pager button{min-width:62px}
#${DRAWER_ID}.dark .post-list{border-color:#303944;background:#181f27} #${DRAWER_ID}.dark .post-item{color:#e8edf2;border-color:#29323c} #${DRAWER_ID}.dark .post-item:hover{background:#202934} #${DRAWER_ID}.dark .post-meta{color:#84909c}
#${DRAWER_ID}:not(.dark){background:#f7efdf} #${DRAWER_ID}:not(.dark) .top,#${DRAWER_ID}:not(.dark) .query,#${DRAWER_ID}:not(.dark) .foot{background:#f0e4cf;border-color:#ded0b9} #${DRAWER_ID}:not(.dark) .content{background:#f7efdf} #${DRAWER_ID}:not(.dark) .kpi{background:#fff9ed;border-color:#dfd0b8} #${DRAWER_ID}:not(.dark) .tabs{background:#eadfc9} #${DRAWER_ID}:not(.dark) .tabs button.active{background:#fff9ed} #${DRAWER_ID}:not(.dark) .group{background:#fff9ed;border-color:#dfd0b8} #${DRAWER_ID}:not(.dark) .group-head{background:#f0e4cf;border-color:#dfd0b8} #${DRAWER_ID}:not(.dark) .post-list{background:#fff9ed;border-color:#dfd0b8} #${DRAWER_ID}:not(.dark) .post-item:hover{background:#f4ead8}
#${DRAWER_ID} .request-note{margin:0 0 10px;padding:11px 13px;border:1px solid #d9d2c6;border-radius:7px;background:#eeebe4;color:#68747f;font-size:11px;line-height:1.65} #${DRAWER_ID} .request-note strong{color:#394650} #${DRAWER_ID}.dark .request-note{border-color:#4b6474;background:#24313c;color:#aab8c4} #${DRAWER_ID}.dark .request-note strong{color:#e5edf3}
#${DRAWER_ID} .badge-count-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;margin:0 2px 8px;padding:11px 12px;border:1px solid #e3c879;border-left:4px solid #d7a933;border-radius:7px;background:#fff8e8} #${DRAWER_ID} .badge-count-label{color:#594a2a;font-size:13px;font-weight:700} #${DRAWER_ID} .badge-count-note{margin-top:2px;color:#85775a;font-size:11px} #${DRAWER_ID} .badge-count-value{color:#9c7413;font-size:20px;font-weight:750;font-variant-numeric:tabular-nums} #${DRAWER_ID} .cat-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-shrink:0} #${DRAWER_ID} .badge-eligibility{display:inline-flex;align-items:center;padding:2px 7px;border-radius:99px;color:#fff;font-size:11px;line-height:1.35;white-space:nowrap} #${DRAWER_ID} .badge-eligibility.is-eligible{background:#2f9d5b} #${DRAWER_ID} .badge-eligibility.is-excluded{background:#c95b52} #${DRAWER_ID} .badge-eligibility.is-unknown{background:#7b8792} #${DRAWER_ID}.dark .badge-count-row{background:#302a1c;border-color:#775f28;border-left-color:#d3a83f} #${DRAWER_ID}.dark .badge-count-label{color:#dfe6ec} #${DRAWER_ID}.dark .badge-count-note{color:#b8aa83} #${DRAWER_ID}.dark .badge-count-value{color:#f1d487}
`);
const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[char]));
function levelOf(category) {
if (!category) return null;
const match = `${category.slug || ''} ${category.name || ''}`.match(/(?:^|[-_\s,,])lv([1-3])(?:$|[-_\s,,])/i);
if (match) return `lv${match[1]}`;
const hasChildren = category.has_children || (Array.isArray(category.subcategory_ids) && category.subcategory_ids.length > 0);
return category.parent_category_id == null || hasChildren ? 'lv0' : null;
}
function chooseSelfCheckMode({ unresolvedTopicCount, authoredTopicCount }) {
const firstPostRequests = Math.max(0, Number(unresolvedTopicCount) || 0);
const authoredCount = Math.max(0, Number(authoredTopicCount) || 0);
const createdTopicRequests = authoredCount > 0
? Math.ceil(authoredCount / CREATED_TOPICS_PAGE_SIZE_ESTIMATE)
: Number.POSITIVE_INFINITY;
if (createdTopicRequests < firstPostRequests) {
return { mode:'created-topics', estimatedRequests:createdTopicRequests };
}
return { mode:'first-post', estimatedRequests:firstPostRequests };
}
function collectBadgeCandidateTopics(posts, badgeCategories) {
const candidatePosts = [];
const topicIds = new Set();
let categoryUnknownCount = 0;
for (const post of posts) {
const categoryId = Number(post.category_id);
if (!Number.isInteger(categoryId) || categoryId <= 0) continue;
const category = badgeCategories.get(categoryId);
if (!category || typeof category.allow_badges !== 'boolean') {
categoryUnknownCount += 1;
continue;
}
if (category.allow_badges && category.read_restricted !== true) {
candidatePosts.push(post);
const topicId = Number(post.topic_id);
if (Number.isInteger(topicId) && topicId > 0) topicIds.add(topicId);
}
}
return { candidatePosts, topicIds, categoryUnknownCount };
}
function summarizeBadgeCandidates(candidatePosts, selfSolvedByTopic) {
let eligibleCount = 0;
let selfSolvedCount = 0;
let selfUnknownCount = 0;
for (const post of candidatePosts) {
const status = selfSolvedByTopic.get(Number(post.topic_id));
if (status === true) selfSolvedCount += 1;
else if (status === false) eligibleCount += 1;
else selfUnknownCount += 1;
}
return { eligibleCount, selfSolvedCount, selfUnknownCount };
}
function parseSelfCheckCache(raw, now = Date.now()) {
const cache = new Map();
if (!raw) return cache;
try {
const parsed = JSON.parse(raw);
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
for (const entry of entries) {
const [key, isSelf, savedAt] = entry;
if (
typeof key === 'string' &&
typeof isSelf === 'boolean' &&
Number.isFinite(Number(savedAt)) &&
now - Number(savedAt) < SELF_CHECK_CACHE_TTL
) {
cache.set(key, { isSelf, savedAt:Number(savedAt) });
}
}
} catch (error) {
console.debug('[ldo-solution] self-check cache unavailable', error);
}
return cache;
}
function mergeSelfCheckCache(
cache,
username,
selfSolvedByTopic,
now = Date.now(),
limit = SELF_CHECK_CACHE_LIMIT,
) {
const merged = new Map(cache);
const userKey = String(username).toLowerCase();
for (const [topicId, isSelf] of selfSolvedByTopic) {
if (typeof isSelf !== 'boolean') continue;
merged.set(`${userKey}:${Number(topicId)}`, { isSelf, savedAt:now });
}
return new Map(
[...merged.entries()]
.sort((left, right) => right[1].savedAt - left[1].savedAt)
.slice(0, limit),
);
}
function readStoredSelfChecks() {
try {
return parseSelfCheckCache(localStorage.getItem(SELF_CHECK_CACHE_KEY));
} catch (error) {
console.debug('[ldo-solution] failed to read self-check cache', error);
return new Map();
}
}
function cachedSelfChecksFor(username, topicIds) {
const result = new Map();
const userKey = String(username).toLowerCase();
for (const topicId of topicIds) {
const cached = selfCheckCache.get(`${userKey}:${Number(topicId)}`);
if (cached) result.set(Number(topicId), cached.isSelf);
}
return result;
}
function storeSelfChecks(username, selfSolvedByTopic) {
selfCheckCache = mergeSelfCheckCache(
selfCheckCache,
username,
selfSolvedByTopic,
);
try {
localStorage.setItem(SELF_CHECK_CACHE_KEY, JSON.stringify({
entries:[...selfCheckCache].map(([key, value]) => [key, value.isSelf, value.savedAt]),
}));
} catch (error) {
console.debug('[ldo-solution] failed to store self-check cache', error);
}
}
async function requestJsonWithRetry(url, signal, options = {}) {
const fetchImpl = options.fetchImpl || fetch;
const wait = options.wait || sleep;
for (let attempt = 0; attempt < 2; attempt++) {
const response = await fetchImpl(url, {
credentials:'include',
signal,
headers:{ Accept:'application/json', 'X-Requested-With':'XMLHttpRequest' },
});
if (response.ok) return response.json();
const transient = response.status === 429 || response.status >= 500;
if (attempt === 0 && transient) {
const retryAfter = Number(response.headers?.get?.('Retry-After'));
const delay = Number.isFinite(retryAfter) && retryAfter > 0
? Math.min(retryAfter * 1000, 30_000)
: 2500;
await wait(delay);
continue;
}
throw new Error(`读取主题作者失败 (${response.status})`);
}
throw new Error('读取主题作者失败');
}
async function resolveSelfSolvedTopics({
username,
userId,
topicIds,
authoredTopicCount,
cachedByTopic,
requestJson,
pause,
confirmLarge,
signal,
onProgress = () => {},
}) {
const selfSolvedByTopic = new Map(cachedByTopic);
const unresolved = new Set(
[...topicIds].filter(topicId => !selfSolvedByTopic.has(Number(topicId))),
);
if (!unresolved.size) {
return { selfSolvedByTopic, mode:'cache', requestCount:0, estimatedRequests:0 };
}
const profileSaysZeroTopics =
authoredTopicCount !== null &&
authoredTopicCount !== undefined &&
Number(authoredTopicCount) === 0;
const fallbackChoice = profileSaysZeroTopics
? { mode:'created-topics', estimatedRequests:1 }
: chooseSelfCheckMode({
unresolvedTopicCount:unresolved.size,
authoredTopicCount,
});
const useQuickAnswerHint = fallbackChoice.mode === 'first-post';
let choice = fallbackChoice;
const estimatedRequests = Math.min(
SELF_CHECK_MAX_REQUESTS,
fallbackChoice.estimatedRequests + (useQuickAnswerHint ? 1 : 0),
);
if (
estimatedRequests > SELF_CHECK_CONFIRM_THRESHOLD &&
!confirmLarge({
mode:useQuickAnswerHint ? 'quick-answer-topics' : choice.mode,
estimatedRequests,
})
) {
return {
selfSolvedByTopic,
mode:useQuickAnswerHint ? 'quick-answer-topics' : choice.mode,
requestCount:0,
estimatedRequests,
skipped:true,
};
}
let requestCount = 0;
if (useQuickAnswerHint) {
const filter = `created-by:${username} tag:${QUICK_ANSWER_TAG_SLUG}`;
try {
requestCount += 1;
const data = await requestJson(
`${API}/filter.json?q=${encodeURIComponent(filter)}`,
signal,
);
if (!data?.topic_list || !Array.isArray(data.topic_list.topics)) {
throw new Error('快问快答主题过滤接口返回异常');
}
for (const topic of data.topic_list.topics) {
const topicId = Number(topic.id);
if (unresolved.has(topicId)) {
selfSolvedByTopic.set(topicId, true);
unresolved.delete(topicId);
}
}
onProgress({
mode:'quick-answer-topics',
requestCount,
estimatedRequests,
remaining:unresolved.size,
});
} catch (error) {
if (error?.name === 'AbortError') throw error;
}
}
if (choice.mode === 'created-topics') {
let url = `${API}/topics/created-by/${encodeURIComponent(username)}.json`;
let reachedEnd = false;
while (url && unresolved.size && requestCount < SELF_CHECK_MAX_REQUESTS) {
try {
requestCount += 1;
const data = await requestJson(url, signal);
const topics = Array.isArray(data?.topic_list?.topics) ? data.topic_list.topics : [];
for (const topic of topics) {
const topicId = Number(topic.id);
if (unresolved.has(topicId)) {
selfSolvedByTopic.set(topicId, true);
unresolved.delete(topicId);
}
}
const moreUrl = data?.topic_list?.more_topics_url;
url = moreUrl ? new URL(moreUrl, API).href : null;
reachedEnd = !url;
onProgress({
mode:choice.mode,
requestCount,
estimatedRequests:choice.estimatedRequests,
remaining:unresolved.size,
});
if (url && unresolved.size && requestCount < SELF_CHECK_MAX_REQUESTS) await pause();
} catch (error) {
if (error?.name === 'AbortError') throw error;
break;
}
}
if (reachedEnd) {
for (const topicId of unresolved) selfSolvedByTopic.set(topicId, false);
}
} else {
const ids = [...unresolved];
for (let index = 0; index < ids.length && requestCount < SELF_CHECK_MAX_REQUESTS; index++) {
const topicId = ids[index];
try {
const data = await requestJson(`${API}/posts/by_number/${topicId}/1.json`, signal);
requestCount += 1;
const firstPost = data?.post || data;
const creatorId = Number(firstPost?.user_id);
if (Number.isFinite(creatorId) && Number.isFinite(Number(userId))) {
selfSolvedByTopic.set(topicId, creatorId === Number(userId));
} else if (firstPost?.username) {
selfSolvedByTopic.set(
topicId,
String(firstPost.username).toLowerCase() === String(username).toLowerCase(),
);
}
onProgress({
mode:choice.mode,
requestCount,
estimatedRequests:choice.estimatedRequests,
remaining:ids.length - index - 1,
});
} catch (error) {
if (error?.name === 'AbortError') throw error;
requestCount += 1;
}
if (index < ids.length - 1 && requestCount < SELF_CHECK_MAX_REQUESTS) await pause();
}
}
return {
selfSolvedByTopic,
mode:choice.mode,
requestCount,
estimatedRequests:choice.estimatedRequests,
};
}
function ensureUi() {
let drawer = document.getElementById(DRAWER_ID);
if (drawer) return drawer;
const launcher = document.createElement('button');
launcher.id = LAUNCHER_ID;
launcher.innerHTML = '<span class="mark">✓</span><span>解决方案统计</span>';
document.body.appendChild(launcher);
drawer = document.createElement('aside');
drawer.id = DRAWER_ID;
drawer.innerHTML = `
<div class="top">
<div class="brand">✓</div>
<div class="title"><h2>解决方案统计</h2><div class="subtitle" data-summary>输入用户名后开始查询</div></div>
<button class="icon-btn" data-theme title="切换明暗主题">☼</button><button class="icon-btn" data-close title="关闭">×</button>
</div>
<div class="query">
<div class="input-row"><input data-username autocomplete="off" placeholder="linux.do 用户名"><button class="primary" data-query>查询</button></div>
<div class="query-tools"><button class="text-btn" data-me>使用当前用户</button><span data-status>每批读取 ${PAGE_SIZE} 条</span><button class="text-btn cancel" data-cancel hidden>取消</button></div>
</div>
<div class="progress"><i></i></div>
<div class="content">
<div class="kpis" data-kpis><div class="kpi"><b>--</b><span>主页解决方案数</span></div><div class="kpi"><b>--</b><span>可统计明细</span></div><div class="kpi"><b>--</b><span>涉及分类数</span></div></div>
<div data-badge></div>
<div class="tabs"><button class="active" data-tab="levels">等级概览</button><button data-tab="categories">分类明细</button><button data-tab="details">解决方案详情</button></div>
<div data-view><div class="empty"><strong>还没有统计数据</strong>输入用户名并点击查询</div></div>
</div>
<div class="foot"><div class="request-note"><strong>请求与缓存说明</strong><br>Solution 分页保持串行并间隔约 0.8 秒;自解核验会先比较用户主题列表与候选首帖的预计请求量,选择压力更小的方式。候选首帖模式只用一次快问快答过滤辅助确认,未匹配项仍会核验;主页话题数为 0 时也只请求一次主题列表确认。所有请求单线程且间隔至少 1.2 秒,超过 ${SELF_CHECK_CONFIRM_THRESHOLD} 次会先确认,单次最多 ${SELF_CHECK_MAX_REQUESTS} 次,结果缓存 6 小时。</div>分类明细继续展示全部 Solution;只有徽章进度会扣除自己主题中的自己解决方案。存在无法确认的数据时显示进度区间,不会作为精确值。</div>`;
document.body.appendChild(drawer);
launcher.onclick = () => drawer.classList.add('open');
drawer.querySelector('[data-close]').onclick = () => drawer.classList.remove('open');
drawer.querySelector('[data-theme]').onclick = () => {
const dark = !drawer.classList.contains('dark');
drawer.classList.toggle('dark', dark);
launcher.classList.toggle('dark', dark);
localStorage.setItem('ldo-solution-theme', dark ? 'dark' : 'light');
drawer.querySelector('[data-theme]').textContent = dark ? '☼' : '☾';
};
drawer.querySelector('[data-query]').onclick = () => runQuery();
drawer.querySelector('[data-cancel]').onclick = cancelQuery;
drawer.querySelector('[data-me]').onclick = () => {
drawer.querySelector('[data-username]').value = state.currentUsername;
if (state.currentUsername) runQuery();
};
drawer.querySelector('[data-username]').addEventListener('keydown', event => {
if (event.key === 'Enter') runQuery();
});
drawer.querySelectorAll('[data-tab]').forEach(button => button.onclick = () => {
drawer.querySelectorAll('[data-tab]').forEach(item => item.classList.remove('active'));
button.classList.add('active');
renderView(button.dataset.tab);
});
const dark = localStorage.getItem('ldo-solution-theme') !== 'light';
drawer.classList.toggle('dark', dark);
launcher.classList.toggle('dark', dark);
drawer.querySelector('[data-theme]').textContent = dark ? '☼' : '☾';
return drawer;
}
function setLoading(loading) {
const drawer = ensureUi();
drawer.classList.toggle('loading', loading);
drawer.querySelector('[data-query]').disabled = loading;
drawer.querySelector('[data-username]').disabled = loading;
drawer.querySelector('[data-me]').disabled = loading;
drawer.querySelector('[data-cancel]').hidden = !loading;
}
async function getCurrentUsername() {
const response = await fetch(`${API}/session/current.json`, { credentials:'include' });
if (!response.ok) throw new Error(`读取登录用户失败 (${response.status})`);
const data = await response.json();
return data?.current_user?.username || data?.user?.username || data?.username || '';
}
async function fetchCategories(signal) {
if (state.categories.length) return state.categories;
const response = await fetch(`${API}/site.json`, { credentials:'include', signal });
if (!response.ok) throw new Error(`读取分类失败 (${response.status})`);
const data = await response.json();
state.categories = Array.isArray(data.categories) ? data.categories : [];
state.byId = new Map(state.categories.map(category => [Number(category.id), category]));
return state.categories;
}
async function fetchBadgeCategories(categoryIds, signal) {
const ids = [...new Set(categoryIds
.map(id => Number(id))
.filter(id => Number.isInteger(id) && id > 0))].sort((a, b) => a - b);
const now = Date.now();
const missing = ids.filter(id => {
const cached = badgeCategoryCache.get(id);
return !cached || now - cached.savedAt >= BADGE_CATEGORY_CACHE_TTL;
});
if (missing.length) {
const params = missing
.map(id => `ids%5B%5D=${encodeURIComponent(id)}`)
.join('&');
const response = await fetch(
`${API}/categories/find.json?${params}&include_permissions=true`,
{ credentials:'include', signal },
);
if (!response.ok) throw new Error(`读取徽章分类设置失败 (${response.status})`);
const data = await response.json();
const returned = new Map(
(Array.isArray(data.categories) ? data.categories : [])
.map(category => [Number(category.id), category]),
);
for (const id of missing) {
badgeCategoryCache.set(id, {
category: returned.get(id) || null,
savedAt: now,
});
}
}
return new Map(ids.map(id => [id, badgeCategoryCache.get(id)?.category || null]));
}
async function fetchOfficialStats(username, signal) {
const response = await fetch(`${API}/u/${encodeURIComponent(username)}/summary.json`, { credentials:'include', signal });
if (!response.ok) throw new Error(`读取主页统计失败 (${response.status})`);
const data = await response.json();
const summary = data?.user_summary || {};
const solvedCount = Number(summary.solved_count);
const topicCount = Number(summary.topic_count);
const matchingUser = (Array.isArray(data?.users) ? data.users : []).find(
user => String(user?.username).toLowerCase() === String(username).toLowerCase(),
);
const userId = Number(summary.user_id ?? summary.id ?? matchingUser?.id ?? data?.user?.id);
return {
solvedCount:Number.isFinite(solvedCount) ? solvedCount : null,
topicCount:Number.isFinite(topicCount) ? topicCount : null,
userId:Number.isFinite(userId) ? userId : null,
};
}
async function fetchSolutions(username, signal, onProgress) {
const unique = new Map();
let offset = 0;
for (let page = 1; page <= MAX_PAGES; page++) {
const url = `${API}/solution/by_user.json?username=${encodeURIComponent(username)}&offset=${offset}&limit=${PAGE_SIZE}`;
const response = await fetch(url, { credentials:'include', signal });
if (!response.ok) throw new Error(`读取 Solution 失败 (${response.status})`);
const data = await response.json();
const batch = Array.isArray(data.user_solved_posts) ? data.user_solved_posts : (Array.isArray(data.posts) ? data.posts : []);
if (!batch.length) break;
const before = unique.size;
for (const post of batch) {
const key = String(post.id ?? `${post.topic_id}:${post.post_number}`);
unique.set(key, post);
}
offset += batch.length;
onProgress({ page, count:unique.size, batchSize:batch.length });
if (unique.size === before) break;
if (batch.length < PAGE_SIZE) break;
await sleep(REQUEST_DELAY + Math.floor(Math.random() * 120));
}
return [...unique.values()];
}
function computeStats() {
const stats = ['lv0','lv1','lv2','lv3'].map(level => ({ level, total:0 }));
const statMap = new Map(stats.map(item => [item.level, item]));
const unknown = [];
for (const post of state.posts) {
const category = state.byId.get(Number(post.category_id));
const level = levelOf(category);
if (!level) {
unknown.push({ ...post, categoryName:category?.name || '未知分类' });
} else {
statMap.get(level).total += 1;
}
}
state.stats = stats;
state.unknown = unknown;
}
function computeBadgeStats() {
if (state.badgeMetaError) {
state.badgeEligibleCount = null;
state.badgeUnknownCount = 0;
state.badgeSelfSolvedCount = 0;
state.badgeSelfUnknownCount = 0;
return;
}
const candidates = collectBadgeCandidateTopics(state.posts, state.badgeCategories);
const summary = summarizeBadgeCandidates(
candidates.candidatePosts,
state.selfSolvedByTopic,
);
state.badgeEligibleCount = summary.eligibleCount;
state.badgeUnknownCount = candidates.categoryUnknownCount;
state.badgeSelfSolvedCount = summary.selfSolvedCount;
state.badgeSelfUnknownCount = summary.selfUnknownCount;
}
function badgeCategoryStatus(categoryId) {
const category = state.badgeCategories.get(Number(categoryId));
if (!category || typeof category.allow_badges !== 'boolean') {
return {
key: 'unknown',
label: '未知',
title: '分类徽章设置不可见,暂时无法判断',
};
}
if (!category.allow_badges) {
return {
key: 'excluded',
label: '不计入',
title: '该分类 allow_badges 为 false',
};
}
if (category.read_restricted === true) {
return {
key: 'excluded',
label: '不计入',
title: '该分类属于 read_restricted 受限分类',
};
}
return {
key: 'eligible',
label: '计入',
title: '该分类允许徽章且不是受限分类',
};
}
function selfCheckModeLabel(mode) {
return ({
cache:'缓存',
'created-topics':'用户主题列表',
'first-post':'主题首帖',
'quick-answer-topics':'快问快答主题',
none:'无需请求',
})[mode] || '未执行';
}
function badgeCountRange() {
if (state.badgeEligibleCount == null) return null;
const lower = state.badgeEligibleCount;
const unknown = state.badgeUnknownCount + state.badgeSelfUnknownCount;
return { lower, upper:lower + unknown, exact:unknown === 0 };
}
function renderView(tab = state.activeTab) {
state.activeTab = tab;
const view = ensureUi().querySelector('[data-view]');
if (tab === 'categories') {
const totals = new Map();
for (const category of state.categories) {
const id = Number(category.id);
if (!Number.isInteger(id) || id <= 0) continue;
totals.set(id, { id, name:category.name || '未知分类', total:0 });
}
for (const post of state.posts) {
const id = Number(post.category_id);
const category = state.byId.get(id) || { name:'未知分类' };
const item = totals.get(id) || { id, name:category.name, total:0 };
item.total += 1;
totals.set(id, item);
}
const roots = new Map();
const rootOf = category => { let item = category; const seen = new Set(); while (item?.parent_category_id != null && !seen.has(item.id)) { seen.add(item.id); item = state.byId.get(Number(item.parent_category_id)); } return item || category; };
for (const item of totals.values()) { const category = state.byId.get(item.id); const root = rootOf(category) || { id:item.id, name:'未知分类' }; const group = roots.get(root.id) || { root, items:[], total:0 }; group.items.push(item); group.total += item.total; roots.set(root.id, group); }
const groups = [...roots.values()].sort((a,b) => b.total - a.total || a.root.id - b.root.id);
view.innerHTML = groups.map(group => {
group.items.sort((a,b) => b.total - a.total || a.id - b.id);
const rows = group.items.map(item => {
const category = state.byId.get(item.id) || {};
const color = '#' + String(category.color || '71808b').replace('#', '');
const level = levelOf(category);
const badgeStatus = badgeCategoryStatus(item.id);
return `<div class="cat-row" data-detail-category="${item.id}"><div class="cat-main"><span class="cat-chip" style="background:${color}">${esc(level ? level.replace('lv','等级 ') : '未标注')}</span><span class="cat-name">${esc(item.name)}</span><span class="cat-id">ID ${item.id}</span></div><div class="cat-actions"><span class="badge-eligibility is-${badgeStatus.key}" title="${esc(badgeStatus.title)}">${esc(badgeStatus.label)}</span><span class="cat-total">${item.total}</span></div></div>`;
}).join('');
return `<div class="group"><button class="group-head" data-detail-category="${group.root.id}"><span class="group-name">${esc(group.root.name)} <span class="cat-id">ID ${group.root.id}</span></span><span class="group-total">${group.total} 条解决方案 ›</span></button>${rows}</div>`;
}).join('');
view.querySelectorAll('[data-detail-category]').forEach(button => button.onclick = () => { state.detailCategoryId = Number(button.dataset.detailCategory); state.detailPage = 1; state.activeTab = 'details'; ensureUi().querySelectorAll('[data-tab]').forEach(item => item.classList.toggle('active', item.dataset.tab === 'details')); renderView('details'); });
return;
}
if (tab === 'details') {
const categoryId = state.detailCategoryId;
const category = categoryId == null ? null : state.byId.get(categoryId);
const query = state.detailQuery.trim().toLowerCase();
const filtered = state.posts.filter(post => {
if (categoryId != null) {
const root = state.byId.get(Number(post.category_id));
let item = root; const seen = new Set();
while (item?.parent_category_id != null && !seen.has(item.id)) { seen.add(item.id); item = state.byId.get(Number(item.parent_category_id)); }
if (Number(post.category_id) !== categoryId && item?.id !== categoryId) return false;
}
if (!query) return true;
const c = state.byId.get(Number(post.category_id));
return `${post.topic_title || ''} ${post.name || ''} ${c?.name || ''} ${post.category_id || ''}`.toLowerCase().includes(query);
});
const pageSize = 20;
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
state.detailPage = Math.min(state.detailPage, pageCount);
const start = (state.detailPage - 1) * pageSize;
const pageRows = filtered.slice(start, start + pageSize);
const title = category ? `${category.name} · 解决方案详情` : '全部解决方案详情';
view.innerHTML = `<div class="detail-tools"><div><strong>${esc(title)}</strong><span class="cat-id"> ${filtered.length} 条</span></div><div class="detail-search"><input data-detail-search placeholder="搜索标题、分类或 ID" value="${esc(state.detailQuery)}"><button data-run-detail-search>搜索</button><button data-clear-detail>清除</button></div></div>${pageRows.length ? `<div class="post-list">${pageRows.map(post => { const c=state.byId.get(Number(post.category_id)); const href=post.url ? new URL(post.url, API).href : `${API}/t/${post.topic_id}`; return `<a class="post-item" href="${esc(href)}" target="_blank" rel="noopener noreferrer"><span class="post-title">${esc(post.topic_title || post.name || `帖子 ${post.topic_id}`)}</span><span class="post-meta">${esc(c?.name || '未知分类')} · ID ${post.category_id ?? '-'} ↗</span></a>`; }).join('')}</div>` : '<div class="empty"><strong>没有匹配的解决方案</strong>换一个关键词或清除分类筛选</div>'}<div class="pager"><button data-page-prev ${state.detailPage <= 1 ? 'disabled' : ''}>上一页</button><span>第 ${state.detailPage} / ${pageCount} 页</span><button data-page-next ${state.detailPage >= pageCount ? 'disabled' : ''}>下一页</button></div>`;
const runSearch = () => { state.detailQuery = view.querySelector('[data-detail-search]').value; state.detailPage = 1; renderView('details'); };
view.querySelector('[data-run-detail-search]').onclick = runSearch;
view.querySelector('[data-detail-search]').onkeydown = event => { if (event.key === 'Enter') runSearch(); };
view.querySelector('[data-clear-detail]').onclick = () => { state.detailCategoryId = null; state.detailQuery = ''; state.detailPage = 1; renderView('details'); };
view.querySelector('[data-page-prev]').onclick = () => { state.detailPage--; renderView('details'); };
view.querySelector('[data-page-next]').onclick = () => { state.detailPage++; renderView('details'); };
return;
}
const max = Math.max(1, ...state.stats.map(item => item.total));
const badgeRange = badgeCountRange();
const badgeValue = badgeRange == null
? '--'
: badgeRange.exact ? badgeRange.lower : `${badgeRange.lower}-${badgeRange.upper}`;
const badgeNote = badgeRange == null
? '分类徽章设置暂不可用'
: `已排除 ${state.badgeSelfSolvedCount} 条自解;${state.badgeUnknownCount + state.badgeSelfUnknownCount ? `另有 ${state.badgeUnknownCount + state.badgeSelfUnknownCount} 条待确认` : '结果已确认'};${selfCheckModeLabel(state.selfCheckMode)} ${state.selfCheckRequestCount} 次请求`;
const badgeRow = `<div class="badge-count-row"><div><div class="badge-count-label">计入解决方案徽章统计数量</div><div class="badge-count-note">${esc(badgeNote)}</div></div><div class="badge-count-value">${badgeValue}</div></div>`;
const levelRows = state.stats.map(item => `<div class="level-row"><span class="level-tag">${item.level.replace('lv', '等级 ')}</span><div class="track"><i style="width:${item.total / max * 100}%"></i></div><span class="value">${item.total}</span></div>`).join('');
view.innerHTML = badgeRow + levelRows;
}
function renderResults() {
const drawer = ensureUi();
const official = state.officialTotal;
const difference = official == null ? null : official - state.posts.length;
drawer.querySelector('[data-kpis]').innerHTML = `
<div class="kpi"><b>${official ?? '--'}</b><span>主页解决方案数</span></div>
<div class="kpi"><b>${state.posts.length}</b><span>可统计明细</span></div>
<div class="kpi"><b>${new Set(state.posts.map(post => Number(post.category_id))).size}</b><span>涉及分类数</span></div>`;
drawer.querySelector('[data-summary]').textContent = difference == null
? `@${state.username} · 明细 ${state.posts.length} 条`
: `@${state.username} · 主页 ${official} 条 · 明细 ${state.posts.length} 条${difference ? ` · 相差 ${difference}` : ''}`;
renderBadgeProgress(drawer);
renderView();
}
function renderBadgeProgress(drawer) {
const range = badgeCountRange();
if (range == null) {
drawer.querySelector('[data-badge]').innerHTML = `<div class="badge-box"><div class="badge-line"><span class="badge-title">徽章进度暂不可用</span><span class="badge-next">分类设置读取失败</span></div><div class="badge-note">无法读取 allow_badges 分类设置,本次没有把解决方案误计入徽章候选数。请稍后重试。</div></div>`;
return;
}
const { lower, upper, exact } = range;
const current = [...BADGES].reverse().find(badge => lower >= badge.threshold);
const next = BADGES.find(badge => lower < badge.threshold);
const possible = [...BADGES].reverse().find(badge => upper >= badge.threshold);
const countText = exact ? `${lower} 条` : `${lower}-${upper} 条`;
const unknownNotes = [];
if (state.badgeUnknownCount) unknownNotes.push(`${state.badgeUnknownCount} 条分类设置未知`);
if (state.badgeSelfUnknownCount) unknownNotes.push(`${state.badgeSelfUnknownCount} 条主题作者未确认`);
const checkNote = `${selfCheckModeLabel(state.selfCheckMode)}核验 ${state.selfCheckRequestCount} 次请求`;
const baseNote = `徽章有效解决方案 ${countText},已排除 ${state.badgeSelfSolvedCount} 条自解;${checkNote}${unknownNotes.length ? `,${unknownNotes.join('、')}` : ''}。`;
if (!next) {
drawer.querySelector('[data-badge]').innerHTML = `<div class="badge-box"><div class="badge-line"><span class="badge-title">已确认达到徽章数量条件</span><span class="badge-next">${current.name}</span></div><div class="badge-track"><i style="width:100%"></i></div><div class="badge-note">${baseNote}实际徽章是否授予,请以个人徽章页为准。</div></div>`;
return;
}
const previous = current?.threshold || 0;
const percent = Math.max(0, Math.min(100, (lower - previous) / (next.threshold - previous) * 100));
if (!exact && possible?.threshold >= next.threshold) {
drawer.querySelector('[data-badge]').innerHTML = `<div class="badge-box"><div class="badge-line"><span class="badge-title">可能已达到:${possible.name}</span><span class="badge-next">已确认 ${lower} 条</span></div><div class="badge-track"><i style="width:${percent}%"></i></div><div class="badge-note">${baseNote}当前区间跨过徽章门槛,需确认未知项后才能得出精确进度。</div></div>`;
return;
}
const remaining = exact
? `${next.threshold - lower} 条`
: `${next.threshold - upper}-${next.threshold - lower} 条`;
drawer.querySelector('[data-badge]').innerHTML = `<div class="badge-box"><div class="badge-line"><span class="badge-title">${current ? `已确认达到:${current.name}` : '尚未达到数量条件'}</span><span class="badge-next">下一目标:${next.name}</span></div><div class="badge-track"><i style="width:${percent}%"></i></div><div class="badge-note">${baseNote}距离 ${next.name} 还差 ${remaining};实际徽章是否授予,请以个人徽章页为准。</div></div>`;
}
function cancelQuery() {
controller?.abort();
}
async function runQuery() {
const drawer = ensureUi();
const input = drawer.querySelector('[data-username]');
const status = drawer.querySelector('[data-status]');
const username = input.value.trim();
if (!username) {
input.focus();
status.textContent = '请输入用户名';
return;
}
const cacheKey = username.toLowerCase();
const cached = resultCache.get(cacheKey);
if (cached && Date.now() - cached.savedAt < CACHE_TTL && state.categories.length) {
state.posts = cached.posts;
state.officialTotal = cached.officialTotal;
state.officialTopicCount = cached.officialTopicCount ?? null;
state.queriedUserId = cached.queriedUserId ?? null;
state.badgeCategories = cached.badgeCategories || new Map();
state.badgeMetaError = cached.badgeMetaError || null;
state.selfSolvedByTopic = cached.selfSolvedByTopic || new Map();
state.selfCheckMode = cached.selfCheckMode || 'none';
state.selfCheckRequestCount = cached.selfCheckRequestCount || 0;
state.username = username;
computeStats();
computeBadgeStats();
renderResults();
status.textContent = '已使用 5 分钟内的缓存数据';
return;
}
controller?.abort();
controller = new AbortController();
state.officialTotal = null;
state.officialTopicCount = null;
state.queriedUserId = null;
state.selfSolvedByTopic = new Map();
state.selfCheckMode = 'none';
state.selfCheckRequestCount = 0;
setLoading(true);
status.textContent = '正在读取分类…';
drawer.querySelector('[data-summary]').textContent = `正在查询 @${username}`;
try {
status.textContent = '正在读取主页统计…';
const officialStats = await fetchOfficialStats(username, controller.signal).catch(error => {
if (error.name === 'AbortError') throw error;
console.debug('[ldo-solution] official stats unavailable', error);
return { solvedCount:null, topicCount:null, userId:null };
});
const officialTotal = officialStats.solvedCount;
if (
officialTotal > LARGE_QUERY_THRESHOLD &&
!window.confirm(
`@${username} 主页显示 ${officialTotal} 条解决方案,完整统计预计需要约 ${Math.ceil(officialTotal / PAGE_SIZE)} 次分页请求。请求会以约 0.8 秒间隔串行执行,是否继续?`,
)
) {
status.textContent = '已取消大数据量查询';
drawer.querySelector('[data-summary]').textContent = '已取消大数据量查询';
return;
}
status.textContent = '正在读取分类…';
await fetchCategories(controller.signal);
const posts = await fetchSolutions(username, controller.signal, progress => {
status.textContent = `第 ${progress.page} 页 · 已获取 ${progress.count} 条`;
});
status.textContent = '正在读取徽章分类设置…';
let badgeCategories = new Map();
let badgeMetaError = null;
try {
badgeCategories = await fetchBadgeCategories(
state.categories.map(category => category.id),
controller.signal,
);
} catch (error) {
if (error.name === 'AbortError') throw error;
badgeMetaError = error;
console.debug('[ldo-solution] badge category metadata unavailable', error);
}
state.posts = posts;
state.officialTotal = officialTotal;
state.officialTopicCount = officialStats.topicCount;
state.queriedUserId = officialStats.userId ?? posts
.map(post => Number(post.user_id))
.find(userId => Number.isFinite(userId)) ?? null;
state.badgeCategories = badgeCategories;
state.badgeMetaError = badgeMetaError;
state.username = username;
state.detailCategoryId = null;
state.detailPage = 1;
state.detailQuery = '';
if (!badgeMetaError) {
const candidates = collectBadgeCandidateTopics(posts, badgeCategories);
if (candidates.topicIds.size) {
status.textContent = `正在核验 ${candidates.topicIds.size} 个候选主题作者…`;
const selfCheckResult = await resolveSelfSolvedTopics({
username,
userId:state.queriedUserId,
topicIds:candidates.topicIds,
authoredTopicCount:officialStats.topicCount,
cachedByTopic:cachedSelfChecksFor(username, candidates.topicIds),
requestJson:(url, signal) => requestJsonWithRetry(url, signal),
pause:() => sleep(SELF_CHECK_REQUEST_DELAY + Math.floor(Math.random() * 180)),
confirmLarge:choice => window.confirm(
`为排除 @${username} 的自解,需要通过${selfCheckModeLabel(choice.mode)}核验未缓存主题,预计约 ${choice.estimatedRequests} 次串行请求。每次间隔至少 1.2 秒,单次查询最多执行 ${SELF_CHECK_MAX_REQUESTS} 次;超过上限的部分会显示为未知区间。是否继续?`,
),
signal:controller.signal,
onProgress:progress => {
if (progress.mode === 'quick-answer-topics') {
const estimated = Math.min(progress.estimatedRequests, SELF_CHECK_MAX_REQUESTS);
status.textContent = `正在读取该用户创建的快问快答主题 · 第 ${progress.requestCount}/${estimated} 页 · 完成后统一计算交集`;
} else if (progress.mode === 'created-topics') {
const estimated = Math.min(progress.estimatedRequests, SELF_CHECK_MAX_REQUESTS);
status.textContent = `组合过滤不可用,正在批量读取用户主题 · 第 ${progress.requestCount}/${estimated} 页`;
} else {
const total = progress.requestCount + progress.remaining;
status.textContent = `正在逐个核验候选主题 · ${progress.requestCount}/${Math.min(total, SELF_CHECK_MAX_REQUESTS)}`;
}
},
});
state.selfSolvedByTopic = selfCheckResult.selfSolvedByTopic;
state.selfCheckMode = selfCheckResult.mode;
state.selfCheckRequestCount = selfCheckResult.requestCount;
storeSelfChecks(username, selfCheckResult.selfSolvedByTopic);
}
}
computeStats();
computeBadgeStats();
resultCache.set(cacheKey, {
posts,
officialTotal,
officialTopicCount:state.officialTopicCount,
queriedUserId:state.queriedUserId,
badgeCategories,
badgeMetaError,
selfSolvedByTopic:new Map(state.selfSolvedByTopic),
selfCheckMode:state.selfCheckMode,
selfCheckRequestCount:state.selfCheckRequestCount,
savedAt:Date.now(),
});
renderResults();
status.textContent = `完成 · 共 ${state.posts.length} 条解决方案`;
} catch (error) {
if (error.name === 'AbortError') {
status.textContent = '已取消';
drawer.querySelector('[data-summary]').textContent = '查询已取消';
} else {
status.textContent = `出错:${error.message}`;
drawer.querySelector('[data-summary]').textContent = '查询失败';
console.error('[ldo-solution]', error);
}
} finally {
setLoading(false);
controller = null;
}
}
async function boot() {
const drawer = ensureUi();
selfCheckCache = readStoredSelfChecks();
try {
state.currentUsername = await getCurrentUsername();
drawer.querySelector('[data-username]').value = state.currentUsername;
drawer.querySelector('[data-summary]').textContent = state.currentUsername ? `当前用户 @${state.currentUsername}` : '输入用户名后开始查询';
} catch (error) {
console.debug('[ldo-solution] current user unavailable', error);
}
}
boot();
})();
* 帖子来源Linux.do
附近帖子