🏁 Final commit: Project Token Usage Viewer completed

This commit is contained in:
2026-01-21 14:21:43 +08:00
parent b967deb4b1
commit a77fcdd3dc
24 changed files with 1087 additions and 651 deletions

View File

@@ -45,40 +45,7 @@ const client: RouterClient = getORPCClient()
* 使用方式:
* ```tsx
* // 查询
* const { data } = useSuspenseQuery(orpc.todo.list.queryOptions())
*
* // 变更
* const mutation = useMutation(orpc.todo.create.mutationOptions())
* mutation.mutate({ title: '新任务' })
* const { data } = useSuspenseQuery(orpc.usage.getUsage.queryOptions())
* ```
*
* 配置了自动缓存失效: 创建/更新/删除操作后自动刷新列表
*/
export const orpc = createTanstackQueryUtils(client, {
// 配置 mutation 成功后自动刷新相关查询
experimental_defaults: {
todo: {
create: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
},
},
})
export const orpc = createTanstackQueryUtils(client)

View File

@@ -1,5 +1,5 @@
import * as todo from './contracts/todo'
import * as usage from './contracts/usage'
export const contract = {
todo,
usage,
}

View File

@@ -1,60 +0,0 @@
/**
* Todo API 契约
*
* 使用 ORPC 契约定义 API 的输入/输出类型。
* drizzle-zod 自动从表 schema 生成验证规则。
*/
import { oc } from '@orpc/contract'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from 'drizzle-zod'
import { z } from 'zod'
import { todoTable } from '@/db/schema'
/** 查询返回的完整 Todo 类型 */
const selectSchema = createSelectSchema(todoTable)
/** 创建 Todo 时的输入类型 (排除自动生成的字段) */
const insertSchema = createInsertSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
/** 更新 Todo 时的输入类型 (所有字段可选) */
const updateSchema = createUpdateSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
// ============================================================
// API 契约定义
// ============================================================
/** 获取所有 Todo */
export const list = oc.input(z.void()).output(z.array(selectSchema))
/** 创建新 Todo */
export const create = oc.input(insertSchema).output(selectSchema)
/** 更新 Todo */
export const update = oc
.input(
z.object({
id: z.uuid(),
data: updateSchema,
}),
)
.output(selectSchema)
/** 删除 Todo */
export const remove = oc
.input(
z.object({
id: z.uuid(),
}),
)
.output(z.void())

View File

@@ -0,0 +1,31 @@
/**
* Token 使用量契约定义
*/
import { oc } from '@orpc/contract'
import { z } from 'zod'
/** 单个模型的使用量数据 */
const ModelUsageSchema = z.object({
/** 账户名称 */
account: z.string(),
/** 模型标识符 */
model: z.string(),
/** 模型显示名称 */
displayName: z.string().optional(),
/** 剩余配额百分比 (0-1) */
remainingFraction: z.number().min(0).max(1),
/** 配额重置时间 (ISO 8601) */
resetTime: z.string().optional(),
})
export type ModelUsage = z.infer<typeof ModelUsageSchema>
/** 获取当前使用量 */
export const getUsage = oc.output(
z.object({
/** 筛选出的 Opus/Thinking 模型列表 */
opusModels: z.array(ModelUsageSchema),
/** 数据获取时间 */
fetchedAt: z.string(),
}),
)

View File

@@ -1,75 +0,0 @@
/**
* Todo API 处理器
*
* 实现 Todo CRUD 操作的业务逻辑。
* 每个处理器都使用 dbProvider 中间件获取数据库连接。
*/
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
import { todoTable } from '@/db/schema'
import { dbProvider } from '@/orpc/middlewares'
import { os } from '@/orpc/server'
/**
* 获取所有 Todo
*
* 按创建时间倒序排列 (最新的在前)
*/
export const list = os.todo.list
.use(dbProvider)
.handler(async ({ context }) => {
const todos = await context.db.query.todoTable.findMany({
orderBy: (todos, { desc }) => [desc(todos.createdAt)],
})
return todos
})
/**
* 创建新 Todo
*
* @throws ORPCError NOT_FOUND - 创建失败时
*/
export const create = os.todo.create
.use(dbProvider)
.handler(async ({ context, input }) => {
const [newTodo] = await context.db
.insert(todoTable)
.values(input)
.returning()
if (!newTodo) {
throw new ORPCError('NOT_FOUND')
}
return newTodo
})
/**
* 更新 Todo
*
* @throws ORPCError NOT_FOUND - Todo 不存在时
*/
export const update = os.todo.update
.use(dbProvider)
.handler(async ({ context, input }) => {
const [updatedTodo] = await context.db
.update(todoTable)
.set(input.data)
.where(eq(todoTable.id, input.id))
.returning()
if (!updatedTodo) {
throw new ORPCError('NOT_FOUND')
}
return updatedTodo
})
/**
* 删除 Todo
*/
export const remove = os.todo.remove
.use(dbProvider)
.handler(async ({ context, input }) => {
await context.db.delete(todoTable).where(eq(todoTable.id, input.id))
})

View File

@@ -0,0 +1,84 @@
/**
* Token 使用量处理器
*
* 从远程 API 获取数据,筛选 Opus/Thinking 模型,并存储历史记录
*/
import { usageHistoryTable } from '@/db/schema'
import { env } from '@/env'
import { dbProvider } from '@/orpc/middlewares'
import { os } from '@/orpc/server'
/** 远程 API 响应中的模型数据结构 */
interface RemoteModelData {
model: string
displayName?: string
remainingFraction: number
resetTime?: string
}
/** 远程 API 响应结构 */
interface RemoteResponse {
result: Record<string, RemoteModelData[]>
}
export const getUsage = os.usage.getUsage
.use(dbProvider)
.handler(async ({ context }) => {
// 1. 获取远程数据
const response = await fetch(env.TOKEN_USAGE_URL)
if (!response.ok) {
throw new Error(`Failed to fetch usage data: ${response.statusText}`)
}
const data = (await response.json()) as RemoteResponse
// 2. 解析并筛选每个账户的 claude-opus-4-5-thinking 模型
const opusModels: Array<{
account: string
model: string
displayName?: string
remainingFraction: number
resetTime?: string
}> = []
for (const [accountFile, models] of Object.entries(data.result)) {
const account = accountFile.replace('.json', '')
// 只找 claude-opus-4-5-thinking 模型
const opusModel = models.find(
(m) => m.model === 'claude-opus-4-5-thinking',
)
if (opusModel) {
opusModels.push({
account,
model: opusModel.model,
displayName: opusModel.displayName,
remainingFraction: opusModel.remainingFraction,
resetTime: opusModel.resetTime,
})
}
}
// 3. 存储到历史表(仅在 Bun 环境下工作)
try {
if (opusModels.length > 0 && context.db) {
await context.db.insert(usageHistoryTable).values(
opusModels.map((m) => ({
account: m.account,
model: m.model,
displayName: m.displayName,
remainingFraction: m.remainingFraction,
resetTime: m.resetTime,
})),
)
}
} catch (err) {
// 在非 Bun 环境下,数据库可能不可用,忽略错误
console.warn('Database insert skipped:', err)
}
return {
opusModels,
fetchedAt: new Date().toISOString(),
}
})

View File

@@ -3,21 +3,33 @@
*
* 为 ORPC 处理器提供数据库连接。使用单例模式管理连接,
* 避免每次请求都创建新连接。
*
* 注意: 在开发模式 (Vite + Node.js) 下,数据库不可用,
* 因为 bun:sqlite 只在 Bun 运行时可用。
*/
import { os } from '@orpc/server'
import { createDb, type Db } from '@/db'
import type { Db } from '@/db'
/** 全局数据库实例 (单例模式) */
let globalDb: Db | null = null
/** 是否在 Bun 环境中运行 */
const isBun = typeof globalThis.Bun !== 'undefined'
/**
* 获取数据库实例
*
* 首次调用时创建连接,后续调用返回同一实例。
* 这种模式适合长时间运行的服务器进程
* 在非 Bun 环境下返回 null
*/
function getDb(): Db {
function getDb(): Db | null {
if (!isBun) {
return null
}
if (!globalDb) {
// 动态导入以避免在 Node.js 环境下解析 bun:sqlite
const { createDb } = require('@/db')
globalDb = createDb()
}
return globalDb
@@ -31,8 +43,10 @@ function getDb(): Db {
* export const list = os.todo.list
* .use(dbProvider)
* .handler(async ({ context }) => {
* // context.db 可
* return context.db.query.todoTable.findMany()
* // context.db 可能为 null (在开发模式下)
* if (context.db) {
* return context.db.query.todoTable.findMany()
* }
* })
* ```
*/

View File

@@ -1,6 +1,6 @@
import * as todo from './handlers/todo'
import * as usage from './handlers/usage'
import { os } from './server'
export const router = os.router({
todo,
usage,
})