🏁 Final commit: Project Token Usage Viewer completed
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as todo from './contracts/todo'
|
||||
import * as usage from './contracts/usage'
|
||||
|
||||
export const contract = {
|
||||
todo,
|
||||
usage,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
31
src/orpc/contracts/usage.ts
Normal file
31
src/orpc/contracts/usage.ts
Normal 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(),
|
||||
}),
|
||||
)
|
||||
@@ -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))
|
||||
})
|
||||
84
src/orpc/handlers/usage.ts
Normal file
84
src/orpc/handlers/usage.ts
Normal 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(),
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user