2026-07-10 05:10:31 +00:00
|
|
|
|
import { KEYS } from "../data/mock/keys";
|
|
|
|
|
|
import { getSession } from "../data/mock/store";
|
|
|
|
|
|
import { newId } from "./id";
|
|
|
|
|
|
import { readJson, writeJson } from "./storage";
|
|
|
|
|
|
import { nowUnixNano } from "./time";
|
|
|
|
|
|
|
|
|
|
|
|
/** 計費維度(對齊未來付費方案) */
|
|
|
|
|
|
export type UsageMeter = "ai_copy" | "ai_research" | "web_search" | "ai_image";
|
|
|
|
|
|
|
|
|
|
|
|
export type PlanId = "free" | "starter" | "pro";
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** platform = 平台代付扣點;byok = 自備 key 只計次數 */
|
|
|
|
|
|
export type UsageKeyMode = "platform" | "byok";
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
export type UsageEvent = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
/** 使用者 uid(舊資料可能缺,會歸到 unknown) */
|
|
|
|
|
|
uid: string;
|
|
|
|
|
|
meter: UsageMeter;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 消耗點數(byok 固定 0) */
|
2026-07-10 05:10:31 +00:00
|
|
|
|
credits: number;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 平台 / 自備 key */
|
|
|
|
|
|
key_mode: UsageKeyMode;
|
2026-07-10 05:10:31 +00:00
|
|
|
|
/** 使用者可讀說明 */
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
/** 功能來源,除錯/對帳 */
|
|
|
|
|
|
source: string;
|
|
|
|
|
|
/** unix nanoseconds UTC */
|
|
|
|
|
|
created_at: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type PlanDef = {
|
|
|
|
|
|
id: PlanId;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
price_label: string;
|
|
|
|
|
|
price_twd: number;
|
|
|
|
|
|
blurb: string;
|
|
|
|
|
|
blurb_key: string;
|
|
|
|
|
|
monthly_credits: number;
|
|
|
|
|
|
soft_caps: Record<UsageMeter, number>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 每人方案/是否不擋額度(用量一律計算) */
|
|
|
|
|
|
export type UsageMemberPrefs = {
|
|
|
|
|
|
plan_id: PlanId;
|
|
|
|
|
|
/** 達方案上限仍可繼續用;AI/Search/點數照樣記帳 */
|
|
|
|
|
|
unlimited: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const METER_META: Record<
|
|
|
|
|
|
UsageMeter,
|
|
|
|
|
|
{ label: string; hint: string; order: number; group: "ai" | "search" }
|
|
|
|
|
|
> = {
|
|
|
|
|
|
ai_copy: {
|
|
|
|
|
|
label: "AI 文案",
|
|
|
|
|
|
hint: "回覆草稿、靈感聊天、仿寫、結構分析",
|
|
|
|
|
|
order: 0,
|
|
|
|
|
|
group: "ai",
|
|
|
|
|
|
},
|
|
|
|
|
|
ai_research: {
|
|
|
|
|
|
label: "AI 研究",
|
|
|
|
|
|
hint: "海巡周邊知識、上網功課",
|
|
|
|
|
|
order: 1,
|
|
|
|
|
|
group: "ai",
|
|
|
|
|
|
},
|
|
|
|
|
|
web_search: {
|
|
|
|
|
|
label: "搜尋",
|
|
|
|
|
|
hint: "熱點刷新、關鍵字搜、研究檢索",
|
|
|
|
|
|
order: 2,
|
|
|
|
|
|
group: "search",
|
|
|
|
|
|
},
|
|
|
|
|
|
ai_image: {
|
|
|
|
|
|
label: "AI 生圖",
|
|
|
|
|
|
hint: "文生圖/配圖",
|
|
|
|
|
|
order: 3,
|
|
|
|
|
|
group: "ai",
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const PLANS: Record<PlanId, PlanDef> = {
|
|
|
|
|
|
free: {
|
|
|
|
|
|
id: "free",
|
|
|
|
|
|
name: "Free",
|
|
|
|
|
|
price_label: "NT$0/月",
|
|
|
|
|
|
price_twd: 0,
|
|
|
|
|
|
blurb: "試用與個人輕量經營",
|
|
|
|
|
|
blurb_key: "usage.plan.free.blurb",
|
|
|
|
|
|
monthly_credits: 80,
|
|
|
|
|
|
soft_caps: {
|
|
|
|
|
|
ai_copy: 40,
|
|
|
|
|
|
ai_research: 8,
|
|
|
|
|
|
web_search: 30,
|
|
|
|
|
|
ai_image: 3,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
starter: {
|
|
|
|
|
|
id: "starter",
|
|
|
|
|
|
name: "Starter",
|
|
|
|
|
|
price_label: "NT$590/月",
|
|
|
|
|
|
price_twd: 590,
|
|
|
|
|
|
blurb: "小團隊日常發文與海巡",
|
|
|
|
|
|
blurb_key: "usage.plan.starter.blurb",
|
|
|
|
|
|
monthly_credits: 500,
|
|
|
|
|
|
soft_caps: {
|
|
|
|
|
|
ai_copy: 250,
|
|
|
|
|
|
ai_research: 60,
|
|
|
|
|
|
web_search: 200,
|
|
|
|
|
|
ai_image: 20,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
pro: {
|
|
|
|
|
|
id: "pro",
|
|
|
|
|
|
name: "Pro",
|
|
|
|
|
|
price_label: "NT$1,990/月",
|
|
|
|
|
|
price_twd: 1990,
|
|
|
|
|
|
blurb: "多帳、重度 AI 與研究",
|
|
|
|
|
|
blurb_key: "usage.plan.pro.blurb",
|
|
|
|
|
|
monthly_credits: 2000,
|
|
|
|
|
|
soft_caps: {
|
|
|
|
|
|
ai_copy: 1000,
|
|
|
|
|
|
ai_research: 250,
|
|
|
|
|
|
web_search: 800,
|
|
|
|
|
|
ai_image: 80,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_CREDIT_COST: Record<UsageMeter, number> = {
|
|
|
|
|
|
ai_copy: 1,
|
|
|
|
|
|
ai_research: 3,
|
|
|
|
|
|
web_search: 1,
|
|
|
|
|
|
ai_image: 5,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type UsageMonthSummary = {
|
|
|
|
|
|
month_key: string;
|
|
|
|
|
|
uid: string;
|
|
|
|
|
|
plan: PlanDef;
|
|
|
|
|
|
unlimited: boolean;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 方案點數(僅 platform)— 進度條用 */
|
2026-07-10 05:10:31 +00:00
|
|
|
|
total_credits: number;
|
|
|
|
|
|
remaining_credits: number;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 無限時為 0;有上限才算用掉比例(platform only) */
|
2026-07-10 05:10:31 +00:00
|
|
|
|
pct: number;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** @deprecated 平台 AI 次數;請用 platform / byok 分欄 */
|
2026-07-10 05:10:31 +00:00
|
|
|
|
ai_calls: number;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** @deprecated 平台搜尋次數 */
|
2026-07-10 05:10:31 +00:00
|
|
|
|
search_calls: number;
|
|
|
|
|
|
by_meter: Record<
|
|
|
|
|
|
UsageMeter,
|
|
|
|
|
|
{ credits: number; count: number; soft_cap: number; pct: number }
|
|
|
|
|
|
>;
|
|
|
|
|
|
events: UsageEvent[];
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 平台代付:點數 + 次數 */
|
|
|
|
|
|
platform: {
|
|
|
|
|
|
credits_used: number;
|
|
|
|
|
|
credits_remaining: number;
|
|
|
|
|
|
credits_total: number;
|
|
|
|
|
|
call_count: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
/** BYOK:僅次數,不進點數條 */
|
|
|
|
|
|
byok: {
|
|
|
|
|
|
call_count: number;
|
|
|
|
|
|
};
|
2026-07-10 05:10:31 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type TenantUsageMemberRow = {
|
|
|
|
|
|
uid: string;
|
|
|
|
|
|
email: string;
|
|
|
|
|
|
display_name: string;
|
|
|
|
|
|
unlimited: boolean;
|
|
|
|
|
|
plan_id: PlanId;
|
|
|
|
|
|
/** 區間內方案配給(購買/額度) */
|
|
|
|
|
|
purchased_credits: number;
|
|
|
|
|
|
/** 區間內消耗 */
|
|
|
|
|
|
total_credits: number;
|
|
|
|
|
|
ai_calls: number;
|
|
|
|
|
|
search_calls: number;
|
|
|
|
|
|
remaining_credits: number;
|
|
|
|
|
|
pct: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type TenantUsageSummary = {
|
|
|
|
|
|
month_key: string;
|
|
|
|
|
|
/** @deprecated 等同 consumed_credits;保留相容 */
|
|
|
|
|
|
total_credits: number;
|
|
|
|
|
|
purchased_credits: number;
|
|
|
|
|
|
consumed_credits: number;
|
|
|
|
|
|
remaining_credits: number;
|
|
|
|
|
|
ai_calls: number;
|
|
|
|
|
|
search_calls: number;
|
|
|
|
|
|
by_meter: Record<UsageMeter, { credits: number; count: number }>;
|
|
|
|
|
|
members: TenantUsageMemberRow[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 圖表時間粒度 */
|
|
|
|
|
|
export type UsageGranularity = "day" | "month" | "year";
|
|
|
|
|
|
|
|
|
|
|
|
export type UsageTimeBucket = {
|
|
|
|
|
|
/** day: YYYY-MM-DD · month: YYYY-MM · year: YYYY */
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
purchased_credits: number;
|
|
|
|
|
|
consumed_credits: number;
|
|
|
|
|
|
ai_calls: number;
|
|
|
|
|
|
search_calls: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type TenantUsageAnalytics = {
|
|
|
|
|
|
granularity: UsageGranularity;
|
|
|
|
|
|
/** 區間起迄(含)YYYY-MM-DD */
|
|
|
|
|
|
from: string;
|
|
|
|
|
|
to: string;
|
|
|
|
|
|
range_label: string;
|
|
|
|
|
|
purchased_credits: number;
|
|
|
|
|
|
consumed_credits: number;
|
|
|
|
|
|
remaining_credits: number;
|
|
|
|
|
|
pct: number;
|
|
|
|
|
|
ai_calls: number;
|
|
|
|
|
|
search_calls: number;
|
|
|
|
|
|
by_meter: Record<UsageMeter, { credits: number; count: number }>;
|
|
|
|
|
|
series: UsageTimeBucket[];
|
|
|
|
|
|
members: TenantUsageMemberRow[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type TenantAnalyticsQuery = {
|
|
|
|
|
|
granularity?: UsageGranularity;
|
|
|
|
|
|
/** YYYY-MM-DD */
|
|
|
|
|
|
from?: string;
|
|
|
|
|
|
/** YYYY-MM-DD */
|
|
|
|
|
|
to?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function monthKeyFromMs(ms: number): string {
|
|
|
|
|
|
const d = new Date(ms);
|
|
|
|
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function currentMonthKey(now = Date.now()): string {
|
|
|
|
|
|
return monthKeyFromMs(now);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadEvents(): UsageEvent[] {
|
|
|
|
|
|
const raw = readJson<(UsageEvent & { uid?: string })[]>(KEYS.usageEvents, []);
|
|
|
|
|
|
return raw.map((e) => ({
|
|
|
|
|
|
...e,
|
|
|
|
|
|
uid: e.uid || "unknown",
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveEvents(list: UsageEvent[]): void {
|
|
|
|
|
|
writeJson(KEYS.usageEvents, list.slice(0, 2000));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PrefsMap = Record<string, UsageMemberPrefs>;
|
|
|
|
|
|
|
|
|
|
|
|
function loadPrefsMap(): PrefsMap {
|
|
|
|
|
|
return readJson(KEYS.usageMemberPrefs, {} as PrefsMap);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function savePrefsMap(map: PrefsMap): void {
|
|
|
|
|
|
writeJson(KEYS.usageMemberPrefs, map);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizePlanId(id: unknown): PlanId {
|
|
|
|
|
|
if (id === "starter" || id === "pro" || id === "free") return id;
|
|
|
|
|
|
return "free";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getMemberPrefs(uid: string): UsageMemberPrefs {
|
|
|
|
|
|
const map = loadPrefsMap();
|
|
|
|
|
|
const row = map[uid];
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
plan_id: normalizePlanId(row.plan_id),
|
|
|
|
|
|
unlimited: row.unlimited === true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
// 相容:舊全域方案
|
|
|
|
|
|
const legacy = readJson<string>(KEYS.usagePlanId, "free");
|
|
|
|
|
|
return { plan_id: normalizePlanId(legacy), unlimited: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function setMemberPrefs(
|
|
|
|
|
|
uid: string,
|
|
|
|
|
|
patch: Partial<UsageMemberPrefs>,
|
|
|
|
|
|
): UsageMemberPrefs {
|
|
|
|
|
|
const map = loadPrefsMap();
|
|
|
|
|
|
const cur = getMemberPrefs(uid);
|
|
|
|
|
|
const next: UsageMemberPrefs = {
|
|
|
|
|
|
plan_id: patch.plan_id != null ? normalizePlanId(patch.plan_id) : cur.plan_id,
|
|
|
|
|
|
unlimited: patch.unlimited != null ? patch.unlimited === true : cur.unlimited,
|
|
|
|
|
|
};
|
|
|
|
|
|
map[uid] = next;
|
|
|
|
|
|
savePrefsMap(map);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @deprecated 改用 getMemberPrefs(uid) */
|
|
|
|
|
|
export function getPlanId(uid?: string): PlanId {
|
|
|
|
|
|
if (uid) return getMemberPrefs(uid).plan_id;
|
|
|
|
|
|
const s = getSession()?.member?.uid;
|
|
|
|
|
|
return getMemberPrefs(s || "unknown").plan_id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @deprecated 改用 setMemberPrefs */
|
|
|
|
|
|
export function setPlanId(id: PlanId, uid?: string): void {
|
|
|
|
|
|
const s = uid || getSession()?.member?.uid || "unknown";
|
|
|
|
|
|
setMemberPrefs(s, { plan_id: id });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sessionUid(): string {
|
|
|
|
|
|
return getSession()?.member?.uid || "unknown";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 記一筆用量。
|
|
|
|
|
|
* - 任何人(含「不擋額度」)每次呼叫都寫入,永不跳過計算
|
|
|
|
|
|
* - unlimited 不影響是否記帳,只表示超過方案仍可繼續用
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function recordUsage(opts: {
|
|
|
|
|
|
meter: UsageMeter;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
source: string;
|
|
|
|
|
|
credits?: number;
|
|
|
|
|
|
uid?: string;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 預設 platform(mock 記點);byok 時 credits 強制 0 */
|
|
|
|
|
|
key_mode?: UsageKeyMode;
|
2026-07-10 05:10:31 +00:00
|
|
|
|
}): UsageEvent {
|
|
|
|
|
|
const uid = opts.uid || sessionUid();
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const key_mode: UsageKeyMode = opts.key_mode ?? "platform";
|
|
|
|
|
|
let credits =
|
2026-07-10 05:10:31 +00:00
|
|
|
|
opts.credits != null && opts.credits > 0
|
|
|
|
|
|
? opts.credits
|
|
|
|
|
|
: DEFAULT_CREDIT_COST[opts.meter];
|
2026-07-13 01:15:30 +00:00
|
|
|
|
if (key_mode === "byok") credits = 0;
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const ev: UsageEvent = {
|
|
|
|
|
|
id: newId("use"),
|
|
|
|
|
|
uid,
|
|
|
|
|
|
meter: opts.meter,
|
|
|
|
|
|
credits,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
key_mode,
|
2026-07-10 05:10:31 +00:00
|
|
|
|
label: opts.label,
|
|
|
|
|
|
source: opts.source,
|
|
|
|
|
|
created_at: nowUnixNano(),
|
|
|
|
|
|
};
|
|
|
|
|
|
const list = loadEvents();
|
|
|
|
|
|
list.unshift(ev);
|
|
|
|
|
|
saveEvents(list);
|
|
|
|
|
|
return ev;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function listUsageEvents(opts?: {
|
|
|
|
|
|
limit?: number;
|
|
|
|
|
|
uid?: string;
|
|
|
|
|
|
monthKey?: string;
|
|
|
|
|
|
}): UsageEvent[] {
|
|
|
|
|
|
const limit = opts?.limit ?? 100;
|
|
|
|
|
|
const monthKey = opts?.monthKey;
|
|
|
|
|
|
let list = loadEvents();
|
|
|
|
|
|
if (opts?.uid) list = list.filter((e) => e.uid === opts.uid);
|
|
|
|
|
|
if (monthKey) {
|
|
|
|
|
|
list = list.filter((e) => {
|
|
|
|
|
|
const ms = Math.floor(e.created_at / 1_000_000);
|
|
|
|
|
|
return monthKeyFromMs(ms) === monthKey;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return list.slice(0, limit);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function emptyByMeter(plan: PlanDef): UsageMonthSummary["by_meter"] {
|
|
|
|
|
|
const by_meter = {} as UsageMonthSummary["by_meter"];
|
|
|
|
|
|
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
|
|
|
|
|
by_meter[m] = {
|
|
|
|
|
|
credits: 0,
|
|
|
|
|
|
count: 0,
|
|
|
|
|
|
soft_cap: plan.soft_caps[m],
|
|
|
|
|
|
pct: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return by_meter;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function aggregateEvents(
|
|
|
|
|
|
events: UsageEvent[],
|
|
|
|
|
|
plan: PlanDef,
|
|
|
|
|
|
_unlimited: boolean,
|
|
|
|
|
|
): Pick<
|
|
|
|
|
|
UsageMonthSummary,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
| "total_credits"
|
|
|
|
|
|
| "remaining_credits"
|
|
|
|
|
|
| "pct"
|
|
|
|
|
|
| "by_meter"
|
|
|
|
|
|
| "ai_calls"
|
|
|
|
|
|
| "search_calls"
|
|
|
|
|
|
| "platform"
|
|
|
|
|
|
| "byok"
|
2026-07-10 05:10:31 +00:00
|
|
|
|
> {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
// 平台點數只加 key_mode=platform;BYOK 只計次數
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const by_meter = emptyByMeter(plan);
|
2026-07-13 01:15:30 +00:00
|
|
|
|
let platformCredits = 0;
|
|
|
|
|
|
let platformCalls = 0;
|
|
|
|
|
|
let byokCalls = 0;
|
2026-07-10 05:10:31 +00:00
|
|
|
|
for (const e of events) {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const mode = e.key_mode ?? "platform";
|
|
|
|
|
|
if (mode === "byok") {
|
|
|
|
|
|
byokCalls += 1;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
platformCredits += e.credits;
|
|
|
|
|
|
platformCalls += 1;
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const row = by_meter[e.meter];
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
row.credits += e.credits;
|
|
|
|
|
|
row.count += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const m of Object.keys(by_meter) as UsageMeter[]) {
|
|
|
|
|
|
const row = by_meter[m];
|
|
|
|
|
|
row.pct =
|
|
|
|
|
|
row.soft_cap > 0 ? Math.min(999, Math.round((row.count / row.soft_cap) * 100)) : 0;
|
|
|
|
|
|
}
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const remaining = Math.max(0, plan.monthly_credits - platformCredits);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
const pct =
|
|
|
|
|
|
plan.monthly_credits > 0
|
2026-07-13 01:15:30 +00:00
|
|
|
|
? Math.min(999, Math.round((platformCredits / plan.monthly_credits) * 100))
|
2026-07-10 05:10:31 +00:00
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
|
|
let ai_calls = 0;
|
|
|
|
|
|
let search_calls = 0;
|
|
|
|
|
|
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
|
|
|
|
|
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count;
|
|
|
|
|
|
else search_calls += by_meter[m].count;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
total_credits: plan.monthly_credits,
|
2026-07-10 05:10:31 +00:00
|
|
|
|
remaining_credits: remaining,
|
|
|
|
|
|
pct,
|
|
|
|
|
|
by_meter,
|
|
|
|
|
|
ai_calls,
|
|
|
|
|
|
search_calls,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
platform: {
|
|
|
|
|
|
credits_used: platformCredits,
|
|
|
|
|
|
credits_remaining: remaining,
|
|
|
|
|
|
credits_total: plan.monthly_credits,
|
|
|
|
|
|
call_count: platformCalls,
|
|
|
|
|
|
},
|
|
|
|
|
|
byok: { call_count: byokCalls },
|
2026-07-10 05:10:31 +00:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function summarizeUsage(
|
|
|
|
|
|
uid?: string,
|
|
|
|
|
|
monthKey = currentMonthKey(),
|
|
|
|
|
|
): UsageMonthSummary {
|
|
|
|
|
|
const target = uid || sessionUid();
|
|
|
|
|
|
const prefs = getMemberPrefs(target);
|
|
|
|
|
|
const plan = PLANS[prefs.plan_id];
|
|
|
|
|
|
const events = loadEvents().filter((e) => {
|
|
|
|
|
|
if (e.uid !== target) return false;
|
|
|
|
|
|
const ms = Math.floor(e.created_at / 1_000_000);
|
|
|
|
|
|
return monthKeyFromMs(ms) === monthKey;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const agg = aggregateEvents(events, plan, prefs.unlimited);
|
|
|
|
|
|
return {
|
|
|
|
|
|
month_key: monthKey,
|
|
|
|
|
|
uid: target,
|
|
|
|
|
|
plan,
|
|
|
|
|
|
unlimited: prefs.unlimited,
|
|
|
|
|
|
...agg,
|
|
|
|
|
|
events: events.slice(0, 80),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pad2(n: number): string {
|
|
|
|
|
|
return String(n).padStart(2, "0");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toDateKey(d: Date): string {
|
|
|
|
|
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseDateKey(key: string): Date | null {
|
|
|
|
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec((key || "").trim());
|
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
|
const y = Number(m[1]);
|
|
|
|
|
|
const mo = Number(m[2]);
|
|
|
|
|
|
const day = Number(m[3]);
|
|
|
|
|
|
if (!y || mo < 1 || mo > 12 || day < 1 || day > 31) return null;
|
|
|
|
|
|
const d = new Date(y, mo - 1, day);
|
|
|
|
|
|
if (d.getFullYear() !== y || d.getMonth() !== mo - 1 || d.getDate() !== day) return null;
|
|
|
|
|
|
return d;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startOfDay(d: Date): Date {
|
|
|
|
|
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addDays(d: Date, n: number): Date {
|
|
|
|
|
|
const x = new Date(d);
|
|
|
|
|
|
x.setDate(x.getDate() + n);
|
|
|
|
|
|
return x;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function daysInMonth(y: number, monthIndex0: number): number {
|
|
|
|
|
|
return new Date(y, monthIndex0 + 1, 0).getDate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function eventDayKey(e: UsageEvent): string {
|
|
|
|
|
|
return toDateKey(new Date(Math.floor(e.created_at / 1_000_000)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function eventMonthKey(e: UsageEvent): string {
|
|
|
|
|
|
return monthKeyFromMs(Math.floor(e.created_at / 1_000_000));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function eventYearKey(e: UsageEvent): string {
|
|
|
|
|
|
return String(new Date(Math.floor(e.created_at / 1_000_000)).getFullYear());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function memberMonthlyQuota(uid: string): number {
|
|
|
|
|
|
return PLANS[getMemberPrefs(uid).plan_id].monthly_credits;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 區間內「購買/配給」點數:依每人方案月額度 × 涵蓋月數(日視圖按當月天數比例) */
|
|
|
|
|
|
function purchasedInRange(
|
|
|
|
|
|
memberUids: string[],
|
|
|
|
|
|
from: Date,
|
|
|
|
|
|
to: Date,
|
|
|
|
|
|
granularity: UsageGranularity,
|
|
|
|
|
|
): number {
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (const uid of memberUids) {
|
|
|
|
|
|
const monthly = memberMonthlyQuota(uid);
|
|
|
|
|
|
if (granularity === "year") {
|
|
|
|
|
|
// 逐年:每年 12 個月 × 月額度(區間裁切到涵蓋月)
|
|
|
|
|
|
const start = new Date(from.getFullYear(), from.getMonth(), 1);
|
|
|
|
|
|
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
|
|
|
|
|
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) {
|
|
|
|
|
|
const m0 = y === start.getFullYear() ? start.getMonth() : 0;
|
|
|
|
|
|
const m1 = y === end.getFullYear() ? end.getMonth() : 11;
|
|
|
|
|
|
total += monthly * (m1 - m0 + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (granularity === "month") {
|
|
|
|
|
|
const start = new Date(from.getFullYear(), from.getMonth(), 1);
|
|
|
|
|
|
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
|
|
|
|
|
let cur = start;
|
|
|
|
|
|
while (cur <= end) {
|
|
|
|
|
|
total += monthly;
|
|
|
|
|
|
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// day:每日配給 = 月額度 / 當月天數
|
|
|
|
|
|
let cur = startOfDay(from);
|
|
|
|
|
|
const end = startOfDay(to);
|
|
|
|
|
|
while (cur <= end) {
|
|
|
|
|
|
const dim = daysInMonth(cur.getFullYear(), cur.getMonth());
|
|
|
|
|
|
total += monthly / dim;
|
|
|
|
|
|
cur = addDays(cur, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Math.round(total);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function defaultRange(granularity: UsageGranularity): { from: Date; to: Date } {
|
|
|
|
|
|
const to = startOfDay(new Date());
|
|
|
|
|
|
if (granularity === "day") {
|
|
|
|
|
|
return { from: addDays(to, -29), to };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (granularity === "year") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
from: new Date(to.getFullYear() - 2, 0, 1),
|
|
|
|
|
|
to,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
// month:近 12 個月
|
|
|
|
|
|
return {
|
|
|
|
|
|
from: new Date(to.getFullYear(), to.getMonth() - 11, 1),
|
|
|
|
|
|
to,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bucketKeys(
|
|
|
|
|
|
from: Date,
|
|
|
|
|
|
to: Date,
|
|
|
|
|
|
granularity: UsageGranularity,
|
|
|
|
|
|
): { key: string; label: string; start: Date; end: Date }[] {
|
|
|
|
|
|
const out: { key: string; label: string; start: Date; end: Date }[] = [];
|
|
|
|
|
|
if (granularity === "day") {
|
|
|
|
|
|
let cur = startOfDay(from);
|
|
|
|
|
|
const end = startOfDay(to);
|
|
|
|
|
|
while (cur <= end) {
|
|
|
|
|
|
const key = toDateKey(cur);
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: `${cur.getMonth() + 1}/${cur.getDate()}`,
|
|
|
|
|
|
start: cur,
|
|
|
|
|
|
end: cur,
|
|
|
|
|
|
});
|
|
|
|
|
|
cur = addDays(cur, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (granularity === "month") {
|
|
|
|
|
|
let cur = new Date(from.getFullYear(), from.getMonth(), 1);
|
|
|
|
|
|
const end = new Date(to.getFullYear(), to.getMonth(), 1);
|
|
|
|
|
|
while (cur <= end) {
|
|
|
|
|
|
const y = cur.getFullYear();
|
|
|
|
|
|
const m = cur.getMonth();
|
|
|
|
|
|
const key = `${y}-${pad2(m + 1)}`;
|
|
|
|
|
|
const last = new Date(y, m + 1, 0);
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: `${y}/${pad2(m + 1)}`,
|
|
|
|
|
|
start: cur,
|
|
|
|
|
|
end: last,
|
|
|
|
|
|
});
|
|
|
|
|
|
cur = new Date(y, m + 1, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
// year
|
|
|
|
|
|
for (let y = from.getFullYear(); y <= to.getFullYear(); y++) {
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
key: String(y),
|
|
|
|
|
|
label: `${y}`,
|
|
|
|
|
|
start: new Date(y, 0, 1),
|
|
|
|
|
|
end: new Date(y, 11, 31),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function eventInBucket(e: UsageEvent, granularity: UsageGranularity, key: string): boolean {
|
|
|
|
|
|
if (granularity === "day") return eventDayKey(e) === key;
|
|
|
|
|
|
if (granularity === "month") return eventMonthKey(e) === key;
|
|
|
|
|
|
return eventYearKey(e) === key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function purchasedForBucket(
|
|
|
|
|
|
memberUids: string[],
|
|
|
|
|
|
bucket: { start: Date; end: Date; key: string },
|
|
|
|
|
|
granularity: UsageGranularity,
|
|
|
|
|
|
): number {
|
|
|
|
|
|
return purchasedInRange(memberUids, bucket.start, bucket.end, granularity);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* mock:若歷史事件太少,補近半年示意資料,方便圖表預覽。
|
|
|
|
|
|
* 只在 events < 12 時寫入一次。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function ensureDemoUsageHistory(
|
|
|
|
|
|
members: { uid: string }[],
|
|
|
|
|
|
): void {
|
|
|
|
|
|
const list = loadEvents();
|
|
|
|
|
|
if (list.length >= 12) return;
|
|
|
|
|
|
if (!members.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
const meters = Object.keys(METER_META) as UsageMeter[];
|
|
|
|
|
|
const labels: Record<UsageMeter, string> = {
|
|
|
|
|
|
ai_copy: "示意:文案",
|
|
|
|
|
|
ai_research: "示意:研究",
|
|
|
|
|
|
web_search: "示意:搜尋",
|
|
|
|
|
|
ai_image: "示意:生圖",
|
|
|
|
|
|
};
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const seeded: UsageEvent[] = [];
|
|
|
|
|
|
for (let dayAgo = 160; dayAgo >= 0; dayAgo -= 2 + (dayAgo % 3)) {
|
|
|
|
|
|
const uid = members[dayAgo % members.length]!.uid;
|
|
|
|
|
|
const meter = meters[dayAgo % meters.length]!;
|
|
|
|
|
|
const ms = now - dayAgo * 86_400_000 - (dayAgo % 7) * 3_600_000;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const key_mode: UsageKeyMode = dayAgo % 5 === 0 ? "byok" : "platform";
|
2026-07-10 05:10:31 +00:00
|
|
|
|
seeded.push({
|
|
|
|
|
|
id: newId("use"),
|
|
|
|
|
|
uid,
|
|
|
|
|
|
meter,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
credits: key_mode === "byok" ? 0 : DEFAULT_CREDIT_COST[meter] * (1 + (dayAgo % 3)),
|
|
|
|
|
|
key_mode,
|
2026-07-10 05:10:31 +00:00
|
|
|
|
label: labels[meter],
|
|
|
|
|
|
source: "demo.seed",
|
|
|
|
|
|
created_at: ms * 1_000_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
saveEvents([...seeded, ...list].slice(0, 2000));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 租戶區間分析:購買 vs 消耗、時間序列、每人 */
|
|
|
|
|
|
export function summarizeTenantAnalytics(
|
|
|
|
|
|
members: { uid: string; email: string; display_name: string }[],
|
|
|
|
|
|
query: TenantAnalyticsQuery = {},
|
|
|
|
|
|
): TenantUsageAnalytics {
|
|
|
|
|
|
const granularity: UsageGranularity =
|
|
|
|
|
|
query.granularity === "day" || query.granularity === "year" ? query.granularity : "month";
|
|
|
|
|
|
|
|
|
|
|
|
const defaults = defaultRange(granularity);
|
|
|
|
|
|
let from = parseDateKey(query.from || "") || defaults.from;
|
|
|
|
|
|
let to = parseDateKey(query.to || "") || defaults.to;
|
|
|
|
|
|
if (from > to) {
|
|
|
|
|
|
const tmp = from;
|
|
|
|
|
|
from = to;
|
|
|
|
|
|
to = tmp;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 限縮避免爆量桶
|
|
|
|
|
|
const maxSpanDays =
|
|
|
|
|
|
granularity === "day" ? 93 : granularity === "month" ? 366 * 3 : 366 * 8;
|
|
|
|
|
|
if ((startOfDay(to).getTime() - startOfDay(from).getTime()) / 86_400_000 > maxSpanDays) {
|
|
|
|
|
|
from = addDays(to, -maxSpanDays);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fromKey = toDateKey(from);
|
|
|
|
|
|
const toKey = toDateKey(to);
|
|
|
|
|
|
const uids = members.map((m) => m.uid);
|
|
|
|
|
|
|
|
|
|
|
|
const rangeStartMs = startOfDay(from).getTime();
|
|
|
|
|
|
const rangeEndMs = startOfDay(to).getTime() + 86_400_000 - 1;
|
|
|
|
|
|
|
|
|
|
|
|
const rangeEvents = loadEvents().filter((e) => {
|
|
|
|
|
|
const ms = Math.floor(e.created_at / 1_000_000);
|
|
|
|
|
|
return ms >= rangeStartMs && ms <= rangeEndMs;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const by_meter = {} as TenantUsageAnalytics["by_meter"];
|
|
|
|
|
|
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
|
|
|
|
|
by_meter[m] = { credits: 0, count: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
let consumed_credits = 0;
|
|
|
|
|
|
for (const e of rangeEvents) {
|
|
|
|
|
|
consumed_credits += e.credits;
|
|
|
|
|
|
const row = by_meter[e.meter];
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
row.credits += e.credits;
|
|
|
|
|
|
row.count += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let ai_calls = 0;
|
|
|
|
|
|
let search_calls = 0;
|
|
|
|
|
|
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
|
|
|
|
|
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count;
|
|
|
|
|
|
else search_calls += by_meter[m].count;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const purchased_credits = purchasedInRange(uids, from, to, granularity);
|
|
|
|
|
|
const remaining_credits = Math.max(0, Math.round(purchased_credits - consumed_credits));
|
|
|
|
|
|
const pct =
|
|
|
|
|
|
purchased_credits > 0
|
|
|
|
|
|
? Math.min(999, Math.round((consumed_credits / purchased_credits) * 100))
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
|
|
const buckets = bucketKeys(from, to, granularity);
|
|
|
|
|
|
const series: UsageTimeBucket[] = buckets.map((b) => {
|
|
|
|
|
|
const evs = rangeEvents.filter((e) => eventInBucket(e, granularity, b.key));
|
|
|
|
|
|
let c = 0;
|
|
|
|
|
|
let ai = 0;
|
|
|
|
|
|
let search = 0;
|
|
|
|
|
|
for (const e of evs) {
|
|
|
|
|
|
c += e.credits;
|
|
|
|
|
|
if (METER_META[e.meter]?.group === "ai") ai += 1;
|
|
|
|
|
|
else search += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: b.key,
|
|
|
|
|
|
label: b.label,
|
|
|
|
|
|
purchased_credits: purchasedForBucket(uids, b, granularity),
|
|
|
|
|
|
consumed_credits: c,
|
|
|
|
|
|
ai_calls: ai,
|
|
|
|
|
|
search_calls: search,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const memberRows: TenantUsageMemberRow[] = members.map((m) => {
|
|
|
|
|
|
const prefs = getMemberPrefs(m.uid);
|
|
|
|
|
|
const plan = PLANS[prefs.plan_id];
|
|
|
|
|
|
const evs = rangeEvents.filter((e) => e.uid === m.uid);
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
let ai = 0;
|
|
|
|
|
|
let search = 0;
|
|
|
|
|
|
for (const e of evs) {
|
|
|
|
|
|
total += e.credits;
|
|
|
|
|
|
if (METER_META[e.meter]?.group === "ai") ai += 1;
|
|
|
|
|
|
else search += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
const purchased = purchasedInRange([m.uid], from, to, granularity);
|
|
|
|
|
|
const remaining = Math.max(0, purchased - total);
|
|
|
|
|
|
const memberPct =
|
|
|
|
|
|
purchased > 0 ? Math.min(999, Math.round((total / purchased) * 100)) : 0;
|
|
|
|
|
|
return {
|
|
|
|
|
|
uid: m.uid,
|
|
|
|
|
|
email: m.email,
|
|
|
|
|
|
display_name: m.display_name,
|
|
|
|
|
|
unlimited: prefs.unlimited,
|
|
|
|
|
|
plan_id: plan.id,
|
|
|
|
|
|
purchased_credits: purchased,
|
|
|
|
|
|
total_credits: total,
|
|
|
|
|
|
ai_calls: ai,
|
|
|
|
|
|
search_calls: search,
|
|
|
|
|
|
remaining_credits: remaining,
|
|
|
|
|
|
pct: memberPct,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
memberRows.sort((a, b) => b.total_credits - a.total_credits);
|
|
|
|
|
|
|
|
|
|
|
|
const unit =
|
|
|
|
|
|
granularity === "day" ? "日" : granularity === "month" ? "月" : "年";
|
|
|
|
|
|
const range_label = `${fromKey} → ${toKey} · 以${unit}檢視`;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
granularity,
|
|
|
|
|
|
from: fromKey,
|
|
|
|
|
|
to: toKey,
|
|
|
|
|
|
range_label,
|
|
|
|
|
|
purchased_credits,
|
|
|
|
|
|
consumed_credits,
|
|
|
|
|
|
remaining_credits,
|
|
|
|
|
|
pct,
|
|
|
|
|
|
ai_calls,
|
|
|
|
|
|
search_calls,
|
|
|
|
|
|
by_meter,
|
|
|
|
|
|
series,
|
|
|
|
|
|
members: memberRows,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 租戶本月全體:總體 + 每人(相容舊呼叫) */
|
|
|
|
|
|
export function summarizeTenantUsage(
|
|
|
|
|
|
members: { uid: string; email: string; display_name: string }[],
|
|
|
|
|
|
monthKey = currentMonthKey(),
|
|
|
|
|
|
): TenantUsageSummary {
|
|
|
|
|
|
const [y, mo] = monthKey.split("-").map(Number);
|
|
|
|
|
|
const from = `${y}-${pad2(mo)}-01`;
|
|
|
|
|
|
const last = daysInMonth(y, mo - 1);
|
|
|
|
|
|
const to = `${y}-${pad2(mo)}-${pad2(last)}`;
|
|
|
|
|
|
const a = summarizeTenantAnalytics(members, {
|
|
|
|
|
|
granularity: "month",
|
|
|
|
|
|
from,
|
|
|
|
|
|
to,
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
month_key: monthKey,
|
|
|
|
|
|
total_credits: a.consumed_credits,
|
|
|
|
|
|
purchased_credits: a.purchased_credits,
|
|
|
|
|
|
consumed_credits: a.consumed_credits,
|
|
|
|
|
|
remaining_credits: a.remaining_credits,
|
|
|
|
|
|
ai_calls: a.ai_calls,
|
|
|
|
|
|
search_calls: a.search_calls,
|
|
|
|
|
|
by_meter: a.by_meter,
|
|
|
|
|
|
members: a.members,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function usageWarning(summary: UsageMonthSummary): string | null {
|
|
|
|
|
|
// 不擋額度:只提示已超出,不說「用完」
|
|
|
|
|
|
if (summary.unlimited) {
|
|
|
|
|
|
if (summary.total_credits > summary.plan.monthly_credits) {
|
|
|
|
|
|
return `本月已用 ${summary.total_credits} 點(方案 ${summary.plan.monthly_credits},不擋額度,仍可繼續)。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (summary.pct >= 100) {
|
|
|
|
|
|
return "本月點數已用完。可升級方案或等待下月重置。";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (summary.pct >= 80) {
|
|
|
|
|
|
return `本月已用 ${summary.pct}% 點數。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
|
|
|
|
|
const row = summary.by_meter[m];
|
|
|
|
|
|
if (row.pct >= 90) {
|
|
|
|
|
|
return `${METER_META[m].label} 接近單項上限(${row.count}/${row.soft_cap} 次)。`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|