Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/gold-price-check/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gold-price-check",
"version": "1.0.0",
"version": "1.0.1",
"description": "A ZTools plugin",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/gold-price-check/public/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"title": "金价动态",
"description": "实时查看每日金价、品牌金店价格、银行金条价格、黄金回收价格,以及日内/月度/年度价格波动走势",
"author": "fantasyke",
"version": "1.0.0",
"version": "1.0.1",
"main": "index.html",
"preload": "preload/services.js",
"logo": "logo.png",
Expand Down
46 changes: 42 additions & 4 deletions plugins/gold-price-check/src/GoldPrice/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import {
fetchRealtimeGoldPrice,
fetchCurrentGoldUSD,
Expand Down Expand Up @@ -73,6 +73,12 @@ export default function GoldPrice() {
const [selectedMonth, setSelectedMonth] = useState(now.getMonth() + 1);
const [selectedYear2, setSelectedYear2] = useState(now.getFullYear());

// 本地月度快照 (从 storage 读取,传给 buildYearlyData 以消除 useMemo 副作用)
const [localMonthly, setLocalMonthly] = useState<MonthlySnapshot[]>([]);

// 用于取消上一次未完成的请求并作为并发锁
const abortRef = useRef<AbortController | null>(null);

// 可用选项 (基于 monthly CSV + 本地每日快照)
const monthlyLegacy = legacyData?.monthly ?? [];
const availableYears = useMemo(
Expand All @@ -93,26 +99,41 @@ export default function GoldPrice() {

// ====== 加载数据 ======
const loadData = useCallback(async (forceRefresh = false) => {
// 如果上一次加载还没完成,取消它
if (abortRef.current) {
abortRef.current.abort();
}

// 创建新的 AbortController
const controller = new AbortController();
abortRef.current = controller;

if (forceRefresh) clearCache();
setError(null);

try {
// 并行请求: Tmini(RMB) + 实时USD + 遗留月/年历史(每个独立容错)
const [tminiData, usdSpot, legacy] = await Promise.all([
fetchRealtimeGoldPrice().catch((err) => {
if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
console.error('获取RMB金价失败:', err);
return null;
}),
fetchCurrentGoldUSD().catch((err) => {
if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
console.error('获取USD金价失败:', err);
return null;
}),
fetchLegacyHistoricalData().catch((err) => {
if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
console.error('获取遗留历史数据失败:', err);
return null;
}),
]);

// 如果请求被取消,静默退出
if (controller.signal.aborted) return;

if (!tminiData && !usdSpot) {
throw new Error('金价数据获取失败,请检查网络连接');
}
Expand All @@ -130,7 +151,7 @@ export default function GoldPrice() {
if (usdSpot) {
setSpotUSD(usdSpot.price);

// 追加本地快照(用USD价格)
// 追加本地快照(用USD价格,内部已做去重和频率控制
const usdPrice = usdSpot.price;
appendHourlySnapshot(usdPrice);
appendDailySnapshot(usdPrice);
Expand All @@ -146,6 +167,9 @@ export default function GoldPrice() {
const daily = getDailySnapshots();
setHistorical(daily);

// 本地月度快照 (从 storage 读取,供 buildYearlyData 使用,消除 useMemo 副作用)
setLocalMonthly(getMonthlySnapshots());

setLastUpdate(new Date().toLocaleTimeString('zh-CN'));
setLoading(false);

Expand All @@ -155,10 +179,17 @@ export default function GoldPrice() {
`遗留 ${legacy?.monthly.length ?? 0} 月 / ${legacy?.annual.length ?? 0} 年`
);
} catch (err) {
// AbortError 是正常的取消,不报错
if (err instanceof DOMException && err.name === 'AbortError') return;

const msg = err instanceof Error ? err.message : '加载失败';
console.error('[金价]', msg);
setError(msg);
setLoading(false);
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
}
}
Comment thread
fantasy-ke marked this conversation as resolved.
}, []);

Expand All @@ -176,6 +207,13 @@ export default function GoldPrice() {
if (isZTools) (window as any).ztools.setExpendHeight?.(620);
}, []);

// 组件卸载时取消所有在途请求
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);

// ====== 派生数据 ======

/** 日内:近24小时小时快照 */
Expand Down Expand Up @@ -206,8 +244,8 @@ export default function GoldPrice() {

/** 年度:指定年份12个月数据 (monthly CSV + 本地快照) */
const yearData: MonthlySnapshot[] = useMemo(
() => buildYearlyData(selectedYear2, monthlyLegacy, historical),
[monthlyLegacy, historical, selectedYear2, lastUpdate],
() => buildYearlyData(selectedYear2, monthlyLegacy, historical, localMonthly),
[monthlyLegacy, historical, selectedYear2, localMonthly],
);

/** 历年对比 (最近10年) */
Expand Down
87 changes: 68 additions & 19 deletions plugins/gold-price-check/src/GoldPrice/services/goldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,17 +196,47 @@ function persist(key: string, value: unknown): void {
} else {
mem.set(key, value);
}
} catch { /* ignore */ }
} catch (e) {
console.warn(`[金价] persist 失败 (${key}):`, e);
}
}

function load<T>(key: string, fallback: T): T {
try {
if (isZTools) {
const raw = db()?.getItem(key);
return raw ? JSON.parse(raw) as T : fallback;
if (!raw) return fallback;
const parsed = JSON.parse(raw) as T;
// 校验数据为数组(快照数据都应是数组)
if (Array.isArray(parsed)) return parsed;
console.warn(`[金价] load 数据异常 (${key}), 已重置`);
return fallback;
}
return (mem.get(key) as T) ?? fallback;
} catch { return fallback; }
const val = mem.get(key);
return (Array.isArray(val) ? val : fallback) as T;
} catch (e) {
console.warn(`[金价] load 失败 (${key}), 已重置:`, e);
return fallback;
}
}

// ---- 写入频率控制 ----

/** 同一条 key 的最小写入间隔 (ms),防止短时间高频写入 */
const MIN_PERSIST_INTERVAL = 60_000; // 1 分钟

const _lastPersistTime: Record<string, number> = {};

/** 检查是否距离上次 persist 已超过最小间隔 */
function shouldPersist(key: string): boolean {
const last = _lastPersistTime[key] || 0;
return Date.now() - last >= MIN_PERSIST_INTERVAL;
}

/** 带频率控制的 persist */
function persistThrottled(key: string, value: unknown): void {
_lastPersistTime[key] = Date.now();
persist(key, value);
}

// ---- CSV 解析 ----
Expand Down Expand Up @@ -311,58 +341,75 @@ export async function fetchRealtimeGoldPrice(): Promise<TminiResponse> {
/** 追加小时快照 (日内波动用) — 每小时调用一次 */
export function appendHourlySnapshot(price: number): void {
const now = new Date();
// 取整到小时: 2026-07-01T14:00
const hour = `${now.toISOString().slice(0, 13)}:00`;

// 频率控制: 同一小时内不需要每 5 分钟写一次
if (!shouldPersist(KEY_HOURLY)) return;

const list = load<HourlySnapshot[]>(KEY_HOURLY, []);
// 去重: 同一小时已有记录则更新
const idx = list.findIndex(x => x.time === hour);
if (idx >= 0) {
// 同一小时价格没变化,不写
if (list[idx].price === price) return;
list[idx].price = price;
} else {
list.push({ time: hour, price });
}
// 保留近 48 小时
const trimmed = list.slice(-48);
persist(KEY_HOURLY, trimmed);
persistThrottled(KEY_HOURLY, trimmed);
}

/** 追加每日快照 (月度/年度波动用) — 每天调用一次 */
export function appendDailySnapshot(price: number): void {
const today = new Date().toISOString().slice(0, 10);

// 频率控制: 同一天不需要每 5 分钟写一次
if (!shouldPersist(KEY_DAILY)) return;

const list = load<HistoricalPrice[]>(KEY_DAILY, []);
const idx = list.findIndex(x => x.date === today);
if (idx >= 0) {
// 同日价格没变化,不写
if (list[idx].price === price) return;
list[idx].price = price;
} else {
list.push({ date: today, price, source: 'local-daily' });
}
const trimmed = list.slice(-400);
persist(KEY_DAILY, trimmed);
persistThrottled(KEY_DAILY, trimmed);
}

/** 更新月度快照 — 每次加载时刷新当月数据 */
export function updateMonthlySnapshot(price: number): void {
const now = new Date();
const month = now.toISOString().slice(0, 7); // YYYY-MM

// 频率控制: 同一个月不需要每 5 分钟写一次
if (!shouldPersist(KEY_MONTHLY)) return;

const list = load<MonthlySnapshot[]>(KEY_MONTHLY, []);
const idx = list.findIndex(x => x.month === month);

if (idx >= 0) {
// 用新价格更新当月高低均
const snap = list[idx];
const count = snap.count || 1;
snap.highPrice = Math.max(snap.highPrice, price);
snap.lowPrice = Math.min(snap.lowPrice, price);
// 正确的运行均值: (旧均值 × 次数 + 新值) / (次数 + 1)
snap.avgPrice = (snap.avgPrice * count + price) / (count + 1);
snap.count = count + 1;
const oldHigh = snap.highPrice;
const oldLow = snap.lowPrice;
const newHigh = Math.max(snap.highPrice, price);
const newLow = Math.min(snap.lowPrice, price);
// count 上限防护: 超过 10000 截断,防止无限增长
const count = Math.min(snap.count || 1, 10_000);
const newAvg = (snap.avgPrice * count + price) / (count + 1);
// 高低价和均价都没变化,跳过
if (newHigh === oldHigh && newLow === oldLow && newAvg === snap.avgPrice) return;
snap.highPrice = newHigh;
snap.lowPrice = newLow;
snap.avgPrice = newAvg;
snap.count = count + 1;
} else {
list.push({ month, avgPrice: price, highPrice: price, lowPrice: price, count: 1 });
}
const trimmed = list.slice(-60);
persist(KEY_MONTHLY, trimmed);
persistThrottled(KEY_MONTHLY, trimmed);
}

// ---- 读取快照 ----
Expand Down Expand Up @@ -436,16 +483,16 @@ export function getRecentDailyData(data: HistoricalPrice[], days: number) {

/**
* 构建年度月线数据:
* 1. 优先用本地 MonthlySnapshot (本年月度高/低更精确)
* 1. 优先用 localMonthly (本月度高/低更精确, 由调用方预先读取)
* 2. 从 monthly CSV 月均价补充历史月份
* 3. 降级: 从 historicalData (本地每日快照) 聚合
*/
export function buildYearlyData(
year: number,
monthlyData: MonthlyGoldRecord[],
dailyData: HistoricalPrice[],
localMonthly: MonthlySnapshot[] = [],
): MonthlySnapshot[] {
const localMonthly = getMonthlySnapshots();
const result: MonthlySnapshot[] = [];

for (let m = 1; m <= 12; m++) {
Expand Down Expand Up @@ -609,4 +656,6 @@ export function clearAllSnapshots(): void {
} else {
for (const k of keys) mem.delete(k);
}
// 重置写入频率计时器
for (const k of keys) delete _lastPersistTime[k];
}
Loading