feat: 新增记账模块与 API Key 管理 — 支持收支记录、账户管理、N8N 外部集成

This commit is contained in:
2026-04-01 02:45:13 +08:00
parent dcccf6675f
commit 41f21ec3a9
32 changed files with 3896 additions and 16 deletions
+67 -3
View File
@@ -18,6 +18,7 @@ TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
- **Hotkeys**: @tanstack/react-hotkeys (`useHotkey` for type-safe keyboard shortcuts)
- **Animation**: motion (page transitions, staggered entrance, layout animation)
- **Command Palette**: cmdk (via shadcn Command component, triggered by ⌘K)
- **API Key**: @better-auth/api-key (API key auth for external integrations like N8N)
- **Build**: Vite + Nitro
## Architecture Overview
@@ -27,6 +28,8 @@ TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
```
/ → Dashboard overview (greeting, quick bookmarks, module summary)
/bookmarks → Bookmarks page (view mode + edit mode toggle)
/finance → Finance page (transaction list with filters, account/category management)
/settings → Settings page (API key management)
/setup → One-time owner setup (first visit only, redirects to /login after)
/login → Login page (redirects to /setup if no owner exists)
```
@@ -56,7 +59,7 @@ src/
├── client/
│ └── orpc.ts # ORPC client (isomorphic: SSR direct call / CSR fetch)
├── components/
│ ├── AppSidebar.tsx # Unified sidebar (reads module registry, collapsible)
│ ├── AppSidebar.tsx # Unified sidebar (reads module registry, collapsible, includes Settings link in footer)
│ ├── CommandPalette.tsx # ⌘K command palette (search bookmarks, engines, navigation)
│ ├── Error.tsx # Error boundary fallback
│ ├── NotFound.tsx # 404 fallback
@@ -67,7 +70,19 @@ src/
│ └── utils.ts # cn() utility
├── modules/
│ ├── registry.ts # ModuleMetadata interface + modules[]
── bookmarks/ # Bookmarks module (schema, contract, router, components/)
── bookmarks/ # Bookmarks module (schema, contract, router, components/)
│ └── finance/ # Finance module
│ ├── index.ts # Module metadata
│ ├── schema.ts # Drizzle tables (financeAccount, transactionCategory, transaction)
│ ├── contract.ts # ORPC contracts (account, category, transaction CRUD + summary)
│ ├── router.ts # ORPC handlers
│ └── components/ # React UI components
│ ├── AccountFormDialog.tsx
│ ├── AccountManager.tsx
│ ├── CategoryFormDialog.tsx
│ ├── CategoryManager.tsx
│ ├── TransactionFormDialog.tsx
│ └── TransactionList.tsx
├── cli/
│ ├── index.ts # citty CLI entrypoint (bun run cli ...)
│ └── commands/auth.ts # auth reset-password command
@@ -76,7 +91,9 @@ src/
│ ├── _protected.tsx # Auth guard + unified shell (SidebarProvider, AppSidebar, CommandPalette)
│ ├── _protected/
│ │ ├── index.tsx # Dashboard overview (greeting, quick bookmarks, module cards)
│ │ ── bookmarks.tsx # Bookmarks page (view/edit toggle, Motion animations)
│ │ ── bookmarks.tsx # Bookmarks page (view/edit toggle, Motion animations)
│ │ ├── finance.tsx # Finance page (transaction list, filters, account/category management)
│ │ └── settings.tsx # Settings page (API key management)
│ ├── login.tsx, setup.tsx
│ └── api/ # $.ts (OpenAPI), auth.$.ts, health.ts, rpc.$.ts
├── server/
@@ -340,6 +357,49 @@ Kairos is a self-hosted single-user app. There is NO public registration. The fi
- **ORPC middleware**: `authMiddleware` calls `auth.api.getSession({ headers })` → injects `context.user`
- **Password reset**: Via server CLI only (`bun run cli auth reset-password`) — no web-based password recovery
### API Key Authentication
- **Plugin**: `@better-auth/api-key` with `enableSessionForAPIKeys: true`
- **Auth middleware**: Unchanged — API keys automatically simulate sessions via Better Auth plugin
- **Header**: `x-api-key: kairos_xxxxxxxx` (or `Authorization: Bearer kairos_xxxxxxxx`)
- **Management**: Via `/settings` page UI (create, list, delete)
- **Use case**: N8N workflow automation → parse emails → call ORPC API to create transactions
- **Default prefix**: `kairos_`
## Finance Module
### Data Model
- **financeAccount**: Bank accounts, wallets, credit cards. Fields: name, type (checking/savings/credit/cash/investment/loan), currencyCode (ISO 4217, default CNY), initialBalance (integer, smallest currency unit), icon, isArchived, orderId
- **transactionCategory**: Flat expense/income categories. Fields: name, icon, type (expense/income), orderId
- **transaction**: Financial records. Fields: accountId, categoryId (nullable), type (expense/income), amount (integer, positive, smallest currency unit), description, note, date, source (manual/n8n/import), externalId (unique, for N8N dedup)
### Amount Convention
- Stored as **integer in smallest currency unit** (e.g., ¥120.30 = 12030)
- Always **positive** — type field determines income vs expense
- Display: `(amount / 100).toFixed(2)` with currency symbol
### N8N Integration
External services call ORPC API with API key:
```bash
curl -X POST https://kairos.example/api/rpc/finance.transaction.create \
-H "x-api-key: kairos_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"accountId":"...","type":"expense","amount":3500,"description":"午餐","date":"2026-04-01T12:30:00Z","source":"n8n","externalId":"email-abc123"}'
```
- `externalId` enables idempotent deduplication — duplicate imports return existing transaction
- `source: "n8n"` marks automated imports
### ORPC Endpoints
- `finance.account.list/create/update/remove/reorder`
- `finance.category.list/create/update/remove/reorder`
- `finance.transaction.list/create/update/remove/summary`
## Critical Rules
**DO:**
@@ -367,3 +427,7 @@ Kairos is a self-hosted single-user app. There is NO public registration. The fi
- Use `@/*` aliases in Drizzle schema files (drizzle-kit can't resolve them)
- Add registration/signup functionality (single-owner model, enforced by `databaseHooks`)
- Create separate admin pages — integrate view/edit modes in each module page
- Use `authClient.apiKey` methods for API key management (not ORPC)
- Store amount as float/decimal — amounts MUST be integer (smallest currency unit)
- Duplicate `externalId` on transactions — must be unique (for N8N dedup)
- Try to retrieve API key after creation — keys are shown only once
@@ -0,0 +1,81 @@
CREATE TABLE "apikey" (
"id" text PRIMARY KEY NOT NULL,
"config_id" text DEFAULT 'default' NOT NULL,
"name" text,
"start" text,
"reference_id" text NOT NULL,
"prefix" text,
"key" text NOT NULL,
"refill_interval" integer,
"refill_amount" integer,
"last_refill_at" timestamp,
"enabled" boolean DEFAULT true,
"rate_limit_enabled" boolean DEFAULT true,
"rate_limit_time_window" integer DEFAULT 86400000,
"rate_limit_max" integer DEFAULT 10,
"request_count" integer DEFAULT 0,
"remaining" integer,
"last_request" timestamp,
"expires_at" timestamp,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"permissions" text,
"metadata" text
);
--> statement-breakpoint
CREATE TABLE "finance_account" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text DEFAULT 'checking' NOT NULL,
"currency_code" text DEFAULT 'CNY' NOT NULL,
"initial_balance" integer DEFAULT 0 NOT NULL,
"icon" text,
"is_archived" boolean DEFAULT false NOT NULL,
"order_id" integer DEFAULT 0 NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "transaction" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"account_id" uuid NOT NULL,
"category_id" uuid,
"type" text DEFAULT 'expense' NOT NULL,
"amount" integer NOT NULL,
"description" text NOT NULL,
"note" text,
"date" timestamp with time zone DEFAULT now() NOT NULL,
"source" text DEFAULT 'manual' NOT NULL,
"external_id" text,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "transaction_category" (
"id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"icon" text,
"type" text DEFAULT 'expense' NOT NULL,
"order_id" integer DEFAULT 0 NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "finance_account" ADD CONSTRAINT "finance_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_account_id_finance_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."finance_account"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_category_id_transaction_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."transaction_category"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transaction_category" ADD CONSTRAINT "transaction_category_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "apikey_configId_idx" ON "apikey" USING btree ("config_id");--> statement-breakpoint
CREATE INDEX "apikey_referenceId_idx" ON "apikey" USING btree ("reference_id");--> statement-breakpoint
CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");--> statement-breakpoint
CREATE INDEX "finance_account_user_id_idx" ON "finance_account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transaction_user_id_idx" ON "transaction" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transaction_account_id_idx" ON "transaction" USING btree ("account_id");--> statement-breakpoint
CREATE INDEX "transaction_category_id_idx" ON "transaction" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "transaction_date_idx" ON "transaction" USING btree ("date");--> statement-breakpoint
CREATE UNIQUE INDEX "transaction_external_id_idx" ON "transaction" USING btree ("external_id");--> statement-breakpoint
CREATE INDEX "transaction_category_user_id_idx" ON "transaction_category" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1774958187626,
"tag": "0000_tricky_stick",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1774980527146,
"tag": "0001_special_titanium_man",
"breakpoints": true
}
]
}
+3
View File
@@ -25,6 +25,7 @@
},
"dependencies": {
"@base-ui/react": "catalog:",
"@better-auth/api-key": "^1.5.6",
"@dnd-kit/dom": "catalog:",
"@dnd-kit/helpers": "catalog:",
"@dnd-kit/react": "catalog:",
@@ -46,6 +47,7 @@
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"lucide-react": "catalog:",
@@ -53,6 +55,7 @@
"next-themes": "catalog:",
"postgres": "catalog:",
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-dom": "catalog:",
"shadcn": "^4.1.1",
"sonner": "catalog:",
+11 -1
View File
@@ -1,7 +1,7 @@
import { Link, useRouter, useRouterState } from '@tanstack/react-router'
import type { LucideIcon } from 'lucide-react'
import * as LucideIcons from 'lucide-react'
import { Circle, Home, LogOut, Search } from 'lucide-react'
import { Circle, Home, LogOut, Search, Settings } from 'lucide-react'
import {
Sidebar,
SidebarContent,
@@ -99,6 +99,16 @@ export const AppSidebar = ({ onOpenCommandPalette }: { onOpenCommandPalette: ()
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
render={<Link to={'/settings' as never} />}
isActive={currentPath === '/settings'}
tooltip="设置"
>
<Settings />
<span></span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleSignOut} tooltip="退出登录">
<LogOut />
+170
View File
@@ -0,0 +1,170 @@
'use client'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import { type DayButton, DayPicker, getDefaultClassNames, type Locale } from 'react-day-picker'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn('relative rounded-(--cell-radius)', defaultClassNames.dropdown_root),
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'text-sm'
: 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday,
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
day: cn(
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
defaultClassNames.day,
),
range_start: cn(
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
defaultClassNames.range_end,
),
today: cn(
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
}
if (orientation === 'right') {
return <ChevronRightIcon className={cn('size-4', className)} {...props} />
}
return <ChevronDownIcon className={cn('size-4', className)} {...props} />
},
DayButton: ({ ...props }) => <CalendarDayButton locale={locale} {...props} />,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+71
View File
@@ -0,0 +1,71 @@
'use client'
import { Popover as PopoverPrimitive } from '@base-ui/react/popover'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<PopoverPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
'z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="popover-header" className={cn('flex flex-col gap-0.5 text-sm', className)} {...props} />
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn('font-heading font-medium', className)}
{...props}
/>
)
}
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn('text-muted-foreground', className)}
{...props}
/>
)
}
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger }
+164
View File
@@ -0,0 +1,164 @@
import { Select as SelectPrimitive } from '@base-ui/react/select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" className={cn('scroll-my-1 p-1', className)} {...props} />
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value data-slot="select-value" className={cn('flex flex-1 text-left', className)} {...props} />
)
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = 'bottom',
sideOffset = 4,
align = 'center',
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
{...props}
/>
)
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,194 @@
import { useMutation } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { IconPickerDialog } from '@/modules/bookmarks/components/IconPickerDialog'
interface AccountFormDialogProps {
account?: {
id: string
name: string
type: string
currencyCode: string
initialBalance: number
icon: string | null
}
orderId?: number
trigger: ReactElement
}
const ACCOUNT_TYPES = [
{ value: 'checking', label: '储蓄卡' },
{ value: 'savings', label: '存款' },
{ value: 'credit', label: '信用卡' },
{ value: 'cash', label: '现金' },
{ value: 'investment', label: '投资' },
{ value: 'loan', label: '贷款' },
] as const
export const AccountFormDialog = ({ account, orderId = 0, trigger }: AccountFormDialogProps) => {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
name: '',
type: 'checking' as 'checking' | 'savings' | 'credit' | 'cash' | 'investment' | 'loan',
currencyCode: 'CNY',
initialBalance: '0',
icon: null as string | null,
})
const isEdit = Boolean(account)
const createAccount = useMutation(orpc.finance.account.create.mutationOptions())
const updateAccount = useMutation(orpc.finance.account.update.mutationOptions())
useEffect(() => {
if (!open) {
setForm({ name: '', type: 'checking', currencyCode: 'CNY', initialBalance: '0', icon: null })
return
}
if (account) {
setForm({
name: account.name,
type: account.type as 'checking' | 'savings' | 'credit' | 'cash' | 'investment' | 'loan',
currencyCode: account.currencyCode,
initialBalance: (account.initialBalance / 100).toFixed(2),
icon: account.icon,
})
}
}, [account, open])
const isPending = createAccount.isPending || updateAccount.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const name = form.name.trim()
if (!name) return
const initialBalanceCents = Math.round(Number.parseFloat(form.initialBalance || '0') * 100)
try {
if (account) {
await updateAccount.mutateAsync({
id: account.id,
data: {
name,
type: form.type,
currencyCode: form.currencyCode,
initialBalance: initialBalanceCents,
icon: form.icon,
},
})
toast.success('账户已更新')
} else {
await createAccount.mutateAsync({
name,
type: form.type,
currencyCode: form.currencyCode,
initialBalance: initialBalanceCents,
icon: form.icon,
orderId,
})
toast.success('账户已创建')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent className="max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑账户' : '添加账户'}</DialogTitle>
<DialogDescription>{isEdit ? '更新账户信息' : '添加一个新的财务账户'}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="account-name" className="text-sm font-medium">
</label>
<Input
id="account-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:招商银行"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.type}
onValueChange={(value) =>
setForm((prev) => ({
...prev,
type: (value as 'checking' | 'savings' | 'credit' | 'cash' | 'investment' | 'loan') || 'checking',
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="account-balance" className="text-sm font-medium">
</label>
<Input
id="account-balance"
type="number"
step="0.01"
value={form.initialBalance}
onChange={(e) => setForm((prev) => ({ ...prev, initialBalance: e.target.value }))}
placeholder="0.00"
/>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<IconPickerDialog value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !form.name.trim()}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '创建账户'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,177 @@
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { AccountFormDialog } from './AccountFormDialog'
interface Account {
id: string
name: string
type: string
currencyCode: string
initialBalance: number
icon: string | null
orderId: number
}
interface AccountManagerProps {
accounts: Account[]
}
const ACCOUNT_TYPE_LABELS: Record<string, string> = {
checking: '储蓄卡',
savings: '存款',
credit: '信用卡',
cash: '现金',
investment: '投资',
loan: '贷款',
}
const SortableAccountItem = ({ account, index }: { account: Account; index: number }) => {
const { ref, handleRef, isDragging } = useSortable({
id: account.id,
index,
group: 'finance-accounts',
})
const removeAccount = useMutation(orpc.finance.account.remove.mutationOptions())
const handleDelete = async () => {
try {
await removeAccount.mutateAsync({ id: account.id })
toast.success('账户已删除')
} catch {
toast.error('操作失败')
}
}
return (
<div
ref={ref}
className={cn(
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors',
isDragging && 'opacity-60',
)}
>
<button
type="button"
ref={handleRef}
className="text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<div className="flex items-center gap-2 truncate">
{account.icon && <span className="text-sm">{account.icon}</span>}
<span className="truncate text-sm font-medium">{account.name}</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{ACCOUNT_TYPE_LABELS[account.type]}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<AccountFormDialog
account={account}
trigger={
<DropdownMenuItem>
<Pencil className="size-4" />
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export const AccountManager = ({ accounts }: AccountManagerProps) => {
const [items, setItems] = useState(accounts)
const queryClient = useQueryClient()
const reorderAccounts = useMutation(orpc.finance.account.reorder.mutationOptions())
useEffect(() => {
setItems(accounts)
}, [accounts])
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
if (event.canceled) return
const reordered = move(items, event)
const previousItems = items
setItems(reordered)
reorderAccounts.mutate(
reordered.map((item, index) => ({ id: item.id, orderId: index })),
{
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: orpc.finance.account.list.queryOptions().queryKey,
})
toast.success('账户顺序已更新')
},
onError: () => {
setItems(previousItems)
toast.error('操作失败')
},
},
)
}
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((account, index) => (
<SortableAccountItem key={account.id} account={account} index={index} />
))}
</DragDropProvider>
{items.length === 0 && (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
<CardFooter className="justify-end">
<AccountFormDialog
orderId={items.length}
trigger={
<Button type="button">
<Plus className="size-4" />
</Button>
}
/>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,151 @@
import { useMutation } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { IconPickerDialog } from '@/modules/bookmarks/components/IconPickerDialog'
interface CategoryFormDialogProps {
category?: {
id: string
name: string
type: string
icon: string | null
}
orderId?: number
trigger: ReactElement
}
export const CategoryFormDialog = ({ category, orderId = 0, trigger }: CategoryFormDialogProps) => {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
name: '',
type: 'expense' as 'expense' | 'income',
icon: null as string | null,
})
const isEdit = Boolean(category)
const createCategory = useMutation(orpc.finance.category.create.mutationOptions())
const updateCategory = useMutation(orpc.finance.category.update.mutationOptions())
useEffect(() => {
if (!open) {
setForm({ name: '', type: 'expense', icon: null })
return
}
if (category) {
setForm({
name: category.name,
type: category.type as 'expense' | 'income',
icon: category.icon,
})
}
}, [category, open])
const isPending = createCategory.isPending || updateCategory.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const name = form.name.trim()
if (!name) return
try {
if (category) {
await updateCategory.mutateAsync({
id: category.id,
data: {
name,
type: form.type,
icon: form.icon,
},
})
toast.success('分类已更新')
} else {
await createCategory.mutateAsync({
name,
type: form.type,
icon: form.icon,
orderId,
})
toast.success('分类已创建')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent className="max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑分类' : '添加分类'}</DialogTitle>
<DialogDescription>{isEdit ? '更新分类信息' : '添加一个新的交易分类'}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="category-name" className="text-sm font-medium">
</label>
<Input
id="category-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:餐饮美食"
required
/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.type}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, type: (value as 'expense' | 'income') || 'expense' }))
}
>
<SelectTrigger>
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="expense"></SelectItem>
<SelectItem value="income"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<IconPickerDialog value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !form.name.trim()}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '创建分类'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,168 @@
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { GripVertical, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { CategoryFormDialog } from './CategoryFormDialog'
interface Category {
id: string
name: string
type: string
icon: string | null
orderId: number
}
interface CategoryManagerProps {
categories: Category[]
}
const SortableCategoryItem = ({ category, index }: { category: Category; index: number }) => {
const { ref, handleRef, isDragging } = useSortable({
id: category.id,
index,
group: 'finance-categories',
})
const removeCategory = useMutation(orpc.finance.category.remove.mutationOptions())
const handleDelete = async () => {
try {
await removeCategory.mutateAsync({ id: category.id })
toast.success('分类已删除')
} catch {
toast.error('操作失败')
}
}
return (
<div
ref={ref}
className={cn(
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors',
isDragging && 'opacity-60',
)}
>
<button
type="button"
ref={handleRef}
className="text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<div className="flex items-center gap-2 truncate">
{category.icon && <span className="text-sm">{category.icon}</span>}
<span className="truncate text-sm font-medium">{category.name}</span>
</div>
<span className={cn('shrink-0 text-xs', category.type === 'income' ? 'text-emerald-500' : 'text-rose-500')}>
{category.type === 'income' ? '收入' : '支出'}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<CategoryFormDialog
category={category}
trigger={
<DropdownMenuItem>
<Pencil className="size-4" />
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export const CategoryManager = ({ categories }: CategoryManagerProps) => {
const [items, setItems] = useState(categories)
const queryClient = useQueryClient()
const reorderCategories = useMutation(orpc.finance.category.reorder.mutationOptions())
useEffect(() => {
setItems(categories)
}, [categories])
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
if (event.canceled) return
const reordered = move(items, event)
const previousItems = items
setItems(reordered)
reorderCategories.mutate(
reordered.map((item, index) => ({ id: item.id, orderId: index })),
{
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: orpc.finance.category.list.queryOptions().queryKey,
})
toast.success('分类顺序已更新')
},
onError: () => {
setItems(previousItems)
toast.error('操作失败')
},
},
)
}
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((category, index) => (
<SortableCategoryItem key={category.id} category={category} index={index} />
))}
</DragDropProvider>
{items.length === 0 && (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
<CardFooter className="justify-end">
<CategoryFormDialog
orderId={items.length}
trigger={
<Button type="button">
<Plus className="size-4" />
</Button>
}
/>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,296 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import { CalendarIcon } from 'lucide-react'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
interface TransactionFormDialogProps {
transaction?: {
id: string
accountId: string
categoryId: string | null
type: string
amount: number
description: string
note: string | null
date: Date
}
trigger: ReactElement
}
export const TransactionFormDialog = ({ transaction, trigger }: TransactionFormDialogProps) => {
const [open, setOpen] = useState(false)
const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions())
const { data: categories } = useSuspenseQuery(orpc.finance.category.list.queryOptions())
const [form, setForm] = useState<{
type: 'expense' | 'income'
amount: string
description: string
accountId: string
categoryId: string
date: Date
note: string
}>({
type: 'expense',
amount: '',
description: '',
accountId: accounts[0]?.id ?? '',
categoryId: 'none',
date: new Date(),
note: '',
})
const isEdit = Boolean(transaction)
const createTransaction = useMutation(orpc.finance.transaction.create.mutationOptions())
const updateTransaction = useMutation(orpc.finance.transaction.update.mutationOptions())
useEffect(() => {
if (!open) {
setForm({
type: 'expense',
amount: '',
description: '',
accountId: accounts[0]?.id ?? '',
categoryId: 'none',
date: new Date(),
note: '',
})
return
}
if (transaction) {
setForm({
type: transaction.type as 'expense' | 'income',
amount: (transaction.amount / 100).toFixed(2),
description: transaction.description,
accountId: transaction.accountId,
categoryId: transaction.categoryId ?? 'none',
date: new Date(transaction.date),
note: transaction.note ?? '',
})
}
}, [transaction, open, accounts])
const isPending = createTransaction.isPending || updateTransaction.isPending
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const description = form.description.trim()
if (!description || !form.accountId || !form.amount) return
const amountCents = Math.round(Number.parseFloat(form.amount) * 100)
if (amountCents <= 0) {
toast.error('金额必须大于 0')
return
}
try {
const data = {
type: form.type,
amount: amountCents,
description,
accountId: form.accountId,
categoryId: form.categoryId === 'none' ? undefined : form.categoryId,
date: form.date,
note: form.note.trim() || undefined,
}
if (transaction) {
await updateTransaction.mutateAsync({
id: transaction.id,
data,
})
toast.success('交易已更新')
} else {
await createTransaction.mutateAsync(data)
toast.success('交易已记录')
}
setOpen(false)
} catch {
toast.error('操作失败')
}
}
const filteredCategories = categories.filter((c) => c.type === form.type)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent className="max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑交易' : '记录交易'}</DialogTitle>
<DialogDescription>{isEdit ? '更新交易信息' : '添加一笔新的收入或支出'}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.type}
onValueChange={(value) => {
setForm((prev) => ({
...prev,
type: (value as 'expense' | 'income') || 'expense',
categoryId: 'none',
}))
}}
>
<SelectTrigger>
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="expense"></SelectItem>
<SelectItem value="income"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="tx-amount" className="text-sm font-medium">
</label>
<Input
id="tx-amount"
type="number"
step="0.01"
min="0.01"
value={form.amount}
onChange={(e) => setForm((prev) => ({ ...prev, amount: e.target.value }))}
placeholder="0.00"
required
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="tx-desc" className="text-sm font-medium">
</label>
<Input
id="tx-desc"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="例如:午餐、工资"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.accountId}
onValueChange={(value) => setForm((prev) => ({ ...prev, accountId: value || '' }))}
>
<SelectTrigger>
<SelectValue placeholder="选择账户" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.icon && <span className="mr-2">{account.icon}</span>}
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={form.categoryId}
onValueChange={(value) => setForm((prev) => ({ ...prev, categoryId: value || 'none' }))}
>
<SelectTrigger>
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{filteredCategories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.icon && <span className="mr-2">{category.icon}</span>}
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 flex flex-col">
<span className="text-sm font-medium"></span>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!form.date && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 size-4" />
{form.date ? format(form.date, 'yyyy-MM-dd') : <span></span>}
</Button>
}
/>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={form.date}
onSelect={(date) => date && setForm((prev) => ({ ...prev, date }))}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<label htmlFor="tx-note" className="text-sm font-medium">
()
</label>
<Input
id="tx-note"
value={form.note}
onChange={(e) => setForm((prev) => ({ ...prev, note: e.target.value }))}
placeholder="添加备注..."
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={isPending || !form.description.trim() || !form.amount || !form.accountId}>
{isPending ? '提交中...' : isEdit ? '保存修改' : '记录交易'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,173 @@
import { useMutation } from '@tanstack/react-query'
import { format } from 'date-fns'
import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { orpc } from '@/client/orpc'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { TransactionFormDialog } from './TransactionFormDialog'
interface Transaction {
id: string
accountId: string
categoryId: string | null
type: string
amount: number
description: string
note: string | null
date: Date
source: string
account: { id: string; name: string; type: string; currencyCode: string; icon: string | null }
category: { id: string; name: string; icon: string | null; type: string } | null
createdAt: Date
updatedAt: Date
}
interface TransactionListProps {
transactions: Transaction[]
}
export const TransactionList = ({ transactions }: TransactionListProps) => {
const removeTransaction = useMutation(orpc.finance.transaction.remove.mutationOptions())
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这笔交易吗?')) return
try {
await removeTransaction.mutateAsync({ id })
toast.success('交易已删除')
} catch {
toast.error('操作失败')
}
}
if (transactions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 flex size-16 items-center justify-center rounded-2xl bg-muted">
<span className="text-2xl">💸</span>
</div>
<h3 className="mb-2 text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
)
}
const grouped = transactions.reduce(
(acc, tx) => {
const date = format(new Date(tx.date), 'yyyy-MM-dd')
if (!acc[date]) acc[date] = []
acc[date].push(tx)
return acc
},
{} as Record<string, Transaction[]>,
)
const sortedDates = Object.keys(grouped).sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
return (
<div className="space-y-8">
{sortedDates.map((date) => {
const dayTransactions = grouped[date] || []
const dayTotal = dayTransactions.reduce((sum, tx) => sum + (tx.type === 'income' ? tx.amount : -tx.amount), 0)
return (
<div key={date} className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="text-sm font-medium text-muted-foreground">{date}</h3>
<span
className={cn(
'text-sm font-medium',
dayTotal > 0 ? 'text-emerald-500' : dayTotal < 0 ? 'text-rose-500' : 'text-muted-foreground',
)}
>
{dayTotal > 0 ? '+' : ''}
{(dayTotal / 100).toFixed(2)}
</span>
</div>
<div className="overflow-hidden rounded-xl border bg-card">
{dayTransactions.map((tx, index) => (
<div
key={tx.id}
className={cn(
'flex items-center justify-between p-4 transition-colors hover:bg-muted/50',
index !== dayTransactions.length - 1 && 'border-b',
)}
>
<div className="flex items-center gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
{tx.category?.icon ? (
<span className="text-lg">{tx.category.icon}</span>
) : (
<span className="text-lg">💰</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{tx.description}</span>
{tx.category && (
<Badge variant="secondary" className="text-[10px] font-normal">
{tx.category.name}
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{tx.account.icon && <span>{tx.account.icon}</span>}
{tx.account.name}
</span>
{tx.note && (
<>
<span></span>
<span className="truncate max-w-[200px]">{tx.note}</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<span className={cn('font-medium', tx.type === 'income' ? 'text-emerald-500' : 'text-foreground')}>
{tx.type === 'income' ? '+' : '-'}¥{(tx.amount / 100).toFixed(2)}
</span>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<TransactionFormDialog
transaction={tx}
trigger={
<DropdownMenuItem>
<Pencil className="size-4" />
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(tx.id)}>
<Trash2 className="size-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,94 @@
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'
import { z } from 'zod'
import * as schema from '@/modules/finance/schema'
import { generatedFieldKeys } from '@/server/db/fields'
const financeAccountSelect = createSelectSchema(schema.financeAccount)
const financeAccountInsert = createInsertSchema(schema.financeAccount).omit(generatedFieldKeys).omit({ userId: true })
const financeAccountUpdate = createUpdateSchema(schema.financeAccount).omit(generatedFieldKeys).omit({ userId: true })
const transactionCategorySelect = createSelectSchema(schema.transactionCategory)
const transactionCategoryInsert = createInsertSchema(schema.transactionCategory)
.omit(generatedFieldKeys)
.omit({ userId: true })
const transactionCategoryUpdate = createUpdateSchema(schema.transactionCategory)
.omit(generatedFieldKeys)
.omit({ userId: true })
const transactionSelect = createSelectSchema(schema.transaction)
const transactionInsert = createInsertSchema(schema.transaction).omit(generatedFieldKeys).omit({ userId: true })
const transactionUpdate = createUpdateSchema(schema.transaction).omit(generatedFieldKeys).omit({ userId: true })
export const account = {
list: oc.input(z.void()).output(z.array(financeAccountSelect)),
create: oc.input(financeAccountInsert).output(financeAccountSelect),
update: oc.input(z.object({ id: z.uuid(), data: financeAccountUpdate })).output(financeAccountSelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
export const category = {
list: oc.input(z.void()).output(z.array(transactionCategorySelect)),
create: oc.input(transactionCategoryInsert).output(transactionCategorySelect),
update: oc.input(z.object({ id: z.uuid(), data: transactionCategoryUpdate })).output(transactionCategorySelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
export const transaction = {
list: oc
.input(
z.object({
accountId: z.uuid().optional(),
categoryId: z.uuid().optional(),
type: z.enum(['expense', 'income']).optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
offset: z.number().int().min(0).default(0),
}),
)
.output(
z.object({
items: z.array(
transactionSelect.extend({
account: financeAccountSelect,
category: transactionCategorySelect.nullable(),
}),
),
total: z.number().int(),
}),
),
create: oc.input(transactionInsert).output(transactionSelect),
update: oc.input(z.object({ id: z.uuid(), data: transactionUpdate })).output(transactionSelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
summary: oc
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
accountId: z.uuid().optional(),
}),
)
.output(
z.object({
totalIncome: z.number().int(),
totalExpense: z.number().int(),
balance: z.number().int(),
transactionCount: z.number().int(),
}),
),
}
+10
View File
@@ -0,0 +1,10 @@
import type { ModuleMetadata } from '@/modules/registry'
export const financeModule: ModuleMetadata = {
id: 'finance',
name: '记账',
description: '收支记录、账户管理与财务分析',
icon: 'Wallet',
route: '/finance',
enabled: true,
}
+232
View File
@@ -0,0 +1,232 @@
import { ORPCError } from '@orpc/server'
import { and, eq, gte, lte, sql } from 'drizzle-orm'
import * as schema from '@/modules/finance/schema'
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
export const account = {
list: os.finance.account.list
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.financeAccount.findMany({
where: (t, { eq }) => eq(t.userId, context.user.id),
orderBy: (t, { asc }) => asc(t.orderId),
})
}),
create: os.finance.account.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(schema.financeAccount)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create account' })
return created
}),
update: os.finance.account.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.financeAccount)
.set(input.data)
.where(and(eq(schema.financeAccount.id, input.id), eq(schema.financeAccount.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.finance.account.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.financeAccount)
.where(and(eq(schema.financeAccount.id, input.id), eq(schema.financeAccount.userId, context.user.id)))
.returning({ id: schema.financeAccount.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.finance.account.reorder
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(schema.financeAccount)
.set({ orderId: item.orderId })
.where(and(eq(schema.financeAccount.id, item.id), eq(schema.financeAccount.userId, context.user.id)))
}
})
}),
}
export const category = {
list: os.finance.category.list
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.transactionCategory.findMany({
where: (t, { eq }) => eq(t.userId, context.user.id),
orderBy: (t, { asc }) => asc(t.orderId),
})
}),
create: os.finance.category.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(schema.transactionCategory)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create category' })
return created
}),
update: os.finance.category.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.transactionCategory)
.set(input.data)
.where(and(eq(schema.transactionCategory.id, input.id), eq(schema.transactionCategory.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.finance.category.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.transactionCategory)
.where(and(eq(schema.transactionCategory.id, input.id), eq(schema.transactionCategory.userId, context.user.id)))
.returning({ id: schema.transactionCategory.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.finance.category.reorder
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(schema.transactionCategory)
.set({ orderId: item.orderId })
.where(
and(eq(schema.transactionCategory.id, item.id), eq(schema.transactionCategory.userId, context.user.id)),
)
}
})
}),
}
export const transaction = {
list: os.finance.transaction.list
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const conditions = [eq(schema.transaction.userId, context.user.id)]
if (input.accountId) conditions.push(eq(schema.transaction.accountId, input.accountId))
if (input.categoryId) conditions.push(eq(schema.transaction.categoryId, input.categoryId))
if (input.type) conditions.push(eq(schema.transaction.type, input.type))
if (input.startDate) conditions.push(gte(schema.transaction.date, new Date(input.startDate)))
if (input.endDate) conditions.push(lte(schema.transaction.date, new Date(input.endDate)))
const where = and(...conditions)
const items = await context.db.query.transaction.findMany({
where: () => where,
with: { account: true, category: true },
orderBy: (t, { desc }) => desc(t.date),
limit: input.limit,
offset: input.offset,
})
const [countResult] = await context.db
.select({ count: sql<number>`COUNT(*)::int` })
.from(schema.transaction)
.where(where)
return { items, total: countResult?.count ?? 0 }
}),
create: os.finance.transaction.create
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
if (input.externalId) {
const externalId = input.externalId
const existing = await context.db.query.transaction.findFirst({
where: (t, { eq, and }) => and(eq(t.externalId, externalId), eq(t.userId, context.user.id)),
})
if (existing) return existing
}
const [created] = await context.db
.insert(schema.transaction)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create transaction' })
return created
}),
update: os.finance.transaction.update
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(schema.transaction)
.set(input.data)
.where(and(eq(schema.transaction.id, input.id), eq(schema.transaction.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.finance.transaction.remove
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(schema.transaction)
.where(and(eq(schema.transaction.id, input.id), eq(schema.transaction.userId, context.user.id)))
.returning({ id: schema.transaction.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
summary: os.finance.transaction.summary
.use(dbMiddleware)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const conditions = [eq(schema.transaction.userId, context.user.id)]
if (input.accountId) conditions.push(eq(schema.transaction.accountId, input.accountId))
if (input.startDate) conditions.push(gte(schema.transaction.date, new Date(input.startDate)))
if (input.endDate) conditions.push(lte(schema.transaction.date, new Date(input.endDate)))
const [result] = await context.db
.select({
totalIncome: sql<number>`COALESCE(SUM(CASE WHEN ${schema.transaction.type} = 'income' THEN ${schema.transaction.amount} ELSE 0 END), 0)::int`,
totalExpense: sql<number>`COALESCE(SUM(CASE WHEN ${schema.transaction.type} = 'expense' THEN ${schema.transaction.amount} ELSE 0 END), 0)::int`,
transactionCount: sql<number>`COUNT(*)::int`,
})
.from(schema.transaction)
.where(and(...conditions))
return {
totalIncome: result?.totalIncome ?? 0,
totalExpense: result?.totalExpense ?? 0,
balance: (result?.totalIncome ?? 0) - (result?.totalExpense ?? 0),
transactionCount: result?.transactionCount ?? 0,
}
}),
}
+64
View File
@@ -0,0 +1,64 @@
import { boolean, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'
import { user } from '../../server/auth/schema'
import { generatedFields } from '../../server/db/fields'
export const financeAccount = pgTable(
'finance_account',
{
...generatedFields,
name: text('name').notNull(),
type: text('type').notNull().default('checking'),
currencyCode: text('currency_code').notNull().default('CNY'),
initialBalance: integer('initial_balance').notNull().default(0),
icon: text('icon'),
isArchived: boolean('is_archived').notNull().default(false),
orderId: integer('order_id').notNull().default(0),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
(table) => [index('finance_account_user_id_idx').on(table.userId)],
)
export const transactionCategory = pgTable(
'transaction_category',
{
...generatedFields,
name: text('name').notNull(),
icon: text('icon'),
type: text('type').notNull().default('expense'),
orderId: integer('order_id').notNull().default(0),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
(table) => [index('transaction_category_user_id_idx').on(table.userId)],
)
export const transaction = pgTable(
'transaction',
{
...generatedFields,
accountId: uuid('account_id')
.notNull()
.references(() => financeAccount.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => transactionCategory.id, { onDelete: 'set null' }),
type: text('type').notNull().default('expense'),
amount: integer('amount').notNull(),
description: text('description').notNull(),
note: text('note'),
date: timestamp('date', { withTimezone: true }).notNull().defaultNow(),
source: text('source').notNull().default('manual'),
externalId: text('external_id'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
(table) => [
index('transaction_user_id_idx').on(table.userId),
index('transaction_account_id_idx').on(table.accountId),
index('transaction_category_id_idx').on(table.categoryId),
index('transaction_date_idx').on(table.date),
uniqueIndex('transaction_external_id_idx').on(table.externalId),
],
)
+2 -1
View File
@@ -1,4 +1,5 @@
import { bookmarksModule } from './bookmarks'
import { financeModule } from './finance'
export interface ModuleMetadata {
id: string
@@ -9,4 +10,4 @@ export interface ModuleMetadata {
enabled: boolean
}
export const modules: ModuleMetadata[] = [bookmarksModule]
export const modules: ModuleMetadata[] = [bookmarksModule, financeModule]
+42
View File
@@ -15,6 +15,8 @@ import { Route as ProtectedRouteImport } from './routes/_protected'
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings'
import { Route as ProtectedFinanceRouteImport } from './routes/_protected/finance'
import { Route as ProtectedBookmarksRouteImport } from './routes/_protected/bookmarks'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
@@ -48,6 +50,16 @@ const ApiSplatRoute = ApiSplatRouteImport.update({
path: '/api/$',
getParentRoute: () => rootRouteImport,
} as any)
const ProtectedSettingsRoute = ProtectedSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => ProtectedRoute,
} as any)
const ProtectedFinanceRoute = ProtectedFinanceRouteImport.update({
id: '/finance',
path: '/finance',
getParentRoute: () => ProtectedRoute,
} as any)
const ProtectedBookmarksRoute = ProtectedBookmarksRouteImport.update({
id: '/bookmarks',
path: '/bookmarks',
@@ -69,6 +81,8 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/bookmarks': typeof ProtectedBookmarksRoute
'/finance': typeof ProtectedFinanceRoute
'/settings': typeof ProtectedSettingsRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/auth/$': typeof ApiAuthSplatRoute
@@ -78,6 +92,8 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/bookmarks': typeof ProtectedBookmarksRoute
'/finance': typeof ProtectedFinanceRoute
'/settings': typeof ProtectedSettingsRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/': typeof ProtectedIndexRoute
@@ -90,6 +106,8 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/setup': typeof SetupRoute
'/_protected/bookmarks': typeof ProtectedBookmarksRoute
'/_protected/finance': typeof ProtectedFinanceRoute
'/_protected/settings': typeof ProtectedSettingsRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/_protected/': typeof ProtectedIndexRoute
@@ -103,6 +121,8 @@ export interface FileRouteTypes {
| '/login'
| '/setup'
| '/bookmarks'
| '/finance'
| '/settings'
| '/api/$'
| '/api/health'
| '/api/auth/$'
@@ -112,6 +132,8 @@ export interface FileRouteTypes {
| '/login'
| '/setup'
| '/bookmarks'
| '/finance'
| '/settings'
| '/api/$'
| '/api/health'
| '/'
@@ -123,6 +145,8 @@ export interface FileRouteTypes {
| '/login'
| '/setup'
| '/_protected/bookmarks'
| '/_protected/finance'
| '/_protected/settings'
| '/api/$'
| '/api/health'
| '/_protected/'
@@ -184,6 +208,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/_protected/settings': {
id: '/_protected/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof ProtectedSettingsRouteImport
parentRoute: typeof ProtectedRoute
}
'/_protected/finance': {
id: '/_protected/finance'
path: '/finance'
fullPath: '/finance'
preLoaderRoute: typeof ProtectedFinanceRouteImport
parentRoute: typeof ProtectedRoute
}
'/_protected/bookmarks': {
id: '/_protected/bookmarks'
path: '/bookmarks'
@@ -210,11 +248,15 @@ declare module '@tanstack/react-router' {
interface ProtectedRouteChildren {
ProtectedBookmarksRoute: typeof ProtectedBookmarksRoute
ProtectedFinanceRoute: typeof ProtectedFinanceRoute
ProtectedSettingsRoute: typeof ProtectedSettingsRoute
ProtectedIndexRoute: typeof ProtectedIndexRoute
}
const ProtectedRouteChildren: ProtectedRouteChildren = {
ProtectedBookmarksRoute: ProtectedBookmarksRoute,
ProtectedFinanceRoute: ProtectedFinanceRoute,
ProtectedSettingsRoute: ProtectedSettingsRoute,
ProtectedIndexRoute: ProtectedIndexRoute,
}
@@ -0,0 +1,240 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Pencil, Plus, X } from 'lucide-react'
import { AnimatePresence } from 'motion/react'
import * as motion from 'motion/react-client'
import { useState } from 'react'
import { orpc } from '@/client/orpc'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AccountManager } from '@/modules/finance/components/AccountManager'
import { CategoryManager } from '@/modules/finance/components/CategoryManager'
import { TransactionFormDialog } from '@/modules/finance/components/TransactionFormDialog'
import { TransactionList } from '@/modules/finance/components/TransactionList'
export const Route = createFileRoute('/_protected/finance' as never)({
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
await Promise.all([
context.queryClient.fetchQuery(orpc.finance.account.list.queryOptions()),
context.queryClient.fetchQuery(orpc.finance.category.list.queryOptions()),
context.queryClient.fetchQuery(orpc.finance.transaction.list.queryOptions({ input: { limit: 50, offset: 0 } })),
context.queryClient.fetchQuery(orpc.finance.transaction.summary.queryOptions({ input: {} })),
])
},
component: FinancePage,
})
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.05, delayChildren: 0.08 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } },
}
function FinancePage() {
const [editing, setEditing] = useState(false)
const [filterAccountId, setFilterAccountId] = useState<string>('all')
const [filterCategoryId, setFilterCategoryId] = useState<string>('all')
const [filterType, setFilterType] = useState<'all' | 'expense' | 'income'>('all')
const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions())
const { data: categories } = useSuspenseQuery(orpc.finance.category.list.queryOptions())
const listParams = {
limit: 50,
offset: 0,
...(filterAccountId !== 'all' && { accountId: filterAccountId }),
...(filterCategoryId !== 'all' && { categoryId: filterCategoryId }),
...(filterType !== 'all' && { type: filterType }),
}
const { data: result } = useSuspenseQuery(orpc.finance.transaction.list.queryOptions({ input: listParams }))
const { data: summary } = useSuspenseQuery(
orpc.finance.transaction.summary.queryOptions({
input: {
...(filterAccountId !== 'all' && { accountId: filterAccountId }),
},
}),
)
return (
<div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{editing ? '财务管理' : '记账'}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{editing ? '管理你的财务账户和交易分类' : '追踪你的收入、支出和资产状况'}
</p>
</div>
<div className="flex items-center gap-3">
{!editing && (
<TransactionFormDialog
trigger={
<Button size="sm">
<Plus className="size-4" />
</Button>
}
/>
)}
<Button variant={editing ? 'default' : 'outline'} size="sm" onClick={() => setEditing(!editing)}>
{editing ? (
<>
<X className="size-4" />
</>
) : (
<>
<Pencil className="size-4" />
</>
)}
</Button>
</div>
</div>
<AnimatePresence mode="wait">
{editing ? (
<motion.div
key="edit"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] as const }}
className="flex min-h-0 flex-1 gap-6"
>
<div className="w-80 shrink-0">
<AccountManager accounts={accounts} />
</div>
<div className="min-w-0 flex-1">
<CategoryManager categories={categories} />
</div>
</motion.div>
) : (
<motion.div
key="view"
variants={containerVariants}
initial="hidden"
animate="visible"
exit={{ opacity: 0 }}
className="min-h-0 flex-1 overflow-y-auto"
>
{accounts.length === 0 ? (
<motion.div
variants={itemVariants}
className="flex flex-col items-center justify-center py-32 text-center"
>
<div className="mb-4 flex size-16 items-center justify-center rounded-2xl bg-muted">
<span className="text-2xl">🏦</span>
</div>
<h3 className="mb-2 text-lg font-medium"></h3>
<p className="mb-6 text-sm text-muted-foreground"></p>
<Button onClick={() => setEditing(true)}>
<Pencil className="size-4" />
</Button>
</motion.div>
) : (
<div className="space-y-8">
<motion.div variants={itemVariants} className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-500">
¥{(summary.totalIncome / 100).toFixed(2)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-rose-500">¥{(summary.totalExpense / 100).toFixed(2)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">¥{(summary.balance / 100).toFixed(2)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summary.transactionCount}</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="flex items-center gap-4">
<Select value={filterAccountId} onValueChange={(v) => setFilterAccountId(v || 'all')}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="所有账户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.icon && <span className="mr-2">{account.icon}</span>}
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterCategoryId} onValueChange={(v) => setFilterCategoryId(v || 'all')}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="所有分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.icon && <span className="mr-2">{category.icon}</span>}
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filterType}
onValueChange={(v) => setFilterType((v as 'all' | 'expense' | 'income') || 'all')}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="所有类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="expense"></SelectItem>
<SelectItem value="income"></SelectItem>
</SelectContent>
</Select>
</motion.div>
<motion.div variants={itemVariants}>
<TransactionList transactions={result.items} />
</motion.div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+39 -7
View File
@@ -2,7 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import * as icons from 'lucide-react'
import { ArrowRight, Compass, Plus } from 'lucide-react'
import { ArrowRight, Compass, TrendingDown, TrendingUp, Wallet } from 'lucide-react'
import * as motion from 'motion/react-client'
import { orpc } from '@/client/orpc'
@@ -10,7 +10,11 @@ const allIcons = icons as unknown as Record<string, React.ComponentType<{ classN
export const Route = createFileRoute('/_protected/' as never)({
loader: async ({ context }: { context: { queryClient: QueryClient } }) => {
await context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions())
await Promise.all([
context.queryClient.fetchQuery(orpc.bookmarks.category.list.queryOptions()),
context.queryClient.fetchQuery(orpc.finance.transaction.summary.queryOptions({ input: {} })),
context.queryClient.fetchQuery(orpc.finance.account.list.queryOptions()),
])
},
component: DashboardPage,
})
@@ -46,6 +50,8 @@ const itemVariants = {
function DashboardPage() {
const { data: categories } = useSuspenseQuery(orpc.bookmarks.category.list.queryOptions())
const { data: summary } = useSuspenseQuery(orpc.finance.transaction.summary.queryOptions({ input: {} }))
const { data: accounts } = useSuspenseQuery(orpc.finance.account.list.queryOptions())
const now = new Date()
const totalBookmarks = categories.reduce(
@@ -119,17 +125,43 @@ function DashboardPage() {
</Link>
<Link
to={'/bookmarks' as never}
className="group flex items-center gap-4 rounded-xl border border-dashed bg-card p-5 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
to={'/finance' as never}
className="group flex items-center gap-4 rounded-xl border bg-card p-5 transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted/60 transition-colors group-hover:bg-muted">
<Plus className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
<Wallet className="size-5 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">
{accounts.length} · {summary.transactionCount}
</p>
</div>
<ArrowRight className="size-4 text-muted-foreground opacity-0 transition-all group-hover:opacity-100" />
</Link>
{summary.transactionCount > 0 && (
<div className="flex items-center gap-4 rounded-xl border bg-card p-5">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<TrendingUp className="size-3 text-emerald-500" />
</span>
<span className="text-xs font-medium text-emerald-600">
¥{(summary.totalIncome / 100).toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<TrendingDown className="size-3 text-red-500" />
</span>
<span className="text-xs font-medium text-red-600">¥{(summary.totalExpense / 100).toFixed(2)}</span>
</div>
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
@@ -0,0 +1,204 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Check, Copy, Key, Plus, Trash2 } from 'lucide-react'
import * as motion from 'motion/react-client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { authClient } from '@/server/auth/client'
export const Route = createFileRoute('/_protected/settings' as never)({
component: SettingsPage,
})
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.05, delayChildren: 0.08 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } },
}
function SettingsPage() {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [keyName, setKeyName] = useState('')
const [newKey, setNewKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const { data: apiKeys, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: async () => {
const result = await authClient.apiKey.list({ query: { limit: 100, sortBy: 'createdAt', sortDirection: 'desc' } })
return result.data
},
})
const createMutation = useMutation({
mutationFn: async (name: string) => {
const result = await authClient.apiKey.create({ name })
return result.data
},
onSuccess: (data) => {
if (data?.key) {
setNewKey(data.key)
}
setKeyName('')
setCreateOpen(false)
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
toast.success('API 密钥已创建')
},
onError: () => {
toast.error('创建失败')
},
})
const deleteMutation = useMutation({
mutationFn: async (keyId: string) => {
await authClient.apiKey.delete({ keyId })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
toast.success('API 密钥已删除')
},
onError: () => {
toast.error('删除失败')
},
})
const handleCopyKey = async () => {
if (!newKey) return
await navigator.clipboard.writeText(newKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleCreateSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!keyName.trim()) return
createMutation.mutate(keyName.trim())
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
return (
<motion.div className="flex-1 px-6 pb-8" variants={containerVariants} initial="hidden" animate="visible">
<motion.div variants={itemVariants} className="mb-8">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="mt-1 text-sm text-muted-foreground"> API </p>
</motion.div>
<motion.div variants={itemVariants}>
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">API </h2>
<p className="text-sm text-muted-foreground"> N8N Kairos API</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="size-4" />
</DialogTrigger>
<DialogContent>
<form onSubmit={handleCreateSubmit}>
<DialogHeader>
<DialogTitle> API </DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
placeholder="密钥名称(如 N8N Integration"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button type="submit" disabled={!keyName.trim() || createMutation.isPending}>
{createMutation.isPending ? '创建中...' : '创建'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-xl border bg-muted/30" />
))}
</div>
) : apiKeys && apiKeys.length > 0 ? (
<div className="space-y-2">
{apiKeys.map((key: { id: string; name: string | null; start: string | null; createdAt: string }) => (
<div
key={key.id}
className="group flex items-center gap-4 rounded-xl border bg-card px-4 py-3 transition-colors hover:bg-muted/30"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted/60">
<Key className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{key.name ?? '未命名密钥'}</p>
<p className="font-mono text-xs text-muted-foreground">{key.start ?? '***'}</p>
</div>
<span className="text-xs text-muted-foreground">{formatDate(key.createdAt)}</span>
<Button
variant="ghost"
size="icon"
className="size-8 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => deleteMutation.mutate(key.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-3.5 text-muted-foreground" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center">
<div className="mb-3 flex size-12 items-center justify-center rounded-xl bg-muted/60">
<Key className="size-5 text-muted-foreground" />
</div>
<p className="mb-1 text-sm font-medium"> API </p>
<p className="mb-4 text-xs text-muted-foreground"> N8N </p>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
</Button>
</div>
)}
</motion.div>
<Dialog open={!!newKey} onOpenChange={(open) => !open && setNewKey(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="mb-3 text-sm text-muted-foreground"></p>
<div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-3">
<code className="flex-1 break-all font-mono text-xs">{newKey}</code>
<Button variant="ghost" size="icon" className="size-8 shrink-0" onClick={handleCopyKey}>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</Button>
</div>
</div>
<DialogFooter>
<Button onClick={() => setNewKey(null)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
)
}
@@ -1,7 +1,9 @@
import * as bookmarks from '@/modules/bookmarks/contract'
import * as finance from '@/modules/finance/contract'
export const contract = {
bookmarks,
finance,
}
export type Contract = typeof contract
@@ -1,6 +1,8 @@
import * as bookmarks from '@/modules/bookmarks/router'
import * as finance from '@/modules/finance/router'
import { os } from '@/server/api/server'
export const router = os.router({
bookmarks,
finance,
})
+4 -1
View File
@@ -1,3 +1,6 @@
import { apiKeyClient } from '@better-auth/api-key/client'
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient()
export const authClient = createAuthClient({
plugins: [apiKeyClient()],
})
+8 -1
View File
@@ -1,3 +1,4 @@
import { apiKey } from '@better-auth/api-key'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { APIError } from 'better-auth/api'
@@ -34,5 +35,11 @@ export const auth = betterAuth({
},
},
},
plugins: [tanstackStartCookies()],
plugins: [
tanstackStartCookies(),
apiKey({
defaultPrefix: 'kairos_',
enableSessionForAPIKeys: true,
}),
],
})
+34 -1
View File
@@ -1,5 +1,5 @@
import { relations } from 'drizzle-orm'
import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
@@ -73,6 +73,39 @@ export const verification = pgTable(
(table) => [index('verification_identifier_idx').on(table.identifier)],
)
export const apikey = pgTable(
'apikey',
{
id: text('id').primaryKey(),
configId: text('config_id').default('default').notNull(),
name: text('name'),
start: text('start'),
referenceId: text('reference_id').notNull(),
prefix: text('prefix'),
key: text('key').notNull(),
refillInterval: integer('refill_interval'),
refillAmount: integer('refill_amount'),
lastRefillAt: timestamp('last_refill_at'),
enabled: boolean('enabled').default(true),
rateLimitEnabled: boolean('rate_limit_enabled').default(true),
rateLimitTimeWindow: integer('rate_limit_time_window').default(86400000),
rateLimitMax: integer('rate_limit_max').default(10),
requestCount: integer('request_count').default(0),
remaining: integer('remaining'),
lastRequest: timestamp('last_request'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
permissions: text('permissions'),
metadata: text('metadata'),
},
(table) => [
index('apikey_configId_idx').on(table.configId),
index('apikey_referenceId_idx').on(table.referenceId),
index('apikey_key_idx').on(table.key),
],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
+35
View File
@@ -1,10 +1,14 @@
import { relations } from 'drizzle-orm'
import { bookmark, category } from '../../modules/bookmarks/schema'
import { financeAccount, transaction, transactionCategory } from '../../modules/finance/schema'
import { user } from '../auth/schema'
export const userRelations = relations(user, ({ many }) => ({
categories: many(category),
bookmarks: many(bookmark),
financeAccounts: many(financeAccount),
transactionCategories: many(transactionCategory),
transactions: many(transaction),
}))
export const categoryRelations = relations(category, ({ one, many }) => ({
@@ -25,3 +29,34 @@ export const bookmarkRelations = relations(bookmark, ({ one }) => ({
references: [category.id],
}),
}))
export const financeAccountRelations = relations(financeAccount, ({ one, many }) => ({
user: one(user, {
fields: [financeAccount.userId],
references: [user.id],
}),
transactions: many(transaction),
}))
export const transactionCategoryRelations = relations(transactionCategory, ({ one, many }) => ({
user: one(user, {
fields: [transactionCategory.userId],
references: [user.id],
}),
transactions: many(transaction),
}))
export const transactionRelations = relations(transaction, ({ one }) => ({
user: one(user, {
fields: [transaction.userId],
references: [user.id],
}),
account: one(financeAccount, {
fields: [transaction.accountId],
references: [financeAccount.id],
}),
category: one(transactionCategory, {
fields: [transaction.categoryId],
references: [transactionCategory.id],
}),
}))
+2 -1
View File
@@ -1,2 +1,3 @@
export * from '../../../modules/bookmarks/schema'
export { account, session, user, verification } from '../../auth/schema'
export * from '../../../modules/finance/schema'
export { account, apikey, session, user, verification } from '../../auth/schema'
+15
View File
@@ -15,6 +15,7 @@
"version": "1.0.0",
"dependencies": {
"@base-ui/react": "catalog:",
"@better-auth/api-key": "^1.5.6",
"@dnd-kit/dom": "catalog:",
"@dnd-kit/helpers": "catalog:",
"@dnd-kit/react": "catalog:",
@@ -36,6 +37,7 @@
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"lucide-react": "catalog:",
@@ -43,6 +45,7 @@
"next-themes": "catalog:",
"postgres": "catalog:",
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-dom": "catalog:",
"shadcn": "^4.1.1",
"sonner": "catalog:",
@@ -206,6 +209,8 @@
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
"@better-auth/api-key": ["@better-auth/api-key@1.5.6", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "1.5.6", "@better-auth/utils": "0.3.1", "better-auth": "1.5.6" } }, "sha512-jr3m4/caFxn9BuY9pGDJ4B1HP1Qoqmyd7heBHm4KUFel+a9Whe/euROgZ/L+o7mbmUdZtreneaU15dpn0tJZ5g=="],
"@better-auth/core": ["@better-auth/core@1.5.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.6", "", { "peerDependencies": { "@better-auth/core": "1.5.6", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw=="],
@@ -242,6 +247,8 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
@@ -520,6 +527,8 @@
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="],
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
@@ -780,6 +789,10 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
@@ -1240,6 +1253,8 @@
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],