From 1ccee1414c8787afe13252f2b7ed4084cb51ed09 Mon Sep 17 00:00:00 2001 From: SerinaNya <34389622+SerinaNya@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:17:42 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20impl=20accounts=20and=20cha?= =?UTF-8?q?nnels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/drizzle/SKILL.md | 444 +++++++++ AGENTS.md | 34 + app/accounts/account-dialog.tsx | 316 +++++++ app/accounts/accounts-view.tsx | 470 ++++++++++ app/accounts/page.tsx | 44 + app/api/auth/[...nextauth]/route.ts | 3 + app/channels/channel-dialog.tsx | 486 ++++++++++ app/channels/channels-view.tsx | 571 ++++++++++++ app/channels/page.tsx | 52 ++ app/globals.css | 10 +- app/layout.tsx | 17 +- app/login/login-form.tsx | 396 ++++++++ app/login/page.tsx | 24 + app/page.tsx | 224 ++++- app/register/page.tsx | 21 + app/register/register-form.tsx | 319 +++++++ components/app-header.tsx | 42 + components/app-sidebar.tsx | 93 ++ components/fluxent-logo.tsx | 48 + components/nav-main.tsx | 66 ++ components/nav-secondary.tsx | 65 ++ components/nav-user.tsx | 144 +++ components/theme-toggle.tsx | 48 + components/ui/alert.tsx | 75 ++ components/ui/avatar.tsx | 108 +++ components/ui/badge.tsx | 51 ++ components/ui/breadcrumb.tsx | 124 +++ components/ui/card.tsx | 102 +++ components/ui/checkbox.tsx | 28 + components/ui/collapsible.tsx | 21 + components/ui/combobox.tsx | 297 ++++++ components/ui/dialog.tsx | 160 ++++ components/ui/dropdown-menu.tsx | 267 ++++++ components/ui/field.tsx | 238 +++++ components/ui/input-group.tsx | 158 ++++ components/ui/input.tsx | 19 + components/ui/label.tsx | 19 + components/ui/select.tsx | 200 +++++ components/ui/separator.tsx | 24 + components/ui/sheet.tsx | 138 +++ components/ui/sidebar.tsx | 723 +++++++++++++++ components/ui/skeleton.tsx | 13 + components/ui/switch.tsx | 31 + components/ui/textarea.tsx | 17 + components/ui/tooltip.tsx | 65 ++ drizzle.config.ts | 15 + hooks/use-mobile.ts | 15 + lib/actions/account.ts | 217 +++++ lib/actions/auth.ts | 85 ++ lib/actions/channel.ts | 278 ++++++ lib/auth/config.ts | 43 + lib/auth/index.ts | 147 +++ lib/auth/password.ts | 21 + lib/db/index.ts | 15 + lib/db/normalize-card-brands.ts | 52 ++ lib/db/reset.ts | 22 + lib/db/schema.ts | 244 +++++ lib/payment/card-brand.ts | 105 +++ package.json | 13 +- pnpm-lock.yaml | 1288 +++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + proxy.ts | 12 + public/payment-logos/amex.svg | 16 + public/payment-logos/diners.svg | 54 ++ public/payment-logos/discover.svg | 354 ++++++++ public/payment-logos/jcb.svg | 54 ++ public/payment-logos/mastercard.svg | 17 + public/payment-logos/unionpay.svg | 1 + public/payment-logos/visa.svg | 20 + skills-lock.json | 6 + 70 files changed, 9879 insertions(+), 31 deletions(-) create mode 100644 .agents/skills/drizzle/SKILL.md create mode 100644 app/accounts/account-dialog.tsx create mode 100644 app/accounts/accounts-view.tsx create mode 100644 app/accounts/page.tsx create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/channels/channel-dialog.tsx create mode 100644 app/channels/channels-view.tsx create mode 100644 app/channels/page.tsx create mode 100644 app/login/login-form.tsx create mode 100644 app/login/page.tsx create mode 100644 app/register/page.tsx create mode 100644 app/register/register-form.tsx create mode 100644 components/app-header.tsx create mode 100644 components/app-sidebar.tsx create mode 100644 components/fluxent-logo.tsx create mode 100644 components/nav-main.tsx create mode 100644 components/nav-secondary.tsx create mode 100644 components/nav-user.tsx create mode 100644 components/theme-toggle.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/combobox.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 drizzle.config.ts create mode 100644 hooks/use-mobile.ts create mode 100644 lib/actions/account.ts create mode 100644 lib/actions/auth.ts create mode 100644 lib/actions/channel.ts create mode 100644 lib/auth/config.ts create mode 100644 lib/auth/index.ts create mode 100644 lib/auth/password.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/normalize-card-brands.ts create mode 100644 lib/db/reset.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/payment/card-brand.ts create mode 100644 proxy.ts create mode 100644 public/payment-logos/amex.svg create mode 100644 public/payment-logos/diners.svg create mode 100644 public/payment-logos/discover.svg create mode 100644 public/payment-logos/jcb.svg create mode 100644 public/payment-logos/mastercard.svg create mode 100644 public/payment-logos/unionpay.svg create mode 100644 public/payment-logos/visa.svg diff --git a/.agents/skills/drizzle/SKILL.md b/.agents/skills/drizzle/SKILL.md new file mode 100644 index 0000000..601cb88 --- /dev/null +++ b/.agents/skills/drizzle/SKILL.md @@ -0,0 +1,444 @@ +--- +name: drizzle +description: 'LobeHub Drizzle ORM schema and query style. Use for pgTable schemas, indexes, joins, inferred types, db.select/db.query, schema fields, foreign keys, junction tables, or postgres query patterns.' +user-invocable: false +--- + +# Drizzle ORM Schema Style Guide + +> **Adding a Model or Repository?** Ship a sibling test in the same PR — every new +> file under `packages/database/src/models/**` or `src/repositories/**` needs a +> matching `__tests__/.test.ts`. See the **testing** skill +> (`.agents/skills/testing/references/db-model-test.md`) for the `getTestDB()` +> integration pattern, user-isolation tests, the BM25 `describe.skipIf(!isServerDB)` +> guard, and schema gotchas. CI's coverage patch gate won't reliably catch a brand-new +> untested file, so this is on you. + +## Configuration + +- Config: `drizzle.config.ts` +- Schemas: `packages/database/src/schemas/` +- Migrations: `packages/database/migrations/` +- Dialect: `postgresql` with `strict: true` + +## Helper Functions + +Location: `packages/database/src/schemas/_helpers.ts` + +- `timestamptz(name)`: Timestamp with timezone +- `createdAt()`, `updatedAt()`, `accessedAt()`: Standard timestamp columns +- `timestamps`: Object with all three for easy spread + +## Naming Conventions + +- **Tables**: Plural snake\_case (`users`, `session_groups`) +- **Columns**: snake\_case (`user_id`, `created_at`) +- **New tables**: Check nearby existing tables before naming a new one. Preserve + the established noun family and suffix. For example, if the user-scoped table + is `user_xxx_logs`, the workspace-scoped counterpart should be + `workspace_xxx_logs`, not `workspace_xxx_records` or another new synonym. + +```typescript +// ✅ Good: follows the existing user/workspace table family. +export const userSignupLogs = pgTable('user_signup_logs', { ... }); +export const workspaceSignupLogs = pgTable('workspace_signup_logs', { ... }); + +// ❌ Bad: introduces a new suffix for the same concept. +export const workspaceSignupRecords = pgTable('workspace_signup_records', { ... }); +``` + +## Column Definitions + +### Primary Keys + +Do not use auto-incrementing primary keys (`serial`, `bigserial`, generated +identity columns). They create sequence-state problems during cross-database +migrations, restores, and data copy jobs. Prefer text IDs from application +generators (`idGenerator`, `createNanoId`) or `uuid` for internal tables. + +Keep `$defaultFn(...)` when a table normally owns ID generation. Callers can +still pass an explicit `id`; the default only runs when the insert omits it. Do +not remove the default just because one flow needs to supply a request-scoped ID. + +```typescript +// ✅ Good: app-generated text ID; explicit inserts can still override it. +id: text('id') + .primaryKey() + .$defaultFn(() => idGenerator('agents')) + .notNull(), + +// ❌ Bad: sequence state is fragile across DB migrations and restores. +id: serial('id').primaryKey(), +``` + +ID prefixes make entity types distinguishable. For internal tables, use `uuid`. + +Do not use composite primary keys on new tables. Give every table a single-column +surrogate PK and carry business uniqueness in a `uniqueIndex` instead. PK columns +cannot be nullable, so when the uniqueness scope later grows by a nullable +dimension the composite PK must be torn down and rebuilt — exactly what happened +when `ai_providers` / `ai_models` were workspace-scoped (migration 0110 replaced +their composite PKs with a surrogate `_id` plus partial unique indexes). A unique +index still works as the arbiter for `onConflictDoUpdate` upserts. + +```typescript +// ✅ Good: surrogate PK; uniqueness scope can evolve without a PK rebuild. +export const workspaceUserSettings = pgTable( + 'workspace_user_settings', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + workspaceId: text('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }).notNull(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + ...timestamps, + }, + (t) => [uniqueIndex('workspace_user_settings_workspace_id_user_id_unique').on(t.workspaceId, t.userId)], +); + +// ❌ Bad: locked to exactly these columns; adding a nullable scope column +// (workspaceId, deviceId, …) later forces a full PK rebuild migration. +(t) => [primaryKey({ columns: [t.workspaceId, t.userId] })], +``` + +Existing composite PKs are legacy — leave them alone unless they block a scope +change, then migrate them the 0110 way. + +### Foreign Keys + +```typescript +userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), +``` + +### Timestamps + +```typescript +...timestamps, // Spread from _helpers.ts +``` + +### Optional and Undefined Values + +Do not introduce artificial sentinel strings for missing values, such as +`unknown`, unless the domain already has that explicit state and existing code +uses it consistently. Prefer nullable columns, optional TypeScript fields, or a +separate concrete status enum when the value is genuinely absent. + +```typescript +// ✅ Good: absent until the final stage writes a real decision. +export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error'; + +finalDecision: varchar('final_decision', { length: 32 }).$type(), + +// ❌ Bad: invents a new state that callers now need to handle everywhere. +export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error' | 'unknown'; + +finalDecision: varchar('final_decision', { length: 32 }) + .$type() + .notNull() + .default('unknown'); +``` + +### Database Enums + +Default to **not** using PostgreSQL/Drizzle `pgEnum`. Database enums are +expensive to evolve safely: adding members needs migrations, removing or +renaming members is awkward, and deployment order becomes more fragile. + +For product/business states, use `text()` or `varchar()` with a TypeScript value +type via `$type<...>()`. Keep those TS-only value types in the domain/shared type +module, then import them into the schema. For cloud DB schemas, that usually +means `cloudDB/types.ts`. + +Do not copy existing DB enums as a pattern. Treat them as legacy or explicitly +reviewed exceptions. If a new `pgEnum` seems necessary, stop and justify why the +value set is effectively immutable and why the migration cost is acceptable. + +### Field Descriptions + +For columns whose meaning is not obvious from the name alone, add JSDoc on the +schema field. Include a concrete example when it clarifies the stored value or +the lifecycle moment that writes it. This is especially important for external +IDs, lifecycle statuses, denormalized snapshots, JSONB signals, and fields whose +name could mean either a request ID or a persisted row ID. + +```typescript +// ✅ Good: explain the table's business object first, then only document +// non-obvious lifecycle or risk-control fields. +/** + * User signup logs - one row per signup flow, collecting stage-level + * risk-control decisions before and after the auth provider creates a user. + */ +export const userSignupLogs = pgTable('user_signup_logs', { + /** Final signup outcome reason, for example user_created, llm_block, or guard_error */ + finalReason: text('final_reason'), + + /** Aggregated risk level derived from stage decisions, for example block -> high */ + riskLevel: varchar('risk_level', { length: 16 }).$type(), + + /** Ordered stage-level decisions and metadata grouped by signup review stage */ + stageResults: jsonb('stage_results').$type(), +}); + +// ❌ Bad: comments restate obvious column names without adding domain meaning. +/** User email */ +email: text('email'), +``` + +### JSONB Types + +Avoid `Record` or similarly loose JSONB types for schema +columns. Define a concrete interface that describes the expected JSON shape, even +when most properties are optional. This keeps callers, migrations, and review +queries aligned on the same data contract. + +```typescript +interface UserSignupLogMetadata { + payloadPath?: string; + requestPath?: string; +} + +metadata: jsonb('metadata').$type(), +``` + +```typescript +// ❌ Bad: hides the contract and makes downstream access untyped. +metadata: jsonb('metadata').$type>(), +``` + +A loosely-typed JSONB column is often a symptom of a deeper problem: the column +was reserved speculatively ("for future extension") and nothing actually writes +it. Don't add `metadata` / `extra` JSONB columns for hypothetical future needs — +a column earns its place only when a concrete writer ships alongside it. When +review finds such a column, the fix is to **delete the column**, not to invent +an interface for data that doesn't exist; add a properly-typed column once the +real requirement arrives. + +### Indexes + +```typescript +// Return array (object style deprecated) +(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)], +``` + +## Type Inference + +```typescript +export const insertAgentSchema = createInsertSchema(agents); +export type NewAgent = typeof agents.$inferInsert; +export type AgentItem = typeof agents.$inferSelect; +``` + +## Example Pattern + +```typescript +export const agents = pgTable( + 'agents', + { + id: text('id') + .primaryKey() + .$defaultFn(() => idGenerator('agents')) + .notNull(), + slug: varchar('slug', { length: 100 }) + .$defaultFn(() => randomSlug(4)) + .unique(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + clientId: text('client_id'), + chatConfig: jsonb('chat_config').$type(), + ...timestamps, + }, + (t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)], +); +``` + +## Common Patterns + +### Junction Tables (Many-to-Many) + +The surrogate-PK rule above applies to junction tables too — pair uniqueness +goes in a `uniqueIndex`, not a composite PK (many existing junction tables +still use composite PKs; that is legacy, not the template): + +```typescript +export const agentsKnowledgeBases = pgTable( + 'agents_knowledge_bases', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + agentId: text('agent_id') + .references(() => agents.id, { onDelete: 'cascade' }) + .notNull(), + knowledgeBaseId: text('knowledge_base_id') + .references(() => knowledgeBases.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + enabled: boolean('enabled').default(true), + ...timestamps, + }, + (t) => [ + uniqueIndex('agents_knowledge_bases_agent_id_knowledge_base_id_unique').on( + t.agentId, + t.knowledgeBaseId, + ), + ], +); +``` + +## Query Style + +**Always use `db.select()` builder API. Never use `db.query.*` relational API** (`findMany`, `findFirst`, `with:`). + +The relational API generates complex lateral joins with `json_build_array` that are fragile and hard to debug. + +### Select Single Row + +```typescript +// ✅ Good +const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1); +return result; + +// ❌ Bad: relational API +return this.db.query.agents.findFirst({ + where: eq(agents.id, id), +}); +``` + +### Select with JOIN + +```typescript +// ✅ Good: explicit select + leftJoin +const rows = await this.db + .select({ + runId: agentEvalRunTopics.runId, + score: agentEvalRunTopics.score, + testCase: agentEvalTestCases, + topic: topics, + }) + .from(agentEvalRunTopics) + .leftJoin(agentEvalTestCases, eq(agentEvalRunTopics.testCaseId, agentEvalTestCases.id)) + .leftJoin(topics, eq(agentEvalRunTopics.topicId, topics.id)) + .where(eq(agentEvalRunTopics.runId, runId)) + .orderBy(asc(agentEvalRunTopics.createdAt)); + +// ❌ Bad: relational API with `with:` +return this.db.query.agentEvalRunTopics.findMany({ + where: eq(agentEvalRunTopics.runId, runId), + with: { testCase: true, topic: true }, +}); +``` + +### Select with Aggregation + +```typescript +// ✅ Good: select + leftJoin + groupBy +const rows = await this.db + .select({ + id: agentEvalDatasets.id, + name: agentEvalDatasets.name, + testCaseCount: count(agentEvalTestCases.id).as('testCaseCount'), + }) + .from(agentEvalDatasets) + .leftJoin(agentEvalTestCases, eq(agentEvalDatasets.id, agentEvalTestCases.datasetId)) + .groupBy(agentEvalDatasets.id); +``` + +### Raw SQL and Advanced Queries + +Prefer Drizzle builders whenever the query reads clearly with `select`, +`insert().select()`, `update().from()`, joins, CTEs, and `groupBy` — this keeps +table/column references tied to schema, so changes surface as TypeScript errors. +Within a builder, expression-level `sql` is fine for features lacking a helper +(JSON path, casts, aggregates, `CASE`, `NOW()`). Row locks are clauses, not +expressions — use `.for('update')`, never raw `FOR UPDATE`. + +Use `COALESCE` only when null-handling is part of required DB semantics (nullable +JSONB append/merge, "keep first non-null"). Don't scatter +`COALESCE(excluded.col, current.col)` across ordinary upsert scalars just to avoid +an update object — build `set` from defined values only, and hide any remaining +SQL behind named helpers (`appendJsonbArray`, `mergeJsonbObject`, `keepFirstValue`) +so the method reads as business intent, not SQL plumbing. + +```typescript +// ✅ Scalars included only when present; SQL hidden behind a named helper. +const updateValues = compactUndefined({ + email: record.email ?? undefined, + ip: record.ip ?? undefined, +}); +await db.insert(userSignupLogs).values(values).onConflictDoUpdate({ + set: { ...updateValues, stageResults: appendStageResult(stage, result), updatedAt: now }, + target: userSignupLogs.id, +}); + +// ❌ Every scalar becomes SQL plumbing. +set: { + email: sql`COALESCE(excluded.email, ${userSignupLogs.email})`, + ip: sql`COALESCE(excluded.ip, ${userSignupLogs.ip})`, +} +``` + +When refactoring raw SQL: + +- Preserve query shape on latency-sensitive paths. If raw SQL is one roundtrip, + don't split it into multiple depth-based queries just to drop `execute`. +- Use `$with(...)` + `insert().select()` / `update().from()` for multi-step + single-roundtrip writes Drizzle can express. +- Don't rely on `execute(sql...)` for safety — it types rows but doesn't keep + selected columns in sync with schema changes. +- If only a PostgreSQL feature Drizzle can't express works, keep the raw SQL and + tighten it: schema refs in interpolations, explicit user scope, a narrow row + interface, and regression tests. + +Recursive CTEs are the canonical "keep raw" case — there's no clean `WITH RECURSIVE` +builder, and a rewrite would add depth-based roundtrips: + +```typescript +interface TaskTreeRow { + id: string; + parent_task_id: string | null; +} + +// execute acceptable: no clean WITH RECURSIVE builder. Keep schema refs in the +// interpolations and scope every leg to the user. +const { rows } = await db.execute(sql` + WITH RECURSIVE task_tree AS ( + SELECT ${tasks.id}, ${tasks.parentTaskId} + FROM ${tasks} + WHERE ${tasks.id} = ${rootTaskId} AND ${tasks.createdByUserId} = ${userId} + UNION ALL + SELECT ${tasks.id}, ${tasks.parentTaskId} + FROM ${tasks} + JOIN task_tree ON ${tasks.parentTaskId} = task_tree.id + WHERE ${tasks.createdByUserId} = ${userId} + ) + SELECT * FROM task_tree +`); +``` + +### One-to-Many (Separate Queries) + +When you need a parent record with its children, use two queries instead of relational `with:`: + +```typescript +// ✅ Good: two simple queries +const [dataset] = await this.db + .select() + .from(agentEvalDatasets) + .where(eq(agentEvalDatasets.id, id)) + .limit(1); + +if (!dataset) return undefined; + +const testCases = await this.db + .select() + .from(agentEvalTestCases) + .where(eq(agentEvalTestCases.datasetId, id)) + .orderBy(asc(agentEvalTestCases.sortOrder)); + +return { ...dataset, testCases }; +``` + +## Database Migrations + +See the `db-migrations` skill for the detailed migration guide. diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..f909a03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,37 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +# Fluxent Web + +## Commands + +- Use `pnpm`. The available checks are `pnpm lint`, `pnpm typecheck`, and `pnpm build`; run `pnpm typecheck && pnpm build` after application changes. There is no committed test suite or CI workflow. +- Add UI primitives only through `pnpm dlx shadcn@latest add --yes`. Before using a shadcn component, run `pnpm dlx shadcn@latest docs ` and read the referenced docs. +- Formatting is Prettier with Tailwind sorting (`pnpm format`). It uses double quotes, no semicolons, 2 spaces, LF, and 80-column wrapping. + +## App And Auth + +- This is a single Next.js App Router application; routes live under `app/`. Authenticated pages compose `SidebarProvider`, `AppSidebar`, and `SidebarInset` themselves. +- Next.js 16 uses `proxy.ts`, not `middleware.ts`. Keep `proxy.ts` edge-safe: it imports only `lib/auth/config.ts`, never database or password-hashing code. +- Login, registration, and `/api/auth` are public. All other routes are protected by `authConfig`; server pages and actions must still validate the session themselves. +- `lib/auth/index.ts` owns Node-side Auth.js providers and database work. OIDC is configuration-driven and disabled unless `AUTH_OIDC_ENABLED=true` with a complete issuer/client configuration. + +## Database + +- PostgreSQL access is Drizzle + `postgres`; the schema source of truth is `lib/db/schema.ts`. Drizzle Kit loads `DATABASE_URL` from `.env.local` and uses `drizzle.config.ts`. +- Use `pnpm drizzle-kit push --force` only when a schema change is intended; it can apply destructive changes. `pnpm tsx lib/db/reset.ts` drops `transactions`, `channels`, `accounts`, `user_accounts`, and `users` and must never be run casually. +- Business tables are user-scoped. Every read, update, or delete in `lib/actions/` must obtain `auth().user.id` and constrain the query with that `user_id`; validate that referenced account/channel IDs belong to the same user. +- Keep soft deletion (`deleted_at`) semantics in reads and mutations. Account and channel management actions already follow this model. + +## UI And Copy + +- The project uses shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, and semantic CSS variables from `app/globals.css`. +- Prefer stock shadcn composition and variants over custom styling. Use `className` only for necessary layout, responsiveness, truncation, or stable dimensions; do not override component padding, margins, colors, typography, or default Dialog/Footer behavior without a verified need. +- Forms use `FieldGroup`, `Field`, and `FieldLabel`; use `FieldSet`/`FieldLegend` only when the grouping adds user-facing meaning. Put `SelectItem` inside `SelectGroup`. +- Errors and callouts use `Alert`; destructive errors use `Alert variant="destructive"` with `AlertTitle` and `AlertDescription`, never a hand-styled `div`. +- Follow the default Dialog composition: `DialogHeader`, form/content, then `DialogFooter` as a direct `DialogContent` child. Do not add `p-0`, custom negative margins, fixed heights, sticky footers, or isolated scroll containers unless the task explicitly requires them. +- Use `Button`, `Badge`, `Empty`, `Separator`, `Tooltip`, and other installed primitives instead of recreating them. Use `data-icon="inline-start"` or `data-icon="inline-end"` for icons in buttons. +- Use `gap-*`, never `space-x-*` or `space-y-*`; use semantic tokens instead of raw color palettes or manual `dark:` overrides. +- Product-facing UI copy is Chinese only. Do not add English parentheticals to menus, labels, options, or headings; retain user-entered business values such as currency codes and card brands verbatim. +- Sidebar active state must derive from `usePathname()` with exact matching for `/` and route-boundary matching for child paths. Do not add navigation links until their route exists. diff --git a/app/accounts/account-dialog.tsx b/app/accounts/account-dialog.tsx new file mode 100644 index 0000000..517439f --- /dev/null +++ b/app/accounts/account-dialog.tsx @@ -0,0 +1,316 @@ +"use client" + +import * as React from "react" +import { CircleAlertIcon, Loader2Icon } from "lucide-react" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + createAccountAction, + updateAccountAction, + type AccountInput, + type AccountWithChannels, +} from "@/lib/actions/account" + +type AccountType = "BANK" | "E_WALLET" | "CASH" +const accountLabels = { + BANK: "银行账户", + E_WALLET: "电子钱包", + CASH: "现金", +} as const +const balanceLabels = { + ASSET: "资产类", + LIABILITY: "负债类", + EQUITY: "权益类", +} as const + +export function AccountDialog({ + open, + onOpenChange, + account, + onSuccess, +}: { + open: boolean + onOpenChange: (open: boolean) => void + account?: AccountWithChannels | null + onSuccess: () => void +}) { + const editing = Boolean(account) + const [accountType, setAccountType] = React.useState( + account?.accountType || "BANK" + ) + const [balanceType, setBalanceType] = React.useState< + "ASSET" | "LIABILITY" | "EQUITY" + >(account?.balanceType || "ASSET") + const [name, setName] = React.useState(account?.name || "") + const [primaryCurrency, setPrimaryCurrency] = React.useState( + account?.primaryCurrency || "" + ) + const [supportedCurrencies, setSupportedCurrencies] = React.useState( + account?.supportedCurrencies?.join(", ") || "" + ) + const [remark, setRemark] = React.useState(account?.remark || "") + const [issuerName, setIssuerName] = React.useState(account?.issuerName || "") + const [accountNumber, setAccountNumber] = React.useState( + account?.accountNumber || "" + ) + const [platform, setPlatform] = React.useState(account?.platform || "") + const [accountId, setAccountId] = React.useState(account?.accountId || "") + const [location, setLocation] = React.useState(account?.location || "") + const [isPending, setIsPending] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState(null) + const submit = async (event: React.FormEvent) => { + event.preventDefault() + setErrorMessage(null) + if (!name.trim()) { + setErrorMessage("请输入账户名称") + return + } + const currencies = supportedCurrencies + .split(/[,,\s]+/) + .map((item) => item.trim().toUpperCase()) + .filter(Boolean) + const payload: AccountInput = { + name: name.trim(), + accountType, + balanceType, + primaryCurrency: primaryCurrency.trim().toUpperCase() || null, + supportedCurrencies: currencies.length ? currencies : null, + remark: remark.trim() || null, + issuerName: accountType === "BANK" ? issuerName.trim() || null : null, + accountNumber: + accountType === "BANK" ? accountNumber.trim() || null : null, + platform: accountType === "E_WALLET" ? platform.trim() || null : null, + accountId: accountType === "E_WALLET" ? accountId.trim() || null : null, + location: accountType === "CASH" ? location.trim() || null : null, + } + setIsPending(true) + try { + const result = + editing && account + ? await updateAccountAction(account.id, payload) + : await createAccountAction(payload) + if (!result.success) { + setErrorMessage(result.error || "保存失败,请检查输入") + return + } + onOpenChange(false) + onSuccess() + } catch { + setErrorMessage("网络异常,提交失败") + } finally { + setIsPending(false) + } + } + return ( + + + + {editing ? "编辑资金账户" : "新建资金账户"} + + 填写账户主体、分类及其识别信息。 + + +
+ + {errorMessage && ( + + + 无法保存账户 + {errorMessage} + + )} + + + 账户类型 + + + + 余额分类 + + + + + + 账户名称 + setName(event.target.value)} + placeholder="例如:招商银行个人消费卡账户" + disabled={isPending} + required + /> + + + + 主币种 + setPrimaryCurrency(event.target.value)} + placeholder="例如:HKD" + disabled={isPending} + /> + + + + 支持币种 + + + setSupportedCurrencies(event.target.value) + } + placeholder="用逗号分隔" + disabled={isPending} + /> + + + + + {accountType === "BANK" && ( + <> + + 银行机构 + setIssuerName(event.target.value)} + placeholder="例如:汇丰银行" + disabled={isPending} + /> + + + + 账户号码或尾号 + + setAccountNumber(event.target.value)} + placeholder="可填写完整账号或尾号" + disabled={isPending} + /> + + + )} + {accountType === "E_WALLET" && ( + <> + + 平台名称 + setPlatform(event.target.value)} + placeholder="例如:支付宝" + disabled={isPending} + /> + + + 平台账户标识 + setAccountId(event.target.value)} + placeholder="手机号、邮箱或会员号" + disabled={isPending} + /> + + + )} + {accountType === "CASH" && ( + + 存放位置 + setLocation(event.target.value)} + placeholder="例如:随身钱包" + disabled={isPending} + /> + + )} + + + 备注 + setRemark(event.target.value)} + placeholder="可选" + disabled={isPending} + /> + + +
+ + + + +
+
+ ) +} diff --git a/app/accounts/accounts-view.tsx b/app/accounts/accounts-view.tsx new file mode 100644 index 0000000..c886853 --- /dev/null +++ b/app/accounts/accounts-view.tsx @@ -0,0 +1,470 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { + BanknoteIcon, + CreditCardIcon, + LandmarkIcon, + Layers2Icon, + Loader2Icon, + MoreHorizontalIcon, + PowerIcon, + PowerOffIcon, + PencilIcon, + PlusIcon, + SearchIcon, + Trash2Icon, + WalletCardsIcon, +} from "lucide-react" +import { + deleteAccountAction, + toggleAccountActiveAction, + type AccountWithChannels, +} from "@/lib/actions/account" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { AccountDialog } from "./account-dialog" + +const typeLabels = { + BANK: "银行账户", + E_WALLET: "电子钱包", + CASH: "现金", +} as const +const balanceLabels = { + ASSET: "资产类", + LIABILITY: "负债类", + EQUITY: "权益类", +} as const +const typeIcon = (type: AccountWithChannels["accountType"]) => + type === "BANK" ? ( + + ) : type === "E_WALLET" ? ( + + ) : ( + + ) + +export function AccountsView({ + initialAccounts, +}: { + initialAccounts: AccountWithChannels[] +}) { + const router = useRouter() + const [accountsList, setAccountsList] = React.useState(initialAccounts) + const [searchQuery, setSearchQuery] = React.useState("") + const [typeFilter, setTypeFilter] = React.useState("ALL") + const [balanceFilter, setBalanceFilter] = React.useState("ALL") + const [dialogOpen, setDialogOpen] = React.useState(false) + const [editingAccount, setEditingAccount] = + React.useState(null) + const [deletingAccount, setDeletingAccount] = + React.useState(null) + const [isDeleting, setIsDeleting] = React.useState(false) + const [togglingId, setTogglingId] = React.useState(null) + const filteredAccounts = React.useMemo(() => { + const query = searchQuery.trim().toLowerCase() + return accountsList.filter((account) => { + const text = [ + account.name, + account.issuerName, + account.platform, + account.accountNumber, + account.accountId, + account.primaryCurrency, + account.remark, + ] + .filter(Boolean) + .join(" ") + .toLowerCase() + return ( + (!query || text.includes(query)) && + (typeFilter === "ALL" || account.accountType === typeFilter) && + (balanceFilter === "ALL" || account.balanceType === balanceFilter) + ) + }) + }, [accountsList, balanceFilter, searchQuery, typeFilter]) + const groups = ( + Object.keys(balanceLabels) as Array + ) + .map((balanceType) => ({ + balanceType, + accounts: filteredAccounts.filter( + (account) => account.balanceType === balanceType + ), + })) + .filter((group) => group.accounts.length) + const clearFilters = () => { + setSearchQuery("") + setTypeFilter("ALL") + setBalanceFilter("ALL") + } + const hasFilters = + Boolean(searchQuery) || typeFilter !== "ALL" || balanceFilter !== "ALL" + const openCreate = () => { + setEditingAccount(null) + setDialogOpen(true) + } + const toggleActive = async ( + account: AccountWithChannels, + checked: boolean + ) => { + setTogglingId(account.id) + setAccountsList((items) => + items.map((item) => + item.id === account.id ? { ...item, isActive: checked } : item + ) + ) + try { + const result = await toggleAccountActiveAction(account.id, checked) + if (!result.success) + setAccountsList((items) => + items.map((item) => + item.id === account.id ? { ...item, isActive: !checked } : item + ) + ) + else router.refresh() + } catch { + setAccountsList((items) => + items.map((item) => + item.id === account.id ? { ...item, isActive: !checked } : item + ) + ) + } finally { + setTogglingId(null) + } + } + const confirmDelete = async () => { + if (!deletingAccount) return + setIsDeleting(true) + try { + const result = await deleteAccountAction(deletingAccount.id) + if (result.success) { + setAccountsList((items) => + items.filter((item) => item.id !== deletingAccount.id) + ) + setDeletingAccount(null) + router.refresh() + } + } finally { + setIsDeleting(false) + } + } + + return ( + +
+
+
+

+ 资金管理 +

+

+ 资金账户 +

+

+ 按资产归属管理账户主体与结算币种。 +

+
+ +
+
+
+ + setSearchQuery(event.target.value)} + placeholder="搜索账户、机构、账号或币种" + className="pl-8" + /> +
+
+ + +
+ {hasFilters && ( + + )} +
+ {groups.length ? ( +
+ {groups.map(({ balanceType, accounts }) => ( +
+
+

+ {balanceLabels[balanceType]} +

+ + {accounts.length} 个账户 + +
+
+
+ 账户名称 + 账户类型 + 关联渠道 + +
+ {accounts.map((account) => { + const actionMenu = ( + + + + } + /> + } + > + + + 更多操作 + + + + toggleActive(account, !account.isActive) + } + > + {account.isActive ? ( + + ) : ( + + )} + {account.isActive ? "停用账户" : "启用账户"} + + { + setEditingAccount(account) + setDialogOpen(true) + }} + > + + 编辑账户 + + + setDeletingAccount(account)} + > + + 删除账户 + + + + ) + + return ( +
+
+
+ + {typeIcon(account.accountType)} + +
+
+

+ {account.name} +

+ {!account.isActive && ( + 已停用 + )} +
+

+ {account.issuerName || + account.platform || + account.location || + account.remark || + ""} +

+
+
+
{actionMenu}
+
+
+ + 类型 + + {typeLabels[account.accountType]} +
+
+ + 关联渠道 + + + + {account.channelCount} 个 + +
+
+ {actionMenu} +
+
+ ) + })} +
+
+ ))} +
+ ) : ( +
+ +

+ {hasFilters ? "没有符合条件的账户" : "还没有资金账户"} +

+

+ {hasFilters + ? "调整关键词或筛选条件后再试。" + : "创建账户后即可关联支付渠道。"} +

+
+ {hasFilters && ( + + )} + +
+
+ )} + router.refresh()} + /> + !open && setDeletingAccount(null)} + > + + + 确认删除账户 + + 将删除「{deletingAccount?.name} + 」。删除后不可恢复,历史关联记录会保留。 + + + + + + + + +
+
+ ) +} diff --git a/app/accounts/page.tsx b/app/accounts/page.tsx new file mode 100644 index 0000000..b427862 --- /dev/null +++ b/app/accounts/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation" +import { auth } from "@/lib/auth" +import { getAccountsAction } from "@/lib/actions/account" +import { AppSidebar } from "@/components/app-sidebar" +import { AppHeader } from "@/components/app-header" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" +import { AccountsView } from "./accounts-view" + +export default async function AccountsPage() { + const session = await auth() + + if (!session?.user) { + redirect("/login") + } + + const user = session.user + const userName = user.name || "Fluxent 用户" + const userEmail = user.email || "" + const userAvatar = user.image || null + + const accountsRes = await getAccountsAction() + const initialAccounts = + accountsRes.success && accountsRes.data ? accountsRes.data : [] + + return ( + + + + + + + + + ) +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c55a45e --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/app/channels/channel-dialog.tsx b/app/channels/channel-dialog.tsx new file mode 100644 index 0000000..6917368 --- /dev/null +++ b/app/channels/channel-dialog.tsx @@ -0,0 +1,486 @@ +"use client" +import * as React from "react" +import Image from "next/image" +import { CircleAlertIcon, Loader2Icon } from "lucide-react" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { + Field, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox" +import { + CARD_BRANDS, + CARD_BRAND_LABELS, + detectCardBrand, + getCardBrandLogoUrl, + normalizeCardBrand, + type CardBrand, +} from "@/lib/payment/card-brand" +import { + createChannelAction, + updateChannelAction, + type ChannelInput, + type ChannelWithAccountNames, +} from "@/lib/actions/channel" +import { type AccountWithChannels } from "@/lib/actions/account" +type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER" +const channelLabels = { + PAYMENT_CARD: "支付卡", + E_WALLET: "电子钱包", + CASH: "现金", + TRANSFER: "转账", +} as const +export function ChannelDialog({ + open, + onOpenChange, + channel, + accounts, + onSuccess, +}: { + open: boolean + onOpenChange: (open: boolean) => void + channel?: ChannelWithAccountNames | null + accounts: AccountWithChannels[] + onSuccess: () => void +}) { + const editing = Boolean(channel) + const [channelType, setChannelType] = React.useState( + channel?.channelType || "PAYMENT_CARD" + ) + const [selectedAccounts, setSelectedAccounts] = React.useState( + channel + ? Array.isArray(channel.refAccounts) + ? channel.refAccounts + : [] + : accounts.length + ? [accounts[0].id] + : [] + ) + const [desc, setDesc] = React.useState(channel?.desc || "") + const [region, setRegion] = React.useState(channel?.region || "") + const [issuerName, setIssuerName] = React.useState(channel?.issuerName || "") + const [cardType, setCardType] = React.useState<"CREDIT" | "DEBIT" | "NONE">( + channel?.cardType || "CREDIT" + ) + const [cardBrand, setCardBrand] = React.useState(channel?.cardBrand || "") + const [cardNumberFull, setCardNumberFull] = React.useState( + channel?.cardNumberFull || "" + ) + const [cardNumberSuffix, setCardNumberSuffix] = React.useState( + channel?.cardNumberFull ? "" : channel?.cardNumberSuffix || "" + ) + const [platform, setPlatform] = React.useState(channel?.platform || "") + const [platformAccountId, setPlatformAccountId] = React.useState( + channel?.platformAccountId || "" + ) + const [subChannel, setSubChannel] = React.useState(channel?.subChannel || "") + const [subChannelType, setSubChannelType] = React.useState< + "CREDIT" | "DEBIT" | "NONE" + >(channel?.subChannelType || "DEBIT") + const [isPending, setIsPending] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState(null) + const cardBrandRequest = React.useRef(0) + const submit = async (event: React.FormEvent) => { + event.preventDefault() + setErrorMessage(null) + if (!selectedAccounts.length) { + setErrorMessage("请至少关联一个资金账户") + return + } + const full = cardNumberFull.trim() + const payload: ChannelInput = { + channelType, + refAccounts: selectedAccounts, + desc: desc.trim() || null, + region: region.trim().toUpperCase() || null, + issuerName: + channelType === "PAYMENT_CARD" ? issuerName.trim() || null : null, + cardType: + channelType === "PAYMENT_CARD" && cardType !== "NONE" ? cardType : null, + cardNumberFull: channelType === "PAYMENT_CARD" ? full || null : null, + cardNumberSuffix: + channelType === "PAYMENT_CARD" && !full + ? cardNumberSuffix.trim() || null + : null, + cardBrand: + channelType === "PAYMENT_CARD" ? normalizeCardBrand(cardBrand) : null, + platform: channelType === "E_WALLET" ? platform.trim() || null : null, + platformAccountId: + channelType === "E_WALLET" ? platformAccountId.trim() || null : null, + subChannel: channelType === "E_WALLET" ? subChannel.trim() || null : null, + subChannelType: + channelType === "E_WALLET" && subChannelType !== "NONE" + ? subChannelType + : null, + } + setIsPending(true) + try { + const result = + editing && channel + ? await updateChannelAction(channel.id, payload) + : await createChannelAction(payload) + if (!result.success) { + setErrorMessage(result.error || "保存失败,请检查输入") + return + } + onOpenChange(false) + onSuccess() + } catch { + setErrorMessage("网络异常,提交失败") + } finally { + setIsPending(false) + } + } + return ( + + + + {editing ? "编辑支付渠道" : "新建支付渠道"} + + 配置渠道信息,并指定实际结算的资金账户。 + + +
+ + {errorMessage && ( + + + 无法保存渠道 + {errorMessage} + + )} + + + 描述 + setDesc(event.target.value)} + placeholder="例如:日常主用渠道" + disabled={isPending} + /> + + + + + 类型 + + + +
+
+ 归属账户 + + 已选 {selectedAccounts.length} 个 + +
+
+ {accounts.length ? ( + accounts.map((account) => ( + + )) + ) : ( +

+ 暂无资金账户,请先创建账户。 +

+ )} +
+
+ + {channelType === "PAYMENT_CARD" && ( +
+ + 发卡机构 + setIssuerName(event.target.value)} + placeholder="例如:汇丰银行" + disabled={isPending} + /> + + + 卡组织 + { + cardBrandRequest.current += 1 + setCardBrand((value as CardBrand | null) || "") + }} + autoHighlight + > + + + 没有匹配的卡组织 + + {(brand: CardBrand) => ( + + + {CARD_BRAND_LABELS[brand]} + + )} + + + + + + 卡片类型 + + + + 地区代码 + setRegion(event.target.value)} + placeholder="例如:HK" + disabled={isPending} + /> + + {!cardNumberSuffix && ( + + 完整卡号 + { + const value = event.target.value + const request = ++cardBrandRequest.current + setCardNumberFull(value) + if (value) setCardNumberSuffix("") + if (!value) return + void detectCardBrand(value).then((brand) => { + if (request === cardBrandRequest.current && brand) + setCardBrand(brand) + }) + }} + placeholder="可留空" + disabled={isPending} + /> + + )} + {!cardNumberFull && ( + + + 卡号末尾四位 + + { + const value = event.target.value + setCardNumberSuffix(value) + if (value) setCardNumberFull("") + }} + placeholder="例如:8888" + disabled={isPending} + /> + + )} +
+ )} + {channelType === "E_WALLET" && ( +
+ + 平台名称 + setPlatform(event.target.value)} + placeholder="例如:支付宝" + disabled={isPending} + /> + + + + 平台账号 + + + setPlatformAccountId(event.target.value) + } + placeholder="手机号或邮箱" + disabled={isPending} + /> + + + 子渠道名称 + setSubChannel(event.target.value)} + placeholder="例如:余额" + disabled={isPending} + /> + + + 子渠道类型 + + + + 地区代码 + setRegion(event.target.value)} + placeholder="例如:HK" + disabled={isPending} + /> + +
+ )} + {(channelType === "CASH" || channelType === "TRANSFER") && ( +

+ 此类型无需额外信息。 +

+ )} +
+
+
+ + + + +
+
+ ) +} diff --git a/app/channels/channels-view.tsx b/app/channels/channels-view.tsx new file mode 100644 index 0000000..943701c --- /dev/null +++ b/app/channels/channels-view.tsx @@ -0,0 +1,571 @@ +"use client" + +import * as React from "react" +import Image from "next/image" +import { useRouter } from "next/navigation" +import { + ArrowRightLeftIcon, + BanknoteIcon, + CreditCardIcon, + CopyIcon, + EyeIcon, + EyeOffIcon, + Loader2Icon, + MoreHorizontalIcon, + PencilIcon, + PowerIcon, + PowerOffIcon, + PlusIcon, + SearchIcon, + Trash2Icon, + WalletCardsIcon, +} from "lucide-react" +import { + type ChannelWithAccountNames, + deleteChannelAction, + toggleChannelActiveAction, +} from "@/lib/actions/channel" +import { type AccountWithChannels } from "@/lib/actions/account" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { ChannelDialog } from "./channel-dialog" +import { + CARD_BRAND_LABELS, + getCardBrandLogoUrl, + normalizeCardBrand, +} from "@/lib/payment/card-brand" + +interface ChannelsViewProps { + initialChannels: ChannelWithAccountNames[] + initialAccounts: AccountWithChannels[] +} + +const channelTypeLabels = { + PAYMENT_CARD: "支付卡", + E_WALLET: "电子钱包", + CASH: "现金", + TRANSFER: "转账", +} as const +const channelIcon = (type: ChannelWithAccountNames["channelType"]) => + type === "PAYMENT_CARD" ? ( + + ) : type === "E_WALLET" ? ( + + ) : type === "CASH" ? ( + + ) : ( + + ) + +function paymentCardIcon(channel: ChannelWithAccountNames) { + const brand = normalizeCardBrand(channel.cardBrand) + const logoUrl = getCardBrandLogoUrl(channel.cardBrand) + + return logoUrl && brand ? ( + {CARD_BRAND_LABELS[brand]} + ) : ( + channelIcon(channel.channelType) + ) +} + +function channelName(channel: ChannelWithAccountNames) { + if (channel.channelType === "PAYMENT_CARD") + return channel.issuerName || "支付卡" + if (channel.channelType === "E_WALLET") return channel.platform || "电子钱包" + return channel.channelType === "CASH" ? "现金渠道" : "转账渠道" +} + +function formatCardNumber(cardNumber: string) { + return cardNumber + .replace(/\D/g, "") + .replace(/(.{4})/g, "$1 ") + .trim() +} + +export function ChannelsView({ + initialChannels, + initialAccounts, +}: ChannelsViewProps) { + const router = useRouter() + const [channelsList, setChannelsList] = React.useState(initialChannels) + const [searchQuery, setSearchQuery] = React.useState("") + const [typeFilter, setTypeFilter] = React.useState("ALL") + const [dialogOpen, setDialogOpen] = React.useState(false) + const [editingChannel, setEditingChannel] = + React.useState(null) + const [deletingChannel, setDeletingChannel] = + React.useState(null) + const [isDeleting, setIsDeleting] = React.useState(false) + const [togglingId, setTogglingId] = React.useState(null) + const [visibleCardIds, setVisibleCardIds] = React.useState>( + () => new Set() + ) + const [copyStatus, setCopyStatus] = React.useState<{ + channelId: string + message: string + } | null>(null) + + const filteredChannels = React.useMemo(() => { + const query = searchQuery.trim().toLowerCase() + return channelsList.filter((item) => { + const searchable = [ + channelName(item), + item.issuerName, + item.cardBrand, + item.cardNumberSuffix, + item.platform, + item.platformAccountId, + item.region, + item.subChannel, + item.desc, + ...(item.linkedAccounts || []).map((account) => account.name), + ] + .filter(Boolean) + .join(" ") + .toLowerCase() + return ( + (!query || searchable.includes(query)) && + (typeFilter === "ALL" || item.channelType === typeFilter) + ) + }) + }, [channelsList, searchQuery, typeFilter]) + + const openCreate = () => { + setEditingChannel(null) + setDialogOpen(true) + } + const confirmDelete = async () => { + if (!deletingChannel) return + setIsDeleting(true) + try { + const result = await deleteChannelAction(deletingChannel.id) + if (result.success) { + setChannelsList((items) => + items.filter((item) => item.id !== deletingChannel.id) + ) + setDeletingChannel(null) + router.refresh() + } + } finally { + setIsDeleting(false) + } + } + const toggleActive = async ( + channel: ChannelWithAccountNames, + isActive: boolean + ) => { + setTogglingId(channel.id) + setChannelsList((items) => + items.map((item) => + item.id === channel.id ? { ...item, isActive } : item + ) + ) + try { + const result = await toggleChannelActiveAction(channel.id, isActive) + if (!result.success) + setChannelsList((items) => + items.map((item) => + item.id === channel.id ? { ...item, isActive: !isActive } : item + ) + ) + else router.refresh() + } catch { + setChannelsList((items) => + items.map((item) => + item.id === channel.id ? { ...item, isActive: !isActive } : item + ) + ) + } finally { + setTogglingId(null) + } + } + const hasFilters = Boolean(searchQuery) || typeFilter !== "ALL" + const clearFilters = () => { + setSearchQuery("") + setTypeFilter("ALL") + } + const toggleCardVisibility = (channelId: string) => { + setVisibleCardIds((ids) => { + const nextIds = new Set(ids) + if (nextIds.has(channelId)) nextIds.delete(channelId) + else nextIds.add(channelId) + return nextIds + }) + } + const copyCardNumber = async (channel: ChannelWithAccountNames) => { + if (!channel.cardNumberFull) return + try { + await navigator.clipboard.writeText(channel.cardNumberFull) + setCopyStatus({ channelId: channel.id, message: "已复制" }) + } catch { + setCopyStatus({ channelId: channel.id, message: "复制失败" }) + } + } + + return ( + +
+
+
+

+ 支付渠道 +

+

+ 集中查看交易工具与归属资金账户。 +

+
+ +
+
+
+ + setSearchQuery(event.target.value)} + placeholder="搜索渠道、平台、尾号或账户" + className="pl-8" + /> +
+ + {hasFilters && ( + + )} +
+ {filteredChannels.length > 0 ? ( +
+
+ 渠道名称 + 标识 + 类型 + 归属账户 + +
+ {filteredChannels.map((channel) => { + const actionMenu = ( + + + + } + /> + } + > + + + 更多操作 + + + toggleActive(channel, !channel.isActive)} + > + {channel.isActive ? ( + + ) : ( + + )} + {channel.isActive ? "停用渠道" : "启用渠道"} + + { + setEditingChannel(channel) + setDialogOpen(true) + }} + > + + 编辑渠道 + + + setDeletingChannel(channel)} + > + + 删除渠道 + + + + ) + + return ( +
+
+
+ + {channel.channelType === "PAYMENT_CARD" + ? paymentCardIcon(channel) + : channelIcon(channel.channelType)} + +
+
+

+ {channel.desc || + channel.platformAccountId || + channel.subChannel || + ""} +

+ {!channel.isActive && ( + 已停用 + )} +
+
+

+ {channelName(channel)} +

+
+
+
+
{actionMenu}
+
+
+ + 标识 + + + {channel.channelType === "PAYMENT_CARD" && + (channel.cardNumberSuffix || channel.cardNumberFull) ? ( + + + {visibleCardIds.has(channel.id) && + channel.cardNumberFull + ? formatCardNumber(channel.cardNumberFull) + : `•••• ${ + channel.cardNumberSuffix || + formatCardNumber( + channel.cardNumberFull || "" + ).slice(-4) + }`} + + {channel.cardNumberFull && ( + <> + + + toggleCardVisibility(channel.id) + } + /> + } + > + {visibleCardIds.has(channel.id) ? ( + + ) : ( + + )} + + + {visibleCardIds.has(channel.id) + ? "隐藏完整卡号" + : "显示完整卡号"} + + + + copyCardNumber(channel)} + /> + } + > + + + 复制卡号 + + {copyStatus?.channelId === channel.id && ( + + {copyStatus.message} + + )} + + )} + + ) : channel.channelType === "E_WALLET" ? ( + channel.platformAccountId || "" + ) : ( + "" + )} + +
+
+ 类型 + {channelTypeLabels[channel.channelType]} +
+
+ + 归属账户 + + + {channel.linkedAccounts?.length ? ( + channel.linkedAccounts + .map((account) => account.name) + .join("、") + ) : ( + 未关联 + )} + +
+
+ {actionMenu} +
+
+ ) + })} +
+ ) : ( +
+ +

+ {hasFilters ? "没有符合条件的渠道" : "还没有支付渠道"} +

+

+ {hasFilters + ? "调整关键词或筛选条件后再试。" + : "添加渠道后即可在交易中选择对应的支付工具。"} +

+
+ {hasFilters && ( + + )} + +
+
+ )} + router.refresh()} + /> + !open && setDeletingChannel(null)} + > + + + 确认删除渠道 + + 将删除「{deletingChannel ? channelName(deletingChannel) : ""} + 」。历史流水仍会保留,之后不能再选择此渠道。 + + + + + + + + +
+
+ ) +} diff --git a/app/channels/page.tsx b/app/channels/page.tsx new file mode 100644 index 0000000..bb43eac --- /dev/null +++ b/app/channels/page.tsx @@ -0,0 +1,52 @@ +import { redirect } from "next/navigation" +import { auth } from "@/lib/auth" +import { getChannelsAction } from "@/lib/actions/channel" +import { getAccountsAction } from "@/lib/actions/account" +import { AppSidebar } from "@/components/app-sidebar" +import { AppHeader } from "@/components/app-header" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" +import { ChannelsView } from "./channels-view" + +export default async function ChannelsPage() { + const session = await auth() + + if (!session?.user) { + redirect("/login") + } + + const user = session.user + const userName = user.name || "Fluxent 用户" + const userEmail = user.email || "" + const userAvatar = user.image || null + + const [channelsRes, accountsRes] = await Promise.all([ + getChannelsAction(), + getAccountsAction(), + ]) + + const initialChannels = + channelsRes.success && channelsRes.data ? channelsRes.data : [] + const initialAccounts = + accountsRes.success && accountsRes.data ? accountsRes.data : [] + + return ( + + + + + + + + + ) +} diff --git a/app/globals.css b/app/globals.css index f61d740..58bc45c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,4 @@ +@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Inter:wght@100..900&display=swap"); @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; @@ -5,8 +6,9 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --font-heading: var(--font-sans); - --font-sans: var(--font-sans); + --font-heading: var(--font-app-sans); + --font-sans: var(--font-app-sans); + --font-mono: var(--font-app-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -48,6 +50,8 @@ } :root { + --font-app-sans: "Inter", "Noto Sans SC", Arial, sans-serif; + --font-app-mono: "Geist Mono", "SFMono-Regular", Consolas, monospace; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); @@ -129,4 +133,4 @@ html { @apply font-sans; } -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index a4c7321..db6da1d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,6 @@ -import { Geist, Geist_Mono, Inter } from "next/font/google" - import "./globals.css" import { ThemeProvider } from "@/components/theme-provider" -import { cn } from "@/lib/utils"; - -const inter = Inter({subsets:['latin'],variable:'--font-sans'}) - -const fontMono = Geist_Mono({ - subsets: ["latin"], - variable: "--font-mono", -}) +import { TooltipProvider } from "@/components/ui/tooltip" export default function RootLayout({ children, @@ -20,10 +11,12 @@ export default function RootLayout({ - {children} + + {children} + ) diff --git a/app/login/login-form.tsx b/app/login/login-form.tsx new file mode 100644 index 0000000..1bae28a --- /dev/null +++ b/app/login/login-form.tsx @@ -0,0 +1,396 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { + ArrowRightIcon, + CheckCircle2Icon, + CoinsIcon, + Globe2Icon, + KeyRoundIcon, + Loader2Icon, + LockIcon, + MailIcon, + PieChartIcon, + TrendingUpIcon, + WalletIcon, +} from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { FluxentLogo } from "@/components/fluxent-logo"; +import { ThemeToggle } from "@/components/theme-toggle"; + +interface LoginFormProps { + oidcEnabled: boolean; + oidcName: string; +} + +export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") || "/"; + const authError = searchParams.get("error"); + const registered = searchParams.get("registered"); + + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [rememberMe, setRememberMe] = React.useState(false); + const [isPending, setIsPending] = React.useState(false); + const [isOidcPending, setIsOidcPending] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(() => { + if (authError === "CredentialsSignin") { + return "邮箱或密码错误,请核对后重试"; + } + if (authError) { + return "认证过程中遇到问题,请重新登录"; + } + return null; + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMessage(null); + + if (!email.trim()) { + setErrorMessage("请输入登录邮箱"); + return; + } + if (!password) { + setErrorMessage("请输入登录密码"); + return; + } + + try { + setIsPending(true); + const res = await signIn("credentials", { + email: email.trim(), + password, + redirect: false, + callbackUrl, + }); + + if (res?.error) { + if (res.error === "CredentialsSignin" || res.code === "credentials") { + setErrorMessage("账号或密码不正确,请重新检查"); + } else { + setErrorMessage("登录失败,请稍后重试"); + } + return; + } + + router.push(callbackUrl); + router.refresh(); + } catch { + setErrorMessage("网络异常,无法连接到认证服务器"); + } finally { + setIsPending(false); + } + }; + + const handleOidcLogin = async () => { + try { + setIsOidcPending(true); + await signIn("oidc", { callbackUrl }); + } catch { + setIsOidcPending(false); + setErrorMessage("SSO 单点登录发起失败,请稍后重试"); + } + }; + + return ( +
+ {/* Decorative ambient background meshes */} +
+
+
+ + {/* Top navigation / branding header */} +
+ + +
+ + Fluxent + + + Financial OS + +
+ +
+ +
+
+ + {/* Main content grid */} +
+
+ {/* Left Hero feature showcase (visible on large screen) */} +
+
+
+ + 全球多币种 • 全场景记账 • 实时汇率 +
+

+ 掌控每一笔资产流动, +
+ 让财务管理行云流水。 +

+

+ 无论是跨国多币种账户、信用卡记账,还是银行与电子钱包资金调度,Fluxent + 提供银行级的精确记录与现代化的优雅交互体验。 +

+
+ + {/* Visual value propositions */} +
+
+
+ +
+

+ 全币种与汇率追踪 +

+

+ 多币种入账与清算汇率对账,资产估值一目了然 +

+
+ +
+
+ +
+

+ 全渠道多账户管理 +

+

+ 银行账户、电子钱包与支付卡渠道统一调度 +

+
+ +
+
+ +
+

+ 复式记账与场景分类 +

+

+ 标准借贷分录与消费场景画像,财务合规严谨 +

+
+ +
+
+ +
+

+ 资产汇总与统计 +

+

+ 跨账户资金结构分布分析,收支报表一览无余 +

+
+
+
+ + {/* Right Login Card */} +
+ + +
+ + 欢迎回到 Fluxent + +
+ +
+
+ + 输入您的注册邮箱与密码以继续使用 + +
+ + + {registered === "1" && ( + + + 账号注册成功 + + 您的 Fluxent 账户已就绪,请使用新密码进行登录。 + + + )} + + {errorMessage && ( + + + {errorMessage} + + + )} + + {/* OIDC Single Sign On Button (if enabled) */} + {oidcEnabled && ( +
+ + +
+ + + 或者使用邮箱密码 + +
+
+ )} + + {/* Main Email & Password Form */} +
+
+ +
+ setEmail(e.target.value)} + required + disabled={isPending} + className="pl-8" + /> + +
+
+ +
+
+ +
+
+ setPassword(e.target.value)} + required + disabled={isPending} + className="pl-8" + /> + +
+
+ +
+
+ + setRememberMe(Boolean(checked)) + } + /> + +
+
+ + +
+
+ + +

+ 还没有 Fluxent 账号?{" "} + + 立即注册新账号 + + +

+
+
+ +

+ 登录即代表您同意 Fluxent 的服务条款与隐私政策 +

+
+
+
+ + {/* Footer */} +
+ © {new Date().getFullYear()} Fluxent Financial. 保留所有权利。 +
+
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..0a6b82c --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; +import { LoginForm } from "./login-form"; + +export const metadata = { + title: "登录 - Fluxent", + description: "Fluxent 多币种资产与全场景记账系统", +}; + +export default function LoginPage() { + const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true"; + const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)"; + + return ( + +
+
+ } + > + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 98ff035..23a1758 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,19 +1,213 @@ -import { Button } from "@/components/ui/button" +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { + ArrowRightIcon, + CreditCardIcon, + DollarSignIcon, + LayersIcon, + PlusIcon, + SlidersHorizontalIcon, + TrendingUpIcon, + WalletIcon, +} from "lucide-react"; + +import { auth } from "@/lib/auth"; +import { AppSidebar } from "@/components/app-sidebar"; +import { AppHeader } from "@/components/app-header"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar"; + +export default async function HomePage() { + const session = await auth(); + + // 若用户未登录,由于 proxy 会拦截并重定向,作为服务端页面增加双重保障 + if (!session?.user) { + redirect("/login"); + } + + const user = session.user; + const userName = user.name || "Fluxent 用户"; + const userEmail = user.email || ""; + const userAvatar = user.image || null; -export default function Page() { return ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

- + + + + + + {/* Inset Main Dashboard Content */} +
+ {/* Welcome greeting banner */} +
+
+

+ 欢迎回来,{userName} +

+

+ 这是您的 Fluxent 多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。 +

+ +
+ + +
+
+ + {/* Subtle background decoration */} +
+
+ + {/* Quick Metrics & Highlights Cards (3 columns on desktop) */} +
+ + + + 总资产估值 (CNY) + + + + +
¥ 0.00
+

+ 暂未录入资金账户余额 +

+
+
+ + + + + 活跃账户与卡片 + + + + +
0
+

+ 支持银行卡、信用卡、电子钱包 +

+
+
+ + + + + 本月记账笔数 + + + + +
0
+

+ 全场景交易流水自动归集 +

+
+
+
+ + {/* Next Steps / Feature Guide (3 columns) */} +
+

+ 开始搭建您的资产版图 +

+
+ +
+
+ +
+

+ 1. 添加账户与支付渠道 +

+

+ 录入您的现金账户、活期存款,或绑定支持港币、美元、日元结算的跨境信用卡与电子钱包。 +

+
+
+ 去配置账户 + +
+ + +
+
+
+ +
+

+ 2. 建立首笔复式流水 +

+

+ 支持自动计算交易币种到入账币种的清算汇率与手续费,让每一分折损清清楚楚。 +

+
+
+ 新增记账 + +
+
+ +
+
+
+ +
+

+ 3. 账户首选项与设置 +

+

+ 配置个人偏好货币、导出记账数据或连接外部服务。 +

+
+
+ 偏好设置 + +
+
+
+
-
- (Press d to toggle dark mode) -
-
-
- ) + + + ); } diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..35c9d2d --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,21 @@ +import { Suspense } from "react"; +import { RegisterForm } from "./register-form"; + +export const metadata = { + title: "注册新账号 - Fluxent", + description: "创建您的 Fluxent 多币种资产与记账账户", +}; + +export default function RegisterPage() { + return ( + +
+
+ } + > + +
+ ); +} diff --git a/app/register/register-form.tsx b/app/register/register-form.tsx new file mode 100644 index 0000000..d4ca9ac --- /dev/null +++ b/app/register/register-form.tsx @@ -0,0 +1,319 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useActionState } from "react"; +import { + ArrowLeftIcon, + CheckCircle2Icon, + KeyRoundIcon, + Loader2Icon, + LockIcon, + MailIcon, + SparklesIcon, + UserIcon, +} from "lucide-react"; + +import { registerAction, type RegisterState } from "@/lib/actions/auth"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { FluxentLogo } from "@/components/fluxent-logo"; +import { ThemeToggle } from "@/components/theme-toggle"; + +const initialState: RegisterState = {}; + +export function RegisterForm() { + const router = useRouter(); + const [state, formAction, isPending] = useActionState( + registerAction, + initialState + ); + + const [password, setPassword] = React.useState(""); + const [confirmPassword, setConfirmPassword] = React.useState(""); + + // 当注册成功时,优雅倒计时或自动跳转 + React.useEffect(() => { + if (state?.success) { + const timer = setTimeout(() => { + router.push("/login?registered=1"); + }, 1500); + return () => clearTimeout(timer); + } + }, [state?.success, router]); + + // 密码复杂度提示计算 + const hasMinLen = password.length >= 8; + const passwordsMatch = Boolean( + password && confirmPassword && password === confirmPassword + ); + + return ( +
+ {/* Decorative ambient gradients */} +
+
+
+ + {/* Header */} +
+ + +
+ + Fluxent + + + Financial OS + +
+ +
+ +
+
+ + {/* Main Card */} +
+
+ + +
+ + 开启您的 Fluxent 空间 + +
+ +
+
+ + 创建主账户,开启多币种资产追踪与专业级记账 + +
+ + + {/* 注册成功反馈 */} + {state?.success ? ( +
+
+ +
+
+

+ 账户已成功创建! +

+

+ 系统正在为您准备控制台,即将自动跳转至登录页... +

+
+ +
+ ) : ( + <> + {/* 全局错误提示 */} + {state?.error && ( + + + {state.error} + + + )} + +
+ {/* 姓名 / 昵称 */} +
+ +
+ + +
+ {state?.fieldErrors?.name && ( +

+ {state.fieldErrors.name[0]} +

+ )} +
+ + {/* 邮箱 */} +
+ +
+ + +
+ {state?.fieldErrors?.email && ( +

+ {state.fieldErrors.email[0]} +

+ )} +
+ + {/* 密码 */} +
+ +
+ setPassword(e.target.value)} + required + disabled={isPending} + className="pl-8" + /> + +
+ {state?.fieldErrors?.password && ( +

+ {state.fieldErrors.password[0]} +

+ )} +
+ + {/* 确认密码 */} +
+ +
+ setConfirmPassword(e.target.value)} + required + disabled={isPending} + className="pl-8" + /> + +
+ {state?.fieldErrors?.confirmPassword && ( +

+ {state.fieldErrors.confirmPassword[0]} +

+ )} +
+ + {/* 密码强度/匹配指引 */} +
+
+ + 密码长度不少于 8 位 +
+
+ + 两次密码输入保持一致 +
+
+ + +
+ + )} +
+ + +

+ 已经拥有 Fluxent 账号?{" "} + + + 返回直接登录 + +

+
+
+
+
+ + {/* Footer */} +
+ © {new Date().getFullYear()} Fluxent Financial. 保留所有权利。 +
+
+ ); +} diff --git a/components/app-header.tsx b/components/app-header.tsx new file mode 100644 index 0000000..2b555a7 --- /dev/null +++ b/components/app-header.tsx @@ -0,0 +1,42 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" +import { Separator } from "@/components/ui/separator" +import { + SidebarTrigger, +} from "@/components/ui/sidebar" +import { ThemeToggle } from "@/components/theme-toggle" + +export function AppHeader({ title }: { title: string }) { + const isHome = title === "控制台" + + return ( +
+
+ + + + + {!isHome && ( + <> + + 控制台 + + + + )} + + {title} + + + +
+ +
+ ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx new file mode 100644 index 0000000..718ecab --- /dev/null +++ b/components/app-sidebar.tsx @@ -0,0 +1,93 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { + CreditCardIcon, + LayoutDashboardIcon, + WalletCardsIcon, +} from "lucide-react"; + +import { FluxentLogo } from "@/components/fluxent-logo"; +import { NavMain, type NavMainItem } from "@/components/nav-main"; +import { NavUser, type NavUserData } from "@/components/nav-user"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +const navMainItems: NavMainItem[] = [ + { + title: "控制台", + url: "/", + icon: LayoutDashboardIcon, + }, + { + title: "资金账户", + url: "/accounts", + icon: WalletCardsIcon, + }, + { + title: "支付渠道", + url: "/channels", + icon: CreditCardIcon, + }, +]; + +export function AppSidebar({ + user, + ...props +}: { + user: NavUserData; +} & React.ComponentProps) { + const { isMobile, setOpenMobile } = useSidebar(); + + const handleLogoNavigation = () => { + if (isMobile) { + setOpenMobile(false); + } + }; + + return ( + + {/* Sidebar Header: Fluxent Logo & Brand */} + + + + } + className="gap-2.5 hover:bg-sidebar-accent" + > + +
+ + Fluxent + + + 财务操作系统 + +
+
+
+
+
+ + {/* Sidebar Content: Main navigation & secondary links */} + + + + + {/* Sidebar Footer: User profile card with dropdown */} + + + +
+ ); +} diff --git a/components/fluxent-logo.tsx b/components/fluxent-logo.tsx new file mode 100644 index 0000000..b05a13d --- /dev/null +++ b/components/fluxent-logo.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +export function FluxentLogo({ + className, + size = 36, +}: { + className?: string; + size?: number; +}) { + return ( +
+ + {/* Modern streamlined geometric flow icon for Fluxent multi-currency assets */} + + + + + +
+ ); +} diff --git a/components/nav-main.tsx b/components/nav-main.tsx new file mode 100644 index 0000000..c1c4d77 --- /dev/null +++ b/components/nav-main.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { type LucideIcon } from "lucide-react"; + +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +export interface NavMainItem { + title: string; + url: string; + icon: LucideIcon; + badge?: string; +} + +export function NavMain({ items }: { items: NavMainItem[] }) { + const pathname = usePathname(); + const { isMobile, setOpenMobile } = useSidebar(); + + const handleNavigation = () => { + if (isMobile) { + setOpenMobile(false); + } + }; + + return ( + + + 核心功能 + + + {items.map((item) => { + const Icon = item.icon; + return ( + + } + > + + {item.title} + {item.badge && ( + + {item.badge} + + )} + + + ); + })} + + + ); +} diff --git a/components/nav-secondary.tsx b/components/nav-secondary.tsx new file mode 100644 index 0000000..61f2e03 --- /dev/null +++ b/components/nav-secondary.tsx @@ -0,0 +1,65 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { type LucideIcon } from "lucide-react"; + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +export interface NavSecondaryItem { + title: string; + url: string; + icon: LucideIcon; + badge?: React.ReactNode; +} + +export function NavSecondary({ + items, + className, + ...props +}: { + items: NavSecondaryItem[]; +} & React.ComponentProps) { + const pathname = usePathname(); + const { isMobile, setOpenMobile } = useSidebar(); + + const handleNavigation = () => { + if (isMobile) { + setOpenMobile(false); + } + }; + + return ( + + + + {items.map((item) => { + const Icon = item.icon; + return ( + + } + > + + {item.title} + + + ); + })} + + + + ); +} diff --git a/components/nav-user.tsx b/components/nav-user.tsx new file mode 100644 index 0000000..65ef301 --- /dev/null +++ b/components/nav-user.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { + ChevronsUpDownIcon, + LogOutIcon, + Settings2Icon, + SparklesIcon, + UserCogIcon, +} from "lucide-react"; +import { signOut } from "next-auth/react"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +export interface NavUserData { + name: string; + email: string; + avatar?: string | null; +} + +export function NavUser({ user }: { user: NavUserData }) { + const { isMobile } = useSidebar(); + const initials = (user.name || user.email || "U") + .slice(0, 2) + .toUpperCase(); + + const handleSignOut = () => { + signOut({ callbackUrl: "/login" }); + }; + + return ( + + + + + } + > + + {user.avatar ? ( + + ) : null} + + {initials} + + +
+ + {user.name} + + + {user.email} + +
+ +
+ + + +
+ + {user.avatar ? ( + + ) : null} + + {initials} + + +
+ + {user.name} + + + {user.email} + +
+
+
+ + + + + + + 升级至专业版 + + + + + + + + + 个人账户设置 + + + + 系统偏好 + + + + + + + + 退出登录 + +
+
+
+
+ ); +} diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000..dcfa1d8 --- /dev/null +++ b/components/theme-toggle.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as React from "react"; +import { useTheme } from "next-themes"; +import { MoonIcon, SunIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const mounted = React.useSyncExternalStore( + () => () => {}, + () => true, + () => false + ); + + if (!mounted) { + return ( + + ); + } + + const isDark = resolvedTheme === "dark"; + + return ( + + ); +} + diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..17ba728 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,75 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..3afe8a9 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" +import { cn } from "cn" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..6bc28c3 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,51 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..b678e78 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,124 @@ +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cn } from "cn" +import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" + +function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { + return ( +