feat: 新增记账模块与 API Key 管理 — 支持收支记录、账户管理、N8N 外部集成
This commit is contained in:
+67
-3
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ModuleMetadata } from '@/modules/registry'
|
||||
|
||||
export const financeModule: ModuleMetadata = {
|
||||
id: 'finance',
|
||||
name: '记账',
|
||||
description: '收支记录、账户管理与财务分析',
|
||||
icon: 'Wallet',
|
||||
route: '/finance',
|
||||
enabled: true,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -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,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=="],
|
||||
|
||||
Reference in New Issue
Block a user