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;
|
|||
|
|
}
|