1const DIMENSION_KEYS = ['value', 'models', 'stability']; 2 3const PLATFORM_DIMENSION_META = [ 4 { key: 'value', label: 'æ§ä»·æ¯' }, 5 { key: 'models', label: '模å' }, 6 { key: 'stability', label: 'å¯ç¨æ§' } 7]; 8 9const PLATFORM_STATUSES = ['open', 'limited', 'paused', 'delisted']; 10const PLATFORM_STATUS_LABELS = { 11 open: '弿¾è´ä¹°', 12 limited: '宿¶æ¾é', 13 paused: 'ææ¶åå®', 14 delisted: '已䏿¶' 15}; 16/** çéæ»åä¸é´æ¡£ä½ææ¡ï¼åå妿åç¼ /ãå¹³å°ãï¼ */ 17const PLATFORM_STATUS_SEGMENT_LABELS = { 18 open: '弿¾è´ä¹°', 19 limited: '宿¶æ¾é', 20 paused: 'ææ¶åå®', 21 delisted: 'ææ' 22}; 23const PLATFORM_STATUS_FILTER_SUFFIX = 'å¹³å°'; 24const DEFAULT_PLATFORM_STATUS_MAX = 'paused'; 25 26function escapeHtml(text) { 27 if (text === null || text === undefined) return ''; 28 return String(text) 29 .replace(/&/g, '&') 30 .replace(/</g, '<') 31 .replace(/>/g, '>') 32 .replace(/"/g, '"'); 33} 34 35function sanitizeInlineHttpUrl(url) { 36 if (typeof url !== 'string' || !url.trim()) return null; 37 try { 38 const base = 39 typeof globalThis !== 'undefined' && 40 globalThis.location && 41 typeof globalThis.location.origin === 'string' 42 ? globalThis.location.origin 43 : 'https://example.invalid'; 44 const parsed = new URL(url.trim(), base); 45 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { 46 return parsed.href; 47 } 48 } catch (error) { 49 return null; 50 } 51 return null; 52} 53 54/** Inline Markdown: `[label](https://...)` links and `**bold**`. */ 55function formatInlineMarkdown(text) { 56 if (text === null || text === undefined) return ''; 57 58 const linkPlaceholders = []; 59 const withLinkPlaceholders = String(text).replace(/\[([^\]]+)\]\(([^\s)]+)\)/g, (match, label, url) => { 60 const safeUrl = sanitizeInlineHttpUrl(url); 61 if (!safeUrl) return match; 62 63 const placeholder = `__INLINE_MD_LINK_${linkPlaceholders.length}__`; 64 linkPlaceholders.push( 65 `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>` 66 ); 67 return placeholder; 68 }); 69 70 let formatted = escapeHtml(withLinkPlaceholders); 71 formatted = formatted.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 72 formatted = formatted.replace(/__INLINE_MD_LINK_(\d+)__/g, (_, index) => { 73 return linkPlaceholders[Number(index)] || ''; 74 }); 75 return formatted; 76} 77 78function formatInlineMarkdownPreserveBreaks(text) { 79 return formatInlineMarkdown(text) 80 .replace(/\r\n/g, '\n') 81 .replace(/\r/g, '\n') 82 .replace(/\n/g, '<br>'); 83} 84 85function normalizePlatformStatus(value) { 86 if (PLATFORM_STATUSES.includes(value)) return value; 87 return 'open'; 88} 89 90function platformStatusRank(value) { 91 const idx = PLATFORM_STATUSES.indexOf(normalizePlatformStatus(value)); 92 return idx < 0 ? 0 : idx; 93} 94 95function platformStatusLabel(value) { 96 return PLATFORM_STATUS_LABELS[normalizePlatformStatus(value)] || PLATFORM_STATUS_LABELS.open; 97} 98 99function platformStatusSegmentLabel(value) { 100 const status = normalizePlatformStatus(value); 101 return PLATFORM_STATUS_SEGMENT_LABELS[status] || PLATFORM_STATUS_SEGMENT_LABELS.open; 102} 103 104function platformStatusFilterPrefix(value) { 105 return normalizePlatformStatus(value) === 'open' ? 'ä» æ¾ç¤º' : 'æ¾ç¤º'; 106} 107 108function platformStatusFilterLabel(value) { 109 const status = normalizePlatformStatus(value); 110 return `${platformStatusFilterPrefix(status)}${platformStatusSegmentLabel(status)}${PLATFORM_STATUS_FILTER_SUFFIX}`; 111} 112 113/** å½åæ¡£ä½ç累计é«äº®ï¼é¤ãææãå¤ï¼é«äº® max 忴弿¾çæ¡£ï¼ãææãåªé«äº®èªèº« */ 114function isPlatformStatusSegmentActive(status, maxStatus) { 115 const max = normalizePlatformStatus(maxStatus); 116 const current = normalizePlatformStatus(status); 117 if (max === 'delisted') return current === 'delisted'; 118 return platformStatusRank(current) <= platformStatusRank(max); 119} 120 121/** 读åºå½åçéï¼å¦ãæ¾ç¤ºå¼æ¾è´ä¹°å宿¶æ¾éå¹³å°ã */ 122function platformStatusFilterPhrase(maxStatus) { 123 const max = normalizePlatformStatus(maxStatus); 124 if (max === 'delisted') { 125 return `${platformStatusFilterPrefix(max)}${platformStatusSegmentLabel(max)}${PLATFORM_STATUS_FILTER_SUFFIX}`; 126 } 127 const labels = PLATFORM_STATUSES 128 .filter((status) => isPlatformStatusSegmentActive(status, max)) 129 .map((status) => platformStatusSegmentLabel(status)); 130 return `${platformStatusFilterPrefix(max)}${labels.join('å')}${PLATFORM_STATUS_FILTER_SUFFIX}`; 131} 132 133function matchesDerivedTag(platform, rule) { 134 if (rule.platformStatus !== undefined) { 135 if (normalizePlatformStatus(platform.platformStatus) !== rule.platformStatus) { 136 return false;
137 } 138 } 139 if (rule.dimension !== undefined && rule.minScore !== undefined) { 140 const dim = platform.dimensions?.[rule.dimension]; 141 if (!dim || !Number.isFinite(dim.score) || dim.score < rule.minScore) { 142 return false; 143 } 144 } 145 return true; 146} 147 148function matchesOperationalTag(platform, label) { 149 return Array.isArray(platform.tags) && platform.tags.includes(label); 150} 151 152function filterPlatforms(platforms, { 153 selectedLabels, 154 platformStatusMax, 155 derivedTags, 156 operationalTags, 157 pinnedIds 158}) { 159 const pinnedSet = new Set(normalizePinnedIds(pinnedIds)); 160 function isPinnedPlatform(platform) { 161 const id = platform && typeof platform.slug === 'string' ? platform.slug.trim() : ''; 162 return !!(id && pinnedSet.has(id)); 163 } 164 165 let result = platforms; 166 const maxRank = platformStatusRank(platformStatusMax ?? DEFAULT_PLATFORM_STATUS_MAX); 167 result = result.filter( 168 (p) => isPinnedPlatform(p) || platformStatusRank(p.platformStatus) <= maxRank 169 ); 170 171 if (!selectedLabels || selectedLabels.length === 0) { 172 return result; 173 } 174 175 const derivedByLabel = new Map((derivedTags || []).map(t => [t.label, t])); 176 177 return result.filter(platform => { 178 if (isPinnedPlatform(platform)) return true; 179 for (const label of selectedLabels) { 180 const derived = derivedByLabel.get(label); 181 if (derived) { 182 if (!matchesDerivedTag(platform, derived.rule)) { 183 return false; 184 } 185 } else { 186 if (!matchesOperationalTag(platform, label)) { 187 return false; 188 } 189 } 190 } 191 return true; 192 }); 193} 194 195const PLATFORM_PIN_STORAGE_KEY = 'platformCatalogPinnedIds'; 196const PLATFORM_PIN_MAX = 50; 197 198function normalizePinnedIds(raw) { 199 if (!Array.isArray(raw)) return []; 200 const out = []; 201 const seen = new Set(); 202 for (const item of raw) { 203 if (typeof item !== 'string' && typeof item !== 'number') continue; 204 const id = String(item).trim(); 205 if (!id || seen.has(id)) continue; 206 seen.add(id); 207 out.push(id); 208 } 209 return out; 210} 211 212function sanitizePinnedIds(pinnedIds, platforms) { 213 const valid = new Set( 214 (Array.isArray(platforms) ? platforms : []) 215 .map((p) => (p && typeof p.slug === 'string' ? p.slug.trim() : '')) 216 .filter(Boolean) 217 ); 218 return normalizePinnedIds(pinnedIds).filter((id) => valid.has(id)); 219} 220 221function isPlatformPinned(platformId, pinnedIds) { 222 const id = typeof platformId === 'string' ? platformId.trim() : ''; 223 if (!id) return false; 224 return normalizePinnedIds(pinnedIds).includes(id); 225} 226 227function togglePinnedId(pinnedIds, platformId, options = {}) { 228 const id = typeof platformId === 'string' ? platformId.trim() : String(platformId || '').trim(); 229 const list = normalizePinnedIds(pinnedIds); 230 if (!id) return list; 231 const max = 232 Number.isFinite(Number(options.max)) && Number(options.max) > 0 233 ? Math.floor(Number(options.max)) 234 : PLATFORM_PIN_MAX; 235 const idx = list.indexOf(id); 236 if (idx >= 0) { 237 return list.filter((_, i) => i !== idx); 238 } 239 return [id, ...list].slice(0, max); 240} 241 242function sortPlatformsByPinned(platforms, pinnedIds) { 243 return sortItemsByPinned(platforms, pinnedIds, (platform) => 244 platform && typeof platform.slug === 'string' ? platform.slug.trim() : '' 245 ); 246} 247 248function sortItemsByPinned(items, pinnedIds, getId) { 249 const list = Array.isArray(items) ? items.slice() : []; 250 const pinned = normalizePinnedIds(pinnedIds); 251 const resolveId = 252 typeof getId === 'function' ? getId : (item) => (item && typeof item.id === 'string' ? item.id.trim() : ''); 253 if (!pinned.length || !list.length) return list; 254 255 const rank = new Map(pinned.map((id, index) => [id, index])); 256 const head = []; 257 const tail = []; 258 for (const item of list) { 259 const id = resolveId(item); 260 if (id && rank.has(id)) head.push(item); 261 else tail.push(item); 262 } 263 head.sort((a, b) => rank.get(resolveId(a)) - rank.get(resolveId(b))); 264 return head.concat(tail); 265} 266 267const PLANS_TABLE_PIN_STORAGE_KEY = 'plansTablePinnedIds'; 268 269function getPlanRowPinId(plan) { 270 if (!plan || typeof plan !== 'object') return ''; 271 return String(plan.slug || '').trim(); 272} 273 274function sanitizePinnedIdList(pinnedIds, validIds) { 275 const valid = new Set( 276 (Array.isArray(validIds) ? validIds : []) 277 .map((id) => String(id == null ? '' : id).trim()) 278 .filter(Boolean) 279 ); 280 return normalizePinnedIds(pinnedIds).filter((id) => valid.has(id)); 281} 282 283/** æå·² pin ä½è¢«çéæç项ä»å ¨éè¡¥å */ 284function mergePinnedIntoFiltered(allItems, filteredItems, pinnedIds, getId) { 285 const resolveId = typeof getId === 'function' ? getId : () => ''; 286 const result = Array.isArray(filteredItems) ? filteredItems.slice() : []; 287 const pinned = normalizePinnedIds(pinnedIds); 288 if (!pinned.length) return result; 289 290 const seen = new Set(); 291 for (const item of result) { 292 const id = resolveId(item); 293 if (id) seen.add(id); 294 } 295 const byId = new Map(); 296 for (const item of Array.isArray(allItems) ? allItems : []) { 297 const id = resolveId(item); 298 if (id && !byId.has(id)) byId.set(id, item); 299 } 300 for (const id of pinned) { 301 if (seen.has(id)) continue; 302 const item = byId.get(id); 303 if (item) { 304 result.push(item); 305 seen.add(id); 306 } 307 } 308 return result; 309} 310 311/** ææï¼æ pin 顺åºçå¤´é¨ + å ¶ä½ï¼ä¾åªæåºæª pin æ®µï¼ */ 312function partitionPinnedItems(items, pinnedIds, getId) { 313 const resolveId = typeof getId === 'function' ? getId : () => ''; 314 const pinned = normalizePinnedIds(pinnedIds); 315 const pinnedSet = new Set(pinned); 316 const headMap = new Map(); 317 const tail = []; 318 for (const item of Array.isArray(items) ? items : []) { 319 const id = resolveId(item); 320 if (id && pinnedSet.has(id)) headMap.set(id, item); 321 else tail.push(item); 322 } 323 const head = []; 324 for (const id of pinned) { 325 if (headMap.has(id)) head.push(headMap.get(id)); 326 } 327 return { head, tail }; 328} 329 330function buildRowPinButtonHtml({ pinId, pinned } = {}) { 331 const id = typeof pinId === 'string' ? pinId.trim() : ''; 332 if (!id) return ''; 333 const isPinned = !!pinned; 334 const label = isPinned ? 'åæ¶ç½®é¡¶' : '置顶'; 335 const classes = ['platform-pin-btn', 'table-row-pin-btn', isPinned ? 'is-pinned' : ''] 336 .filter(Boolean) 337 .join(' '); 338 const icon = isPinned 339 ? '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
339<path fill="currentColor" d="M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>' 340 : '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'; 341 return ( 342 `<button type="button" class="${classes}" data-table-pin="1" data-pin-id="${escapeHtml(id)}" ` + 343 `aria-pressed="${isPinned ? 'true' : 'false'}" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}">` + 344 icon + 345 `</button>` 346 ); 347} 348 349function readPinnedIdsFromStorage(storage, key) { 350 const storageKey = key || PLATFORM_PIN_STORAGE_KEY; 351 if (!storage || typeof storage.getItem !== 'function') return []; 352 try { 353 const raw = storage.getItem(storageKey); 354 if (raw == null || raw === '') return []; 355 return normalizePinnedIds(JSON.parse(raw)); 356 } catch (_) { 357 return []; 358 } 359} 360 361function writePinnedIdsToStorage(storage, pinnedIds, key) { 362 const storageKey = key || PLATFORM_PIN_STORAGE_KEY; 363 if (!storage || typeof storage.setItem !== 'function') return false; 364 try { 365 storage.setItem(storageKey, JSON.stringify(normalizePinnedIds(pinnedIds))); 366 return true; 367 } catch (_) { 368 return false; 369 } 370} 371 372function buildPlatformPinButtonHtml({ platformId, pinned, variant } = {}) { 373 const id = typeof platformId === 'string' ? platformId.trim() : ''; 374 if (!id) return ''; 375 const isPinned = !!pinned; 376 const kind = variant === 'detail' ? 'detail' : 'card'; 377 const label = isPinned ? 'åæ¶ç½®é¡¶' : '置顶'; 378 const classes = [ 379 kind === 'detail' ? 'platform-detail-pin-btn' : 'platform-pin-btn', 380 isPinned ? 'is-pinned' : '' 381 ] 382 .filter(Boolean) 383 .join(' '); 384 const icon = isPinned 385 ? '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>' 386 : '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'; 387 return ( 388 `<button type="button" class="${classes}" data-platform-pin="1" data-platform-id="${escapeHtml(id)}" ` + 389 `aria-pressed="${isPinned ? 'true' : 'false'}" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}">` + 390 icon + 391 `</button>` 392 ); 393} 394 395function collectModelsForVendor(plans, platformSlug) { 396 const seen = new Set(); 397 const models = []; 398 399 for (const plan of plans) { 400 if (plan.platformSlug !== platformSlug || plan.discontinued) { 401 continue; 402 } 403 for (const model of plan.modelLabels || []) { 404 if (!seen.has(model)) { 405 seen.add(model); 406 models.push(model); 407 } 408 } 409 } 410 411 return models; 412} 413 414function dimensionCopy(dim) { 415 if (!dim || typeof dim !== 'object') return ''; 416 const detail = typeof dim.detail === 'string' ? dim.detail.trim() : ''; 417 if (detail) return detail; 418 return typeof dim.reason === 'string' ? dim.reason : ''; 419} 420 421function collectPlansForVendor(plans, platformSlug) { 422 if (!Array.isArray(plans) || !platformSlug) return []; 423 return plans.filter(p => p && p.platformSlug === platformSlug); 424} 425 426function matchMonitorPlatform(platform, boardPlatforms) { 427 if (!platform || !Array.isArray(boardPlatforms)) return null; 428 const slug = typeof platform.monitorSlug === 'string' ? platform.monitorSlug.trim() : ''; 429 if (slug) { 430 const bySlug = boardPlatforms.find(b => b && b.platform_slug === slug); 431 if (bySlug) return bySlug; 432 } 433 const name = String(platform.name || '').trim(); 434 if (!name) return null; 435 return boardPlatforms.find(b => b && String(b.platform_display_name || '').trim() === name) || null; 436} 437 438function resolvePlatformAction(platform, plans) { 439 if (platform.action) { 440 return platform.action; 441 } 442 443 const plan = plans.find(p => p.platformSlug === platform.slug); 444 return plan?.action ?? null; 445} 446 447function validatePlatformRecords(platforms, plans) { 448 const errors = []; 449 const platformSlugs = new Set(platforms.map(p => p.slug)); 450 451 const referencedSlugs = [...new Set(plans.map(p => p.platformSlug))]; 452 for (const platformSlug of referencedSlugs) { 453 if (!platformSlugs.has(platformSlug)) { 454 errors.push(`Plan platformSlug "${platformSlug}" has no matching platform`); 455 } 456 } 457 458 for (const platform of platforms) { 459 const prefix = `Platform "${platform.name}"`; 460 461 if (!PLATFORM_STATUSES.includes(platform.platformStatus)) { 462 errors.push(`${prefix}: platformStatus must be one of ${PLATFORM_STATUSES.join(', ')}`); 463 } 464 465 if (platform.tags !== undefined && !Array.isArray(platform.tags)) { 466 errors.push(`${prefix}: tags must be an array`); 467 } 468 469 if (Object.prototype.hasOwnProperty.call(platform, 'summary')) { 470 if (typeof platform.summary !== 'string' || platform.summary.trim() === '') { 471 errors.push(`${prefix}: summary must be a non-empty string when present`); 472 } 473 } 474 if (Object.prototype.hasOwnProperty.call(platform, 'detail')) { 475 if (typeof platform.detail !== 'string' || platform.detail.trim() === '') { 476 errors.push(`${prefix}: detail must be a non-empty string when present`); 477 } 478 } 479 480 for (const key of DIMENSION_KEYS) { 481 const dim = platform.dimensions?.[key]; 482 if (!dim) { 483 errors.push(`${prefix}: missing dimension "${key}"`); 484 continue; 485 } 486 487 const { score, reason } = dim; 488 if (score !== null && (!Number.isInteger(score) || score < 1 || score > 5)) { 489 errors.push(`${prefix}: dimension "${key}" score must be an integer in [1, 5]`); 490 } 491 if (typeof reason !== 'string' || reason.trim() === '') { 492 errors.push(`${prefix}: dimension "${key}" reason must be a non-empty string`); 493 } 494 if (Object.prototype.hasOwnProperty.call(dim, 'detail')) { 495 if (typeof dim.detail !== 'string' || dim.detail.trim() === '') { 496 errors.push(`${prefix}: dimension "${key}" detail must be a non-empty string when present`); 497 } 498 } 499 } 500 } 501 502 return { ok: errors.length === 0, errors }; 503} 504 505function buildPlatformTagBarHtml(catalogConfig, selectedLabels, platformStatusMax) { 506 const cat = catalogConfig || {}; 507 const selected = selectedLabels || []; 508 const mainParts = []; 509 510 const derivedChips = []; 511 for (const tag of cat.derivedTags || []) { 512 const label = tag.label; 513 const active = selected.includes(label); 514 derivedChips.push( 515 `<button type="button" class="platform-tag-chip platform-tag-chip--derived${active ? ' is-active' : ''}" data-platform-tag="${escapeHtml(label)}" aria-pressed="${active ? 'true' : 'false'}">${escapeHtml(label)}</button>` 516 ); 517 } 518 if (derivedChips.length) { 519 mainParts.push(`<div class="platform-tag-group platform-tag-group--derived" role="group" aria-label="é¢è®¾çé">${derivedChips.join('')}</div>`); 520 } 521 522 const operationalChips = []; 523 for (const label of cat.operationalTags || []) { 524 const active = selected.includes(label); 525 operationalChips.push( 526 `<button type="button" class="platform-tag-chip platform-tag-chip--operational${active ? ' is-active' : ''}" data-platform-tag="${escapeHtml(label)}" aria-pressed="${active ? 'true' : 'false'}">${escapeHtml(label)}</button>` 527 ); 528 } 529 if (operationalChips.length) { 530 if (derivedChips.length) { 531 mainParts.push('<span class="platform-tag-sep" aria-hidden="true"></span>'); 532 } 533 mainParts.push(`<div class="platform-tag-group platform-tag-group--operational" role="group" aria-label="平尿 ç¾">${operationalChips.join('')}</div>`); 534 } 535 536 const mainHtml = mainParts.length 537 ? `<div class="platform-tag-bar-main">${mainParts.join('')}</div>` 538 : ''; 539 540 return `${mainHtml}${buildPlatformStatusSliderHtml(platformStatusMax)}`; 541} 542 543function buildPlatformStatusSliderHtml(platformStatusMax) { 544 const max = normalizePlatformStatus(platformStatusMax ?? DEFAULT_PLATFORM_STATUS_MAX); 545 const parts = []; 546 PLATFORM_STATUSES.forEach((status, rank) => { 547 const active = isPlatformStatusSegmentActive(status, max); 548 const segmentLabel = platformStatusSegmentLabel(status); 549 const fullLabel = platformStatusFilterLabel(status); 550 parts.push( 551 `<button type="button" class="platform-status-seg${active ? ' is-active' : ''}" data-platform-status="${status}" aria-pressed="${active ? 'true' : 'false'}" title="${escapeHtml(fullLabel)}">` + 552 `${escapeHtml(segmentLabel)}` + 553 `</button>` 554 ); 555 const next = PLATFORM_STATUSES[rank + 1]; 556 // ãåã常驻ï¼ãææãå䏿ï¼ç¨ is-on æ§å¶æ¾é 557 if (next && next !== 'delisted') { 558 const andOn = 559 isPlatformStatusSegmentActive(status, max) && 560 isPlatformStatusSegmentActive(next, max); 561 parts.push( 562 `<span class="platform-status-and${andOn ? ' is-on' : ''}" aria-hidden="true">å</span>` 563 ); 564 } 565 }); 566 567 return ( 568 `<div class="platform-status-slider" data-platform-status-max="${max}" role="group" aria-label="${escapeHtml(platformStatusFilterPhrase(max))}">` + 569 `<span class="platform-status-prefix">${escapeHtml(platformStatusFilterPrefix(max))}</span>` + 570 `<div class="platform-status-segments" tabindex="0">${parts.join('')}</div>` + 571 `<span class="platform-status-suffix">${escapeHtml(PLATFORM_STATUS_FILTER_SUFFIX)}</span>` + 572 `</div>` 573 ); 574} 575 576const EXTERNAL_LINK_ICON =
577 '<svg class="link-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>'; 578 579function buildPlatformCardHtml(platform, plans, options = {}) { 580 const sanitizeUrl = options.sanitizeUrl || (url => url); 581 const supportedModels = Array.isArray(options.supportedModels) 582 ? options.supportedModels 583 : []; 584 const hasApiPlan = !!options.hasApiPlan; 585 const rawAction = resolvePlatformAction(platform, plans); 586 const action = sanitizeUrl(rawAction); 587 const name = escapeHtml(platform.name || ''); 588 const nameHtml = action 589 ? `<a class="platform-name-link" href="${escapeHtml(action)}" target="_blank" rel="noopener noreferrer"><span class="platform-name-text">${name}</span>${EXTERNAL_LINK_ICON}</a>` 590 : `<span class="platform-name">${name}</span>`; 591 592 const rating = Math.max(0, Math.min(5, Number(platform.rating) || 0)); 593 const stars = 'âï¸'.repeat(rating); 594 const summaryText = 595 typeof platform.summary === 'string' && platform.summary.trim() 596 ? platform.summary.trim() 597 : ''; 598 const summary = summaryText 599 ? `<p class="platform-summary">${formatInlineMarkdownPreserveBreaks(summaryText)}</p>` 600 : ''; 601 602 const status = normalizePlatformStatus(platform.platformStatus); 603 const statusHtml = 604 status === 'open' 605 ? '' 606 : `<span class="platform-rush" data-platform-status="${status}">${escapeHtml(platformStatusLabel(status))}</span>`; 607 const apiBadge = hasApiPlan 608 ? `<span class="platform-rush platform-rush--api">æé</span>` 609 : ''; 610 611 const dimsHtml = PLATFORM_DIMENSION_META.map(({ key, label }) => { 612 const dim = (platform.dimensions && platform.dimensions[key]) || {}; 613 const score = dim.score == null ? 'â' : String(dim.score); 614 const scoreHtml = 615 score === 'â' 616 ? `<span class="dim-score">${escapeHtml(score)}</span>` 617 : `<span class="dim-score">${escapeHtml(score)}<span class="dim-score-unit">å</span></span>`; 618 return `<li data-dim="${escapeHtml(key)}"><div class="dim-meta">${scoreHtml}<span class="dim-label">${escapeHtml(label)}</span></div><span class="dim-reason">${formatInlineMarkdownPreserveBreaks(dim.reason || '')}</span></li>`; 619 }).join(''); 620 621 const tags = Array.isArray(platform.tags) ? platform.tags : []; 622 const tagsHtml = tags.length 623 ? `<div class="platform-tags" aria-label="æ ç¾">${tags.map((tag) => `<span class="platform-tag">${escapeHtml(tag)}</span>`).join('')}</div>` 624 : ''; 625 626 const models = supportedModels.length ? supportedModels.map(model => model.name) : collectModelsForVendor(plans, platform.slug); 627 const modelLimit = 5; 628 const shownModels = models.slice(0, modelLimit); 629 const extraModels = models.length - shownModels.length; 630 const modelsHtml = models.length 631 ? `<div class="platform-models" aria-label="模å">${shownModels.map((model) => `<span class="model-tag">${escapeHtml(model)}</span>`).join('')}${extraModels > 0 ? `<span class="model-tag model-tag-more">+${extraModels}</span>` : ''}</div>` 632 : ''; 633 634 const discontinuedClass = status === 'delisted' ? ' is-discontinued' : ''; 635 const platformId = platform && typeof platform.slug === 'string' ? platform.slug.trim() : ''; 636 const pinned = isPlatformPinned(platformId, options.pinnedIds); 637 const pinnedClass = pinned ? ' is-pinned' : ''; 638 const pinHtml = buildPlatformPinButtonHtml({ 639 platformId, 640 pinned, 641 variant: 'card' 642 }); 643 644 return ` 645 <article class="platform-card${discontinuedClass}${pinnedClass}" data-platform-id="${escapeHtml(platformId)}" tabindex="0" role="button" aria-label="${name} 详æ "> 646 <header> 647 <div class="platform-card-heading"> 648 ${nameHtml} 649 ${statusHtml} 650 ${apiBadge} 651 </div> 652 <div class="platform-card-aside"> 653 ${pinHtml}
654 <span class="platform-rating" aria-label="${rating > 0 ? `${rating} æ` : 'å¾ è¯å®'}">${rating > 0 ? stars : 'å¾ è¯å®'}</span> 655 </div> 656 </header> 657 ${summary} 658 <ul class="platform-dimensions">${dimsHtml}</ul> 659 ${tagsHtml} 660 ${modelsHtml} 661 <span class="platform-card-hint">æ¥ç详æ <span aria-hidden="true">â</span></span> 662 </article> 663 `; 664} 665 666const PlatformCatalog = { 667 DIMENSION_KEYS, 668 PLATFORM_DIMENSION_META, 669 PLATFORM_STATUSES, 670 PLATFORM_STATUS_LABELS, 671 PLATFORM_STATUS_SEGMENT_LABELS, 672 PLATFORM_STATUS_FILTER_SUFFIX, 673 DEFAULT_PLATFORM_STATUS_MAX, 674 escapeHtml, 675 formatInlineMarkdown, 676 formatInlineMarkdownPreserveBreaks, 677 normalizePlatformStatus, 678 platformStatusRank, 679 platformStatusLabel, 680 platformStatusSegmentLabel, 681 platformStatusFilterPrefix, 682 platformStatusFilterLabel, 683 isPlatformStatusSegmentActive, 684 platformStatusFilterPhrase, 685 matchesDerivedTag, 686 matchesOperationalTag, 687 filterPlatforms, 688 PLATFORM_PIN_STORAGE_KEY, 689 PLATFORM_PIN_MAX, 690 PLANS_TABLE_PIN_STORAGE_KEY, 691 normalizePinnedIds, 692 sanitizePinnedIds, 693 sanitizePinnedIdList, 694 isPlatformPinned, 695 togglePinnedId, 696 sortPlatformsByPinned, 697 sortItemsByPinned, 698 getPlanRowPinId, 699 mergePinnedIntoFiltered, 700 partitionPinnedItems, 701 readPinnedIdsFromStorage, 702 writePinnedIdsToStorage, 703 buildPlatformPinButtonHtml, 704 buildRowPinButtonHtml, 705 collectModelsForVendor, 706 dimensionCopy, 707 collectPlansForVendor, 708 matchMonitorPlatform, 709 resolvePlatformAction, 710 validatePlatformRecords, 711 buildPlatformTagBarHtml, 712 buildPlatformStatusSliderHtml, 713 buildPlatformCardHtml 714}; 715 716if (typeof module !== 'undefined' && module.exports) { 717 module.exports = PlatformCatalog; 718} else { 719 window.PlatformCatalog = PlatformCatalog; 720}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.