PageSourceSearch

https://dreamfree.space/scripts/platform-catalog.js

js dreamfree.space collected 2026-10-06 14:12:48 UTC 26,582 bytes, 720 lines download raw bytes

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, '&lt;')
31    .replace(/>/g, '&gt;')
32    .replace(/"/g, '&quot;');
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.