80 lines
2.6 KiB
JavaScript
80 lines
2.6 KiB
JavaScript
// 追蹤個股:分群清單(持久化於 SQLite KV,與財報日曆 watchlist 分開)
|
||
import { getCachedEntry, putCachedJSON } from './db.js';
|
||
|
||
const STORE_KEY = 'stock:watchlist:v1';
|
||
const MAX_GROUPS = 24;
|
||
const MAX_SYMBOLS_PER_GROUP = 48;
|
||
const MAX_SYMBOLS_TOTAL = 200;
|
||
|
||
export const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
|
||
|
||
function newGroupId() {
|
||
return `g_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
export function defaultWatchlist() {
|
||
return {
|
||
groups: [
|
||
{ id: 'default', name: '我的追蹤', symbols: [], order: 0 },
|
||
],
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function cleanSymbol(s) {
|
||
const sym = String(s || '').trim().toUpperCase();
|
||
return SYMBOL_RE.test(sym) ? sym : null;
|
||
}
|
||
|
||
function normalizeGroup(g, idx) {
|
||
const id = String(g?.id || '').trim() || newGroupId();
|
||
const name = String(g?.name || '').trim().slice(0, 40) || '未命名分群';
|
||
const symbols = [...new Set((g?.symbols || []).map(cleanSymbol).filter(Boolean))].slice(0, MAX_SYMBOLS_PER_GROUP);
|
||
return { id, name, symbols, order: Number.isFinite(g?.order) ? g.order : idx };
|
||
}
|
||
|
||
/** 驗證並正規化前端/API 送來的完整結構 */
|
||
export function normalizeWatchlistPayload(raw) {
|
||
const base = defaultWatchlist();
|
||
if (!raw || typeof raw !== 'object') return base;
|
||
let groups = Array.isArray(raw.groups) ? raw.groups.map(normalizeGroup) : base.groups;
|
||
if (!groups.length) groups = base.groups;
|
||
groups = groups.slice(0, MAX_GROUPS).sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, 'zh-Hant'));
|
||
const seenSym = new Set();
|
||
for (const g of groups) {
|
||
g.symbols = g.symbols.filter(sym => {
|
||
if (seenSym.has(sym) || seenSym.size >= MAX_SYMBOLS_TOTAL) return false;
|
||
seenSym.add(sym);
|
||
return true;
|
||
});
|
||
}
|
||
if (!groups.some(g => g.id === 'default')) {
|
||
groups.unshift({ id: 'default', name: '我的追蹤', symbols: [], order: -1 });
|
||
}
|
||
groups.forEach((g, i) => { g.order = i; });
|
||
return { groups, updatedAt: new Date().toISOString() };
|
||
}
|
||
|
||
export function getStockWatchlist() {
|
||
const row = getCachedEntry(STORE_KEY);
|
||
const val = row?.value;
|
||
if (!val?.groups) return defaultWatchlist();
|
||
return normalizeWatchlistPayload(val);
|
||
}
|
||
|
||
export function saveStockWatchlist(payload) {
|
||
const normalized = normalizeWatchlistPayload(payload);
|
||
putCachedJSON(STORE_KEY, normalized);
|
||
return normalized;
|
||
}
|
||
|
||
export function allWatchlistSymbols(data) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const g of data?.groups || []) {
|
||
for (const sym of g.symbols || []) {
|
||
if (!seen.has(sym)) { seen.add(sym); out.push(sym); }
|
||
}
|
||
}
|
||
return out;
|
||
} |