feat(server): use stripe product as flux pricing (#1640)

This commit is contained in:
RainbowBird
2026-04-12 04:32:42 +08:00
committed by GitHub
parent bc5397b3f5
commit 93888e6935
16 changed files with 1156 additions and 383 deletions
+67
View File
@@ -0,0 +1,67 @@
# Server CLAUDE.md
Agent-facing guide for `apps/server`. Detailed topic docs live in `docs/ai-context/` — read the relevant file before modifying that area.
## Overview
Hono-based Node.js backend. Owns auth, billing, chat sync, LLM gateway forwarding, and observability. **Multi-instance deployed on Railway** — design all features assuming N>1 instances sharing the same Postgres and Redis.
## Deployment Model
- Hosted on **Railway**, multiple instances behind a load balancer.
- Each instance runs one CLI role: `api` or `billing-consumer` (see `src/bin/run.ts`).
- Stateless per-instance: no local state that matters across requests.
- Cross-instance coordination via Redis Pub/Sub (WebSocket broadcast) and Redis Streams (billing events).
- Rate limiting is currently **in-memory** (not distributed) — keep this in mind when adding rate-sensitive features.
## Tech Stack
Hono, Better Auth (OIDC provider, RS256 JWT), Drizzle ORM, PostgreSQL, Redis, Stripe, OpenTelemetry, Valibot, injeca (DI), tsx.
## Commands
```sh
pnpm -F @proj-airi/server dev # dev with dotenvx (.env.local)
pnpm -F @proj-airi/server typecheck
pnpm -F @proj-airi/server exec vitest run # all server tests
pnpm exec vitest run apps/server/src/... # single test file
pnpm -F @proj-airi/server db:generate # drizzle-kit generate
pnpm -F @proj-airi/server db:push # drizzle-kit push
pnpm -F @proj-airi/server auth:generate # better-auth → src/schemas/accounts.ts
```
Local observability: `docker compose -f apps/server/docker-compose.otel.yml up -d`
## Architecture Summary
**Entry & DI**: `src/app.ts` (`createApp()`) → logger, env, OTel, Postgres/Redis, DB migrations, services via `injeca`, routes/middleware. CLI entry `src/bin/run.ts`.
**Layering**:
- **Routes** (`src/routes/`): thin — param validation (Valibot), auth guards, error mapping. No business logic here.
- **Services** (`src/services/`): core business logic and DB transactions.
- **Schemas** (`src/schemas/`): Drizzle table definitions. Migrations in `@proj-airi/server-schema`.
**Middleware chain** (`/api/*`): CORS → hono/logger → optional otel → sessionMiddleware → bodyLimit(1MB) → per-route guards. WebSocket `/ws/chat` registered before bodyLimit.
**Error model**: `ApiError(statusCode, errorCode, message, details)` in `src/utils/error.ts`.
## Key Design Decisions
- **Flux read/write separation**: `FluxService` reads (Redis cache-aside), `BillingService` writes (Postgres tx + Redis Stream XADD). Never put write-balance logic in `flux.ts`.
- **LLM gateway proxy**: `/api/v1/openai` forwards to `GATEWAY_BASE_URL`. Server handles auth/billing/logging — not model execution.
- **Redis is cache + messaging, not truth**: balance cache, app_settings read cache, WS cross-instance pub/sub, billing event streams. Truth is always Postgres.
- **Auth**: Better Auth + OIDC. `sessionMiddleware` fills context but doesn't block; `authGuard` returns 401.
- **Multi-instance safe**: all writes go through Postgres transactions; cross-instance messaging uses Redis Pub/Sub and Streams. No in-process singletons that hold mutable state across requests.
## Detailed Context Docs
See `docs/ai-context/README.md` for the full index. Key files:
- `architecture-overview.md` — entry, DI, assembly, boundaries
- `transport-and-routes.md` — API surface, route→service mapping
- `data-model-and-state.md` — tables, state ownership, caching
- `billing-architecture.md` — Flux/Stripe/outbox/Streams
- `redis-boundaries-and-pubsub.md` — Redis key/channel boundaries
- `auth-and-oidc.md` — auth flows, OIDC, trusted clients
- `config-and-naming-conventions.md` — configKV, naming rules
- `workers-and-runtime.md` — CLI roles, outbox, Streams consumer
- `observability-conventions.md` — OTel naming, custom attributes
@@ -49,6 +49,10 @@ Stream: `billing-events`
- `api` — HTTP 服务
- `billing-consumer` — 消费 Redis Stream,异步写入 transaction log、LLM 请求日志到 DB
### Stripe 定价
Flux 充值定价完全由 Stripe Product/Price 管理,详见 [stripe-pricing.md](stripe-pricing.md)。
## 关键服务
### BillingService (`services/billing-service.ts`)
@@ -0,0 +1,137 @@
# Stripe Pricing Architecture
## 设计决策
Stripe 是 Flux 充值定价的**单一真相源**。服务端不再在 Redis 维护 `FLUX_PACKAGES`,所有 package 信息直接从 Stripe API 获取。
### 为什么不用 Redis 维护 packages
之前的设计在 Redis ConfigKV 中维护 `FLUX_PACKAGES`(含 amount、label、price 等),导致:
- 价格信息在 Stripe 和 Redis 之间重复维护
- currency 硬编码为 USD,无法支持微信支付(需要 CNY/GBP)
- 新增/修改 package 需要同时改 Stripe 和 Redis
现在只需在 Stripe Dashboard 操作 Product/Price,服务端自动同步。
## 数据模型
### Stripe 侧
- **Product** — 代表 "Flux 充值" 这个商品(一个即可)
- **Price** — 代表一个具体的价格方案,每个 Price 包含:
- `unit_amount` + `currency`(如 300 USD = $3
- `currency_options`(可选)— 支持多币种展示,如 `cny: { unit_amount: 2200 }`
- `metadata.fluxAmount` — 购买此 Price 获得的 Flux 数量
- `metadata.recommended` — (可选)设为 `'true'` 时前端会高亮展示为推荐套餐
Product ID 存储在 ConfigKV `STRIPE_FLUX_PRODUCT_ID` 中(运营配置,非环境变量)。
### 多币种支持
通过 Stripe Price 的 `currency_options` 实现。一个 USD Price 可以同时支持 CNY 结算:
- 前端展示所有可用货币(从 `currency_options` 自动提取),用户通过 SelectTab 切换
- 前端 checkout 时传 `{ stripePriceId, currency }` 给服务端
- 服务端在 Checkout Session 上设 `currency` 参数,Stripe 自动用对应 `currency_options` 的金额
- Stripe Checkout 页面根据货币自动展示兼容的支付方式(如 CNY → 微信支付)
**创建带多币种的 Price**
```bash
curl https://api.stripe.com/v1/prices \
-u "$STRIPE_API_KEY:" \
-d "product=prod_xxx" \
-d "unit_amount=300" \
-d "currency=usd" \
-d "metadata[fluxAmount]=500" \
-d "currency_options[cny][unit_amount]=2200"
```
> 注意:Stripe CLI 的 `prices create` 对嵌套参数支持不好,`currency_options` 需要用 `curl` 直接调 API。
### 支付方式
`STRIPE_PAYMENT_METHODS` 在 ConfigKV 中为可选配置:
- **未设置(推荐)**:不传 `payment_method_types`Stripe 根据 Dashboard 设置和货币自动决定
- **已设置**:覆盖 Stripe 自动选择,如 `["card", "wechat_pay", "alipay"]`
如果手动指定了 `wechat_pay`,还需设 `STRIPE_PAYMENT_METHOD_OPTIONS``{"wechat_pay":{"client":"web"}}`
## 缓存
Stripe Price 列表通过 Redis 缓存(key: `cache:stripe:prices`TTL 5 分钟),所有实例共享。
- 命中:直接返回缓存的 Price 列表
- 未命中:调 Stripe API `prices.list` (含 `expand: ['data.currency_options']`),按 `unit_amount` 升序排列后写入缓存
- Checkout 时如果 priceId 不在缓存中,fallback 到 `prices.retrieve` 并 invalidate 缓存
## API 流程
### GET /api/v1/stripe/packages
1. 从 ConfigKV 读取 `STRIPE_FLUX_PRODUCT_ID`
2. 从 Redis 缓存或 Stripe API 获取 active prices
3. 返回每个 price 的所有可用货币价格:
```json
{
"stripePriceId": "price_xxx",
"label": "500 Flux",
"defaultCurrency": "usd",
"currencies": { "usd": "$3.00", "cny": "¥22.00" },
"recommended": false
}
```
### POST /api/v1/stripe/checkout
1. 前端发送 `{ stripePriceId, currency? }`
2. 服务端验证 price 归属和 `fluxAmount` metadata
3. 创建 Checkout Session
- `currency` 参数(如有)让 Stripe 用 `currency_options` 中的金额
- `payment_method_types` 根据 ConfigKV 是否配置决定传或不传
4. Webhook 收到 `checkout.session.completed` 后从 metadata 读取 fluxAmount 充值
## 运营操作
### 新增价格
用 curl 创建带多币种的 PriceStripe CLI 不支持嵌套参数):
```bash
curl https://api.stripe.com/v1/prices \
-u "$STRIPE_API_KEY:" \
-d "product=prod_xxx" \
-d "unit_amount=1200" \
-d "currency=usd" \
-d "metadata[fluxAmount]=2000" \
-d "metadata[recommended]=true" \
-d "currency_options[cny][unit_amount]=8800"
```
无需修改代码或 Redis`/packages` 端点在缓存过期后自动返回新 Price。
### 下架价格
在 Stripe Dashboard 将 Price 设为 inactive,缓存过期后 `/packages` 自动不再返回。
### 修改 Product ID
```bash
redis-cli SET "config:STRIPE_FLUX_PRODUCT_ID" '"prod_new_id"'
```
### 手动清缓存(立即生效)
```bash
redis-cli DEL "cache:stripe:prices"
```
## ConfigKV 配置清单
| Key | 类型 | 默认 | 说明 |
|-----|------|------|------|
| `STRIPE_FLUX_PRODUCT_ID` | `string?` | 无 | Stripe Product ID,未设置时 top-up 不可用 |
| `STRIPE_PAYMENT_METHODS` | `string[]?` | 无 | 不设则 Stripe 自动决定;设了则覆盖 |
| `STRIPE_PAYMENT_METHOD_OPTIONS` | `Record?` | `{}` | 支付方式选项,如 `{"wechat_pay":{"client":"web"}}` |
+7 -2
View File
@@ -132,7 +132,12 @@ export async function buildApp(deps: AppDeps) {
})
.onError((err, c) => {
if (err instanceof ApiError) {
logger.withError(err).warn('API error occurred')
if (err.statusCode >= 500) {
logger.withError(err).error('API error occurred')
}
else if (err.statusCode !== 401) {
logger.withError(err).warn('API error occurred')
}
return c.json({
error: err.errorCode,
@@ -193,7 +198,7 @@ export async function buildApp(deps: AppDeps) {
/**
* Stripe routes.
*/
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.otel?.revenue))
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue))
return { app: builtApp, injectWebSocket }
}
+5
View File
@@ -19,6 +19,11 @@ export function createFluxRoutes(
const flux = await fluxService.getFlux(user.id)
return c.json(flux)
})
.get('/stats', async (c) => {
const user = c.get('user')!
const stats = await fluxTransactionService.getStats(user.id)
return c.json(stats)
})
.get('/history', async (c) => {
const user = c.get('user')!
const { limit, offset } = parse(LimitOffsetPaginationQuerySchema, {
@@ -406,6 +406,46 @@ describe('v1CompletionsRoutes', () => {
})
})
describe('gET /api/v1/openai/audio/models', () => {
it('should return configured TTS model from config', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV({ DEFAULT_TTS_MODEL: 'microsoft/v1' }))
const res = await app.fetch(
new Request('http://localhost/api/v1/openai/audio/models', { method: 'GET' }),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
const data = await res.json() as { models: { id: string, name: string }[] }
expect(data.models).toHaveLength(1)
expect(data.models[0].id).toBe('microsoft/v1')
})
it('should return 401 when unauthenticated', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.request('/api/v1/openai/audio/models', { method: 'GET' })
expect(res.status).toBe(401)
})
it('should return 503 when DEFAULT_TTS_MODEL is not configured', async () => {
const configKV = createMockConfigKV()
configKV.getOptional = vi.fn(async (key: string) => {
if (key === 'DEFAULT_TTS_MODEL')
return null
return (configKV as any).__defaults?.[key] ?? null
})
const app = createTestApp(createMockFluxService(), configKV)
const res = await app.fetch(
new Request('http://localhost/api/v1/openai/audio/models', { method: 'GET' }),
{ user: testUser } as any,
)
expect(res.status).toBe(503)
})
})
describe('route matching', () => {
it('gET /api/v1/openai/chat/completions should return 404', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV())
+149 -33
View File
@@ -1,3 +1,5 @@
import type Redis from 'ioredis'
import type { Env } from '../../libs/env'
import type { RevenueMetrics } from '../../libs/otel'
import type { BillingService } from '../../services/billing/billing-service'
@@ -13,57 +15,158 @@ import { Hono } from 'hono'
import { safeParse } from 'valibot'
import { authGuard } from '../../middlewares/auth'
import { configGuard } from '../../middlewares/config-guard'
import { rateLimiter } from '../../middlewares/rate-limit'
import { createBadRequestError, createServiceUnavailableError } from '../../utils/error'
import { errorMessageFromUnknown } from '../../utils/error-message'
import { resolveTrustedRequestOrigin } from '../../utils/origin'
import { createRedisKey } from '../../utils/redis-keys'
import { CheckoutBodySchema } from './schema'
const logger = useLogger('stripe')
const PRICES_CACHE_KEY = createRedisKey('cache', 'stripe', 'prices')
const PRICES_CACHE_TTL_SEC = 5 * 60
interface CachedCurrencyOption {
unitAmount: number | null
}
interface CachedPrice {
id: string
unitAmount: number | null
currency: string
product: string
active: boolean
metadata: Record<string, string>
currencyOptions: Record<string, CachedCurrencyOption>
}
export function createStripeRoutes(
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
configKV: ConfigKVService,
env: Env,
redis: Redis,
metrics?: RevenueMetrics | null,
) {
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
const fluxConfigGuard = configGuard(configKV, ['FLUX_PACKAGES'], 'Top-up is not available yet')
async function getActivePrices(productId: string): Promise<CachedPrice[]> {
// Try Redis cache first
const cached = await redis.get(PRICES_CACHE_KEY)
if (cached) {
try {
const parsed = JSON.parse(cached) as { productId: string, prices: CachedPrice[] }
if (parsed.productId === productId)
return parsed.prices
}
catch { /* corrupted cache, refetch */ }
}
let result: Stripe.ApiList<Stripe.Price>
try {
result = await stripe!.prices.list({ product: productId, active: true, expand: ['data.currency_options'] })
}
catch (err) {
logger.withError(err).warn('Failed to fetch prices from Stripe')
return []
}
const prices: CachedPrice[] = result.data
.sort((a, b) => (a.unit_amount ?? 0) - (b.unit_amount ?? 0))
.map(p => ({
id: p.id,
unitAmount: p.unit_amount,
currency: p.currency,
product: typeof p.product === 'string' ? p.product : p.product.id,
active: p.active,
metadata: p.metadata,
currencyOptions: Object.fromEntries(
Object.entries(p.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
}))
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ productId, prices }), 'EX', PRICES_CACHE_TTL_SEC)
return prices
}
return new Hono<HonoEnv>()
.get('/packages', async (c) => {
const packages = await configKV.get('FLUX_PACKAGES')
return c.json(packages)
const fluxProductId = await configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!stripe || !fluxProductId)
return c.json([])
const prices = await getActivePrices(fluxProductId)
// Build per-currency price map for each package
return c.json(prices.map((p) => {
const currencies: Record<string, string> = {
[p.currency]: formatPrice(p.unitAmount, p.currency),
}
for (const [cur, opt] of Object.entries(p.currencyOptions)) {
currencies[cur] = formatPrice(opt.unitAmount, cur)
}
return {
stripePriceId: p.id,
label: `${p.metadata.fluxAmount ?? '?'} Flux`,
defaultCurrency: p.currency,
currencies,
recommended: p.metadata.recommended === 'true',
}
}))
})
.post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60 }), fluxConfigGuard, async (c) => {
if (!stripe)
.post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60 }), async (c) => {
const fluxProductId = await configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!stripe || !fluxProductId)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const user = c.get('user')!
const body = await c.req.json()
const maxCheckoutAmount = await configKV.get('MAX_CHECKOUT_AMOUNT_CENTS')
const result = safeParse(CheckoutBodySchema, body)
if (!result.success)
throw createBadRequestError('Invalid checkout amount', 'INVALID_REQUEST', result.issues)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', result.issues)
const { amount } = result.output
if (amount > maxCheckoutAmount) {
throw createBadRequestError('Invalid checkout amount', 'INVALID_REQUEST', {
amount,
maxCheckoutAmount,
})
const { stripePriceId, currency } = result.output
// Validate against cached prices first, fall back to direct Stripe API
const cachedPrices = await getActivePrices(fluxProductId)
let price = cachedPrices.find(p => p.id === stripePriceId)
if (!price) {
// Cache miss — price may have just been created
let fetched: Stripe.Price
try {
fetched = await stripe.prices.retrieve(stripePriceId)
}
catch {
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
}
if (!fetched.active || (typeof fetched.product === 'string' ? fetched.product : fetched.product.id) !== fluxProductId) {
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
}
price = {
id: fetched.id,
unitAmount: fetched.unit_amount,
currency: fetched.currency,
product: typeof fetched.product === 'string' ? fetched.product : fetched.product.id,
active: fetched.active,
metadata: fetched.metadata,
currencyOptions: Object.fromEntries(
Object.entries(fetched.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
}
// Invalidate cache so all instances pick up the new price
await redis.del(PRICES_CACHE_KEY)
}
// Match amount to a configured package so we know the fluxAmount
const packages = await configKV.get('FLUX_PACKAGES')
const pkg = packages.find(p => p.amount === amount)
if (!pkg) {
throw createBadRequestError('No matching package for the given amount', 'INVALID_PACKAGE', { amount })
const fluxAmount = Number(price.metadata.fluxAmount)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
throw createBadRequestError('Price is missing fluxAmount metadata', 'INVALID_PACKAGE', { stripePriceId })
}
// Reuse existing stripe customer if available
@@ -75,20 +178,17 @@ export function createStripeRoutes(
throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN')
}
const paymentMethods = await configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Flux Top-up',
},
unit_amount: amount,
},
quantity: 1,
},
],
// When STRIPE_PAYMENT_METHODS is not set, omit payment_method_types to let Stripe
// automatically determine available methods based on currency and Dashboard settings
...(paymentMethods && { payment_method_types: paymentMethods as any }),
...(Object.keys(paymentMethodOptions).length > 0 && { payment_method_options: paymentMethodOptions as any }),
// When currency is specified, Stripe uses the matching currency_options on the Price
...(currency && { currency }),
line_items: [{ price: stripePriceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: `${redirectBase}/settings/flux?success=true`,
@@ -97,7 +197,7 @@ export function createStripeRoutes(
customer_email: stripeCustomerId ? undefined : user.email,
metadata: {
userId: user.id,
fluxAmount: String(pkg.fluxAmount),
fluxAmount: String(fluxAmount),
},
})
@@ -377,3 +477,19 @@ async function handleInvoiceEvent(
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
}
}
/** Format Stripe smallest-unit amount into a human-readable price string */
export function formatPrice(unitAmount: number | null, currency: string): string {
if (unitAmount == null)
return currency.toUpperCase()
try {
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency })
const fractionDigits = formatter.resolvedOptions().minimumFractionDigits ?? 2
const amount = unitAmount / (10 ** fractionDigits)
return formatter.format(amount)
}
catch {
return `${unitAmount / 100} ${currency.toUpperCase()}`
}
}
+63 -113
View File
@@ -8,7 +8,7 @@ import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createStripeRoutes } from '.'
import { createStripeRoutes, formatPrice } from '.'
import { ApiError } from '../../utils/error'
// --- Mock helpers ---
@@ -46,8 +46,8 @@ function createMockBillingService(): BillingService {
function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVService {
const defaults: Record<string, any> = {
FLUX_PACKAGES: [{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' }],
MAX_CHECKOUT_AMOUNT_CENTS: 1_000_000,
STRIPE_FLUX_PRODUCT_ID: 'prod_test_flux',
STRIPE_PAYMENT_METHODS: ['card'],
...overrides,
}
return {
@@ -62,6 +62,15 @@ function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVServic
} as any
}
function createMockRedis(): any {
const store = new Map<string, string>()
return {
get: vi.fn(async (key: string) => store.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => { store.set(key, value) }),
del: vi.fn(async (key: string) => { store.delete(key) }),
}
}
const testEnv = {
STRIPE_SECRET_KEY: 'sk_test_fake',
STRIPE_WEBHOOK_SECRET: 'whsec_test_fake',
@@ -123,8 +132,9 @@ function createTestApp(
stripeService: StripeService,
billingService: BillingService,
configKV: ConfigKVService,
envOverrides: Record<string, any> = {},
) {
const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, testEnv)
const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, { ...testEnv, ...envOverrides }, createMockRedis())
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
@@ -153,30 +163,43 @@ function createTestApp(
// --- Tests ---
describe('formatPrice', () => {
it('formats USD cents correctly', () => {
expect(formatPrice(300, 'usd')).toBe('$3.00')
expect(formatPrice(1200, 'usd')).toBe('$12.00')
expect(formatPrice(2500, 'usd')).toBe('$25.00')
})
it('formats CNY cents correctly', () => {
expect(formatPrice(2100, 'cny')).toBe('CN¥21.00')
})
it('formats JPY (zero-decimal currency) correctly', () => {
expect(formatPrice(500, 'jpy')).toBe('¥500')
})
it('formats GBP correctly', () => {
expect(formatPrice(1599, 'gbp')).toBe('£15.99')
})
it('returns currency code for null amount', () => {
expect(formatPrice(null, 'usd')).toBe('USD')
})
it('handles zero amount', () => {
expect(formatPrice(0, 'usd')).toBe('$0.00')
})
})
describe('stripeRoutes', () => {
describe('gET /api/v1/stripe/packages', () => {
it('returns configured packages', async () => {
it('returns empty array when Stripe is not configured', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.request('/api/v1/stripe/packages')
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toEqual([{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' }])
})
it('returns empty array when no packages configured', async () => {
const configKV = createMockConfigKV({ FLUX_PACKAGES: [] })
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
configKV,
createMockConfigKV({ STRIPE_FLUX_PRODUCT_ID: undefined }),
{ STRIPE_SECRET_KEY: '' },
)
const res = await app.request('/api/v1/stripe/packages')
@@ -197,12 +220,12 @@ describe('stripeRoutes', () => {
const res = await app.request('/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 500 }),
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
})
expect(res.status).toBe(401)
})
it('returns 400 for invalid amount (zero)', async () => {
it('returns 400 for empty stripePriceId', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
@@ -214,14 +237,14 @@ describe('stripeRoutes', () => {
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 0 }),
body: JSON.stringify({ stripePriceId: '' }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(400)
})
it('returns 400 for invalid amount (negative)', async () => {
it('returns 400 for missing stripePriceId', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
@@ -233,65 +256,7 @@ describe('stripeRoutes', () => {
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: -100 }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(400)
})
it('returns 400 for amount exceeding max ($10,000)', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 1_000_001 }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(400)
})
it('respects configured max checkout amount', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV({ MAX_CHECKOUT_AMOUNT_CENTS: 500 }),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 501 }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(400)
})
it('returns 400 for non-integer amount', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 9.99 }),
body: JSON.stringify({}),
}),
{ user: testUser } as any,
)
@@ -299,30 +264,22 @@ describe('stripeRoutes', () => {
})
it('returns 503 when Stripe is not configured', async () => {
const routes = createStripeRoutes(
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
{ ...testEnv, STRIPE_SECRET_KEY: '' } as any,
createMockConfigKV({ STRIPE_FLUX_PRODUCT_ID: undefined }),
{ STRIPE_SECRET_KEY: '' },
)
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
if (err instanceof ApiError)
return c.json({ error: err.errorCode }, err.statusCode)
return c.json({ error: 'Internal Server Error' }, 500)
})
app.use('*', async (c, next) => {
c.set('user', testUser as any)
await next()
})
app.route('/api/v1/stripe', routes)
const res = await app.request('/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 500 }),
})
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(503)
})
})
@@ -478,20 +435,13 @@ describe('stripeRoutes', () => {
})
it('returns 503 when Stripe is not configured', async () => {
const routes = createStripeRoutes(
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
{ ...testEnv, STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '' } as any,
{ STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '' },
)
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
if (err instanceof ApiError)
return c.json({ error: err.errorCode }, err.statusCode)
return c.json({ error: 'Internal Server Error' }, 500)
})
app.route('/api/v1/stripe', routes)
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
+3 -2
View File
@@ -1,5 +1,6 @@
import { integer, minValue, number, object, pipe } from 'valibot'
import { minLength, object, optional, pipe, string } from 'valibot'
export const CheckoutBodySchema = object({
amount: pipe(number(), integer(), minValue(1)),
stripePriceId: pipe(string(), minLength(1)),
currency: optional(string()),
})
+6 -16
View File
@@ -1,24 +1,11 @@
import type Redis from 'ioredis'
import type { InferOutput } from 'valibot'
import { array, number, object, optional, parse, string } from 'valibot'
import { any, array, number, optional, parse, record, string } from 'valibot'
import { createServiceUnavailableError } from '../utils/error'
import { configRedisKey } from '../utils/redis-keys'
export interface FluxPackage {
/** Amount in cents sent to Stripe */
amount: number
/** How much Flux the buyer receives for this package */
fluxAmount: number
/** Display label, e.g. "500 Flux" */
label: string
/** Display price, e.g. "$5" */
price: string
}
const FluxPackageSchema = object({ amount: number(), fluxAmount: number(), label: string(), price: string() })
/**
* Config entry schemas are the single source of truth for:
* - runtime validation
@@ -30,13 +17,16 @@ const ConfigEntrySchemas = {
FLUX_PER_REQUEST_TTS: number(),
FLUX_PER_REQUEST_ASR: number(),
INITIAL_USER_FLUX: optional(number(), 0),
FLUX_PACKAGES: optional(array(FluxPackageSchema), []),
FLUX_PER_1K_TOKENS: optional(number(), 1),
MAX_CHECKOUT_AMOUNT_CENTS: optional(number(), 1_000_000),
GATEWAY_BASE_URL: string(),
DEFAULT_CHAT_MODEL: string(),
AUTH_RATE_LIMIT_MAX: optional(number(), 20),
AUTH_RATE_LIMIT_WINDOW_SEC: optional(number(), 60),
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
// No default — absent lets Stripe auto-select payment methods via Dashboard config
STRIPE_PAYMENT_METHODS: optional(array(string())),
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
} as const
type ConfigDefinitions = {
+25 -1
View File
@@ -1,7 +1,7 @@
import type { Database } from '../libs/db'
import { useLogger } from '@guiiai/logg'
import { desc, eq } from 'drizzle-orm'
import { desc, eq, sql } from 'drizzle-orm'
import * as schema from '../schemas/flux-transaction'
@@ -46,6 +46,30 @@ export function createFluxTransactionService(db: Database) {
return { records, hasMore }
},
async getStats(userId: string) {
const rows = await db.select({
type: schema.fluxTransaction.type,
total: sql<number>`sum(${schema.fluxTransaction.amount})`.mapWith(Number),
})
.from(schema.fluxTransaction)
.where(eq(schema.fluxTransaction.userId, userId))
.groupBy(schema.fluxTransaction.type)
let totalReceived = 0
let totalConsumed = 0
for (const row of rows) {
if (row.type === 'credit' || row.type === 'initial') {
totalReceived += row.total
}
else if (row.type === 'debit') {
totalConsumed += row.total
}
}
return { totalReceived, totalConsumed }
},
}
}
@@ -84,49 +84,6 @@ describe('configKVService', () => {
expect(value).toBe(500)
})
// --- FLUX_PACKAGES (JSON) ---
it('get FLUX_PACKAGES should parse JSON array', async () => {
const packages = [
{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' },
{ amount: 1000, fluxAmount: 12000, label: '12000 Flux', price: '$10' },
]
redis._store.set(configRedisKey('FLUX_PACKAGES'), JSON.stringify(packages))
const value = await service.getOrThrow('FLUX_PACKAGES')
expect(value).toEqual(packages)
})
it('set FLUX_PACKAGES should serialize as JSON', async () => {
const packages = [{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' }]
await service.set('FLUX_PACKAGES', packages)
const stored = redis._store.get(configRedisKey('FLUX_PACKAGES'))
expect(stored).toBe(JSON.stringify(packages))
})
it('fLUX_PACKAGES round-trip should preserve structure', async () => {
const packages = [
{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' },
{ amount: 1000, fluxAmount: 12000, label: '12000 Flux', price: '$10' },
{ amount: 5000, fluxAmount: 75000, label: '75000 Flux', price: '$50' },
]
await service.set('FLUX_PACKAGES', packages)
const value = await service.getOrThrow('FLUX_PACKAGES')
expect(value).toEqual(packages)
})
it('getOptional FLUX_PACKAGES should return schema default when not set', async () => {
const value = await service.getOptional('FLUX_PACKAGES')
expect(value).toEqual([])
})
it('get MAX_CHECKOUT_AMOUNT_CENTS should return schema default when not set', async () => {
const value = await service.get('MAX_CHECKOUT_AMOUNT_CENTS')
expect(value).toBe(1_000_000)
})
it('set should store string values as JSON strings', async () => {
await service.set('GATEWAY_BASE_URL', 'https://gateway.example.com')
@@ -568,6 +568,7 @@ pages:
flux:
title: Flux
buy: Charge
currency: Currency
description: Current Flux
packagesError: Failed to load packages. Please try again later.
checkout:
@@ -587,6 +588,7 @@ pages:
empty: No transaction records yet.
loadMore: Load More
delayHint: Usage records may be delayed by up to 1 minute
ttsRequests: requests
packages:
title: Flux Packages
buy: Charge
@@ -538,6 +538,7 @@ pages:
flux:
title: Flux
buy: 充值
currency: 货币
description: 充值 Flux
packagesError: 暂时无法获取可用的 Flux 电量包。可以之后再试试吗?
checkout:
@@ -557,6 +558,7 @@ pages:
empty: 暂无消耗记录
loadMore: 更多信息
delayHint: Flux 电量历史记录是缓慢更新的,可能有延迟
ttsRequests: 次请求
packages:
title: Flux 电池包
buy: 充值
+388 -106
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { client } from '@proj-airi/stage-ui/composables/api'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Button } from '@proj-airi/ui'
import { Button, SelectTab } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -13,9 +13,27 @@ const router = useRouter()
const authStore = useAuthStore()
const { credits } = storeToRefs(authStore)
const loadingAmount = ref<number | null>(null)
interface FluxPackage {
stripePriceId: string
label: string
defaultCurrency: string
currencies: Record<string, string>
}
const loadingPriceId = ref<string | null>(null)
const message = ref<{ type: 'success' | 'error', text: string } | null>(null)
const packages = ref<{ amount: number, label: string, price: string }[]>([])
const packages = ref<FluxPackage[]>([])
const selectedCurrency = ref<string>('usd')
const currencyOptions = computed(() => {
if (packages.value.length === 0)
return []
// Currencies supported by all packages
const first = Object.keys(packages.value[0].currencies)
return first
.filter(c => packages.value.every(p => c in p.currencies))
.map(c => ({ label: c.toUpperCase(), value: c }))
})
// NOTICE: Manual interface instead of hono InferResponseType because hono client
// type instantiation hits TS recursion limits ("excessively deep and possibly infinite").
@@ -29,10 +47,15 @@ interface AuditRecord {
createdAt: string
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
/** Display amount with sign: debit is negative, credit/initial are positive */
function displayAmount(record: AuditRecord): string {
const signed = record.type === 'debit' ? -record.amount : record.amount
return signed >= 0 ? `+${signed}` : String(signed)
const formatted = formatNumber(Math.abs(signed))
return signed >= 0 ? `+${formatted}` : `-${formatted}`
}
function isPositive(record: AuditRecord): boolean {
@@ -45,6 +68,30 @@ const auditHasMore = ref(false)
const auditOffset = ref(0)
const AUDIT_PAGE_SIZE = 20
const totalReceived = ref(0)
const totalConsumed = ref(0)
const fluxPercentage = computed(() => {
if (totalReceived.value === 0)
return credits.value > 0 ? 100 : 0
const remaining = Math.max(0, totalReceived.value - totalConsumed.value)
return Math.min(100, Math.round((remaining / totalReceived.value) * 100))
})
async function fetchStats() {
try {
const res = await client.api.v1.flux.stats.$get()
if (res.ok) {
const data = await res.json()
totalReceived.value = data.totalReceived
totalConsumed.value = data.totalConsumed
}
}
catch {
// silently fail
}
}
async function fetchAuditHistory(loadMore = false) {
auditLoading.value = true
try {
@@ -76,11 +123,83 @@ function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
// Group consecutive TTS debit records into collapsible rows
type GroupedRow = {
type: 'single'
record: AuditRecord
} | {
type: 'group'
key: string
description: string
model: string
count: number
totalAmount: number
firstTime: string
lastTime: string
records: AuditRecord[]
}
const expandedGroups = ref<Set<string>>(new Set())
function toggleGroup(key: string) {
if (expandedGroups.value.has(key))
expandedGroups.value.delete(key)
else
expandedGroups.value.add(key)
}
const groupedRows = computed<GroupedRow[]>(() => {
const rows: GroupedRow[] = []
let i = 0
const records = auditRecords.value
while (i < records.length) {
const record = records[i]
if (record.type === 'debit' && record.description?.startsWith('tts:')) {
// Collect consecutive TTS records with the same description
const group: AuditRecord[] = [record]
while (i + 1 < records.length
&& records[i + 1].type === 'debit'
&& records[i + 1].description === record.description) {
i++
group.push(records[i])
}
if (group.length > 1) {
rows.push({
type: 'group',
key: `tts-group-${record.id}`,
description: record.description,
model: (record.metadata?.model as string) || '',
count: group.length,
totalAmount: group.reduce((sum, r) => sum + r.amount, 0),
firstTime: group.at(-1).createdAt,
lastTime: group[0].createdAt,
records: group,
})
}
else {
rows.push({ type: 'single', record })
}
}
else {
rows.push({ type: 'single', record })
}
i++
}
return rows
})
async function fetchPackages() {
try {
const res = await client.api.v1.stripe.packages.$get()
if (res.ok)
packages.value = await res.json() as { amount: number, label: string, price: string }[]
if (res.ok) {
const data = await res.json() as FluxPackage[]
packages.value = data
if (data.length > 0)
selectedCurrency.value = data[0].defaultCurrency
}
}
catch {
message.value = { type: 'error', text: t('settings.pages.flux.packagesError') }
@@ -88,7 +207,7 @@ async function fetchPackages() {
}
onMounted(async () => {
Promise.allSettled([fetchPackages(), authStore.updateCredits(), fetchAuditHistory()])
Promise.allSettled([fetchPackages(), authStore.updateCredits(), fetchStats(), fetchAuditHistory()])
if (route.query.success === 'true') {
message.value = { type: 'success', text: t('settings.pages.flux.checkout.success') }
@@ -100,11 +219,11 @@ onMounted(async () => {
}
})
async function handleBuy(amount: number) {
loadingAmount.value = amount
async function handleBuy(stripePriceId: string) {
loadingPriceId.value = stripePriceId
message.value = null
try {
const res = await client.api.v1.stripe.checkout.$post({ json: { amount } })
const res = await client.api.v1.stripe.checkout.$post({ json: { stripePriceId, currency: selectedCurrency.value } })
if (!res.ok) {
const data = await res.json() as { error?: string, message?: string }
message.value = { type: 'error', text: data.message || t('settings.pages.flux.checkout.error') }
@@ -119,7 +238,7 @@ async function handleBuy(amount: number) {
message.value = { type: 'error', text: t('settings.pages.flux.checkout.error') }
}
finally {
loadingAmount.value = null
loadingPriceId.value = null
}
}
</script>
@@ -137,34 +256,67 @@ async function handleBuy(amount: number) {
{{ message.text }}
</div>
<div bg="primary-500/10 dark:primary-400/10" rounded-xl p-6 text-center>
<div i-solar:battery-charge-bold-duotone mx-auto size-16 text-primary-500 />
<h2 mt-4 text-3xl font-bold>
{{ credits }}
</h2>
<p text="sm neutral-500">
{{ t('settings.pages.flux.description') }}
</p>
<!-- Battery Card -->
<div relative overflow-hidden rounded-2xl bg="neutral-100 dark:neutral-800" p-8 text-center>
<!-- Background Progress -->
<div
class="flux-progress-bar absolute inset-y-0 left-0 bg-primary-500/20 dark:bg-primary-400/20"
/>
<!-- Content -->
<div relative z-1>
<div i-solar:battery-charge-bold-duotone mx-auto size-12 text-primary-500 />
<h2 mt-2 text-4xl font-bold tracking-tight>
{{ formatNumber(credits) }}
</h2>
<p text="sm neutral-500">
{{ t('settings.pages.flux.description') }}
</p>
</div>
</div>
<div grid="~ cols-1 sm:cols-3 gap-4">
<div
v-for="pkg in packages" :key="pkg.amount"
border="1 neutral-200 dark:neutral-800" flex="~ col gap-2" items-center rounded-xl p-4
>
<div font-bold>
{{ pkg.label }}
</div>
<div text="2xl" font-bold>
{{ pkg.price }}
</div>
<Button
:label="t('settings.pages.flux.buy')"
:loading="loadingAmount === pkg.amount"
:disabled="loadingAmount !== null && loadingAmount !== pkg.amount"
@click="handleBuy(pkg.amount)"
<div flex="~ col gap-4">
<!-- Currency selector -->
<div v-if="currencyOptions.length > 1" flex="~ justify-end">
<SelectTab
v-model="selectedCurrency"
:options="currencyOptions"
size="sm"
/>
</div>
<div grid="~ cols-1 sm:cols-3 gap-4">
<button
v-for="pkg in packages" :key="pkg.stripePriceId"
:disabled="loadingPriceId !== null"
:class="[
'group relative flex flex-col items-center gap-2 overflow-hidden',
'rounded-2xl border border-neutral-200 bg-white p-6 transition-all duration-300 ease-out',
'dark:border-neutral-800 dark:bg-neutral-900',
'hover:-translate-y-1 hover:border-primary-400 hover:shadow-md dark:hover:border-primary-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
loadingPriceId !== null && loadingPriceId !== pkg.stripePriceId ? 'opacity-50 grayscale-50 cursor-not-allowed' : 'cursor-pointer',
]"
@click="handleBuy(pkg.stripePriceId)"
>
<!-- Loading Overlay -->
<div
v-if="loadingPriceId === pkg.stripePriceId"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-sm dark:bg-neutral-900/60"
>
<div class="i-svg-spinners:90-ring-with-bg size-8 text-primary-500" />
</div>
<div text="sm neutral-500 dark:neutral-400" font-medium transition-colors class="group-hover:text-primary-600 dark:group-hover:text-primary-400">
{{ pkg.label }}
</div>
<div flex="~ items-baseline gap-1">
<span text="2xl neutral-800 dark:neutral-100" font-bold>
{{ pkg.currencies[selectedCurrency] ?? pkg.currencies[pkg.defaultCurrency] }}
</span>
</div>
</button>
</div>
</div>
<!-- Audit History -->
@@ -206,84 +358,196 @@ async function handleBuy(amount: number) {
</tr>
</thead>
<tbody>
<tr
v-for="record in auditRecords"
:key="record.id"
border="b neutral-100 dark:neutral-800/50 last:none"
>
<td whitespace-nowrap px-4 py-3 text="neutral-500">
{{ formatDate(record.createdAt) }}
</td>
<td px-4 py-3>
<span
inline-block rounded-full px-2 py-0.5 text-xs font-medium
:class="record.type === 'debit'
? 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
: 'bg-green-500/10 text-green-600 dark:text-green-400'"
>
{{ record.type === 'debit'
? t('settings.pages.flux.audit.typeConsumption')
: record.type === 'credit'
? t('settings.pages.flux.audit.typeAddition')
: t('settings.pages.flux.audit.typeInitial') }}
</span>
</td>
<td px-4 py-3>
<span>{{ record.description }}</span>
<span
v-if="record.metadata?.promptTokens != null"
ml-1 text="xs neutral-400"
>
({{ record.metadata.promptTokens }}+{{ record.metadata.completionTokens }} tokens)
</span>
</td>
<td px-4 py-3 text-right font-mono>
<span :class="isPositive(record) ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
{{ displayAmount(record) }}
</span>
</td>
</tr>
<template v-for="row in groupedRows" :key="row.type === 'single' ? row.record.id : row.key">
<!-- Single record -->
<tr
v-if="row.type === 'single'"
border="b neutral-100 dark:neutral-800/50 last:none"
>
<td whitespace-nowrap px-4 py-3 text="neutral-500">
{{ formatDate(row.record.createdAt) }}
</td>
<td px-4 py-3>
<span
inline-block rounded-full px-2 py-0.5 text-xs font-medium
:class="row.record.type === 'debit'
? 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
: 'bg-green-500/10 text-green-600 dark:text-green-400'"
>
{{ row.record.type === 'debit'
? t('settings.pages.flux.audit.typeConsumption')
: row.record.type === 'credit'
? t('settings.pages.flux.audit.typeAddition')
: t('settings.pages.flux.audit.typeInitial') }}
</span>
</td>
<td px-4 py-3>
<span>{{ row.record.description }}</span>
<span
v-if="row.record.metadata?.promptTokens != null"
ml-1 text="xs neutral-400"
>
({{ row.record.metadata.promptTokens }}+{{ row.record.metadata.completionTokens }} tokens)
</span>
<span
v-else-if="row.record.description?.startsWith('tts:') && row.record.metadata?.model"
ml-1 text="xs neutral-400"
>
({{ row.record.metadata.model }})
</span>
</td>
<td px-4 py-3 text-right font-mono>
<span :class="isPositive(row.record) ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
{{ displayAmount(row.record) }}
</span>
</td>
</tr>
<!-- Grouped TTS records -->
<tr
v-else
:class="['cursor-pointer', 'hover:bg-neutral-50', 'dark:hover:bg-neutral-800/30']"
border="b neutral-100 dark:neutral-800/50"
@click="toggleGroup(row.key)"
>
<td whitespace-nowrap px-4 py-3 text="neutral-500">
{{ formatDate(row.lastTime) }}
</td>
<td px-4 py-3>
<span
:class="['inline-block', 'rounded-full', 'px-2', 'py-0.5', 'text-xs', 'font-medium',
'bg-orange-500/10', 'text-orange-600', 'dark:text-orange-400']"
>
{{ t('settings.pages.flux.audit.typeConsumption') }}
</span>
</td>
<td px-4 py-3>
<span flex="~ items-center gap-1">
<span
:class="expandedGroups.has(row.key) ? 'i-solar:alt-arrow-down-line-duotone' : 'i-solar:alt-arrow-right-line-duotone'"
inline-block size-4 text="neutral-400"
/>
{{ row.description }}
<span ml-1 text="xs neutral-400">
({{ row.count }} {{ t('settings.pages.flux.audit.ttsRequests') }})
</span>
</span>
</td>
<td px-4 py-3 text-right font-mono>
<span text="orange-600 dark:orange-400">
-{{ row.totalAmount }}
</span>
</td>
</tr>
<!-- Expanded group children -->
<tr
v-for="child in (row.type === 'group' && expandedGroups.has(row.key) ? row.records : [])"
:key="child.id"
border="b neutral-100 dark:neutral-800/50 last:none" bg="neutral-50/50 dark:neutral-800/20"
>
<td whitespace-nowrap px-4 py-2 pl-8 text="xs neutral-400">
{{ formatDate(child.createdAt) }}
</td>
<td px-4 py-2 />
<td px-4 py-2 text="xs neutral-400">
{{ child.description }}
</td>
<td px-4 py-2 text-right font-mono text="xs orange-500 dark:orange-400">
-{{ child.amount }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Mobile: card list -->
<div v-if="auditRecords.length > 0" flex="~ col gap-2" sm:hidden>
<div
v-for="record in auditRecords"
:key="record.id"
border="1 neutral-200 dark:neutral-800" flex="~ col gap-1.5" rounded-lg px-3 py-2.5
>
<div flex="~ items-center justify-between">
<span
inline-block rounded-full px-2 py-0.5 text-xs font-medium
:class="record.type === 'debit'
? 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
: 'bg-green-500/10 text-green-600 dark:text-green-400'"
>
{{ record.type === 'debit'
? t('settings.pages.flux.audit.typeConsumption')
: record.type === 'credit'
? t('settings.pages.flux.audit.typeAddition')
: t('settings.pages.flux.audit.typeInitial') }}
</span>
<span text-sm font-semibold font-mono :class="isPositive(record) ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
{{ displayAmount(record) }}
</span>
<template v-for="row in groupedRows" :key="row.type === 'single' ? row.record.id : row.key">
<!-- Single record card -->
<div
v-if="row.type === 'single'"
border="1 neutral-200 dark:neutral-800" flex="~ col gap-1.5" rounded-lg px-3 py-2.5
>
<div flex="~ items-center justify-between">
<span
inline-block rounded-full px-2 py-0.5 text-xs font-medium
:class="row.record.type === 'debit'
? 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
: 'bg-green-500/10 text-green-600 dark:text-green-400'"
>
{{ row.record.type === 'debit'
? t('settings.pages.flux.audit.typeConsumption')
: row.record.type === 'credit'
? t('settings.pages.flux.audit.typeAddition')
: t('settings.pages.flux.audit.typeInitial') }}
</span>
<span text-sm font-semibold font-mono :class="isPositive(row.record) ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
{{ displayAmount(row.record) }}
</span>
</div>
<div text="sm neutral-600 dark:neutral-300" truncate>
{{ row.record.description }}
<span
v-if="row.record.metadata?.promptTokens != null"
ml-1 text="xs neutral-400"
>
({{ row.record.metadata.promptTokens }}+{{ row.record.metadata.completionTokens }} tokens)
</span>
<span
v-else-if="row.record.description?.startsWith('tts:') && row.record.metadata?.model"
ml-1 text="xs neutral-400"
>
({{ row.record.metadata.model }})
</span>
</div>
<div text="xs neutral-400">
{{ formatDate(row.record.createdAt) }}
</div>
</div>
<div text="sm neutral-600 dark:neutral-300" truncate>
{{ record.description }}
<span
v-if="record.metadata?.promptTokens != null"
ml-1 text="xs neutral-400"
>
({{ record.metadata.promptTokens }}+{{ record.metadata.completionTokens }} tokens)
</span>
<!-- Grouped TTS card -->
<div
v-else
border="1 neutral-200 dark:neutral-800" flex="~ col gap-1.5" cursor-pointer rounded-lg px-3 py-2.5
@click="toggleGroup(row.key)"
>
<div flex="~ items-center justify-between">
<span
:class="['inline-block', 'rounded-full', 'px-2', 'py-0.5', 'text-xs', 'font-medium',
'bg-orange-500/10', 'text-orange-600', 'dark:text-orange-400']"
>
{{ t('settings.pages.flux.audit.typeConsumption') }}
</span>
<span text-sm font-semibold font-mono text="orange-600 dark:orange-400">
-{{ row.totalAmount }}
</span>
</div>
<div flex="~ items-center gap-1" text="sm neutral-600 dark:neutral-300">
<span
:class="expandedGroups.has(row.key) ? 'i-solar:alt-arrow-down-line-duotone' : 'i-solar:alt-arrow-right-line-duotone'"
inline-block size-4 text="neutral-400"
/>
{{ row.description }}
<span text="xs neutral-400">({{ row.count }} {{ t('settings.pages.flux.audit.ttsRequests') }})</span>
</div>
<div text="xs neutral-400">
{{ formatDate(row.lastTime) }}
</div>
<!-- Expanded children -->
<div v-if="row.type === 'group' && expandedGroups.has(row.key)" flex="~ col gap-1" mt-1 border="t neutral-200 dark:neutral-700" pt-2>
<div
v-for="child in row.records" :key="child.id"
flex="~ items-center justify-between" text="xs neutral-400"
>
<span>{{ formatDate(child.createdAt) }}</span>
<span font-mono>-{{ child.amount }}</span>
</div>
</div>
</div>
<div text="xs neutral-400">
{{ formatDate(record.createdAt) }}
</div>
</div>
</template>
</div>
<div v-if="auditHasMore" text-center>
@@ -297,6 +561,24 @@ async function handleBuy(amount: number) {
</div>
</template>
<style scoped>
.flux-progress-bar {
width: 100%;
animation: flux-progress-bar-grow 1s cubic-bezier(0.4, 0, 0.2, 1) 0.5s forwards;
}
@keyframes flux-progress-bar-grow {
0% {
width: 100%;
opacity: 0.5;
}
100% {
width: v-bind('`${fluxPercentage}%`');
opacity: 1;
}
}
</style>
<route lang="yaml">
meta:
layout: settings
+258 -67
View File
@@ -1375,7 +1375,7 @@ importers:
version: 3.0.2(electron@40.8.5)
'@electron-toolkit/tsconfig':
specifier: ^2.0.0
version: 2.0.0(@types/node@25.6.0)
version: 2.0.0(@types/node@24.12.2)
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@40.8.5)
@@ -1414,7 +1414,7 @@ importers:
version: 3.1.0
'@intlify/unplugin-vue-i18n':
specifier: ^11.0.7
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
@@ -1450,10 +1450,10 @@ importers:
version: link:../../packages/ui-transitions
'@proj-airi/unplugin-fetch':
specifier: 'catalog:'
version: 0.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 0.2.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@proj-airi/unplugin-live2d-sdk':
specifier: ^0.1.6
version: 0.1.6(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 0.1.6(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@types/audioworklet':
specifier: 'catalog:'
version: 0.0.97
@@ -1477,7 +1477,7 @@ importers:
version: 2.10.3
'@vitejs/plugin-vue':
specifier: ^6.0.5
version: 6.0.5(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 6.0.5(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar':
specifier: ^3.1.2
version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
@@ -1507,7 +1507,7 @@ importers:
version: 6.8.3
electron-vite:
specifier: ^5.0.0
version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
get-port-please:
specifier: 'catalog:'
version: 3.2.0
@@ -1528,34 +1528,34 @@ importers:
version: 2.2.6
unocss-preset-scrollbar:
specifier: ^4.0.0
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
unplugin-info:
specifier: ^1.3.2
version: 1.3.2(esbuild@0.27.7)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 1.3.2(esbuild@0.27.7)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-vue-router:
specifier: ^0.19.2
version: 0.19.2(@vue/compiler-sfc@3.5.32)(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
unplugin-yaml:
specifier: ^4.1.0
version: 4.1.0(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.0(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite:
specifier: 'catalog:'
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-bundle-visualizer:
specifier: ^1.2.1
version: 1.2.1(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)
vite-plugin-mkcert:
specifier: 'catalog:'
version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-devtools:
specifier: ^8.1.1
version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
vite-plugin-vue-layouts:
specifier: ^0.11.0
version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue-macros:
specifier: ^3.1.2
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
vue-tsc:
specifier: ^3.2.6
version: 3.2.6(typescript@5.9.3)
@@ -19214,9 +19214,9 @@ snapshots:
dependencies:
electron: 40.8.5
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)':
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
dependencies:
'@types/node': 25.6.0
'@types/node': 24.12.2
'@electron-toolkit/utils@4.0.0(electron@40.8.5)':
dependencies:
@@ -20102,6 +20102,31 @@ snapshots:
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1))
'@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))
'@intlify/shared': 11.3.2
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
'@typescript-eslint/scope-manager': 8.58.1
'@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
pathe: 2.0.3
picocolors: 1.1.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/compiler-dom'
- eslint
- rollup
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1))
@@ -22072,11 +22097,34 @@ snapshots:
transitivePeerDependencies:
- magicast
'@proj-airi/unplugin-fetch@0.2.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-fetch@0.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-live2d-sdk@0.1.6(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
yauzl: 3.3.0
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
'@proj-airi/unplugin-live2d-sdk@0.1.6(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
@@ -23024,6 +23072,7 @@ snapshots:
'@types/node@25.6.0':
dependencies:
undici-types: 7.19.2
optional: true
'@types/nprogress@0.2.3': {}
@@ -23049,7 +23098,7 @@ snapshots:
'@types/pg@8.20.0':
dependencies:
'@types/node': 25.6.0
'@types/node': 24.12.2
pg-protocol: 1.13.0
pg-types: 2.2.0
@@ -23110,7 +23159,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.6.0
'@types/node': 24.12.2
'@types/xast@2.0.4':
dependencies:
@@ -23118,7 +23167,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.6.0
'@types/node': 24.12.2
'@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
@@ -23460,19 +23509,6 @@ snapshots:
unplugin-utils: 0.3.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@unocss/vite@66.6.8(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@jridgewell/remapping': 2.3.5
'@unocss/config': 66.6.8
'@unocss/core': 66.6.8
'@unocss/inspector': 66.6.8
chokidar: 5.0.0
magic-string: 0.30.21
pathe: 2.0.3
tinyglobby: 0.2.16
unplugin-utils: 0.3.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@unrteljs/eval@0.2.1':
dependencies:
builtin-modules: 5.0.0
@@ -23596,6 +23632,12 @@ snapshots:
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.5(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.5(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
@@ -23898,6 +23940,15 @@ snapshots:
transitivePeerDependencies:
- vue
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- typescript
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
@@ -26002,7 +26053,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
@@ -26010,7 +26061,7 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -26780,8 +26831,7 @@ snapshots:
extsprintf@1.3.0: {}
extsprintf@1.4.1:
optional: true
extsprintf@1.4.1: {}
fast-deep-equal@3.1.3: {}
@@ -31841,7 +31891,8 @@ snapshots:
undici-types@7.16.0: {}
undici-types@7.19.2: {}
undici-types@7.19.2:
optional: true
undici@6.24.1: {}
@@ -31951,11 +32002,6 @@ snapshots:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))):
dependencies:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@unocss/cli': 66.6.8
@@ -32004,30 +32050,6 @@ snapshots:
- '@emnapi/runtime'
- vite
unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@unocss/cli': 66.6.8
'@unocss/core': 66.6.8
'@unocss/preset-attributify': 66.6.8
'@unocss/preset-icons': 66.6.8
'@unocss/preset-mini': 66.6.8
'@unocss/preset-tagify': 66.6.8
'@unocss/preset-typography': 66.6.8
'@unocss/preset-uno': 66.6.8
'@unocss/preset-web-fonts': 66.6.8
'@unocss/preset-wind': 66.6.8
'@unocss/preset-wind3': 66.6.8
'@unocss/preset-wind4': 66.6.8
'@unocss/transformer-attributify-jsx': 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
'@unocss/transformer-compile-class': 66.6.8
'@unocss/transformer-directives': 66.6.8
'@unocss/transformer-variant-group': 66.6.8
'@unocss/vite': 66.6.8(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
- vite
unpack-string@0.0.2: {}
unpipe@1.0.0: {}
@@ -32040,6 +32062,14 @@ snapshots:
unplugin: 2.3.11
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.7
rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
rollup: 4.60.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.7
@@ -32074,6 +32104,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.7)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
git-url-parse: 16.1.0
simple-git: 3.35.2
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.7
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.7)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
@@ -32172,6 +32215,17 @@ snapshots:
rollup: 2.80.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-yaml@4.1.0(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
unplugin: 3.0.0
yaml: 2.8.3
optionalDependencies:
esbuild: 0.27.7
rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-yaml@4.1.0(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
@@ -32337,7 +32391,7 @@ snapshots:
dependencies:
assert-plus: 1.0.0
core-util-is: 1.0.2
extsprintf: 1.3.0
extsprintf: 1.4.1
verror@1.10.1:
dependencies:
@@ -32383,12 +32437,22 @@ snapshots:
- rollup
- supports-color
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@@ -32435,6 +32499,21 @@ snapshots:
- tsx
- yaml
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
debug: 4.4.3(supports-color@10.2.2)
error-stack-parser-es: 1.0.5
ohash: 2.0.11
open: 10.2.0
perfect-debounce: 2.1.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
@@ -32466,6 +32545,13 @@ snapshots:
- typescript
- ws
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
supports-color: 10.2.2
undici: 8.0.2
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -32484,6 +32570,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
'@vue/devtools-kit': 8.1.1
'@vue/devtools-shared': 8.1.1
sirv: 3.0.2
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-inspector: 5.4.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
@@ -32498,6 +32598,21 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-inspector@5.4.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0)
'@vue/compiler-dom': 3.5.32
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.4.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
@@ -32513,6 +32628,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
vue-router: 5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -32541,6 +32666,24 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.9
rollup: 4.60.1
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 2.6.1
less: 4.6.4
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.7
@@ -32771,6 +32914,54 @@ snapshots:
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-prop': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3)
'@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
unplugin: 2.3.11
unplugin-combine: 2.3.0(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
- '@rspack/core'
- '@vueuse/core'
- esbuild
- rolldown
- rollup
- typescript
- vite
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.7)(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))