(null);
+ React.useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ const t = e.target as Node;
+ if ((langRef.current && langRef.current.contains(t)) ||
+ (langDropdownRef.current && langDropdownRef.current.contains(t))) return;
+ setLangOpen(false);
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, []);
+
+ const currentLangLabel = LANG_OPTIONS.find(l => l.code === lang)?.label || '简体中文';
+
+ // Category list view
+ const categories = [
+ { key: "account", icon: , label: t('settings.cat_account') },
+ { key: "general", icon: , label: t('settings.cat_general') },
+ { key: "display", icon: , label: t('settings.cat_display') },
+ { key: "notify", icon: , label: t('notify.title') },
+ { key: "data", icon: , label: t('settings.cat_data') },
+ { key: "about", icon: , label: t('settings.cat_about') },
+ ];
+
+ // Account category content - always show both platforms
+ const accountContent = (
+ <>
+ {/* DeepSeek Account */}
+
+
DeepSeek
+
} title="API Key">
+
用于调用 DeepSeek API 获取余额和用量数据。
+
API Key 只在当前这台 Windows 电脑本地保留。
+
setApiKey(e.target.value)} />
+
+
+ {config?.apiKeyConfigured ? "已配置" : "未配置"}
+
+
+
+
} title="用量同步 Token">
+
用于同步 Token 用量、消费和趋势图。DeepSeek 无官方用量 API,需网页登录 token(与 API Key 不同)。
+
+
+ {config?.usageTokenConfigured ? "已配置" : "未配置"}
+
+
+
{usageStatus}
+
+ {showManualPaste && (<>
+
获取:浏览器登录 platform.deepseek.com,按 F12 打开控制台,输入 JSON.parse(localStorage.userToken).value 回车,复制返回的字符串。
+
setUsageToken(e.target.value)} />
+
+ >)}
+
+
+
+ {/* MiMo Account */}
+
+
MiMo
+
} title="MiMo 登录">
+
通过小米账号登录 MiMo 平台,登录成功后即可查看余额和用量数据。
+
+ {mimoStatus &&
{mimoStatus}
}
+
+
+ >
+ );
+ const generalContent = (
+ <>
+ } title={t('settings.general')}>
+
+ {t('settings.autostart_desc')}
+
+ {t('settings.auto_refresh_desc')}
+ {autoRefresh && (
+
+
+
DeepSeek 刷新间隔
+
+
+ {customDsRefresh && (
+ <>
+ { if (e.key === 'Enter') { const val = parseInt((e.target as HTMLInputElement).value); if (val > 0) saveRefreshInterval(val * 60); } }} />
+ 分钟
+ >
+ )}
+
+
+
+
MiMo 刷新间隔
+
+
+ {customMimoRefresh && (
+ <>
+
{ if (e.key === 'Enter') { const val = parseInt((e.target as HTMLInputElement).value); if (val > 0) void invoke
("save_mimo_refresh_interval", { seconds: val * 60 }).then(setConfig).catch(() => {}); } }} />
+ 分钟
+ >
+ )}
+
+
+
+ )}
+ {t('settings.default_provider')}
+
+
+ } title={t('settings.language')}>
+
+
{t('settings.language_desc')}
+
+
+ {langOpen && createPortal(
+
+ {LANG_OPTIONS.map((opt) => (
+
+ ))}
+
,
+ document.body
+ )}
+
+
+
+ >
+ );
+
+ // Display category content
+ const displayContent = (
+ <>
+ } title={t('settings.currency')}>
+ 选择金额显示的货币。
+
+ {(["cny", "usd"] as const).map((opt) => (
+
+ ))}
+
+ 效率指标显示方式:
+
+ {(["token_per_currency", "currency_per_token"] as const).map((opt) => (
+
+ ))}
+
+
+ } title={t('settings.theme')}>
+ 选择应用的外观主题。
+
+ {(["light", "dark", "system"] as const).map((opt) => (
+
+ ))}
+
+
+ } title={t('settings.window_size')}>
+ {t('settings.window_desc')}
+
+ {[{ label: t('settings.compact'), w: 380, h: 600 }, { label: t('settings.standard'), w: 463, h: 660 }, { label: t('settings.wide'), w: 600, h: 700 }, { label: t('settings.large'), w: 660, h: 900 }].map((preset) => (
+
+ ))}
+
+
+ >
+ );
+
+ // Notify category content
+ const notifyContent = (
+ } title={t('notify.title')}>
+
+ {t('notify.desc')}
+ {lowBalanceNotify && (
+ <>
+
+ {t('notify.threshold')}:
+ saveLowBalanceThreshold(e.target.value)} style={{ width: 100 }} />
+ {currency === "usd" ? "$" : "¥"}
+
+ {t('settings.notify_cooldown')}
+ {t('settings.notify_cooldown_desc')}
+
+
+ {customCooldown && (
+ <>
+
{ if (e.key === 'Enter') { const val = parseInt((e.target as HTMLInputElement).value); if (val > 0) void invoke
("save_notify_cooldown", { minutes: val }).then(setConfig).catch(() => {}); } }} />
+ 分钟
+ >
+ )}
+
+ >
+ )}
+
+ );
+
+ // About category content
+ const aboutContent = (
+ } title={t('settings.about')}>
+ {t('settings.version')}v{appVersion}
+
+ {!updateInfo && (
+
+ )}
+ {updateInfo && !downloading && !downloadDone && (
+
+ )}
+ {downloading && (
+
+
+
+ {downloadProgress?.total
+ ? `${(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} / ${(downloadProgress.total / 1024 / 1024).toFixed(1)} MB (${Math.min(100, (downloadProgress.downloaded / downloadProgress.total) * 100).toFixed(1)}%)`
+ : t('settings.downloading_update')}
+
+
+ )}
+ {downloadDone &&
{t('settings.update_installed')}}
+ {!updateInfo && !updateError && !checkingUpdate &&
{t('settings.latest')}}
+ {updateError &&
⚠ {updateError}}
+
+
+ {changelogError && {changelogError}
}
+ {changelogHtml && }
+ {updateInfo && !downloading && !downloadDone && (
+
+
v{updateInfo.version} {updateInfo.date ? `(${updateInfo.date})` : ""}
+
+ )}
+
+ 配置文件:{configPath}
+
+
+ );
+
+ // Data management category content
+ const [exportFormat, setExportFormat] = React.useState<"json" | "csv">("json");
+ const [exportPlatform, setExportPlatform] = React.useState<"all" | "deepseek" | "mimo">("all");
+
+ const handleExport = () => {
+ const keys: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (!key || !key.startsWith('dsm-')) continue;
+ if (exportPlatform === "deepseek" && key.includes('mimo')) continue;
+ if (exportPlatform === "mimo" && !key.includes('mimo') && !key.includes('platform')) continue;
+ keys.push(key);
+ }
+
+ if (exportFormat === "json") {
+ const data: Record = {};
+ for (const key of keys) {
+ try { data[key] = JSON.parse(localStorage.getItem(key) || ''); } catch { data[key] = localStorage.getItem(key); }
+ }
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `dsm-data-${exportPlatform}.json`; a.click(); URL.revokeObjectURL(url);
+ } else {
+ // CSV: flatten each key-value pair
+ const rows = [["key", "value"]];
+ for (const key of keys) {
+ const val = localStorage.getItem(key) || '';
+ try {
+ const parsed = JSON.parse(val);
+ if (typeof parsed === 'object' && parsed !== null) {
+ // Flatten nested objects
+ for (const [k, v] of Object.entries(parsed)) {
+ rows.push([`${key}.${k}`, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
+ }
+ } else {
+ rows.push([key, String(parsed)]);
+ }
+ } catch { rows.push([key, val]); }
+ }
+ const csv = rows.map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n');
+ const blob = new Blob(['\uFEFF' + csv], { type: "text/csv;charset=utf-8" });
+ const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `dsm-data-${exportPlatform}.csv`; a.click(); URL.revokeObjectURL(url);
+ }
+ };
+
+ const dataContent = (
+ <>
+ } title={t('settings.clear_cache')}>
+ {t('settings.clear_cache_desc')}
+
+
+
+
+ } title="导出使用数据">
+ 导出缓存的使用数据(余额、用量等),支持多种格式和平台过滤。
+
+
格式:
+
+ {(["json", "csv"] as const).map((opt) => (
+
+ ))}
+
+
+
+
平台:
+
+ {(["all", "deepseek", "mimo"] as const).map((opt) => (
+
+ ))}
+
+
+
+
+
+
+ } title="导入使用数据">
+ 从 JSON 文件导入使用数据,将覆盖当前缓存。
+
+
+
+
+ >
+ );
+
+ const categoryContent: Record = {
+ account: accountContent,
+ general: generalContent,
+ display: displayContent,
+ notify: notifyContent,
+ data: dataContent,
+ about: aboutContent,
+ };
+
+ return (
+
+
+
+
+ DeepSeek / MiMo Monitor
+
+
+
+
+ {categories.map((cat) => (
+
+
+
+
+ {categoryContent[cat.key]}
+
+
+
+ ))}
+
+
+
+ );
+}
+
+// ─── Shared ────────────────────────────────────────────────
+function SettingsSection({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
+ return ();
+}
+function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
+ return ();
+}
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 0000000..538c86b
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,152 @@
+// ─── i18n 国际化支持 ──────────────────────────────────────
+// 中文 / English 双语切换
+
+export type Lang = 'zh' | 'en';
+
+export const LANG_OPTIONS: { code: Lang; label: string }[] = [
+ { code: 'zh', label: '简体中文' },
+ { code: 'en', label: 'English' },
+];
+
+const translations: Record> = {
+ // 通用
+ 'app.loading': { zh: '查询中…', en: 'Loading…' },
+ 'app.error': { zh: '查询失败', en: 'Query failed' },
+ 'app.unconfigured': { zh: '未配置', en: 'Not configured' },
+ 'app.unconfigured_token': { zh: '未配置 Token', en: 'Token not configured' },
+ 'app.unavailable': { zh: '不可用', en: 'Unavailable' },
+ 'app.no_data': { zh: '暂无数据', en: 'No data' },
+ 'app.tokens': { zh: 'tokens', en: 'tokens' },
+
+ // 余额
+ 'balance.title': { zh: '账户余额', en: 'Account Balance' },
+ 'balance.available': { zh: '可用', en: 'Available' },
+ 'balance.insufficient': { zh: '余额不足', en: 'Insufficient' },
+ 'balance.today': { zh: '当日消耗', en: 'Today' },
+ 'balance.monthly': { zh: '本月消费', en: 'This Month' },
+
+ // 设置
+ 'settings.title': { zh: '设置', en: 'Settings' },
+ 'settings.api_key': { zh: 'API Key', en: 'API Key' },
+ 'settings.api_key_desc': { zh: '用于调用 API 获取余额和用量数据。', en: 'Used to call API for balance and usage data.' },
+ 'settings.save': { zh: '验证并保存', en: 'Verify & Save' },
+ 'settings.clear': { zh: '清除 Key', en: 'Clear Key' },
+ 'settings.verified': { zh: '已配置', en: 'Configured' },
+ 'settings.not_configured': { zh: '未配置', en: 'Not configured' },
+ 'settings.general': { zh: '通用', en: 'General' },
+ 'settings.autostart': { zh: '开机自启', en: 'Auto Start' },
+ 'settings.autostart_desc': { zh: '开启后,每次登录 Windows 时自动启动应用。', en: 'Auto start on Windows login.' },
+ 'settings.auto_refresh': { zh: '自动刷新', en: 'Auto Refresh' },
+ 'settings.auto_refresh_desc': { zh: '开启后,按设定周期自动拉取最新数据。', en: 'Automatically fetch latest data at set intervals.' },
+ 'settings.window_size': { zh: '窗口大小', en: 'Window Size' },
+ 'settings.window_desc': { zh: '选择预设窗口尺寸,或拖拽窗口边缘自由调整。', en: 'Choose a preset size or drag window edges.' },
+ 'settings.compact': { zh: '紧凑', en: 'Compact' },
+ 'settings.standard': { zh: '标准', en: 'Standard' },
+ 'settings.wide': { zh: '宽屏', en: 'Wide' },
+ 'settings.large': { zh: '大屏', en: 'Large' },
+ 'settings.about': { zh: '关于', en: 'About' },
+ 'settings.version': { zh: '当前版本', en: 'Version' },
+ 'settings.check_update': { zh: '检查更新', en: 'Check for Updates' },
+ 'settings.checking': { zh: '检查中…', en: 'Checking…' },
+ 'settings.latest': { zh: '已是最新版本', en: 'Up to date' },
+ 'settings.update_found': { zh: '发现新版本', en: 'Update available' },
+ 'settings.download_update': { zh: '下载更新', en: 'Download Update' },
+ 'settings.downloading_update': { zh: '正在下载更新…', en: 'Downloading update…' },
+ 'settings.update_installed': { zh: '更新已下载,即将安装', en: 'Update downloaded, installing soon' },
+ 'settings.cat_account': { zh: '账户', en: 'Account' },
+ 'settings.cat_general': { zh: '通用', en: 'General' },
+ 'settings.cat_display': { zh: '显示', en: 'Display' },
+ 'settings.cat_data': { zh: '数据', en: 'Data' },
+ 'settings.cat_about': { zh: '关于', en: 'About' },
+ 'settings.theme': { zh: '主题', en: 'Theme' },
+ 'settings.theme_desc': { zh: '选择深色、浅色或跟随系统主题。', en: 'Choose dark, light, or follow system theme.' },
+ 'settings.theme_light': { zh: '浅色', en: 'Light' },
+ 'settings.theme_dark': { zh: '深色', en: 'Dark' },
+ 'settings.theme_system': { zh: '跟随系统', en: 'System' },
+ 'settings.currency': { zh: '货币单位', en: 'Currency' },
+ 'settings.currency_desc': { zh: '选择显示金额的货币类型。', en: 'Choose currency for amounts displayed.' },
+ 'settings.efficiency': { zh: '效率单位', en: 'Efficiency Unit' },
+ 'settings.efficiency_desc': { zh: '选择显示效率指标的方向。', en: 'Choose efficiency metric direction.' },
+ 'settings.token_per_currency': { zh: 'MT/¥', en: 'MT/$' },
+ 'settings.currency_per_token': { zh: '¥/MT', en: '$/MT' },
+ 'settings.language': { zh: '语言', en: 'Language' },
+ 'settings.language_desc': { zh: '选择界面显示语言。', en: 'Choose display language.' },
+ 'settings.default_provider': { zh: '默认平台', en: 'Default Provider' },
+ 'settings.currency_cny': { zh: '人民币 (¥)', en: 'CNY (¥)' },
+ 'settings.currency_usd': { zh: '美元 ($)', en: 'USD ($)' },
+ 'settings.clear_cache': { zh: '清除缓存', en: 'Clear Cache' },
+ 'settings.clear_cache_desc': { zh: '清除本地缓存的使用数据,下次启动时重新获取。', en: 'Clear local cached usage data, refresh on next launch.' },
+ 'settings.notify_cooldown': { zh: '通知冷却时间', en: 'Notification Cooldown' },
+ 'settings.notify_cooldown_desc': { zh: '两次余额不足通知之间的最小间隔。', en: 'Minimum interval between low balance notifications.' },
+
+ // 通知
+ 'notify.title': { zh: '通知', en: 'Notifications' },
+ 'notify.toggle': { zh: '余额不足时发送 Windows 通知', en: 'Notify on low balance' },
+ 'notify.desc': { zh: '当 API 余额低于设定阈值时,通过 Windows 通知提醒。', en: 'Send Windows notification when balance drops below threshold.' },
+ 'notify.threshold': { zh: '阈值', en: 'Threshold' },
+
+ // DeepSeek 用量同步
+ 'usage.title': { zh: '用量同步 Token', en: 'Usage Sync Token' },
+ 'usage.desc': { zh: '用于同步 Token 用量、消费和趋势图。需网页登录 token。', en: 'Sync token usage and trends. Requires web login token.' },
+ 'usage.auto_sync': { zh: '网页登录自动同步', en: 'Web Login Auto Sync' },
+ 'usage.waiting': { zh: '等待登录', en: 'Waiting for login' },
+ 'usage.manual': { zh: '方式二:手动粘贴 token', en: 'Method 2: Paste token manually' },
+ 'usage.manual_collapse': { zh: '收起手动粘贴', en: 'Collapse manual paste' },
+ 'usage.save_token': { zh: '保存 Token', en: 'Save Token' },
+ 'usage.clear_token': { zh: '清除 Token', en: 'Clear Token' },
+
+ // MiMo
+ 'mimo.login': { zh: 'MiMo 登录', en: 'MiMo Login' },
+ 'mimo.login_desc': { zh: '通过小米账号登录 MiMo 平台,登录成功后即可查看余额和用量数据。', en: 'Login to MiMo with Xiaomi account to view balance and usage.' },
+ 'mimo.login_btn': { zh: '打开 MiMo 登录', en: 'Open MiMo Login' },
+ 'mimo.opening': { zh: '正在打开…', en: 'Opening…' },
+ 'mimo.no_key': { zh: 'MiMo 平台通过小米账号登录认证,无需 API Key。', en: 'MiMo uses Xiaomi account login, no API Key needed.' },
+ 'mimo.not_logged_in': { zh: 'MiMo 未登录,请在设置中重新登录小米账号', en: 'MiMo not logged in, please re-login in settings' },
+
+ // 图表
+ 'chart.cache_hit': { zh: '缓存命中明细', en: 'Cache Hit Details' },
+ 'chart.hit': { zh: '命中', en: 'Hit' },
+ 'chart.miss': { zh: '未命中', en: 'Miss' },
+ 'chart.output': { zh: '输出', en: 'Output' },
+ 'chart.hit_rate': { zh: '命中率', en: 'Hit Rate' },
+ 'chart.total': { zh: '合计', en: 'Total' },
+ 'chart.this_week': { zh: '本周', en: 'This week' },
+ 'chart.last_week': { zh: '上周', en: 'Last week' },
+ 'chart.weeks_ago': { zh: '周前', en: 'weeks ago' },
+ 'chart.input_hit': { zh: '输入(命中缓存)', en: 'Input (cache hit)' },
+ 'chart.input_miss': { zh: '输入(未命中缓存)', en: 'Input (cache miss)' },
+
+ // 模型详情
+ 'detail.requests': { zh: 'API 请求次数', en: 'API Requests' },
+ 'detail.daily': { zh: '按日 Token 消耗', en: 'Daily Token Usage' },
+ 'detail.back': { zh: '返回主面板', en: 'Back to Dashboard' },
+
+ // 导航
+ 'nav.refresh': { zh: '刷新', en: 'Refresh' },
+ 'nav.settings': { zh: '设置', en: 'Settings' },
+ 'nav.close': { zh: '关闭', en: 'Close' },
+};
+
+let currentLang: Lang = 'zh';
+
+export function setLang(lang: Lang) {
+ currentLang = lang;
+ try { localStorage.setItem('dsm-lang', lang); } catch {}
+}
+
+export function getLang(): Lang {
+ return currentLang;
+}
+
+export function initLang() {
+ try {
+ const saved = localStorage.getItem('dsm-lang');
+ if (saved === 'en' || saved === 'zh') currentLang = saved as Lang;
+ } catch {}
+}
+
+export function t(key: string): string {
+ const entry = translations[key];
+ if (!entry) return key;
+ return entry[currentLang] || entry['zh'] || key;
+}
diff --git a/src/main.tsx b/src/main.tsx
index 963e14f..4d7fa17 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,1162 +2,178 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
-import { getVersion } from "@tauri-apps/api/app";
-import {
- BarChart3,
- Brain,
- CalendarDays,
- CheckCircle2,
- Clipboard,
- CreditCard,
- Info,
- KeyRound,
- Power,
- RefreshCw,
- Settings,
- Shirt,
- SunMedium,
- X,
- Zap,
-} from "lucide-react";
-import "./styles.css";
-
-type ViewName = "dashboard" | "settings" | "detail";
-type ModelName = "flash" | "pro";
-type AppConfig = {
- apiKeyConfigured: boolean;
- apiKeyPreview: string | null;
- usageTokenConfigured: boolean;
- refreshIntervalSeconds: number;
- autoRefreshEnabled: boolean;
- autostart: boolean;
- configPath: string;
-};
-type BalanceData = {
- isAvailable: boolean;
- currency: string;
- totalBalance: string;
- grantedBalance: string;
- toppedUpBalance: string;
-};
-type BalanceState = "loading" | "ok" | "error" | "nokey";
-type UsageModel = {
- key: string;
- name: string;
- totalTokens: number;
- requestCount: number;
- cacheHitTokens: number;
- cacheMissTokens: number;
- responseTokens: number;
- cost: number;
-};
-type UsageDay = {
- date: string;
- flashTokens: number;
- flashCacheHit: number;
- flashCacheMiss: number;
- flashResponse: number;
- proTokens: number;
- proCacheHit: number;
- proCacheMiss: number;
- proResponse: number;
- totalTokens: number;
- totalCost: number;
-};
-type UsageResult = {
- models: UsageModel[];
- days: UsageDay[];
- monthCost: number;
-};
+import "./styles.css";
-const fmtInt = (n: number) => Math.round(n).toLocaleString("en-US");
-const fmtTokensShort = (n: number) => {
- if (n >= 1e8) return (n / 1e6).toFixed(0) + "M";
- if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
- if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
- return String(Math.round(n));
-};
-const fmtMoney = (n: number) => "¥" + n.toFixed(2);
-const mmdd = (date: string) => {
- const parts = date.split("-");
- return parts.length === 3 ? `${Number(parts[1])}/${Number(parts[2])}` : date;
-};
-const todayStr = () => {
- const now = new Date();
- return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
-};
-const dateKey = (date: Date) =>
- `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
-const addDays = (date: Date, offset: number) => {
- const next = new Date(date);
- next.setDate(next.getDate() + offset);
- return next;
-};
-const recentUsageDays = (days: UsageDay[], count = 7): UsageDay[] => {
- const source = new Map(days.filter((day) => day.date <= todayStr()).map((day) => [day.date, day]));
- const today = new Date();
- return Array.from({ length: count }, (_, index) => {
- const date = dateKey(addDays(today, index - count + 1));
- return (
- source.get(date) ?? {
- date,
- flashTokens: 0,
- flashCacheHit: 0,
- flashCacheMiss: 0,
- flashResponse: 0,
- proTokens: 0,
- proCacheHit: 0,
- proCacheMiss: 0,
- proResponse: 0,
- totalTokens: 0,
- totalCost: 0,
- }
- );
- });
-};
-const previousMonth = (date: Date) => {
- const previous = new Date(date.getFullYear(), date.getMonth() - 1, 1);
- return { month: previous.getMonth() + 1, year: previous.getFullYear() };
-};
-const fetchMonthUsage = (month: number, year: number) => {
- return invoke("fetch_usage", { month, year });
-};
-const fetchCurrentUsage = async () => {
- const now = new Date();
- const current = await fetchMonthUsage(now.getMonth() + 1, now.getFullYear());
- const needsPreviousMonth = addDays(now, -6).getMonth() !== now.getMonth();
- if (!needsPreviousMonth) {
- return current;
- }
- try {
- const previous = previousMonth(now);
- const previousUsage = await fetchMonthUsage(previous.month, previous.year);
- return {
- ...current,
- days: [...previousUsage.days, ...current.days],
- };
- } catch {
- return current;
- }
-};
+import type { ViewName, ModelName, Provider, AppConfig, BalanceData, MimoBalanceData, BalanceState, UsageResult, MimoUsageResult } from "./types";
+import { addDays, previousMonth, fetchWithCache } from "./utils";
+import { initLang } from "./i18n";
+import { DashboardPanel } from "./components/DashboardPanel";
+import { SettingsPanel } from "./components/SettingsPanel";
+import { ModelDetailPanel } from "./components/ModelDetailPanel";
-const refreshOptions = [
- { label: "1 分钟", value: 60 },
- { label: "5 分钟", value: 300 },
- { label: "30 分钟", value: 1800 },
- { label: "1 小时", value: 3600 },
-];
+initLang();
+// ─── App ───────────────────────────────────────────────────
function App() {
const [view, setView] = React.useState("dashboard");
const [model, setModel] = React.useState("flash");
-
- const [balance, setBalance] = React.useState(null);
+ const [provider, setProviderState] = React.useState("deepseek");
+ const [balance, setBalance] = React.useState(null);
const [balanceState, setBalanceState] = React.useState("loading");
const [balanceError, setBalanceError] = React.useState("");
-
- const [usage, setUsage] = React.useState(null);
+ const [usage, setUsage] = React.useState(null);
const [usageState, setUsageState] = React.useState("loading");
const [usageError, setUsageError] = React.useState("");
const [refreshIntervalSeconds, setRefreshIntervalSeconds] = React.useState(60);
const [autoRefreshEnabled, setAutoRefreshEnabled] = React.useState(false);
+ const [currency, setCurrency] = React.useState<"cny" | "usd">("cny");
+ const [exchangeRate, setExchangeRate] = React.useState(0.137);
+ const [efficiencyUnit, setEfficiencyUnit] = React.useState<"token_per_currency" | "currency_per_token">("currency_per_token");
- const loadBalance = React.useCallback(() => {
+ const loadBalance = React.useCallback((p?: Provider) => {
+ const active = p ?? provider;
setBalanceState("loading");
- void invoke("fetch_balance")
- .then((data) => {
- setBalance(data);
- setBalanceState("ok");
- })
+ const cmd = active === "deepseek" ? "fetch_balance" : "fetch_mimo_balance";
+ void fetchWithCache(`dsm-balance-${active}`, () => invoke(cmd))
+ .then((data) => { setBalance(data); setBalanceState("ok"); })
.catch((error) => {
const message = typeof error === "string" ? error : "查询失败";
- setBalanceError(message);
- setBalanceState(message.includes("未配置") ? "nokey" : "error");
+ setBalance(null); setBalanceError(message); setBalanceState(message.includes("未配置") ? "nokey" : "error");
});
- }, []);
+ }, [provider]);
- const loadUsage = React.useCallback(() => {
+ const loadUsage = React.useCallback((p?: Provider) => {
+ const active = p ?? provider;
setUsageState("loading");
- void fetchCurrentUsage()
- .then((data) => {
- setUsage(data);
- setUsageState("ok");
- setUsageError("");
- })
- .catch((error) => {
- const message = typeof error === "string" ? error : "查询失败";
- setUsageError(message);
- setUsage(null);
- setUsageState(message.includes("未配置") ? "nokey" : "error");
- });
+ if (active === "deepseek") {
+ void fetchWithCache("dsm-usage-deepseek", () => invoke("fetch_usage", { month: new Date().getMonth() + 1, year: new Date().getFullYear() }).then(async (current) => {
+ const now = new Date();
+ const needsPrev = addDays(now, -6).getMonth() !== now.getMonth();
+ if (!needsPrev) return current;
+ try {
+ const prev = previousMonth(now);
+ const prevUsage = await invoke("fetch_usage", { month: prev.month, year: prev.year });
+ return { ...current, days: [...prevUsage.days, ...current.days] };
+ } catch { return current; }
+ }))
+ .then((data) => { setUsage(data); setUsageState("ok"); setUsageError(""); })
+ .catch((error) => {
+ const message = typeof error === "string" ? error : "查询失败"; setUsageError(message); setUsage(null); setUsageState(message.includes("未配置") ? "nokey" : "error");
+ });
+ } else {
+ const now = new Date();
+ void fetchWithCache("dsm-usage-mimo", () => invoke("fetch_mimo_usage", { month: now.getMonth() + 1, year: now.getFullYear() }))
+ .then((data) => { setUsage(data); setUsageState("ok"); setUsageError(""); })
+ .catch((error) => {
+ const message = typeof error === "string" ? error : "查询失败"; setUsageError(message); setUsage(null); setUsageState(message.includes("未配置") ? "nokey" : "error");
+ });
+ }
+ }, [provider]);
+
+ const refreshAll = React.useCallback(() => { loadBalance(); loadUsage(); }, [loadBalance, loadUsage]);
+
+ const setProvider = React.useCallback((next: Provider) => {
+ setProviderState(next);
+ setBalance(null); setBalanceState("loading");
+ setUsage(null); setUsageState("loading");
+ if (next === "mimo") void invoke("ensure_mimo_webview").catch(console.warn);
+ void invoke("set_provider", { provider: next }).catch(console.warn);
+ // loadBalance/loadUsage 由 useEffect 监听 provider 变化后统一调用,避免双重调用竞态
}, []);
- const refreshAll = React.useCallback(() => {
- loadBalance();
- loadUsage();
- }, [loadBalance, loadUsage]);
+ const providerRef = React.useRef(provider);
+ const initialLoadDone = React.useRef(false);
React.useEffect(() => {
- refreshAll();
- }, [refreshAll]);
+ if (providerRef.current !== provider) { providerRef.current = provider; loadBalance(provider); loadUsage(provider); }
+ }, [provider, loadBalance, loadUsage]);
React.useEffect(() => {
void invoke("get_app_config")
.then((config) => {
- setRefreshIntervalSeconds(config.refreshIntervalSeconds || 60);
- setAutoRefreshEnabled(config.autoRefreshEnabled);
+ if (!initialLoadDone.current) {
+ initialLoadDone.current = true;
+ if (config.provider !== providerRef.current) { setBalance(null); setBalanceState("loading"); setUsage(null); setUsageState("loading"); }
+ providerRef.current = config.defaultProvider || config.provider; setProviderState(config.defaultProvider || config.provider);
+ setRefreshIntervalSeconds(config.refreshIntervalSeconds || 60); setAutoRefreshEnabled(config.autoRefreshEnabled);
+ setCurrency(config.currency || "cny");
+ setEfficiencyUnit(config.efficiencyUnit || "currency_per_token");
+ // Fetch exchange rate with localStorage cache (24h TTL)
+ const cached = localStorage.getItem("dsm-exrate-v2");
+ if (cached) {
+ try {
+ const { rate, ts } = JSON.parse(cached);
+ if (Date.now() - ts < 24 * 3600 * 1000 && rate > 0) { setExchangeRate(rate); }
+ else { throw new Error("invalid or expired"); }
+ } catch { localStorage.removeItem("dsm-exrate-v2"); }
+ }
+ if (!localStorage.getItem("dsm-exrate-v2")) {
+ void fetch("https://open.er-api.com/v6/latest/CNY")
+ .then(r => r.json())
+ .then(data => {
+ if (data?.rates?.USD) {
+ const rate = data.rates.USD; // e.g. 7.25 CNY per 1 USD
+ setExchangeRate(rate);
+ localStorage.setItem("dsm-exrate-v2", JSON.stringify({ rate, ts: Date.now() }));
+ }
+ })
+ .catch(() => { /* keep default 7.25 */ });
+ }
+ loadBalance(config.provider); loadUsage(config.provider);
+ }
})
- .catch(() => {
- setRefreshIntervalSeconds(60);
- setAutoRefreshEnabled(false);
- });
- }, []);
+ .catch(() => { if (!initialLoadDone.current) { initialLoadDone.current = true; setRefreshIntervalSeconds(60); setAutoRefreshEnabled(false); loadBalance(); loadUsage(); } });
+ }, [loadBalance, loadUsage]);
React.useEffect(() => {
- if (!autoRefreshEnabled) {
- return;
- }
+ if (!autoRefreshEnabled) return;
const timer = window.setInterval(refreshAll, refreshIntervalSeconds * 1000);
return () => window.clearInterval(timer);
}, [autoRefreshEnabled, refreshAll, refreshIntervalSeconds]);
- const hideWindow = React.useCallback(() => {
- void invoke("hide_main_window").catch(() => {
- // Browser preview has no Tauri IPC. Keep it non-blocking for visual checks.
+ React.useEffect(() => {
+ const unlistenPromise = listen("mimo-auth-required", () => {
+ setUsageState("error"); setUsageError("MiMo 未登录,请在设置中重新登录小米账号");
+ setBalanceState("error"); setBalanceError("MiMo 未登录");
});
+ return () => { void unlistenPromise.then((unlisten) => unlisten()); };
}, []);
+ const hideWindow = React.useCallback(() => { void invoke("hide_main_window").catch(() => {}); }, []);
+
return (
{view === "dashboard" && (
setView("settings")}
- onDetail={(nextModel) => {
- setModel(nextModel);
- setView("detail");
- }}
+ onDetail={(nextModel) => { setModel(nextModel); setView("detail"); }}
+ currency={currency}
+ exchangeRate={exchangeRate}
+ efficiencyUnit={efficiencyUnit}
/>
)}
{view === "settings" && (
{
- setUsage(nextUsage);
- setUsageState("ok");
- setUsageError("");
- }}
- onUsageCleared={() => {
- setUsage(null);
- setUsageState("nokey");
- setUsageError("未配置用量 Token");
- }}
- onRefreshIntervalChanged={setRefreshIntervalSeconds}
- onAutoRefreshChanged={setAutoRefreshEnabled}
- onBack={() => setView("dashboard")}
+ provider={provider} onProviderChange={setProvider} onBack={() => setView("dashboard")}
+ onUsageLoaded={(nextUsage) => { setUsage(nextUsage); setUsageState("ok"); }}
+ onUsageCleared={() => { setUsage(null); setUsageState("loading"); }}
+ onRefreshIntervalChanged={setRefreshIntervalSeconds} onAutoRefreshChanged={setAutoRefreshEnabled}
+ onCurrencyChanged={setCurrency}
+ onEfficiencyUnitChanged={setEfficiencyUnit}
/>
)}
{view === "detail" && (
- setView("dashboard")} />
+ setView("dashboard")} provider={provider} currency={currency} exchangeRate={exchangeRate} efficiencyUnit={efficiencyUnit} />
)}
);
}
-function BrandIcon({ size = 32 }: { size?: number }) {
- return (
-
-

-
- );
-}
-
-function DashboardPanel({
- balance,
- balanceState,
- balanceError,
- usage,
- usageState,
- usageError,
- onRefresh,
- onClose,
- onSettings,
- onDetail,
-}: {
- balance: BalanceData | null;
- balanceState: BalanceState;
- balanceError: string;
- usage: UsageResult | null;
- usageState: BalanceState;
- usageError: string;
- onRefresh: () => void;
- onClose: () => void;
- onSettings: () => void;
- onDetail: (model: ModelName) => void;
-}) {
- const [theme, setTheme] = React.useState(
- () => localStorage.getItem("ui-theme") || "dark",
- );
- const toggleTheme = () => {
- const next = theme === "dark" ? "light" : "dark";
- setTheme(next);
- localStorage.setItem("ui-theme", next);
- document.documentElement.setAttribute("data-theme", next);
- };
- const flash = usage?.models.find((item) => item.key === "flash") ?? null;
- const pro = usage?.models.find((item) => item.key === "pro") ?? null;
- const maxTokens = Math.max(flash?.totalTokens ?? 0, pro?.totalTokens ?? 0, 1);
- const today = usage?.days.find((day) => day.date === todayStr()) ?? null;
- const todayCost = usageState === "ok" && today ? today.totalCost : null;
- const monthCost = usageState === "ok" && usage ? usage.monthCost : null;
-
- return (
-
-
-
-
-
DeepSeek Monitor
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- onDetail("flash")}
- />
- onDetail("pro")}
- />
-
-
-
-
- );
-}
-
-function BalanceCard({
- balance,
- state,
- error,
- todayCost,
- monthCost,
-}: {
- balance: BalanceData | null;
- state: BalanceState;
- error: string;
- todayCost: number | null;
- monthCost: number | null;
-}) {
- const symbol = balance?.currency === "USD" ? "$" : "¥";
- const amount =
- state === "loading"
- ? "查询中…"
- : state === "nokey"
- ? "未配置"
- : state === "error"
- ? "查询失败"
- : `${symbol}${balance?.totalBalance ?? "0.00"}`;
- const statusText = state === "ok" ? (balance?.isAvailable ? "可用" : "余额不足") : "—";
- const statusOff = state === "ok" && balance != null && !balance.isAvailable;
-
- return (
-
-
-
-
- 账户余额
-
-
-
- {statusText}
-
-
- {amount}
- {state === "error" && {error}
}
-
-
-
-
- 当日消耗
-
-
{todayCost != null ? fmtMoney(todayCost) : "—"}
-
-
-
-
- 本月消费
-
-
{monthCost != null ? fmtMoney(monthCost) : "—"}
-
-
-
- );
-}
-
-function UsageRow({
- modelKey,
- data,
- maxTokens,
- state,
- onClick,
-}: {
- modelKey: ModelName;
- data: UsageModel | null;
- maxTokens: number;
- state: BalanceState;
- onClick: () => void;
-}) {
- const isFlash = modelKey === "flash";
- const name = isFlash ? "V4 Flash" : "V4 Pro";
- const tokensText = data
- ? `${fmtInt(data.totalTokens)} Tokens`
- : state === "loading"
- ? "查询中…"
- : state === "nokey"
- ? "未配置 Token"
- : state === "error"
- ? "用量不可用"
- : "—";
- const cost = data ? fmtMoney(data.cost) : "—";
- const ratio = data && data.cost > 0 ? `${fmtTokensShort(data.totalTokens / data.cost)} T/¥` : "—";
- const width = data ? `${Math.max(2, (data.totalTokens / maxTokens) * 100)}%` : "0%";
-
- return (
-
- );
-}
-
-function UsageChart({
- usage,
- state,
- error,
-}: {
- usage: UsageResult | null;
- state: BalanceState;
- error: string;
-}) {
- const [hoveredIdx, setHoveredIdx] = React.useState(null);
- const MIN_BAR = 3;
- const days = recentUsageDays(usage?.days ?? []);
- const points = days.map((day) => {
- // Flash 与 Pro 合并,不分模型
- const hit = day.flashCacheHit + day.proCacheHit;
- const miss = day.flashCacheMiss + day.proCacheMiss;
- const response = day.flashResponse + day.proResponse;
- return { date: day.date, hit, miss, response, total: hit + miss + response };
- });
- const maxVal = Math.max(...points.map((point) => point.total), 1);
- const sumHit = points.reduce((sum, point) => sum + point.hit, 0);
- const sumMiss = points.reduce((sum, point) => sum + point.miss, 0);
- const sumTotal = points.reduce((sum, point) => sum + point.total, 0);
- const hitRate = sumHit + sumMiss > 0 ? ((sumHit / (sumHit + sumMiss)) * 100).toFixed(0) : "0";
- const placeholder =
- state === "loading"
- ? "查询中…"
- : state === "nokey"
- ? "未配置用量 Token"
- : state === "error"
- ? error
- : "暂无数据";
-
- return (
-
-
-
-
- 缓存命中明细
-
-
- {state === "ok" ? `命中率 ${hitRate}% · 合计 ${fmtTokensShort(sumTotal)}` : "—"}
-
-
- {state === "ok" && points.length > 0 ? (
- <>
- setHoveredIdx(null)}>
- {points.map((point, idx) => (
-
- {hoveredIdx === idx && point.total > 0 && (
-
= points.length - 2 ? " align-right" : ""
- }`}
- >
-
- {point.date}
- {fmtInt(point.total)} tokens
-
-
- 输入(命中缓存)
- {fmtInt(point.hit)} tokens
-
-
- 输入(未命中缓存)
- {fmtInt(point.miss)} tokens
-
-
- 输出
- {fmtInt(point.response)} tokens
-
-
- )}
-
- {point.total > 0 ? fmtTokensShort(point.total) : "0"}
-
-
-
0 ? Math.max(MIN_BAR, (point.total / maxVal) * 100) : MIN_BAR}%`,
- }}
- onMouseEnter={() => setHoveredIdx(idx)}
- onMouseLeave={() => setHoveredIdx(null)}
- >
- {point.total > 0 ? (
- <>
- {point.hit > 0 && }
- {point.miss > 0 && }
- {point.response > 0 && (
-
- )}
- >
- ) : (
-
- )}
-
-
-
{mmdd(point.date)}
-
- ))}
-
-
-
- 命中
-
-
- 未命中
-
-
- 输出
-
-
- >
- ) : (
- {placeholder}
- )}
-
- );
-}
-
-function SettingsPanel({
- onBack,
- onUsageLoaded,
- onUsageCleared,
- onRefreshIntervalChanged,
- onAutoRefreshChanged,
-}: {
- onBack: () => void;
- onUsageLoaded: (usage: UsageResult) => void;
- onUsageCleared: () => void;
- onRefreshIntervalChanged: (seconds: number) => void;
- onAutoRefreshChanged: (enabled: boolean) => void;
-}) {
- const [apiKey, setApiKey] = React.useState("");
- const [config, setConfig] = React.useState(null);
- const [status, setStatus] = React.useState("正在读取本地配置");
- const [busy, setBusy] = React.useState(false);
- const [refresh, setRefresh] = React.useState(60);
- const [autoRefresh, setAutoRefresh] = React.useState(false);
- const [autostart, setAutostart] = React.useState(false);
- const [usageToken, setUsageToken] = React.useState("");
- const [usageStatus, setUsageStatus] = React.useState("");
- const [usageSyncing, setUsageSyncing] = React.useState(false);
- const [showManualPaste, setShowManualPaste] = React.useState(false);
- const [appVersion, setAppVersion] = React.useState("1.1.0");
- const configPath = config?.configPath ?? "%APPDATA%\\DeepSeekMonitorWindows\\config.json";
-
- React.useEffect(() => {
- void invoke("get_app_config")
- .then((nextConfig) => {
- setConfig(nextConfig);
- setRefresh(nextConfig.refreshIntervalSeconds || 60);
- setAutoRefresh(nextConfig.autoRefreshEnabled);
- setAutostart(nextConfig.autostart);
- setStatus(nextConfig.apiKeyConfigured ? `已配置 ${nextConfig.apiKeyPreview}` : "未配置 API Key");
- setUsageStatus(nextConfig.usageTokenConfigured ? "用量 Token 已配置" : "未配置用量 Token");
- })
- .catch(() => {
- setStatus("浏览器预览模式,未连接本地配置");
- });
- }, []);
-
- React.useEffect(() => {
- void getVersion()
- .then(setAppVersion)
- .catch(() => setAppVersion("1.1.0"));
- }, []);
-
- const refreshUsageAfterToken = React.useCallback(
- (prefix: string) => {
- setUsageStatus(`${prefix},正在刷新用量数据…`);
- return fetchCurrentUsage()
- .then((usage) => {
- onUsageLoaded(usage);
- setUsageStatus(`${prefix},本月消费 ${fmtMoney(usage.monthCost)}`);
- return usage;
- })
- .catch((error) => {
- const message = typeof error === "string" ? error : "用量刷新失败";
- setUsageStatus(`${prefix},但用量刷新失败:${message}`);
- throw error;
- });
- },
- [onUsageLoaded],
- );
-
- React.useEffect(() => {
- const unlistenPromise = listen("usage-token-captured", (event) => {
- setConfig(event.payload);
- setUsageSyncing(false);
- void refreshUsageAfterToken("已通过网页登录自动同步用量 Token");
- });
- return () => {
- void unlistenPromise.then((unlisten) => unlisten());
- };
- }, [refreshUsageAfterToken]);
-
- React.useEffect(() => {
- const unlistenPromise = listen("usage-sync-ended", () => {
- setUsageSyncing(false);
- setUsageStatus("登录窗口已关闭,Token 未获取到。可重新点击同步或使用方式二手动粘贴。");
- });
- return () => {
- void unlistenPromise.then((unlisten) => unlisten());
- };
- }, []);
-
- const pasteApiKey = React.useCallback(async () => {
- try {
- const text = await navigator.clipboard.readText();
- setApiKey(text.trim());
- setStatus("已从剪贴板读取");
- } catch {
- setStatus("剪贴板读取失败");
- }
- }, []);
-
- const saveApiKey = React.useCallback(() => {
- setBusy(true);
- void invoke("save_api_key", { apiKey })
- .then((nextConfig) => {
- setConfig(nextConfig);
- setApiKey("");
- setStatus("已保存,正在验证 Key…");
- return invoke("fetch_balance");
- })
- .then((balance) => {
- const symbol = balance.currency === "USD" ? "$" : "¥";
- const tip = balance.isAvailable ? "" : "(余额不足)";
- setStatus(`验证通过,当前余额 ${symbol}${balance.totalBalance}${tip}`);
- })
- .catch((error) => {
- setStatus(typeof error === "string" ? error : "保存或验证失败");
- })
- .finally(() => setBusy(false));
- }, [apiKey]);
-
- const clearApiKey = React.useCallback(() => {
- setBusy(true);
- void invoke("clear_api_key")
- .then((nextConfig) => {
- setConfig(nextConfig);
- setApiKey("");
- setStatus("已清除 API Key");
- })
- .catch((error) => {
- setStatus(typeof error === "string" ? error : "清除失败");
- })
- .finally(() => setBusy(false));
- }, []);
-
- const pasteUsageToken = React.useCallback(async () => {
- try {
- const text = await navigator.clipboard.readText();
- setUsageToken(text.trim());
- setUsageStatus("已从剪贴板读取");
- } catch {
- setUsageStatus("剪贴板读取失败");
- }
- }, []);
-
- const startUsageSync = React.useCallback(() => {
- setUsageSyncing(true);
- setUsageStatus("正在打开登录窗口…");
- void invoke("start_usage_sync")
- .then((synced) => {
- if (!synced) {
- setUsageStatus("登录完成后,再次点击本按钮即可同步用量(可多点几次)");
- }
- // synced=true 时由 usage-token-captured 事件刷新数据并更新状态
- })
- .catch((error) => {
- setUsageStatus(typeof error === "string" ? error : "打开登录窗口失败");
- })
- .finally(() => {
- // 短暂忙碌后自动恢复可点击,允许用户登录后反复点击触发同步
- window.setTimeout(() => setUsageSyncing(false), 2500);
- });
- }, []);
-
- const saveUsageToken = React.useCallback(() => {
- setBusy(true);
- void invoke("save_usage_token", { usageToken })
- .then((nextConfig) => {
- setConfig(nextConfig);
- setUsageToken("");
- setUsageStatus("已保存,正在验证用量 Token…");
- return refreshUsageAfterToken("手动 Token 已保存");
- })
- .catch((error) => {
- setUsageStatus(typeof error === "string" ? error : "保存或验证失败");
- })
- .finally(() => setBusy(false));
- }, [refreshUsageAfterToken, usageToken]);
-
- const clearUsageToken = React.useCallback(() => {
- setBusy(true);
- void invoke("clear_usage_token")
- .then((nextConfig) => {
- setConfig(nextConfig);
- setUsageToken("");
- setUsageStatus("已清除用量 Token");
- onUsageCleared();
- })
- .catch((error) => {
- setUsageStatus(typeof error === "string" ? error : "清除失败");
- })
- .finally(() => setBusy(false));
- }, [onUsageCleared]);
-
- const saveRefreshInterval = React.useCallback(
- (seconds: number) => {
- const previous = refresh;
- setRefresh(seconds);
- onRefreshIntervalChanged(seconds);
- void invoke("save_refresh_interval", { refreshIntervalSeconds: seconds })
- .then((nextConfig) => {
- setConfig(nextConfig);
- setRefresh(nextConfig.refreshIntervalSeconds || 60);
- onRefreshIntervalChanged(nextConfig.refreshIntervalSeconds || 60);
- })
- .catch(() => {
- setRefresh(previous);
- onRefreshIntervalChanged(previous);
- });
- },
- [onRefreshIntervalChanged, refresh],
- );
-
- const saveAutoRefreshEnabled = React.useCallback(
- (enabled: boolean) => {
- const previous = autoRefresh;
- setAutoRefresh(enabled);
- onAutoRefreshChanged(enabled);
- void invoke("save_auto_refresh_enabled", { autoRefreshEnabled: enabled })
- .then((nextConfig) => {
- setConfig(nextConfig);
- setAutoRefresh(nextConfig.autoRefreshEnabled);
- onAutoRefreshChanged(nextConfig.autoRefreshEnabled);
- })
- .catch(() => {
- setAutoRefresh(previous);
- onAutoRefreshChanged(previous);
- });
- },
- [autoRefresh, onAutoRefreshChanged],
- );
-
- const saveAutostart = React.useCallback((enabled: boolean) => {
- const previous = autostart;
- setAutostart(enabled);
- void invoke("save_autostart", { autostart: enabled })
- .then((nextConfig) => {
- setConfig(nextConfig);
- setAutostart(nextConfig.autostart);
- })
- .catch(() => setAutostart(previous));
- }, [autostart]);
-
- return (
-
-
-
-
-
-
-
DeepSeek Monitor
-
设置
-
-
-
-
} title="API Key">
-
用于调用 DeepSeek API 获取余额和用量数据。当前 Windows 版本会保存在应用本地设置中。
-
API Key 只在当前这台 Windows 电脑本地保留。
-
- 本地位置:
- {configPath}
-
-
- setApiKey(event.target.value)}
- />
-
-
-
-
-
- {config?.apiKeyConfigured ? "已配置" : "未配置"}
-
-
-
-
-
-
} title="用量同步 Token">
-
用于同步 Token 用量、消费和趋势图。DeepSeek 无官方用量 API,需网页登录 token(与上面的 API Key 不同)。
-
方式一网页登录自动同步
-
-
-
-
- {config?.usageTokenConfigured ? "已配置" : "未配置"}
-
-
-
-
{usageStatus}
-
- {showManualPaste && (
- <>
-
- 获取:浏览器登录 platform.deepseek.com,按 F12 打开控制台,输入
- JSON.parse(localStorage.userToken).value 回车,复制返回的字符串。
-
-
token 会过期,用量查询失败时重新获取一次即可。
-
- setUsageToken(event.target.value)}
- />
-
-
-
-
- >
- )}
-
-
-
} title="开机自启">
-
开启后,每次登录 Windows 时自动启动 DeepSeek Monitor。
-
-
-
-
} title="自动刷新">
-
开启后,按设定周期自动从 DeepSeek API 拉取最新数据。
-
- {autoRefresh && (
-
- {refreshOptions.map((option) => (
-
- ))}
-
- )}
-
-
-
} title="关于">
-
- 当前版本
- v{appVersion}
-
-
-
-
-
- );
-}
-
-function SettingsSection({
- icon,
- title,
- children,
-}: {
- icon: React.ReactNode;
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
-
- {icon}
- {title}
-
- {children}
-
- );
-}
-
-function Toggle({
- label,
- checked,
- onChange,
-}: {
- label: string;
- checked: boolean;
- onChange: (checked: boolean) => void;
-}) {
- return (
-
- );
-}
-
-function ModelDetailPanel({
- model,
- usage,
- usageState,
- onBack,
-}: {
- model: ModelName;
- usage: UsageResult | null;
- usageState: BalanceState;
- onBack: () => void;
-}) {
- const isFlash = model === "flash";
- const data = usage?.models.find((item) => item.key === model) ?? null;
- const title = isFlash ? "V4 Flash" : "V4 Pro";
- const tintClass = isFlash ? "flash" : "pro";
- const cost = data ? fmtMoney(data.cost) : "—";
- const totalText = data ? fmtTokensShort(data.totalTokens) : "—";
-
- const days = recentUsageDays(usage?.days ?? []);
- const points = days.map((day) => {
- const hit = isFlash ? day.flashCacheHit : day.proCacheHit;
- const miss = isFlash ? day.flashCacheMiss : day.proCacheMiss;
- const response = isFlash ? day.flashResponse : day.proResponse;
- return { date: day.date, hit, miss, response, total: hit + miss + response };
- });
- const maxVal = Math.max(...points.map((point) => point.total), 1);
- const rangeText =
- points.length > 0 ? `${mmdd(points[0].date)} - ${mmdd(points[points.length - 1].date)}` : "";
-
- const [hoveredIdx, setHoveredIdx] = React.useState(null);
- const MIN_BAR = 3; // 整根柱子的最小可见高度百分比(含空数据占位)
-
- return (
-
-
-
-
- {isFlash ? : }
-
-
-
-
-
-
- API 请求次数
- {data ? fmtInt(data.requestCount) : "—"}
-
-
- Tokens
- {totalText}
-
-
-
-
-
-
-
按日 Token 消耗
- {rangeText}
-
-
- {usageState === "ok" && points.length > 0 ? (
- <>
- setHoveredIdx(null)}>
- {points.map((point, idx) => (
-
- {hoveredIdx === idx && point.total > 0 && (
-
= points.length - 2 ? " align-right" : ""
- }`}
- >
-
- {point.date}
- {fmtInt(point.total)} tokens
-
-
- 输入(命中缓存)
- {fmtInt(point.hit)} tokens
-
-
- 输入(未命中缓存)
- {fmtInt(point.miss)} tokens
-
-
- 输出
- {fmtInt(point.response)} tokens
-
-
- )}
-
{point.total > 0 ? fmtTokensShort(point.total) : ""}
-
- {/* 柱高按当天合计占最大值的比例;内部三段用 flex-grow 按真实 token 数分配,比例精确且永不溢出裁剪 */}
-
0 ? Math.max(MIN_BAR, (point.total / maxVal) * 100) : MIN_BAR}%`,
- }}
- onMouseEnter={() => setHoveredIdx(idx)}
- onMouseLeave={() => setHoveredIdx(null)}
- >
- {point.total > 0 ? (
- <>
- {point.hit > 0 && }
- {point.miss > 0 && }
- {point.response > 0 && }
- >
- ) : (
-
- )}
-
-
-
{mmdd(point.date)}
-
- ))}
-
-
- 命中
- 未命中
- 输出
-
- >
- ) : (
-
- {usageState === "nokey" ? "未配置用量 Token" : usageState === "loading" ? "查询中…" : "暂无数据"}
-
- )}
-
-
- );
-}
-
-// Apply the saved theme before first render to avoid a flash of the wrong skin.
-document.documentElement.setAttribute("data-theme", localStorage.getItem("ui-theme") || "dark");
-ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
- ,
-);
+// ─── Mount ─────────────────────────────────────────────────
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render();
diff --git a/src/styles.css b/src/styles.css
index 991c184..a7a8eeb 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,6 +1,8 @@
:root {
font-family: "Microsoft YaHei UI", "Segoe UI", system-ui, sans-serif;
color: var(--text-strong);
+
+ /* ---- 原始配色变量 ---- */
--fg: 246, 239, 222;
--text-strong: rgba(255, 255, 255, 0.92);
--brand: #4d6bfe;
@@ -9,12 +11,31 @@
--pro: #da38f0;
--orange: #ff9c2b;
--skin-accent: #e0a838;
- --panel-bg: rgba(105, 86, 38, 0.82);
- --panel-bg-deep: rgba(49, 58, 18, 0.72);
- --card-bg: rgba(38, 35, 18, 0.58);
- --card-bg-light: rgba(110, 91, 42, 0.52);
--text-muted: rgba(var(--fg), 0.62);
--text-faint: rgba(var(--fg), 0.42);
+
+ /* ---- 液态玻璃参数 ---- */
+ --glass-blur: 42px;
+ --glass-radius: 22px;
+ --glass-border-width: 1px;
+
+ --glass-panel-tint: rgba(110, 90, 45, 0.48);
+ --glass-panel-blend: rgba(160, 130, 60, 0.06);
+ --glass-panel-border: rgba(255, 235, 180, 0.32);
+ --glass-panel-highlight: rgba(255, 240, 200, 0.30);
+ --glass-panel-shadow-1: rgba(0, 0, 0, 0.15);
+ --glass-panel-shadow-2: rgba(0, 0, 0, 0.20);
+ --glass-panel-shadow-3: rgba(0, 0, 0, 0.08);
+
+ --glass-card-tint: rgba(70, 60, 30, 0.40);
+ --glass-card-blend: rgba(120, 100, 50, 0.04);
+ --glass-card-border: rgba(255, 235, 180, 0.14);
+ --glass-card-highlight: rgba(255, 240, 210, 0.10);
+ --glass-card-shadow-1: rgba(0, 0, 0, 0.08);
+ --glass-card-shadow-2: rgba(0, 0, 0, 0.06);
+
+ --glass-tooltip-tint: rgba(40, 36, 20, 0.82);
+ --glass-tooltip-blur: 16px;
}
* {
@@ -50,20 +71,42 @@ button {
.panel,
.settings-panel {
position: relative;
- border: 1px solid rgba(255, 255, 255, 0.24);
+ isolation: isolate;
+ background-clip: padding-box;
+
+ border: var(--glass-border-width) solid var(--glass-panel-border);
background:
- linear-gradient(145deg, rgba(147, 120, 55, 0.72), rgba(61, 76, 33, 0.68)),
- rgba(97, 78, 36, 0.76);
- box-shadow: inset 0 1px rgba(255, 255, 255, 0.12);
- backdrop-filter: blur(26px) saturate(1.12);
+ radial-gradient(140% 120% at 30% 20%, transparent 35%, var(--glass-panel-blend) 100%),
+ var(--glass-panel-tint);
+
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
+ backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
+
+ box-shadow:
+ 0 0 0 1px var(--glass-panel-highlight) inset,
+ 0 8px 40px var(--glass-panel-shadow-1),
+ 0 2px 10px var(--glass-panel-shadow-2),
+ 0 1px 4px var(--glass-panel-shadow-3);
+}
+
+.panel::before,
+.settings-panel::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: radial-gradient(120% 100% at 15% 10%, rgba(255,248,230,0.08) 0%, transparent 65%);
+ pointer-events: none;
+ z-index: -1;
}
.panel {
- width: min(356px, 100vw);
- height: min(600px, 100vh);
+ width: 100%;
+ height: 100%;
border-radius: 22px;
padding: 0 16px 16px;
- overflow: hidden;
+ overflow-y: auto;
+ overflow-x: hidden;
}
.dashboard-panel {
@@ -93,12 +136,64 @@ button {
letter-spacing: 0;
}
-/* 标题区子元素穿透点击,让 data-tauri-drag-region 拖拽生效(按钮不在这些容器内,不受影响) */
+.provider-toggle {
+ background: none;
+ border: none;
+ color: var(--fg);
+ font-size: 15px;
+ font-weight: 800;
+ letter-spacing: 0;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0;
+ margin: 0;
+ pointer-events: auto;
+ transition: opacity 0.15s;
+}
+
+.provider-toggle:hover {
+ opacity: 0.7;
+}
+
+.provider-arrow {
+ font-size: 13px;
+ opacity: 0.5;
+ transition: opacity 0.15s;
+}
+
+.provider-toggle:hover .provider-arrow {
+ opacity: 0.8;
+}
+
+/* 设置页标题(静态,不可点击) */
+.settings-provider-title {
+ color: var(--fg);
+ font-size: 15px;
+ font-weight: 800;
+ letter-spacing: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0;
+ margin: 0;
+ pointer-events: auto;
+ user-select: none;
+}
+
+/* 标题区子元素穿透点击,让 data-tauri-drag-region 拖拽生效 */
.title-lockup > *,
.settings-header > *,
.detail-hero > * {
pointer-events: none;
}
+.title-lockup > .provider-toggle,
+.settings-header > .provider-toggle,
+.title-lockup > .settings-provider-title,
+.settings-header > .settings-provider-title {
+ pointer-events: auto;
+}
.brand-icon {
display: grid;
@@ -133,16 +228,51 @@ button {
height: 24px;
padding: 0;
border: 0;
+ border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
+ transition: background 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
+}
+
+.header-actions button:hover {
+ background: rgba(var(--fg), 0.14);
+ box-shadow: inset 0 0 0 1px rgba(var(--fg), 0.12);
+}
+
+.header-actions button:active {
+ background: rgba(var(--fg), 0.22);
}
.card {
- background: linear-gradient(180deg, rgba(44, 38, 16, 0.68), rgba(36, 35, 16, 0.62));
- border: 1px solid rgba(255, 255, 255, 0.025);
+ position: relative;
+ isolation: isolate;
+ background-clip: padding-box;
+
+ border: var(--glass-border-width) solid var(--glass-card-border);
border-radius: 12px;
- box-shadow: inset 0 1px rgba(255, 255, 255, 0.025);
+
+ background:
+ radial-gradient(150% 120% at 30% 20%, transparent 40%, var(--glass-card-blend) 100%),
+ var(--glass-card-tint);
+
+ -webkit-backdrop-filter: blur(calc(var(--glass-blur) * 0.5)) saturate(1.06);
+ backdrop-filter: blur(calc(var(--glass-blur) * 0.5)) saturate(1.06);
+
+ box-shadow:
+ 0 0 0 1px var(--glass-card-highlight) inset,
+ 0 4px 16px var(--glass-card-shadow-1),
+ 0 1px 4px var(--glass-card-shadow-2);
+}
+
+.card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: radial-gradient(100% 80% at 20% 10%, rgba(255,248,230,0.04) 0%, transparent 60%);
+ pointer-events: none;
+ z-index: -1;
}
.balance-card {
@@ -234,7 +364,8 @@ button {
min-height: 56px;
padding: 9px 12px;
border-radius: 10px;
- background: rgba(122, 101, 50, 0.46);
+ background: rgba(140, 118, 58, 0.38);
+ border: var(--glass-border-width) solid rgba(255,235,180,0.10);
min-width: 0;
overflow: hidden;
display: flex;
@@ -333,8 +464,8 @@ button {
}
.token-line span {
- min-width: 88px;
- max-width: 98px;
+ min-width: 130px;
+ max-width: 145px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -364,7 +495,7 @@ button {
}
.usage-price {
- width: 55px;
+ width: 80px;
text-align: right;
}
@@ -474,6 +605,10 @@ button {
background: #a78bfa;
}
+.cache-bar .seg.mimo-tokens {
+ background: #60a5fa;
+}
+
.cache-bar .seg.empty {
flex-grow: 1;
min-height: 0;
@@ -486,11 +621,60 @@ button {
font-weight: 800;
}
+/* 周导航按钮 */
+.chart-nav {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.chart-nav-btn {
+ background: none;
+ border: 1px solid rgba(var(--fg), 0.15);
+ border-radius: 4px;
+ color: var(--text-strong);
+ font-size: 14px;
+ line-height: 1;
+ width: 22px;
+ height: 22px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ transition: background 0.15s;
+}
+.chart-nav-btn:hover:not(:disabled) {
+ background: rgba(var(--fg), 0.1);
+}
+.chart-nav-btn:disabled {
+ opacity: 0.3;
+ cursor: default;
+}
+.chart-nav-label {
+ font-size: 10px;
+ color: var(--text-faint);
+ min-width: 36px;
+ text-align: center;
+}
+
+/* 柱状图列整体可悬停(解决矮柱子难以 hover 的问题) */
+.bar-column,
+.detail-bar-column {
+ cursor: default;
+}
+
.settings-panel {
- width: min(420px, 100vw);
- height: min(620px, 100vh);
+ width: 100%;
+ height: 100%;
border-radius: 22px;
overflow: hidden;
+
+ --text-strong: rgba(var(--fg), 0.90);
+ --text-muted: rgba(var(--fg), 0.75);
+ --text-faint: rgba(var(--fg), 0.60);
+
+ --glass-panel-tint: rgba(110, 90, 45, 0.70);
+ --glass-card-tint: rgba(70, 60, 30, 0.60);
}
.settings-inner {
@@ -556,6 +740,17 @@ button {
color: var(--text-faint);
}
+.changelog-body h3 { margin: 14px 0 8px; font-size: 1.15em; color: var(--text-strong); }
+.changelog-body summary { cursor: pointer; margin: 8px 0 4px; font-size: 1em; font-weight: 700; color: var(--text-strong); user-select: none; }
+.changelog-body summary:hover { opacity: 0.8; }
+.changelog-body details { margin-bottom: 6px; }
+.changelog-body p { margin: 2px 0; }
+.changelog-body ul { margin: 2px 0 6px; padding-left: 18px; }
+.changelog-body li { margin: 1px 0; }
+.changelog-body code { font-size: 0.92em; background: rgba(var(--fg), 0.1); padding: 1px 5px; border-radius: 4px; }
+.changelog-body a { color: var(--brand-light); }
+.changelog-body blockquote { border-left: 3px solid rgba(var(--fg), 0.25); margin: 6px 0; padding-left: 10px; color: var(--text-faint); }
+
.config-path {
display: flex;
flex-wrap: wrap;
@@ -725,30 +920,6 @@ button:disabled {
left: 24px;
}
-.segmented {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 8px;
- margin-top: 12px;
-}
-
-.segmented button {
- border: 0;
- border-radius: 8px;
- min-width: 0;
- padding: 8px 6px;
- background: rgba(var(--fg), 0.18);
- color: white;
- font-size: 13px;
- font-weight: 900;
- cursor: pointer;
- white-space: nowrap;
-}
-
-.segmented .selected {
- background: var(--brand);
-}
-
.back-button {
width: 100%;
margin-top: 2px;
@@ -842,7 +1013,10 @@ button:disabled {
flex-direction: column;
border-radius: 18px;
padding: 14px 18px 16px;
- background: linear-gradient(180deg, rgba(26, 50, 21, 0.58), rgba(34, 41, 17, 0.62));
+ background: rgba(50, 55, 25, 0.42);
+ border: var(--glass-border-width) solid rgba(255,235,180,0.10);
+ -webkit-backdrop-filter: blur(calc(var(--glass-blur) * 0.4)) saturate(1.04);
+ backdrop-filter: blur(calc(var(--glass-blur) * 0.4)) saturate(1.04);
}
.detail-chart-head {
@@ -879,8 +1053,8 @@ button:disabled {
grid-template-rows: 18px 1fr 20px; /* 柱顶数值 / 柱子区 / 底部日期 */
justify-items: center;
align-items: stretch;
+ position: relative;
}
-
.detail-bar-column span {
color: rgba(var(--fg), 0.68);
max-width: 100%;
@@ -920,6 +1094,7 @@ button:disabled {
.detail-bar-stacked .seg.hit { background: #34d399; }
.detail-bar-stacked .seg.miss { background: var(--orange); }
.detail-bar-stacked .seg.response { background: #a78bfa; }
+.detail-bar-stacked .seg.mimo-tokens { background: #60a5fa; }
.detail-bar-stacked .seg.empty {
flex-grow: 1; /* 空数据占位段填满 MIN_BAR 高度的柱子 */
min-height: 0;
@@ -928,10 +1103,6 @@ button:disabled {
}
/* ---------- 磨玻璃浮窗 ---------- */
-.detail-bar-column {
- position: relative;
-}
-
.bar-tooltip {
position: absolute;
bottom: 100%;
@@ -945,9 +1116,10 @@ button:disabled {
padding: 10px 12px;
margin-bottom: 6px;
border-radius: 10px;
- border: 1px solid rgba(255, 255, 255, 0.18);
- background: rgba(30, 28, 16, 0.78);
- backdrop-filter: blur(14px) saturate(1.2);
+ border: var(--glass-border-width) solid rgba(255, 255, 255, 0.18);
+ background: var(--glass-tooltip-tint);
+ -webkit-backdrop-filter: blur(var(--glass-tooltip-blur)) saturate(1.2);
+ backdrop-filter: blur(var(--glass-tooltip-blur)) saturate(1.2);
font-size: 11px;
font-weight: 700;
line-height: 1.5;
@@ -1047,6 +1219,10 @@ button:disabled {
background: #a78bfa;
}
+.bar-tooltip-row .dot.mimo-tokens {
+ background: #60a5fa;
+}
+
.bar-tooltip-row strong {
float: none;
margin-left: auto;
@@ -1084,6 +1260,7 @@ button:disabled {
.chart-legend-item .dot.hit { background: #34d399; }
.chart-legend-item .dot.miss { background: var(--orange); }
.chart-legend-item .dot.response { background: #a78bfa; }
+.chart-legend-item .dot.mimo-tokens { background: #60a5fa; }
.detail-bar-column em {
color: var(--text-faint);
@@ -1102,21 +1279,23 @@ button:disabled {
--pro: #c02fde;
--orange: #ef8400;
--skin-accent: #2d6cf6;
-}
-[data-theme="light"] .panel,
-[data-theme="light"] .settings-panel {
- border-color: rgba(255, 255, 255, 0.55);
- background:
- linear-gradient(150deg, rgba(150, 205, 236, 0.85), rgba(172, 224, 206, 0.82)),
- rgba(216, 233, 239, 0.92);
- box-shadow: inset 0 1px rgba(255, 255, 255, 0.6);
-}
+ --glass-panel-tint: rgba(210, 230, 245, 0.68);
+ --glass-panel-blend: rgba(180, 220, 240, 0.12);
+ --glass-panel-border: rgba(255, 255, 255, 0.55);
+ --glass-panel-highlight: rgba(255, 255, 255, 0.50);
+ --glass-panel-shadow-1: rgba(40, 80, 120, 0.10);
+ --glass-panel-shadow-2: rgba(40, 80, 120, 0.14);
+ --glass-panel-shadow-3: rgba(40, 80, 120, 0.05);
-[data-theme="light"] .card {
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.42));
- border-color: rgba(255, 255, 255, 0.6);
- box-shadow: inset 0 1px rgba(255, 255, 255, 0.7), 0 2px 8px rgba(40, 80, 120, 0.08);
+ --glass-card-tint: rgba(235, 245, 252, 0.55);
+ --glass-card-blend: rgba(255, 255, 255, 0.08);
+ --glass-card-border: rgba(255, 255, 255, 0.55);
+ --glass-card-highlight: rgba(255, 255, 255, 0.55);
+ --glass-card-shadow-1: rgba(40, 80, 120, 0.06);
+ --glass-card-shadow-2: rgba(40, 80, 120, 0.04);
+
+ --glass-tooltip-tint: rgba(245, 248, 252, 0.94);
}
[data-theme="light"] .header-actions button,
@@ -1151,6 +1330,7 @@ button:disabled {
}
[data-theme="light"] .mini-card {
background: rgba(255, 255, 255, 0.52);
+ border-color: rgba(255, 255, 255, 0.40);
}
[data-theme="light"] .cache-hit-rate.flash {
@@ -1185,13 +1365,20 @@ button:disabled {
background: #8b5cf6;
}
+[data-theme="light"] .detail-bar-stacked .seg.mimo-tokens,
+[data-theme="light"] .cache-bar .seg.mimo-tokens,
+[data-theme="light"] .chart-legend-item .dot.mimo-tokens,
+[data-theme="light"] .bar-tooltip-row .dot.mimo-tokens {
+ background: #3b82f6;
+}
+
[data-theme="light"] .detail-chart {
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.34));
+ background: rgba(230, 242, 250, 0.50);
+ border-color: rgba(255, 255, 255, 0.40);
}
[data-theme="light"] .bar-tooltip {
- background: rgba(255, 255, 255, 0.92);
- border-color: rgba(40, 70, 100, 0.14);
+ border-color: rgba(40, 70, 100, 0.10);
}
[data-theme="light"] .bar-tooltip-total {
border-top-color: rgba(40, 70, 100, 0.14);
@@ -1203,6 +1390,11 @@ button:disabled {
border-top-color: rgba(40, 70, 100, 0.12);
}
+[data-theme="light"] .settings-panel {
+ --glass-panel-tint: rgba(210, 230, 245, 0.85);
+ --glass-card-tint: rgba(235, 245, 252, 0.75);
+}
+
[data-theme="light"] .settings-section {
border-top-color: rgba(40, 70, 100, 0.14);
}
@@ -1220,12 +1412,7 @@ button:disabled {
[data-theme="light"] .toggle-row input:checked + i {
background: #2d6cf6;
}
-[data-theme="light"] .segmented button {
- color: #1a2a40;
-}
-[data-theme="light"] .segmented .selected {
- color: #fff;
-}
+
/* 皮肤选择按钮与弹窗 */
.skin-menu-wrap {
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..120f2b3
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,102 @@
+export type ViewName = "dashboard" | "settings" | "detail";
+export type ModelName = "flash" | "pro" | (string & {});
+export type Provider = "deepseek" | "mimo";
+
+export type AppConfig = {
+ apiKeyConfigured: boolean;
+ apiKeyPreview: string | null;
+ usageTokenConfigured: boolean;
+ provider: Provider;
+ mimoTokenConfigured: boolean;
+ refreshIntervalSeconds: number;
+ autoRefreshEnabled: boolean;
+ autostart: boolean;
+ configPath: string;
+ lowBalanceNotify: boolean;
+ lowBalanceThreshold: number;
+ theme: "light" | "dark" | "system";
+ currency: "cny" | "usd";
+ efficiencyUnit: "token_per_currency" | "currency_per_token";
+ defaultProvider: Provider;
+ mimoRefreshIntervalSeconds: number;
+ notifyCooldownMinutes: number;
+};
+
+export type BalanceData = {
+ isAvailable: boolean;
+ currency: string;
+ totalBalance: string;
+ grantedBalance: string;
+ toppedUpBalance: string;
+};
+
+export type BalanceState = "loading" | "ok" | "error" | "nokey";
+
+export type UsageModel = {
+ key: string;
+ name: string;
+ totalTokens: number;
+ requestCount: number;
+ cacheHitTokens: number;
+ cacheMissTokens: number;
+ responseTokens: number;
+ cost: number;
+};
+
+export type UsageDay = {
+ date: string;
+ flashTokens: number;
+ flashCacheHit: number;
+ flashCacheMiss: number;
+ flashResponse: number;
+ proTokens: number;
+ proCacheHit: number;
+ proCacheMiss: number;
+ proResponse: number;
+ totalTokens: number;
+ totalCost: number;
+};
+
+export type UsageResult = {
+ models: UsageModel[];
+ days: UsageDay[];
+ monthCost: number;
+};
+
+export type MimoBalanceData = {
+ availableBalance: string;
+ currency: string;
+ totalConsumption: string;
+ monthlyExpense: string;
+};
+
+export type MimoUsageModel = {
+ key: string;
+ name: string;
+ totalTokens: number;
+ requestCount: number;
+ cacheHitTokens: number;
+ cacheMissTokens: number;
+ responseTokens: number;
+ cost: number;
+};
+
+export type MimoUsageDay = {
+ date: string;
+ totalTokens: number;
+ totalCost: number;
+ models: Array<{
+ key: string;
+ totalTokens: number;
+ cacheHitTokens: number;
+ cacheMissTokens: number;
+ responseTokens: number;
+ totalCost: number;
+ }>;
+};
+
+export type MimoUsageResult = {
+ models: MimoUsageModel[];
+ days: MimoUsageDay[];
+ monthCost: number;
+};
diff --git a/src/utils.test.ts b/src/utils.test.ts
new file mode 100644
index 0000000..3b27b0f
--- /dev/null
+++ b/src/utils.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from "vitest";
+import { fmtInt, fmtTokensShort, fmtMoney, mmdd, todayStr, dateKey, addDays, modelDisplayName, modelIcon } from "./utils";
+
+describe("fmtInt", () => {
+ it("formats integers with locale separators", () => {
+ expect(fmtInt(102614051)).toBe("102,614,051");
+ expect(fmtInt(0)).toBe("0");
+ expect(fmtInt(999)).toBe("999");
+ });
+ it("rounds decimals", () => {
+ expect(fmtInt(1234.6)).toBe("1,235");
+ expect(fmtInt(1234.4)).toBe("1,234");
+ });
+});
+
+describe("fmtTokensShort", () => {
+ it("formats millions", () => {
+ expect(fmtTokensShort(102614051)).toBe("103M");
+ expect(fmtTokensShort(1500000)).toBe("1.5M");
+ });
+ it("formats thousands", () => {
+ expect(fmtTokensShort(425581)).toBe("425.6K");
+ expect(fmtTokensShort(5000)).toBe("5.0K");
+ });
+ it("formats small numbers", () => {
+ expect(fmtTokensShort(100)).toBe("100");
+ expect(fmtTokensShort(0)).toBe("0");
+ });
+});
+
+describe("fmtMoney", () => {
+ it("formats with yen symbol and 2 decimals", () => {
+ expect(fmtMoney(4.637495)).toBe("¥4.64");
+ expect(fmtMoney(0)).toBe("¥0.00");
+ expect(fmtMoney(42.55)).toBe("¥42.55");
+ });
+});
+
+describe("mmdd", () => {
+ it("extracts month/day from YYYY-MM-DD", () => {
+ expect(mmdd("2026-06-25")).toBe("6/25");
+ expect(mmdd("2026-01-01")).toBe("1/1");
+ });
+ it("returns original if format is wrong", () => {
+ expect(mmdd("invalid")).toBe("invalid");
+ });
+});
+
+describe("todayStr", () => {
+ it("returns YYYY-MM-DD format", () => {
+ const result = todayStr();
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+});
+
+describe("dateKey", () => {
+ it("formats Date to YYYY-MM-DD", () => {
+ expect(dateKey(new Date(2026, 5, 25))).toBe("2026-06-25");
+ expect(dateKey(new Date(2026, 0, 1))).toBe("2026-01-01");
+ });
+});
+
+describe("addDays", () => {
+ it("adds days correctly", () => {
+ const base = new Date(2026, 5, 25);
+ expect(dateKey(addDays(base, 1))).toBe("2026-06-26");
+ expect(dateKey(addDays(base, -1))).toBe("2026-06-24");
+ expect(dateKey(addDays(base, 0))).toBe("2026-06-25");
+ });
+ it("handles month boundaries", () => {
+ const base = new Date(2026, 5, 1);
+ expect(dateKey(addDays(base, -1))).toBe("2026-05-31");
+ });
+});
+
+describe("modelDisplayName", () => {
+ it("maps known keys", () => {
+ expect(modelDisplayName("mimo-v2.5")).toBe("V2.5");
+ expect(modelDisplayName("mimo-v2.5-pro")).toBe("V2.5 Pro");
+ });
+ it("returns original for unknown keys", () => {
+ expect(modelDisplayName("unknown")).toBe("unknown");
+ });
+});
+
+describe("modelIcon", () => {
+ it("returns pro for pro models", () => {
+ expect(modelIcon("mimo-v2.5-pro")).toBe("pro");
+ expect(modelIcon("flash-pro")).toBe("pro");
+ });
+ it("returns flash for non-pro models", () => {
+ expect(modelIcon("mimo-v2.5")).toBe("flash");
+ expect(modelIcon("flash")).toBe("flash");
+ });
+});
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..2d2db62
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,101 @@
+import type { UsageDay } from "./types";
+
+export const REFRESH_OPTIONS = [
+ { label: "1 分钟", value: 60 },
+ { label: "5 分钟", value: 300 },
+ { label: "30 分钟", value: 1800 },
+ { label: "1 小时", value: 3600 },
+];
+
+export const fmtInt = (n: number) => Math.round(n).toLocaleString("en-US");
+
+export const fmtTokensShort = (n: number) => {
+ if (n >= 1e8) return (n / 1e6).toFixed(0) + "M";
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
+ return String(Math.round(n));
+};
+
+export const fmtMoney = (n: number, currency?: string, rate?: number) => {
+ if (currency === "usd" && rate && rate > 0) {
+ return "$" + (n * rate).toFixed(2);
+ }
+ return "¥" + n.toFixed(2);
+};
+
+export const mmdd = (date: string) => {
+ const parts = date.split("-");
+ return parts.length === 3 ? `${Number(parts[1])}/${Number(parts[2])}` : date;
+};
+
+export const todayStr = () => {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+};
+
+export const dateKey = (date: Date) =>
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
+
+export const addDays = (date: Date, offset: number) => {
+ const next = new Date(date);
+ next.setDate(next.getDate() + offset);
+ return next;
+};
+
+export const recentUsageDays = (days: UsageDay[], count = 7): UsageDay[] => {
+ const source = new Map(days.filter((day) => day.date <= todayStr()).map((day) => [day.date, day]));
+ const today = new Date();
+ return Array.from({ length: count }, (_, index) => {
+ const date = dateKey(addDays(today, index - count + 1));
+ return (
+ source.get(date) ?? {
+ date,
+ flashTokens: 0,
+ flashCacheHit: 0,
+ flashCacheMiss: 0,
+ flashResponse: 0,
+ proTokens: 0,
+ proCacheHit: 0,
+ proCacheMiss: 0,
+ proResponse: 0,
+ totalTokens: 0,
+ totalCost: 0,
+ }
+ );
+ });
+};
+
+export const previousMonth = (date: Date) => {
+ const previous = new Date(date.getFullYear(), date.getMonth() - 1, 1);
+ return { month: previous.getMonth() + 1, year: previous.getFullYear() };
+};
+
+export const modelDisplayName = (key: string): string => {
+ const map: Record = {
+ "mimo-v2.5": "V2.5",
+ "mimo-v2.5-pro": "V2.5 Pro",
+ };
+ return map[key] ?? key;
+};
+
+export const modelIcon = (key: string): "flash" | "pro" | "mimo" => {
+ if (key.startsWith("mimo-")) return "mimo";
+ if (key.includes("pro")) return "pro";
+ return "flash";
+};
+
+/** 通用缓存工具:成功写入 localStorage,失败时回退到缓存 */
+export async function fetchWithCache(key: string, fetcher: () => Promise): Promise {
+ try {
+ const data = await fetcher();
+ try { localStorage.setItem(key, JSON.stringify(data)); } catch {}
+ return data;
+ } catch (error) {
+ try {
+ const cached = localStorage.getItem(key);
+ if (cached) return JSON.parse(cached) as T;
+ } catch {}
+ throw error;
+ }
+}
+