chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
1
web/.env.example
Normal file
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8083
|
||||
42
web/.gitignore
vendored
Normal file
42
web/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
web/README.md
Normal file
36
web/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to self-host [Inter](https://rsms.me/inter/) for UI text and [IBM Plex Mono](https://www.ibm.com/plex/mono/) for monospace text, both declared once in `lib/fonts.ts` and wired into the design tokens through `--font-app-sans` / `--font-app-mono`. Latin and Cyrillic subsets are included so the `zh-CN`, `en-US`, and `ru-RU` locales all render from the same font files.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
368
web/app/(dashboard)/dashboard.css
Normal file
368
web/app/(dashboard)/dashboard.css
Normal file
@@ -0,0 +1,368 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-app-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
--font-mono: var(--font-app-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root,
|
||||
:root[data-palette="plain"] {
|
||||
--radius: 0.5rem;
|
||||
--background: #f9f9f9;
|
||||
--foreground: #151923;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #151923;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #151923;
|
||||
--primary: #2475fc;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #e6efff;
|
||||
--secondary-foreground: #155be8;
|
||||
--muted: #f4f7fc;
|
||||
--muted-foreground: #667085;
|
||||
--accent: #e6efff;
|
||||
--accent-foreground: #155be8;
|
||||
--destructive: oklch(0.59 0.225 25);
|
||||
--border: #dfe7f3;
|
||||
--input: #dfe7f3;
|
||||
--ring: #2475fc;
|
||||
--chart-1: #2475fc;
|
||||
--chart-2: #14b8a6;
|
||||
--chart-3: #f59e0b;
|
||||
--chart-4: #ec4899;
|
||||
--chart-5: #8b5cf6;
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #273142;
|
||||
--sidebar-primary: #2475fc;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #e6efff;
|
||||
--sidebar-accent-foreground: #155be8;
|
||||
--sidebar-border: #dfe7f3;
|
||||
--sidebar-ring: #2475fc;
|
||||
}
|
||||
|
||||
.dark[data-palette="plain"] {
|
||||
--background: #111827;
|
||||
--foreground: #f9fafb;
|
||||
--card: #182235;
|
||||
--card-foreground: #f9fafb;
|
||||
--popover: #182235;
|
||||
--popover-foreground: #f9fafb;
|
||||
--primary: #6da2ff;
|
||||
--primary-foreground: #0f172a;
|
||||
--secondary: #1f3354;
|
||||
--secondary-foreground: #dbe8ff;
|
||||
--muted: #1f2937;
|
||||
--muted-foreground: #9ca3af;
|
||||
--accent: #1f3354;
|
||||
--accent-foreground: #dbe8ff;
|
||||
--destructive: oklch(0.68 0.19 25);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: #6da2ff;
|
||||
--chart-1: #6da2ff;
|
||||
--chart-2: #2dd4bf;
|
||||
--chart-3: #fbbf24;
|
||||
--chart-4: #f472b6;
|
||||
--chart-5: #a78bfa;
|
||||
--sidebar: #111827;
|
||||
--sidebar-foreground: #f3f4f6;
|
||||
--sidebar-primary: #6da2ff;
|
||||
--sidebar-primary-foreground: #0f172a;
|
||||
--sidebar-accent: #1f3354;
|
||||
--sidebar-accent-foreground: #dbe8ff;
|
||||
--sidebar-border: oklch(1 0 0 / 9%);
|
||||
--sidebar-ring: #6da2ff;
|
||||
}
|
||||
|
||||
:root[data-palette="green"] {
|
||||
--background: oklch(0.973 0.011 112);
|
||||
--foreground: oklch(0.22 0.021 210);
|
||||
--card: oklch(0.996 0.004 105);
|
||||
--card-foreground: oklch(0.22 0.021 210);
|
||||
--popover: oklch(0.996 0.004 105);
|
||||
--popover-foreground: oklch(0.22 0.021 210);
|
||||
--primary: oklch(0.475 0.118 178);
|
||||
--primary-foreground: oklch(0.985 0.012 170);
|
||||
--secondary: oklch(0.94 0.018 124);
|
||||
--secondary-foreground: oklch(0.255 0.035 178);
|
||||
--muted: oklch(0.942 0.012 115);
|
||||
--muted-foreground: oklch(0.49 0.026 205);
|
||||
--accent: oklch(0.91 0.028 169);
|
||||
--accent-foreground: oklch(0.245 0.045 178);
|
||||
--destructive: oklch(0.59 0.225 25);
|
||||
--border: oklch(0.872 0.018 116);
|
||||
--input: oklch(0.872 0.018 116);
|
||||
--ring: oklch(0.56 0.115 178);
|
||||
--chart-1: oklch(0.52 0.13 178);
|
||||
--chart-2: oklch(0.61 0.14 220);
|
||||
--chart-3: oklch(0.72 0.15 78);
|
||||
--chart-4: oklch(0.62 0.15 330);
|
||||
--chart-5: oklch(0.58 0.12 262);
|
||||
--sidebar: oklch(0.925 0.018 118);
|
||||
--sidebar-foreground: oklch(0.245 0.03 190);
|
||||
--sidebar-primary: oklch(0.475 0.118 178);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.012 170);
|
||||
--sidebar-accent: oklch(0.89 0.032 156);
|
||||
--sidebar-accent-foreground: oklch(0.235 0.048 178);
|
||||
--sidebar-border: oklch(0.835 0.022 118);
|
||||
--sidebar-ring: oklch(0.56 0.115 178);
|
||||
}
|
||||
|
||||
:root[data-palette="blue"] {
|
||||
--background: oklch(0.982 0.007 250);
|
||||
--foreground: oklch(0.205 0.018 255);
|
||||
--card: oklch(0.998 0.002 250);
|
||||
--card-foreground: oklch(0.205 0.018 255);
|
||||
--popover: oklch(0.998 0.002 250);
|
||||
--popover-foreground: oklch(0.205 0.018 255);
|
||||
--primary: oklch(0.548 0.19 257);
|
||||
--primary-foreground: oklch(0.985 0.006 248);
|
||||
--secondary: oklch(0.948 0.013 250);
|
||||
--secondary-foreground: oklch(0.245 0.02 252);
|
||||
--muted: oklch(0.95 0.01 250);
|
||||
--muted-foreground: oklch(0.49 0.024 245);
|
||||
--accent: oklch(0.922 0.026 255);
|
||||
--accent-foreground: oklch(0.255 0.044 254);
|
||||
--destructive: oklch(0.59 0.225 25);
|
||||
--border: oklch(0.888 0.014 250);
|
||||
--input: oklch(0.888 0.014 250);
|
||||
--ring: oklch(0.61 0.15 252);
|
||||
--chart-1: oklch(0.59 0.18 255);
|
||||
--chart-2: oklch(0.62 0.13 178);
|
||||
--chart-3: oklch(0.72 0.16 82);
|
||||
--chart-4: oklch(0.61 0.17 333);
|
||||
--chart-5: oklch(0.56 0.13 285);
|
||||
--sidebar: oklch(0.94 0.016 250);
|
||||
--sidebar-foreground: oklch(0.245 0.024 252);
|
||||
--sidebar-primary: oklch(0.548 0.19 257);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.006 248);
|
||||
--sidebar-accent: oklch(0.908 0.025 252);
|
||||
--sidebar-accent-foreground: oklch(0.245 0.038 254);
|
||||
--sidebar-border: oklch(0.858 0.018 250);
|
||||
--sidebar-ring: oklch(0.61 0.15 252);
|
||||
}
|
||||
|
||||
:root[data-palette="gray"] {
|
||||
--background: oklch(0.974 0.006 170);
|
||||
--foreground: oklch(0.205 0.018 255);
|
||||
--card: oklch(0.998 0.001 106);
|
||||
--card-foreground: oklch(0.205 0.018 255);
|
||||
--popover: oklch(0.998 0.001 106);
|
||||
--popover-foreground: oklch(0.205 0.018 255);
|
||||
--primary: oklch(0.538 0.183 257);
|
||||
--primary-foreground: oklch(0.985 0.006 248);
|
||||
--secondary: oklch(0.944 0.011 175);
|
||||
--secondary-foreground: oklch(0.245 0.018 250);
|
||||
--muted: oklch(0.948 0.008 180);
|
||||
--muted-foreground: oklch(0.49 0.024 245);
|
||||
--accent: oklch(0.925 0.018 218);
|
||||
--accent-foreground: oklch(0.255 0.033 250);
|
||||
--destructive: oklch(0.59 0.225 25);
|
||||
--border: oklch(0.886 0.012 178);
|
||||
--input: oklch(0.886 0.012 178);
|
||||
--ring: oklch(0.61 0.15 252);
|
||||
--chart-1: oklch(0.59 0.18 255);
|
||||
--chart-2: oklch(0.62 0.13 178);
|
||||
--chart-3: oklch(0.72 0.16 82);
|
||||
--chart-4: oklch(0.61 0.17 333);
|
||||
--chart-5: oklch(0.56 0.13 285);
|
||||
--sidebar: oklch(0.94 0.011 170);
|
||||
--sidebar-foreground: oklch(0.255 0.022 250);
|
||||
--sidebar-primary: oklch(0.538 0.183 257);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.006 248);
|
||||
--sidebar-accent: oklch(0.904 0.017 190);
|
||||
--sidebar-accent-foreground: oklch(0.245 0.028 250);
|
||||
--sidebar-border: oklch(0.86 0.014 176);
|
||||
--sidebar-ring: oklch(0.61 0.15 252);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.18 0 0);
|
||||
--foreground: oklch(0.94 0 0);
|
||||
--card: oklch(0.225 0 0);
|
||||
--card-foreground: oklch(0.94 0 0);
|
||||
--popover: oklch(0.225 0 0);
|
||||
--popover-foreground: oklch(0.94 0 0);
|
||||
--primary: oklch(0.82 0 0);
|
||||
--primary-foreground: oklch(0.18 0 0);
|
||||
--secondary: oklch(0.285 0 0);
|
||||
--secondary-foreground: oklch(0.94 0 0);
|
||||
--muted: oklch(0.285 0 0);
|
||||
--muted-foreground: oklch(0.7 0 0);
|
||||
--accent: oklch(0.32 0 0);
|
||||
--accent-foreground: oklch(0.94 0 0);
|
||||
--destructive: oklch(0.68 0.19 25);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: oklch(0.62 0 0);
|
||||
--chart-1: oklch(0.82 0 0);
|
||||
--chart-2: oklch(0.72 0 0);
|
||||
--chart-3: oklch(0.62 0 0);
|
||||
--chart-4: oklch(0.52 0 0);
|
||||
--chart-5: oklch(0.42 0 0);
|
||||
--sidebar: oklch(0.16 0 0);
|
||||
--sidebar-foreground: oklch(0.93 0 0);
|
||||
--sidebar-primary: oklch(0.82 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.18 0 0);
|
||||
--sidebar-accent: oklch(0.27 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.94 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 9%);
|
||||
--sidebar-ring: oklch(0.62 0 0);
|
||||
}
|
||||
|
||||
.dark[data-palette="green"] {
|
||||
--background: oklch(0.18 0.016 190);
|
||||
--foreground: oklch(0.94 0.009 155);
|
||||
--card: oklch(0.225 0.018 190);
|
||||
--card-foreground: oklch(0.94 0.009 155);
|
||||
--popover: oklch(0.225 0.018 190);
|
||||
--popover-foreground: oklch(0.94 0.009 155);
|
||||
--primary: oklch(0.67 0.13 174);
|
||||
--primary-foreground: oklch(0.16 0.024 185);
|
||||
--secondary: oklch(0.285 0.019 185);
|
||||
--secondary-foreground: oklch(0.94 0.009 155);
|
||||
--muted: oklch(0.285 0.019 185);
|
||||
--muted-foreground: oklch(0.72 0.02 165);
|
||||
--accent: oklch(0.33 0.032 174);
|
||||
--accent-foreground: oklch(0.955 0.011 155);
|
||||
--destructive: oklch(0.68 0.19 25);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: oklch(0.67 0.13 174);
|
||||
--chart-1: oklch(0.68 0.13 174);
|
||||
--chart-2: oklch(0.68 0.13 220);
|
||||
--chart-3: oklch(0.75 0.15 78);
|
||||
--chart-4: oklch(0.68 0.16 333);
|
||||
--chart-5: oklch(0.67 0.13 262);
|
||||
--sidebar: oklch(0.16 0.019 185);
|
||||
--sidebar-foreground: oklch(0.93 0.01 155);
|
||||
--sidebar-primary: oklch(0.67 0.13 174);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.024 185);
|
||||
--sidebar-accent: oklch(0.27 0.028 178);
|
||||
--sidebar-accent-foreground: oklch(0.955 0.011 155);
|
||||
--sidebar-border: oklch(1 0 0 / 9%);
|
||||
--sidebar-ring: oklch(0.67 0.13 174);
|
||||
}
|
||||
|
||||
.dark[data-palette="blue"] {
|
||||
--background: oklch(0.178 0.012 245);
|
||||
--foreground: oklch(0.948 0.006 230);
|
||||
--card: oklch(0.226 0.014 245);
|
||||
--card-foreground: oklch(0.948 0.006 230);
|
||||
--popover: oklch(0.226 0.014 245);
|
||||
--popover-foreground: oklch(0.948 0.006 230);
|
||||
--primary: oklch(0.66 0.16 252);
|
||||
--primary-foreground: oklch(0.168 0.015 250);
|
||||
--secondary: oklch(0.285 0.014 245);
|
||||
--secondary-foreground: oklch(0.94 0.006 230);
|
||||
--muted: oklch(0.285 0.014 245);
|
||||
--muted-foreground: oklch(0.72 0.015 238);
|
||||
--accent: oklch(0.32 0.022 225);
|
||||
--accent-foreground: oklch(0.955 0.006 230);
|
||||
--destructive: oklch(0.68 0.19 25);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: oklch(0.66 0.16 252);
|
||||
--chart-1: oklch(0.69 0.15 252);
|
||||
--chart-2: oklch(0.68 0.12 178);
|
||||
--chart-3: oklch(0.75 0.15 82);
|
||||
--chart-4: oklch(0.68 0.16 333);
|
||||
--chart-5: oklch(0.67 0.13 285);
|
||||
--sidebar: oklch(0.158 0.012 248);
|
||||
--sidebar-foreground: oklch(0.93 0.006 230);
|
||||
--sidebar-primary: oklch(0.66 0.16 252);
|
||||
--sidebar-primary-foreground: oklch(0.168 0.015 250);
|
||||
--sidebar-accent: oklch(0.255 0.017 245);
|
||||
--sidebar-accent-foreground: oklch(0.955 0.006 230);
|
||||
--sidebar-border: oklch(1 0 0 / 9%);
|
||||
--sidebar-ring: oklch(0.66 0.16 252);
|
||||
}
|
||||
|
||||
.dark[data-palette="gray"] {
|
||||
--background: oklch(0.178 0.012 245);
|
||||
--foreground: oklch(0.948 0.006 230);
|
||||
--card: oklch(0.226 0.014 245);
|
||||
--card-foreground: oklch(0.948 0.006 230);
|
||||
--popover: oklch(0.226 0.014 245);
|
||||
--popover-foreground: oklch(0.948 0.006 230);
|
||||
--primary: oklch(0.66 0.16 252);
|
||||
--primary-foreground: oklch(0.168 0.015 250);
|
||||
--secondary: oklch(0.285 0.014 245);
|
||||
--secondary-foreground: oklch(0.94 0.006 230);
|
||||
--muted: oklch(0.285 0.014 245);
|
||||
--muted-foreground: oklch(0.72 0.015 238);
|
||||
--accent: oklch(0.32 0.022 225);
|
||||
--accent-foreground: oklch(0.955 0.006 230);
|
||||
--destructive: oklch(0.68 0.19 25);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: oklch(0.66 0.16 252);
|
||||
--chart-1: oklch(0.69 0.15 252);
|
||||
--chart-2: oklch(0.68 0.12 178);
|
||||
--chart-3: oklch(0.75 0.15 82);
|
||||
--chart-4: oklch(0.68 0.16 333);
|
||||
--chart-5: oklch(0.67 0.13 285);
|
||||
--sidebar: oklch(0.158 0.012 248);
|
||||
--sidebar-foreground: oklch(0.93 0.006 230);
|
||||
--sidebar-primary: oklch(0.66 0.16 252);
|
||||
--sidebar-primary-foreground: oklch(0.168 0.015 250);
|
||||
--sidebar-accent: oklch(0.255 0.017 245);
|
||||
--sidebar-accent-foreground: oklch(0.955 0.006 230);
|
||||
--sidebar-border: oklch(1 0 0 / 9%);
|
||||
--sidebar-ring: oklch(0.66 0.16 252);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
74
web/app/(dashboard)/dashboard/_components/alert-list.tsx
Normal file
74
web/app/(dashboard)/dashboard/_components/alert-list.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { ArrowRightIcon, ShieldAlertIcon } from "lucide-react"
|
||||
|
||||
import type { DashboardAlert } from "@/lib/api/dashboard"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type AlertListProps = {
|
||||
alerts: DashboardAlert[]
|
||||
}
|
||||
|
||||
function getAlertBadgeVariant(level: DashboardAlert["level"]) {
|
||||
if (level === "error") {
|
||||
return "destructive" as const
|
||||
}
|
||||
if (level === "warning") {
|
||||
return "secondary" as const
|
||||
}
|
||||
return "outline" as const
|
||||
}
|
||||
|
||||
export function AlertList({ alerts }: AlertListProps) {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<Card className="rounded-md shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboardHome.riskAlerts")}</CardTitle>
|
||||
<CardDescription>{t("dashboardHome.riskAlertsDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-10 text-center">
|
||||
<ShieldAlertIcon className="mx-auto mb-3 size-8 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">{t("dashboardHome.noRiskTitle")}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{t("dashboardHome.noRiskDescription")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
alerts.map((item) => (
|
||||
<Link key={item.id} href={item.link} className="block">
|
||||
<div className="rounded-md border p-4 transition-colors hover:border-primary/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">{item.title}</div>
|
||||
<Badge variant={getAlertBadgeVariant(item.level)}>
|
||||
{item.count}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="mt-0.5 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
169
web/app/(dashboard)/dashboard/_components/dashboard-home.tsx
Normal file
169
web/app/(dashboard)/dashboard/_components/dashboard-home.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
fetchDashboardOverview,
|
||||
type DashboardOverview,
|
||||
type DashboardRange,
|
||||
} from "@/lib/api/dashboard"
|
||||
import { SummaryCards } from "./summary-cards"
|
||||
import { TrendPanel } from "./trend-panel"
|
||||
import { TeamLoadPanel } from "./team-load-panel"
|
||||
import { AlertList } from "./alert-list"
|
||||
import { SetupReadinessBanner } from "./setup-readiness-banner"
|
||||
|
||||
function LoadingCards() {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Card key={index} className="rounded-md shadow-none">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardHome() {
|
||||
const t = useI18n()
|
||||
const [range, setRange] = useState<DashboardRange>("7d")
|
||||
const [data, setData] = useState<DashboardOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const loadData = useCallback(
|
||||
async (nextRange: DashboardRange, showRefreshing = false) => {
|
||||
if (showRefreshing) {
|
||||
setRefreshing(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
try {
|
||||
const result = await fetchDashboardOverview(nextRange)
|
||||
setData(result)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("dashboardHome.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(range)
|
||||
}, [loadData, range])
|
||||
|
||||
const rangeOptions: Array<{ value: DashboardRange; label: string }> = [
|
||||
{ value: "today", label: t("dashboardHome.rangeToday") },
|
||||
{ value: "7d", label: t("dashboardHome.range7d") },
|
||||
{ value: "30d", label: t("dashboardHome.range30d") },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 lg:p-5">
|
||||
<SetupReadinessBanner onUpdated={() => void loadData(range, true)} />
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="rounded-md border bg-background p-1">
|
||||
{rangeOptions.map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant={range === item.value ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setRange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadData(range, true)}
|
||||
disabled={loading || refreshing}
|
||||
>
|
||||
<RefreshCwIcon className={refreshing ? "mr-2 size-4 animate-spin" : "mr-2 size-4"} />
|
||||
{t("dashboardHome.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<LoadingCards />
|
||||
) : data ? (
|
||||
<>
|
||||
<SummaryCards summary={data.summary} />
|
||||
|
||||
<TrendPanel
|
||||
title={t("dashboardHome.conversationTrend")}
|
||||
description={t("dashboardHome.conversationTrendDescription")}
|
||||
trend={data.conversationStats.trend}
|
||||
distribution={data.conversationStats.statusDistribution}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<TeamLoadPanel agentStats={data.agentStats} />
|
||||
|
||||
<Card className="rounded-md shadow-none">
|
||||
<CardContent className="grid gap-3 p-4 sm:grid-cols-2">
|
||||
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">{t("dashboardHome.enabledAiAgents")}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{data.aiStats.enabledAiAgents}</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">{t("dashboardHome.enabledChannels")}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{data.aiStats.enabledChannels}</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieves")}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{data.aiStats.todayKnowledgeRetrieves}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieveFailRate")}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{data.aiStats.todayKnowledgeRetrieveFailRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayAgentRunFailCount")}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{data.aiStats.todayAgentRunFailCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayAiHandoffCount")}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{data.aiStats.todayAiHandoffCount}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AlertList alerts={data.alerts} />
|
||||
</>
|
||||
) : (
|
||||
<Card className="rounded-md shadow-none">
|
||||
<CardContent className="flex min-h-60 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
{t("dashboardHome.empty")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
Loader2Icon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
notifySetupReadinessUpdated,
|
||||
SETUP_READINESS_UPDATED_EVENT,
|
||||
} from "@/components/operator-readiness-indicator"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
type SetupReadinessResponse,
|
||||
} from "@/lib/api/setup-readiness"
|
||||
|
||||
export function SetupReadinessBanner({
|
||||
onUpdated,
|
||||
}: {
|
||||
onUpdated?: () => void
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [fixing, setFixing] = useState(false)
|
||||
|
||||
const loadReadiness = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSetupReadiness()
|
||||
setReadiness(data)
|
||||
} catch {
|
||||
// Ignore in banner if failed
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadReadiness()
|
||||
const handleUpdated = () => {
|
||||
void loadReadiness()
|
||||
}
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
}
|
||||
}, [loadReadiness])
|
||||
|
||||
async function handleQuickSetupAll() {
|
||||
setFixing(true)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action: "quick_setup_all" })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
onUpdated?.()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setFixing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!readiness) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allReady =
|
||||
readiness.can_receive_requests_now &&
|
||||
readiness.current_operator.can_receive_now &&
|
||||
readiness.overall_status === "ok"
|
||||
|
||||
if (allReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
const canQuickSetupAll = readiness.steps.some((s) =>
|
||||
s.quick_fix_actions.includes("quick_setup_all"),
|
||||
)
|
||||
|
||||
const incompleteSteps = readiness.steps.filter((s) => s.status !== "ok")
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AlertTriangleIcon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t("setupWizard.banner.title")}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-md border-amber-500/40 bg-amber-500/10 text-xs text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{t("setupWizard.stepsProgress", {
|
||||
ready: readiness.ready_steps,
|
||||
total: readiness.total_steps,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("setupWizard.banner.description")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{incompleteSteps.map((step) => (
|
||||
<Badge
|
||||
key={step.key}
|
||||
variant="secondary"
|
||||
className="rounded-md text-xs font-normal"
|
||||
>
|
||||
{step.step_number}. {t(`setupWizard.steps.${step.key}.title`)}
|
||||
</Badge>
|
||||
))}
|
||||
{!readiness.current_operator.can_receive_now
|
||||
? readiness.current_operator.blocker_codes.map((code) => (
|
||||
<Badge
|
||||
key={code}
|
||||
variant="outline"
|
||||
className="rounded-md border-amber-500/30 text-xs font-normal text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{t(`setupWizard.operatorBlockers.${code}`)}
|
||||
</Badge>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{canQuickSetupAll ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-md"
|
||||
disabled={fixing}
|
||||
onClick={() => void handleQuickSetupAll()}
|
||||
>
|
||||
{fixing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{fixing
|
||||
? t("setupWizard.quickSetupRunning")
|
||||
: t("setupWizard.quickSetupAll")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-md"
|
||||
render={<Link href="/dashboard/setup" />}
|
||||
>
|
||||
<span>{t("setupWizard.openSetupWizard")}</span>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CheckCircle2Icon }
|
||||
108
web/app/(dashboard)/dashboard/_components/summary-cards.tsx
Normal file
108
web/app/(dashboard)/dashboard/_components/summary-cards.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {
|
||||
BotMessageSquareIcon,
|
||||
CircleDashedIcon,
|
||||
HeadsetIcon,
|
||||
SparklesIcon,
|
||||
WavesIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { DashboardOverview } from "@/lib/api/dashboard"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type SummaryCardsProps = {
|
||||
summary: DashboardOverview["summary"]
|
||||
}
|
||||
|
||||
type SummaryCardItem = {
|
||||
key: keyof DashboardOverview["summary"]
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
link: string
|
||||
icon: typeof BotMessageSquareIcon
|
||||
format?: (value: number) => string
|
||||
}
|
||||
|
||||
const cards: SummaryCardItem[] = [
|
||||
{
|
||||
key: "todayNewConversations",
|
||||
titleKey: "dashboardHome.summaryTodayNewConversations",
|
||||
descriptionKey: "dashboardHome.summaryTodayNewConversationsDescription",
|
||||
link: "/dashboard/conversations",
|
||||
icon: BotMessageSquareIcon,
|
||||
},
|
||||
{
|
||||
key: "processingConversations",
|
||||
titleKey: "dashboardHome.summaryProcessingConversations",
|
||||
descriptionKey: "dashboardHome.summaryProcessingConversationsDescription",
|
||||
link: "/dashboard/conversations",
|
||||
icon: WavesIcon,
|
||||
},
|
||||
{
|
||||
key: "pendingDispatchConversations",
|
||||
titleKey: "dashboardHome.summaryPendingDispatchConversations",
|
||||
descriptionKey: "dashboardHome.summaryPendingDispatchConversationsDescription",
|
||||
link: "/dashboard/conversations",
|
||||
icon: CircleDashedIcon,
|
||||
},
|
||||
{
|
||||
key: "onlineAgents",
|
||||
titleKey: "dashboardHome.summaryOnlineAgents",
|
||||
descriptionKey: "dashboardHome.summaryOnlineAgentsDescription",
|
||||
link: "/dashboard/agents",
|
||||
icon: HeadsetIcon,
|
||||
},
|
||||
{
|
||||
key: "aiServiceRate",
|
||||
titleKey: "dashboardHome.summaryAiServiceRate",
|
||||
descriptionKey: "dashboardHome.summaryAiServiceRateDescription",
|
||||
link: "/dashboard/ai-agents",
|
||||
icon: SparklesIcon,
|
||||
format: (value: number) => `${value.toFixed(1)}%`,
|
||||
},
|
||||
]
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
{cards.map((item) => {
|
||||
const Icon = item.icon
|
||||
const rawValue = summary[item.key]
|
||||
const value =
|
||||
typeof item.format === "function"
|
||||
? item.format(Number(rawValue))
|
||||
: Number(rawValue).toLocaleString()
|
||||
|
||||
return (
|
||||
<Link key={item.key} href={item.link}>
|
||||
<Card className="h-full rounded-md shadow-none transition-colors hover:border-primary/40">
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 p-4 pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-sm font-medium">{t(item.titleKey)}</CardTitle>
|
||||
<CardDescription>{t(item.descriptionKey)}</CardDescription>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted p-2 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="text-2xl font-semibold tracking-tight">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
web/app/(dashboard)/dashboard/_components/team-load-panel.tsx
Normal file
112
web/app/(dashboard)/dashboard/_components/team-load-panel.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import type { DashboardOverview } from "@/lib/api/dashboard"
|
||||
|
||||
type TeamLoadPanelProps = {
|
||||
agentStats: DashboardOverview["agentStats"]
|
||||
}
|
||||
|
||||
function getLoadTone(loadRate: number) {
|
||||
if (loadRate >= 85) {
|
||||
return "bg-red-500"
|
||||
}
|
||||
if (loadRate >= 60) {
|
||||
return "bg-amber-500"
|
||||
}
|
||||
return "bg-emerald-500"
|
||||
}
|
||||
|
||||
export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<Card className="rounded-md shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboardHome.teamLoad")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("dashboardHome.teamLoadDescription", {
|
||||
online: agentStats.onlineAgents,
|
||||
busy: agentStats.busyAgents,
|
||||
offline: agentStats.offlineAgents,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{agentStats.teamLoads.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("dashboardHome.noTeamData")}
|
||||
</div>
|
||||
) : (
|
||||
agentStats.teamLoads.map((item) => (
|
||||
<div key={item.teamId} className="rounded-md border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">{item.teamName}</div>
|
||||
{item.hasScheduleNow ? (
|
||||
<Badge variant="secondary">{t("dashboardHome.scheduled")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("dashboardHome.notScheduled")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{t("dashboardHome.teamStats", {
|
||||
total: item.totalAgents,
|
||||
online: item.onlineAgents,
|
||||
busy: item.busyAgents,
|
||||
offline: item.offlineAgents,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-semibold">{item.loadRate.toFixed(1)}%</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("dashboardHome.load", {
|
||||
processing: item.processingConversations,
|
||||
capacity: item.maxConcurrentCapacity || 0,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-2 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getLoadTone(item.loadRate)}`}
|
||||
style={{ width: `${Math.min(item.loadRate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||
<div className="text-muted-foreground">{t("dashboardHome.waiting")}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{item.waitingConversations}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||
<div className="text-muted-foreground">{t("dashboardHome.processing")}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{item.processingConversations}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||
<div className="text-muted-foreground">{t("dashboardHome.capacity")}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{item.maxConcurrentCapacity}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||
<div className="text-muted-foreground">{t("dashboardHome.busyAgents")}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{item.busyAgents}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
141
web/app/(dashboard)/dashboard/_components/trend-panel.tsx
Normal file
141
web/app/(dashboard)/dashboard/_components/trend-panel.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||
|
||||
import type { DashboardStatusDistributionItem, DashboardTrendItem } from "@/lib/api/dashboard"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type TrendPanelProps = {
|
||||
title: string
|
||||
description: string
|
||||
trend: DashboardTrendItem[]
|
||||
distribution: DashboardStatusDistributionItem[]
|
||||
}
|
||||
|
||||
export function TrendPanel({
|
||||
title,
|
||||
description,
|
||||
trend,
|
||||
distribution,
|
||||
}: TrendPanelProps) {
|
||||
const t = useI18n()
|
||||
const trendConfig = {
|
||||
newCount: {
|
||||
label: t("dashboardHome.chartNew"),
|
||||
color: "hsl(24 95% 53%)",
|
||||
},
|
||||
closedCount: {
|
||||
label: t("dashboardHome.chartClosed"),
|
||||
color: "hsl(190 95% 39%)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
const distributionConfig = {
|
||||
count: {
|
||||
label: t("dashboardHome.chartCount"),
|
||||
theme: {
|
||||
light: "hsl(222 47% 11%)",
|
||||
dark: "hsl(210 40% 98%)",
|
||||
},
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
const localizedDistribution = distribution.map((item) => ({
|
||||
...item,
|
||||
label: getStatusLabel(item.status, item.label, t),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[1.5fr_0.9fr]">
|
||||
<Card className="rounded-md shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={trendConfig} className="h-72 w-full">
|
||||
<AreaChart data={trend}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="newCount"
|
||||
stroke="var(--color-newCount)"
|
||||
fill="var(--color-newCount)"
|
||||
fillOpacity={0.18}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="closedCount"
|
||||
stroke="var(--color-closedCount)"
|
||||
fill="var(--color-closedCount)"
|
||||
fillOpacity={0.08}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-md shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboardHome.statusDistribution")}</CardTitle>
|
||||
<CardDescription>{t("dashboardHome.statusDistributionDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ChartContainer config={distributionConfig} className="h-72 w-full">
|
||||
<BarChart data={localizedDistribution} layout="vertical" margin={{ left: 20 }}>
|
||||
<CartesianGrid horizontal={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={64}
|
||||
/>
|
||||
<XAxis type="number" hide />
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="var(--color-count)"
|
||||
radius={[0, 8, 8, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusLabel(
|
||||
status: number,
|
||||
fallback: string,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return t("dashboardHome.statusAiServing")
|
||||
case 2:
|
||||
return t("dashboardHome.statusPending")
|
||||
case 3:
|
||||
return t("dashboardHome.statusActive")
|
||||
case 4:
|
||||
return t("dashboardHome.statusClosed")
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
163
web/app/(dashboard)/dashboard/agent-runs/page.tsx
Normal file
163
web/app/(dashboard)/dashboard/agent-runs/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { AlertTriangleIcon, BotMessageSquareIcon, Clock3Icon, WorkflowIcon, WrenchIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardListPage } from "@/components/dashboard/list"
|
||||
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { fetchAgentRun, fetchAgentRunMetrics, fetchAgentRuns, fetchAIWorkflowRun, saveAgentRunQualityFeedback, type AgentRun, type AgentRunMetrics, type AgentStep, type AgentToolCall, type AIWorkflowRun } from "@/lib/api/admin"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { WorkflowRunAuditGraph } from "../ai-workflow-runs/_components/workflow-run-audit-graph"
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === "failed") return "destructive" as const
|
||||
if (status === "interrupted") return "outline" as const
|
||||
if (status === "completed") return "default" as const
|
||||
return "secondary" as const
|
||||
}
|
||||
|
||||
export default function DashboardAgentRunsPage() {
|
||||
const t = useI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [run, setRun] = useState<AgentRun | null>(null)
|
||||
const [workflowAuditOpen, setWorkflowAuditOpen] = useState(false)
|
||||
const [workflowAuditLoading, setWorkflowAuditLoading] = useState(false)
|
||||
const [workflowRun, setWorkflowRun] = useState<AIWorkflowRun | null>(null)
|
||||
const [metrics, setMetrics] = useState<AgentRunMetrics | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAgentRunMetrics().then(setMetrics).catch(() => setMetrics(null))
|
||||
}, [])
|
||||
|
||||
async function openDetail(id: number) {
|
||||
setOpen(true)
|
||||
setLoading(true)
|
||||
try {
|
||||
setRun(await fetchAgentRun(id))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentRun.loadDetailFailed"))
|
||||
setOpen(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openWorkflowAudit(id: number) {
|
||||
if (id <= 0) return
|
||||
setWorkflowAuditOpen(true)
|
||||
setWorkflowAuditLoading(true)
|
||||
try {
|
||||
setWorkflowRun(await fetchAIWorkflowRun(id))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadDetailFailed"))
|
||||
setWorkflowAuditOpen(false)
|
||||
} finally {
|
||||
setWorkflowAuditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{metrics ? <div className="grid grid-cols-2 gap-px border-b bg-border sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-10">
|
||||
<Metric label={t("agentRun.metricCompletionRate")} value={`${Math.round(metrics.completionRate * 100)}%`} detail={`${metrics.completedRuns}/${metrics.totalRuns}`} />
|
||||
<Metric label={t("agentRun.metricResolutionRate")} value={metrics.reviewedRuns ? `${Math.round(metrics.resolutionRate * 100)}%` : "-"} detail={`${metrics.resolvedRuns}/${metrics.reviewedRuns}`} />
|
||||
<Metric label={t("agentRun.metricUnsupportedRate")} value={metrics.reviewedRuns ? `${Math.round(metrics.unsupportedEvidenceRate * 100)}%` : "-"} detail={`${metrics.unsupportedEvidenceRuns}/${metrics.reviewedRuns}`} />
|
||||
<Metric label={t("agentRun.metricToolSuccessRate")} value={metrics.toolCalls ? `${Math.round(metrics.toolSuccessRate * 100)}%` : "-"} detail={`${metrics.toolCalls}`} />
|
||||
<Metric label={t("agentRun.metricAvgSteps")} value={metrics.averageSteps.toFixed(1)} detail={`${metrics.totalRuns}`} />
|
||||
<Metric label={t("agentRun.metricP95Latency")} value={`${metrics.p95DurationMs} ms`} detail={`avg ${metrics.averageDurationMs} ms`} />
|
||||
<Metric label={t("agentRun.metricToken")} value={`${metrics.promptTokens + metrics.completionTokens}`} detail={`${metrics.promptTokens}/${metrics.completionTokens}`} />
|
||||
<Metric label={t("agentRun.metricHandoffRate")} value={`${Math.round(metrics.handoffRate * 100)}%`} detail="Handoff" />
|
||||
<Metric label={t("agentRun.metricFallbackRate")} value={`${Math.round(metrics.knowledgeFallbackRate * 100)}%`} detail="Fallback" />
|
||||
<Metric label={t("agentRun.metricInterruptRecoveryRate")} value={metrics.resumedInterrupts ? `${Math.round(metrics.interruptRecoveryRate * 100)}%` : "-"} detail={`${metrics.resolvedInterrupts}/${metrics.resumedInterrupts}`} />
|
||||
</div> : null}
|
||||
<DashboardListPage<AgentRun>
|
||||
filters={[
|
||||
{ name: "conversationId", label: t("agentRun.conversation"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
|
||||
{ name: "aiAgentId", label: t("agentRun.agent"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
|
||||
{ name: "status", label: t("agentRun.status"), defaultValue: "", className: "w-full sm:w-40" },
|
||||
]}
|
||||
fetchList={fetchAgentRuns}
|
||||
getItemId={(item) => item.id}
|
||||
getRowClassName={() => "cursor-pointer"}
|
||||
onRowClick={(item) => void openDetail(item.id)}
|
||||
columns={[
|
||||
{ key: "startedAt", label: t("agentRun.startedAt"), className: "w-42 text-xs text-muted-foreground", render: (item) => formatDateTime(item.startedAt || item.createdAt) },
|
||||
{ key: "agent", label: t("agentRun.agent"), className: "w-28", render: (item) => `#${item.aiAgentId || "-"}` },
|
||||
{ key: "conversation", label: t("agentRun.conversation"), className: "w-28", render: (item) => `#${item.conversationId || "-"}` },
|
||||
{ key: "status", label: t("agentRun.status"), className: "w-30", render: (item) => <Badge variant={statusVariant(item.status)}>{item.status || "-"}</Badge> },
|
||||
{ key: "duration", label: t("agentRun.duration"), className: "w-24 text-right", render: (item) => `${item.durationMs || 0} ms` },
|
||||
{ key: "tokens", label: t("agentRun.tokens"), className: "w-28 text-right", render: (item) => `${item.promptTokens || 0}/${item.completionTokens || 0}` },
|
||||
{ key: "error", label: t("agentRun.error"), className: "w-72 max-w-72", render: (item) => item.errorMessage ? <span className="block truncate text-xs text-destructive" title={item.errorMessage}>{item.errorMessage}</span> : "-" },
|
||||
]}
|
||||
labels={{ refresh: t("agentRun.refresh"), query: t("agentRun.query"), loading: t("agentRun.loading"), empty: t("agentRun.empty"), loadFailed: t("agentRun.loadFailed") }}
|
||||
/>
|
||||
<AgentRunDetailDialog open={open} loading={loading} run={run} onOpenWorkflowAudit={openWorkflowAudit} onQualityFeedbackSaved={(id) => void openDetail(id)} onOpenChange={(next) => { setOpen(next); if (!next) setRun(null) }} t={t} />
|
||||
<WorkflowAuditDialog open={workflowAuditOpen} loading={workflowAuditLoading} run={workflowRun} onOpenChange={(next) => { setWorkflowAuditOpen(next); if (!next) setWorkflowRun(null) }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) { return <div className="bg-background px-4 py-3"><div className="text-xs text-muted-foreground">{label}</div><div className="mt-1 text-lg font-semibold">{value}</div><div className="text-xs text-muted-foreground">{detail}</div></div> }
|
||||
|
||||
function AgentRunDetailDialog({ open, loading, run, onOpenChange, onOpenWorkflowAudit, onQualityFeedbackSaved, t }: { open: boolean; loading: boolean; run: AgentRun | null; onOpenChange: (open: boolean) => void; onOpenWorkflowAudit: (workflowRunId: number) => void; onQualityFeedbackSaved: (agentRunId: number) => void; t: (key: string) => string }) {
|
||||
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><BotMessageSquareIcon className="size-4" />{t("agentRun.detailTitle")}</span>} description={run ? `Run #${run.id}` : t("agentRun.detailDescription")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("agentRun.close")}</Button>}>
|
||||
{loading ? <div className="py-10 text-sm text-muted-foreground">{t("agentRun.loadingDetail")}</div> : run ? <div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("agentRun.status")} value={run.status} /><Meta label={t("agentRun.agent")} value={`#${run.aiAgentId}`} /><Meta label={t("agentRun.revision")} value={`#${run.agentRevisionId || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /><Meta label={t("agentRun.tokens")} value={`${run.promptTokens || 0}/${run.completionTokens || 0}`} /></div>
|
||||
{run.workflowRunId > 0 ? <section className="flex items-center justify-between gap-3 border px-3 py-2"><div><div className="text-sm font-medium">{t("agentRun.workflowAudit")}</div><div className="text-xs text-muted-foreground">Workflow Run #{run.workflowRunId}</div></div><Button type="button" variant="outline" size="sm" onClick={() => onOpenWorkflowAudit(run.workflowRunId)}><WorkflowIcon />{t("agentRun.viewWorkflowAudit")}</Button></section> : null}
|
||||
<QualityFeedbackPanel run={run} onSaved={onQualityFeedbackSaved} t={t} />
|
||||
{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}
|
||||
<Preview title={t("agentRun.trace")} raw={run.traceData} />
|
||||
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.steps")}</h3>{(run.steps ?? []).map((step) => <StepBlock key={step.id} step={step} t={t} />)}{!run.steps?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptySteps")}</p> : null}</section>
|
||||
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.toolCalls")}</h3>{(run.toolCalls ?? []).map((call) => <ToolCallBlock key={call.id} call={call} t={t} />)}{!run.toolCalls?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptyToolCalls")}</p> : null}</section>
|
||||
</div> : <div className="py-10 text-sm text-muted-foreground">{t("agentRun.notFound")}</div>}
|
||||
</ProjectDialog>
|
||||
}
|
||||
|
||||
function QualityFeedbackPanel({ run, onSaved, t }: { run: AgentRun; onSaved: (agentRunId: number) => void; t: (key: string) => string }) {
|
||||
const [resolutionStatus, setResolutionStatus] = useState<"unknown" | "resolved" | "unresolved">("unknown")
|
||||
const [evidenceStatus, setEvidenceStatus] = useState<"unknown" | "supported" | "unsupported">("unknown")
|
||||
const [comment, setComment] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setResolutionStatus(run.qualityFeedback?.resolutionStatus ?? "unknown")
|
||||
setEvidenceStatus(run.qualityFeedback?.evidenceStatus ?? "unknown")
|
||||
setComment(run.qualityFeedback?.comment ?? "")
|
||||
}, [run.id, run.qualityFeedback])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveAgentRunQualityFeedback({ agentRunId: run.id, resolutionStatus, evidenceStatus, comment })
|
||||
toast.success(t("agentRun.qualitySaved"))
|
||||
onSaved(run.id)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Save failed")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <section className="space-y-3 border p-3"><div><h3 className="text-sm font-medium">{t("agentRun.qualityTitle")}</h3><p className="text-xs text-muted-foreground">{t("agentRun.qualityDescription")}</p></div><div className="grid gap-3 sm:grid-cols-2"><OptionCombobox value={resolutionStatus} placeholder={t("agentRun.selectResolution")} options={[{ value: "unknown", label: "Resolution: Unknown" }, { value: "resolved", label: "Resolution: Resolved" }, { value: "unresolved", label: "Resolution: Unresolved" }]} onChange={(value) => setResolutionStatus(value === "resolved" || value === "unresolved" ? value : "unknown")} /><OptionCombobox value={evidenceStatus} placeholder={t("agentRun.selectEvidence")} options={[{ value: "unknown", label: "Evidence: Unknown" }, { value: "supported", label: "Evidence: Supported" }, { value: "unsupported", label: "Evidence: Unsupported" }]} onChange={(value) => setEvidenceStatus(value === "supported" || value === "unsupported" ? value : "unknown")} /></div><Textarea rows={3} value={comment} onChange={(event) => setComment(event.target.value)} placeholder={t("agentRun.qualityCommentPlaceholder")} /><div className="flex items-center justify-between gap-3"><span className="text-xs text-muted-foreground">{run.qualityFeedback?.updatedAt ? `Last annotated: ${run.qualityFeedback.updatedAt}` : "-"}</span><Button type="button" size="sm" disabled={saving} onClick={save}>{t("agentRun.saveQuality")}</Button></div></section>
|
||||
}
|
||||
|
||||
function WorkflowAuditDialog({ open, loading, run, onOpenChange }: { open: boolean; loading: boolean; run: AIWorkflowRun | null; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useI18n()
|
||||
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><WorkflowIcon className="size-4" />{t("agentRun.workflowAudit")}</span>} description={run ? `Workflow Run #${run.id}` : t("agentRun.workflowAudit")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("common.close")}</Button>}>
|
||||
{loading ? <div className="py-10 text-sm text-muted-foreground">{t("common.loading")}</div> : run ? <div className="space-y-3"><div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("common.status")} value={run.statusName} /><Meta label="Workflow" value={run.workflowName || `#${run.workflowId}`} /><Meta label={t("agentRun.revision")} value={`v${run.workflowVersion || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /></div>{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}<WorkflowRunAuditGraph run={run} /></div> : <div className="py-10 text-sm text-muted-foreground">{t("common.emptyData")}</div>}
|
||||
</ProjectDialog>
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) { return <span className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-1"><span className="text-muted-foreground">{label}</span><span className="font-medium">{value || "-"}</span></span> }
|
||||
function StepBlock({ step, t }: { step: AgentStep; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><Clock3Icon className="size-4 text-muted-foreground" /><span className="font-medium">{step.stepCode || step.stepType}</span><Badge variant={statusVariant(step.status)}>{step.status}</Badge><span className="text-xs text-muted-foreground">{step.durationMs || 0} ms</span></div>{step.errorMessage ? <p className="mt-2 text-xs text-destructive">{step.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.input")} raw={step.inputPreview} /><Preview title={t("agentRun.output")} raw={step.outputPreview} /></div></div> }
|
||||
function ToolCallBlock({ call, t }: { call: AgentToolCall; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><WrenchIcon className="size-4 text-muted-foreground" /><span className="font-medium">{call.toolCode}</span><Badge variant={statusVariant(call.status)}>{call.status}</Badge><span className="text-xs text-muted-foreground">{call.riskLevel}</span></div>{call.errorMessage ? <p className="mt-2 text-xs text-destructive">{call.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.arguments")} raw={call.argumentsPreview} /><Preview title={t("agentRun.result")} raw={call.resultPreview} /></div></div> }
|
||||
function Preview({ title, raw }: { title: string; raw: string }) { const value = parseJSON(raw); return <div className="min-w-0"><div className="mb-1 text-xs text-muted-foreground">{title}</div>{value !== null ? <JsonTreeViewer value={value} collapsed={2} /> : raw?.trim() ? <pre className="max-h-52 overflow-auto rounded-md border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-all">{raw}</pre> : <div className="rounded-md border bg-muted/20 px-2 py-1.5 text-xs text-muted-foreground">-</div>}</div> }
|
||||
function parseJSON(raw: string): unknown | null { try { return raw?.trim() ? JSON.parse(raw) : null } catch { return null } }
|
||||
@@ -0,0 +1,547 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ArrowLeftIcon, CheckIcon, Loader2Icon, XIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
fetchAgentTeamsAll,
|
||||
generateAgentTeamScheduleBatch,
|
||||
previewAgentTeamScheduleBatch,
|
||||
type AdminAgentTeam,
|
||||
type AdminAgentTeamScheduleBatchPreview,
|
||||
type BatchAdminAgentTeamSchedulePayload,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type BatchScheduleDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: (created: number) => void | Promise<void>
|
||||
}
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
const weekdayKeys = [
|
||||
"weekdayMon",
|
||||
"weekdayTue",
|
||||
"weekdayWed",
|
||||
"weekdayThu",
|
||||
"weekdayFri",
|
||||
"weekdaySat",
|
||||
"weekdaySun",
|
||||
] as const
|
||||
|
||||
function getWeekdayOptions(t: TFunction) {
|
||||
return weekdayKeys.map((key, index) => ({
|
||||
value: index + 1,
|
||||
label: t(`agentTeamSchedule.${key}`),
|
||||
}))
|
||||
}
|
||||
|
||||
function todayDateValue() {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(today.getDate()).padStart(2, "0")
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function defaultFormState() {
|
||||
const today = todayDateValue()
|
||||
return {
|
||||
selectedTeamIds: [] as number[],
|
||||
startDate: today,
|
||||
endDate: today,
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
remark: "",
|
||||
}
|
||||
}
|
||||
|
||||
type BatchFormState = ReturnType<typeof defaultFormState>
|
||||
type DialogStep = "form" | "preview"
|
||||
|
||||
function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload {
|
||||
return {
|
||||
teamIds: [...form.selectedTeamIds],
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate,
|
||||
weekdays: [...form.weekdays],
|
||||
startTime: form.startTime,
|
||||
endTime: form.endTime,
|
||||
remark: form.remark.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function getWeekdayLabel(value: number, t: TFunction) {
|
||||
const weekdayOptions = getWeekdayOptions(t)
|
||||
return weekdayOptions.find((option) => option.value === value)?.label ?? String(value)
|
||||
}
|
||||
|
||||
function validateForm(form: BatchFormState, t: TFunction) {
|
||||
const today = todayDateValue()
|
||||
if (form.selectedTeamIds.length === 0) {
|
||||
return t("agentTeamSchedule.selectAtLeastOneTeam")
|
||||
}
|
||||
if (!form.startDate || !form.endDate) {
|
||||
return t("agentTeamSchedule.selectDateRange")
|
||||
}
|
||||
if (form.startDate < today) {
|
||||
return t("agentTeamSchedule.startBeforeToday")
|
||||
}
|
||||
if (form.endDate < form.startDate) {
|
||||
return t("agentTeamSchedule.endBeforeStart")
|
||||
}
|
||||
if (form.weekdays.length === 0) {
|
||||
return t("agentTeamSchedule.selectAtLeastOneWeekday")
|
||||
}
|
||||
if (!form.startTime || !form.endTime) {
|
||||
return t("agentTeamSchedule.selectStartEndTime")
|
||||
}
|
||||
if (form.endTime <= form.startTime) {
|
||||
return t("agentTeamSchedule.endAfterStart")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
export function BatchScheduleDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: BatchScheduleDialogProps) {
|
||||
const t = useI18n()
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [form, setForm] = useState(defaultFormState)
|
||||
const [step, setStep] = useState<DialogStep>("form")
|
||||
const [preview, setPreview] = useState<AdminAgentTeamScheduleBatchPreview | null>(null)
|
||||
const [previewPayload, setPreviewPayload] = useState<BatchAdminAgentTeamSchedulePayload | null>(null)
|
||||
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const previewRequestIdRef = useRef(0)
|
||||
const busy = loadingTeams || previewing || submitting
|
||||
const weekdayOptions = useMemo(() => getWeekdayOptions(t), [t])
|
||||
|
||||
const teamOptions = useMemo(
|
||||
() =>
|
||||
teams
|
||||
.filter((team) => !form.selectedTeamIds.includes(team.id))
|
||||
.map((team) => ({ value: String(team.id), label: team.name })),
|
||||
[form.selectedTeamIds, teams]
|
||||
)
|
||||
|
||||
const selectedTeams = useMemo(() => {
|
||||
const teamMap = new Map(teams.map((team) => [team.id, team]))
|
||||
return form.selectedTeamIds.map((teamId) => teamMap.get(teamId)).filter(Boolean) as AdminAgentTeam[]
|
||||
}, [form.selectedTeamIds, teams])
|
||||
|
||||
const selectedWeekdays = useMemo(
|
||||
() => new Set(form.weekdays),
|
||||
[form.weekdays]
|
||||
)
|
||||
|
||||
const hasConflict = preview?.conflict === true
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
previewRequestIdRef.current += 1
|
||||
setForm(defaultFormState())
|
||||
setStep("form")
|
||||
setPreview(null)
|
||||
setPreviewPayload(null)
|
||||
setPreviewing(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
let ignore = false
|
||||
async function loadTeams() {
|
||||
setLoadingTeams(true)
|
||||
try {
|
||||
const data = await fetchAgentTeamsAll()
|
||||
if (!ignore) {
|
||||
setTeams(data)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoadingTeams(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadTeams()
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [open, t])
|
||||
|
||||
function updateForm(values: Partial<BatchFormState>) {
|
||||
previewRequestIdRef.current += 1
|
||||
setForm((current) => ({ ...current, ...values }))
|
||||
setPreview(null)
|
||||
setPreviewPayload(null)
|
||||
setPreviewing(false)
|
||||
setStep("form")
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && busy) {
|
||||
return
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
function handleTeamSelect(value: string) {
|
||||
const teamId = Number(value)
|
||||
if (!Number.isFinite(teamId) || form.selectedTeamIds.includes(teamId)) {
|
||||
return
|
||||
}
|
||||
updateForm({ selectedTeamIds: [...form.selectedTeamIds, teamId] })
|
||||
}
|
||||
|
||||
function removeTeam(teamId: number) {
|
||||
updateForm({
|
||||
selectedTeamIds: form.selectedTeamIds.filter((selectedTeamId) => selectedTeamId !== teamId),
|
||||
})
|
||||
}
|
||||
|
||||
function toggleWeekday(weekday: number) {
|
||||
const nextWeekdays = selectedWeekdays.has(weekday)
|
||||
? form.weekdays.filter((value) => value !== weekday)
|
||||
: [...form.weekdays, weekday].sort((a, b) => a - b)
|
||||
updateForm({ weekdays: nextWeekdays })
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
const validationMessage = validateForm(form, t)
|
||||
if (validationMessage) {
|
||||
toast.error(validationMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildPayload(form)
|
||||
const requestId = previewRequestIdRef.current + 1
|
||||
previewRequestIdRef.current = requestId
|
||||
setPreviewing(true)
|
||||
try {
|
||||
const data = await previewAgentTeamScheduleBatch(payload)
|
||||
if (!openRef.current || previewRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
setPreview(data)
|
||||
setPreviewPayload(payload)
|
||||
setStep("preview")
|
||||
} catch (error) {
|
||||
if (openRef.current && previewRequestIdRef.current === requestId) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.previewFailed"))
|
||||
}
|
||||
} finally {
|
||||
if (openRef.current && previewRequestIdRef.current === requestId) {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!preview || !previewPayload) {
|
||||
toast.error(t("agentTeamSchedule.previewFirst"))
|
||||
return
|
||||
}
|
||||
if (preview.conflict) {
|
||||
toast.error(t("agentTeamSchedule.conflictCannotSubmit"))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = previewPayload
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const data = await generateAgentTeamScheduleBatch(payload)
|
||||
toast.success(t("agentTeamSchedule.batchCreated", { count: data.created }))
|
||||
setPreview(null)
|
||||
setPreviewPayload(null)
|
||||
onOpenChange(false)
|
||||
try {
|
||||
await onSuccess(data.created)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? t("agentTeamSchedule.batchRefreshFailed", { message: error.message }) : t("agentTeamSchedule.batchRefreshFailedFallback"))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.generateFailed"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
|
||||
<DialogHeader className="shrink-0 px-6 pt-6">
|
||||
<DialogTitle>{t("agentTeamSchedule.batch")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("agentTeamSchedule.batchDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
{step === "form" ? (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("agentTeamSchedule.team")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<OptionCombobox
|
||||
value=""
|
||||
options={teamOptions}
|
||||
placeholder={loadingTeams ? t("agentTeamSchedule.loadingTeams") : t("agentTeamSchedule.addTeam")}
|
||||
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
|
||||
emptyText={teams.length === 0 ? t("agentTeamSchedule.noTeams") : t("agentTeamSchedule.allTeamsSelected")}
|
||||
disabled={loadingTeams}
|
||||
onChange={handleTeamSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTeams.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTeams.map((team) => (
|
||||
<Badge key={team.id} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="max-w-44 truncate">{team.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-5 rounded-sm"
|
||||
onClick={() => removeTeam(team.id)}
|
||||
aria-label={t("agentTeamSchedule.removeTeam", { name: team.name })}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{t("agentTeamSchedule.noSelectedTeams")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-schedule-start-date">{t("agentTeamSchedule.startDate")}</Label>
|
||||
<Input
|
||||
id="batch-schedule-start-date"
|
||||
type="date"
|
||||
min={todayDateValue()}
|
||||
value={form.startDate}
|
||||
onChange={(event) => updateForm({ startDate: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-schedule-end-date">{t("agentTeamSchedule.endDate")}</Label>
|
||||
<Input
|
||||
id="batch-schedule-end-date"
|
||||
type="date"
|
||||
min={form.startDate || todayDateValue()}
|
||||
value={form.endDate}
|
||||
onChange={(event) => updateForm({ endDate: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("agentTeamSchedule.weekday")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{weekdayOptions.map((option) => {
|
||||
const selected = selectedWeekdays.has(option.value)
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={selected ? "default" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleWeekday(option.value)}
|
||||
>
|
||||
{selected ? <CheckIcon className="size-4" /> : null}
|
||||
{option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-schedule-start-time">{t("agentTeamSchedule.startTime")}</Label>
|
||||
<Input
|
||||
id="batch-schedule-start-time"
|
||||
type="time"
|
||||
value={form.startTime}
|
||||
onChange={(event) => updateForm({ startTime: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-schedule-end-time">{t("agentTeamSchedule.endTime")}</Label>
|
||||
<Input
|
||||
id="batch-schedule-end-time"
|
||||
type="time"
|
||||
value={form.endTime}
|
||||
onChange={(event) => updateForm({ endTime: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-schedule-remark">{t("agentTeamSchedule.remark")}</Label>
|
||||
<Textarea
|
||||
id="batch-schedule-remark"
|
||||
rows={4}
|
||||
placeholder={t("agentTeamSchedule.remarkPlaceholder")}
|
||||
value={form.remark}
|
||||
onChange={(event) => updateForm({ remark: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasConflict
|
||||
? t("agentTeamSchedule.previewSummaryConflict", { total: preview?.total ?? 0 })
|
||||
: t("agentTeamSchedule.previewSummaryReady", { total: preview?.total ?? 0 })}
|
||||
</div>
|
||||
{hasConflict ? (
|
||||
<Badge variant="destructive">{t("agentTeamSchedule.hasConflict")}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{t("agentTeamSchedule.noConflict")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="min-w-[760px]">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
|
||||
<TableHead>{t("agentTeamSchedule.date")}</TableHead>
|
||||
<TableHead>{t("agentTeamSchedule.weekday")}</TableHead>
|
||||
<TableHead>{t("agentTeamSchedule.time")}</TableHead>
|
||||
<TableHead>{t("agentTeamSchedule.remark")}</TableHead>
|
||||
<TableHead>{t("agentTeamSchedule.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{preview?.items.map((item, index) => (
|
||||
<TableRow
|
||||
key={`${item.teamId}-${item.startAt}-${index}`}
|
||||
className={cn(
|
||||
item.conflict && "bg-destructive/10 text-destructive hover:bg-destructive/10"
|
||||
)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.date}</TableCell>
|
||||
<TableCell>{getWeekdayLabel(item.weekday, t)}</TableCell>
|
||||
<TableCell>
|
||||
{item.startAt.slice(11, 16)} - {item.endAt.slice(11, 16)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-56 truncate">{item.remark || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{item.conflict ? (
|
||||
<span className="font-medium">
|
||||
{item.conflictReason || t("agentTeamSchedule.conflictFallback")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("agentTeamSchedule.canGenerate")}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{preview && preview.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
|
||||
{t("agentTeamSchedule.emptyPreview")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mx-0 mb-0 shrink-0 border-t px-6 py-4">
|
||||
{step === "preview" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep("form")}
|
||||
disabled={submitting}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
{t("agentTeamSchedule.backToEdit")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("agentTeamSchedule.cancel")}
|
||||
</Button>
|
||||
{step === "form" ? (
|
||||
<Button type="button" onClick={() => void handlePreview()} disabled={busy}>
|
||||
{previewing ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
|
||||
{t("agentTeamSchedule.preview")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting || hasConflict || !preview || !previewPayload || preview.items.length === 0}
|
||||
>
|
||||
{submitting ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
|
||||
{t("agentTeamSchedule.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { addDays, formatWeekTitle, isSameLocalDay, startOfWeek } from "./calendar-date-range.ts"
|
||||
|
||||
test("builds Monday-based week range and title", () => {
|
||||
const start = startOfWeek(new Date(2026, 3, 29, 14, 0, 0))
|
||||
|
||||
assert.equal(start.getFullYear(), 2026)
|
||||
assert.equal(start.getMonth(), 3)
|
||||
assert.equal(start.getDate(), 27)
|
||||
assert.equal(formatWeekTitle(start), "2026-04-27 - 2026-05-03")
|
||||
assert.equal(addDays(start, 7).getDate(), 4)
|
||||
})
|
||||
|
||||
test("compares local calendar dates", () => {
|
||||
assert.equal(isSameLocalDay(new Date(2026, 3, 29, 0, 0), new Date(2026, 3, 29, 23, 59)), true)
|
||||
assert.equal(isSameLocalDay(new Date(2026, 3, 29, 23, 59), new Date(2026, 3, 30, 0, 0)), false)
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
export function startOfDay(date: Date) {
|
||||
const ret = new Date(date)
|
||||
ret.setHours(0, 0, 0, 0)
|
||||
return ret
|
||||
}
|
||||
|
||||
export function startOfWeek(date: Date) {
|
||||
const ret = startOfDay(date)
|
||||
const day = ret.getDay()
|
||||
const offset = day === 0 ? -6 : 1 - day
|
||||
ret.setDate(ret.getDate() + offset)
|
||||
return ret
|
||||
}
|
||||
|
||||
export function isSameLocalDay(a: Date, b: Date) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
export function startOfMonth(date: Date) {
|
||||
const ret = startOfDay(date)
|
||||
ret.setDate(1)
|
||||
return ret
|
||||
}
|
||||
|
||||
export function startOfMonthCalendar(date: Date) {
|
||||
return startOfWeek(startOfMonth(date))
|
||||
}
|
||||
|
||||
export function endOfMonthCalendar(date: Date) {
|
||||
const monthEnd = startOfMonth(date)
|
||||
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
||||
const ret = startOfWeek(monthEnd)
|
||||
if (ret.getTime() < monthEnd.getTime()) {
|
||||
ret.setDate(ret.getDate() + 7)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number) {
|
||||
const ret = new Date(date)
|
||||
ret.setDate(ret.getDate() + days)
|
||||
return ret
|
||||
}
|
||||
|
||||
export function addMonths(date: Date, months: number) {
|
||||
const ret = startOfMonth(date)
|
||||
ret.setMonth(ret.getMonth() + months)
|
||||
return ret
|
||||
}
|
||||
|
||||
export function formatDateTimeValue(date: Date) {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
const hour = String(date.getHours()).padStart(2, "0")
|
||||
const minute = String(date.getMinutes()).padStart(2, "0")
|
||||
const second = String(date.getSeconds()).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
export function formatMonthTitle(monthStart: Date) {
|
||||
return `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function formatWeekTitle(weekStart: Date) {
|
||||
return `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { buildDayTimeLayout } from "./calendar-time-layout.ts"
|
||||
|
||||
test("scales schedule bars by the visible span of that day", () => {
|
||||
const layout = buildDayTimeLayout(
|
||||
[
|
||||
{ id: 1, startAt: "2026-04-29 07:00:00", endAt: "2026-04-29 17:00:00" },
|
||||
{ id: 2, startAt: "2026-04-29 09:00:00", endAt: "2026-04-29 12:00:00" },
|
||||
{ id: 3, startAt: "2026-04-29 13:00:00", endAt: "2026-04-29 17:00:00" },
|
||||
],
|
||||
new Date(2026, 3, 29)
|
||||
)
|
||||
|
||||
assert.equal(layout.rangeLabel, "07:00 - 17:00")
|
||||
assert.deepEqual(layout.items.get(1), {
|
||||
leftPercent: 0,
|
||||
widthPercent: 100,
|
||||
startLabel: "07:00",
|
||||
endLabel: "17:00",
|
||||
})
|
||||
assert.deepEqual(layout.items.get(2), {
|
||||
leftPercent: 20,
|
||||
widthPercent: 30,
|
||||
startLabel: "09:00",
|
||||
endLabel: "12:00",
|
||||
})
|
||||
assert.deepEqual(layout.items.get(3), {
|
||||
leftPercent: 60,
|
||||
widthPercent: 40,
|
||||
startLabel: "13:00",
|
||||
endLabel: "17:00",
|
||||
})
|
||||
})
|
||||
|
||||
test("clips cross-day schedules to the current day before scaling", () => {
|
||||
const layout = buildDayTimeLayout(
|
||||
[
|
||||
{ id: 1, startAt: "2026-04-28 22:00:00", endAt: "2026-04-29 08:00:00" },
|
||||
{ id: 2, startAt: "2026-04-29 07:00:00", endAt: "2026-04-29 17:00:00" },
|
||||
],
|
||||
new Date(2026, 3, 29)
|
||||
)
|
||||
|
||||
assert.equal(layout.rangeLabel, "00:00 - 17:00")
|
||||
assert.deepEqual(layout.items.get(1), {
|
||||
leftPercent: 0,
|
||||
widthPercent: 47.06,
|
||||
startLabel: "00:00",
|
||||
endLabel: "08:00",
|
||||
})
|
||||
assert.deepEqual(layout.items.get(2), {
|
||||
leftPercent: 41.18,
|
||||
widthPercent: 58.82,
|
||||
startLabel: "07:00",
|
||||
endLabel: "17:00",
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
export type TimeLayoutSchedule = {
|
||||
id: number
|
||||
startAt: string
|
||||
endAt: string
|
||||
}
|
||||
|
||||
export type TimeLayoutItem = {
|
||||
leftPercent: number
|
||||
widthPercent: number
|
||||
startLabel: string
|
||||
endLabel: string
|
||||
}
|
||||
|
||||
export type DayTimeLayout = {
|
||||
rangeLabel: string
|
||||
items: Map<number, TimeLayoutItem>
|
||||
}
|
||||
|
||||
function parseLocalDateTime(value: string) {
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/)
|
||||
if (!match) {
|
||||
return new Date(value)
|
||||
}
|
||||
return new Date(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6] ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const ret = new Date(date)
|
||||
ret.setHours(0, 0, 0, 0)
|
||||
return ret
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
const hour = String(date.getHours()).padStart(2, "0")
|
||||
const minute = String(date.getMinutes()).padStart(2, "0")
|
||||
return `${hour}:${minute}`
|
||||
}
|
||||
|
||||
function roundPercent(value: number) {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
export function buildDayTimeLayout(schedules: TimeLayoutSchedule[], day: Date): DayTimeLayout {
|
||||
const dayStart = startOfDay(day)
|
||||
const dayEnd = new Date(dayStart.getTime() + dayMs)
|
||||
const visibleItems = schedules
|
||||
.map((item) => {
|
||||
const scheduleStart = parseLocalDateTime(item.startAt)
|
||||
const scheduleEnd = parseLocalDateTime(item.endAt)
|
||||
const visibleStart = new Date(Math.max(scheduleStart.getTime(), dayStart.getTime()))
|
||||
const visibleEnd = new Date(Math.min(scheduleEnd.getTime(), dayEnd.getTime()))
|
||||
return { item, visibleStart, visibleEnd }
|
||||
})
|
||||
.filter(({ visibleStart, visibleEnd }) => visibleEnd > visibleStart)
|
||||
|
||||
if (visibleItems.length === 0) {
|
||||
return { rangeLabel: "", items: new Map() }
|
||||
}
|
||||
|
||||
const rangeStart = new Date(Math.min(...visibleItems.map(({ visibleStart }) => visibleStart.getTime())))
|
||||
const rangeEnd = new Date(Math.max(...visibleItems.map(({ visibleEnd }) => visibleEnd.getTime())))
|
||||
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), 1)
|
||||
const items = new Map<number, TimeLayoutItem>()
|
||||
|
||||
visibleItems.forEach(({ item, visibleStart, visibleEnd }) => {
|
||||
items.set(item.id, {
|
||||
leftPercent: roundPercent(((visibleStart.getTime() - rangeStart.getTime()) / rangeMs) * 100),
|
||||
widthPercent: roundPercent(((visibleEnd.getTime() - visibleStart.getTime()) / rangeMs) * 100),
|
||||
startLabel: formatTime(visibleStart),
|
||||
endLabel: formatTime(visibleEnd),
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
rangeLabel: `${formatTime(rangeStart)} - ${formatTime(rangeEnd)}`,
|
||||
items,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarPlusIcon, GripVerticalIcon } from "lucide-react"
|
||||
import { useState, type PointerEvent as ReactPointerEvent } from "react"
|
||||
|
||||
import type {
|
||||
AdminAgentTeam,
|
||||
AdminAgentTeamSchedule,
|
||||
CreateAdminAgentTeamSchedulePayload,
|
||||
UpdateAdminAgentTeamSchedulePayload,
|
||||
} from "@/lib/api/admin"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
import { isSameLocalDay } from "./calendar-date-range"
|
||||
import { buildDayTimeLayout } from "./calendar-time-layout"
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
const weekDayKeys = [
|
||||
"weekdayShortMon",
|
||||
"weekdayShortTue",
|
||||
"weekdayShortWed",
|
||||
"weekdayShortThu",
|
||||
"weekdayShortFri",
|
||||
"weekdayShortSat",
|
||||
"weekdayShortSun",
|
||||
] as const
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const minuteMs = 60 * 1000
|
||||
const minDurationMs = 15 * minuteMs
|
||||
|
||||
type ScheduleCalendarProps = {
|
||||
variant?: "month" | "week"
|
||||
monthStart: Date
|
||||
calendarStart: Date
|
||||
calendarEnd: Date
|
||||
teams: AdminAgentTeam[]
|
||||
schedules: AdminAgentTeamSchedule[]
|
||||
loading: boolean
|
||||
savingId: number | null
|
||||
onCreate: (defaults: Partial<CreateAdminAgentTeamSchedulePayload>) => void
|
||||
onEdit: (item: AdminAgentTeamSchedule) => void
|
||||
onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
|
||||
onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
type DragState =
|
||||
| {
|
||||
type: "move"
|
||||
item: AdminAgentTeamSchedule
|
||||
startX: number
|
||||
startY: number
|
||||
moved: boolean
|
||||
}
|
||||
| {
|
||||
type: "resize"
|
||||
edge: "start" | "end"
|
||||
item: AdminAgentTeamSchedule
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
type InteractionPreview = {
|
||||
itemId: number
|
||||
date: string | null
|
||||
label: string
|
||||
invalid: boolean
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const ret = new Date(date)
|
||||
ret.setDate(ret.getDate() + days)
|
||||
return ret
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const ret = new Date(date)
|
||||
ret.setHours(0, 0, 0, 0)
|
||||
return ret
|
||||
}
|
||||
|
||||
function parseLocalDateTime(value: string) {
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/)
|
||||
if (!match) {
|
||||
return new Date(value)
|
||||
}
|
||||
return new Date(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6] ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatDateTimeValue(date: Date) {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
const hour = String(date.getHours()).padStart(2, "0")
|
||||
const minute = String(date.getMinutes()).padStart(2, "0")
|
||||
const second = String(date.getSeconds()).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
function formatTime(dateTime: string) {
|
||||
return formatDateTime(dateTime).slice(11, 16)
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function roundToQuarterHour(date: Date) {
|
||||
const ret = new Date(date)
|
||||
ret.setSeconds(0, 0)
|
||||
const minutes = ret.getHours() * 60 + ret.getMinutes()
|
||||
const rounded = Math.round(minutes / 15) * 15
|
||||
ret.setHours(Math.floor(rounded / 60), rounded % 60, 0, 0)
|
||||
return ret
|
||||
}
|
||||
|
||||
function getPointerDateInCell(event: PointerEvent, cell: Element) {
|
||||
const rect = cell.getBoundingClientRect()
|
||||
const day = startOfDay(parseLocalDateTime(`${cell.getAttribute("data-date")} 00:00:00`))
|
||||
const ratio = clamp((event.clientX - rect.left) / rect.width, 0, 1)
|
||||
return roundToQuarterHour(new Date(day.getTime() + ratio * dayMs))
|
||||
}
|
||||
|
||||
function getDropCell(event: PointerEvent) {
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY)
|
||||
return element?.closest("[data-schedule-cell]")
|
||||
}
|
||||
|
||||
function isHistoricalDay(day: Date) {
|
||||
return startOfDay(day).getTime() < startOfDay(new Date()).getTime()
|
||||
}
|
||||
|
||||
function buildMovePayload(item: AdminAgentTeamSchedule, date: string): UpdateAdminAgentTeamSchedulePayload {
|
||||
const originalStart = parseLocalDateTime(item.startAt)
|
||||
const originalEnd = parseLocalDateTime(item.endAt)
|
||||
const duration = originalEnd.getTime() - originalStart.getTime()
|
||||
const nextDay = startOfDay(parseLocalDateTime(`${date} 00:00:00`))
|
||||
const nextStart = new Date(nextDay)
|
||||
nextStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0)
|
||||
const nextEnd = new Date(nextStart.getTime() + duration)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
teamId: item.teamId,
|
||||
startAt: formatDateTimeValue(nextStart),
|
||||
endAt: formatDateTimeValue(nextEnd),
|
||||
remark: item.remark,
|
||||
}
|
||||
}
|
||||
|
||||
function buildResizePayload(
|
||||
item: AdminAgentTeamSchedule,
|
||||
edge: "start" | "end",
|
||||
nextTime: Date
|
||||
): UpdateAdminAgentTeamSchedulePayload | null {
|
||||
const startAt = parseLocalDateTime(item.startAt)
|
||||
const endAt = parseLocalDateTime(item.endAt)
|
||||
if (!isSameLocalDay(startAt, nextTime)) {
|
||||
return null
|
||||
}
|
||||
if (edge === "start") {
|
||||
if (endAt.getTime() - nextTime.getTime() < minDurationMs) {
|
||||
return null
|
||||
}
|
||||
startAt.setTime(nextTime.getTime())
|
||||
} else {
|
||||
if (nextTime.getTime() - startAt.getTime() < minDurationMs) {
|
||||
return null
|
||||
}
|
||||
endAt.setTime(nextTime.getTime())
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
teamId: item.teamId,
|
||||
startAt: formatDateTimeValue(startAt),
|
||||
endAt: formatDateTimeValue(endAt),
|
||||
remark: item.remark,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewFromPayload(
|
||||
itemId: number,
|
||||
date: string | null,
|
||||
payload: UpdateAdminAgentTeamSchedulePayload | null,
|
||||
point: { x: number; y: number },
|
||||
fallbackLabel: string
|
||||
): InteractionPreview {
|
||||
return {
|
||||
itemId,
|
||||
date,
|
||||
label: payload ? `${formatTime(payload.startAt)} - ${formatTime(payload.endAt)}` : fallbackLabel,
|
||||
invalid: !payload,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
}
|
||||
}
|
||||
|
||||
function intersectsDay(item: AdminAgentTeamSchedule, day: Date) {
|
||||
const dayStart = startOfDay(day)
|
||||
const dayEnd = addDays(dayStart, 1)
|
||||
const scheduleStart = parseLocalDateTime(item.startAt)
|
||||
const scheduleEnd = parseLocalDateTime(item.endAt)
|
||||
return scheduleStart < dayEnd && scheduleEnd > dayStart
|
||||
}
|
||||
|
||||
function buildCalendarDays(calendarStart: Date, calendarEnd: Date) {
|
||||
const days: Date[] = []
|
||||
for (let current = startOfDay(calendarStart); current < calendarEnd; current = addDays(current, 1)) {
|
||||
days.push(current)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
export function ScheduleCalendar({
|
||||
variant = "month",
|
||||
monthStart,
|
||||
calendarStart,
|
||||
calendarEnd,
|
||||
teams,
|
||||
schedules,
|
||||
loading,
|
||||
savingId,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onMove,
|
||||
onResize,
|
||||
t,
|
||||
}: ScheduleCalendarProps) {
|
||||
const days = buildCalendarDays(calendarStart, calendarEnd)
|
||||
const defaultTeamID = teams[0]?.id ?? 0
|
||||
const [interactionPreview, setInteractionPreview] = useState<InteractionPreview | null>(null)
|
||||
|
||||
function handleBlankCellClick(day: Date) {
|
||||
const startAt = new Date(day)
|
||||
startAt.setHours(9, 0, 0, 0)
|
||||
const endAt = new Date(day)
|
||||
endAt.setHours(18, 0, 0, 0)
|
||||
onCreate({
|
||||
teamId: defaultTeamID || undefined,
|
||||
startAt: formatDateTimeValue(startAt),
|
||||
endAt: formatDateTimeValue(endAt),
|
||||
remark: "",
|
||||
})
|
||||
}
|
||||
|
||||
function buildInteractionPreview(state: DragState, pointerEvent: PointerEvent): InteractionPreview | null {
|
||||
const cell = getDropCell(pointerEvent)
|
||||
if (!cell) {
|
||||
return {
|
||||
itemId: state.item.id,
|
||||
date: null,
|
||||
label: t("agentTeamSchedule.dropInsideCalendar"),
|
||||
invalid: true,
|
||||
x: pointerEvent.clientX,
|
||||
y: pointerEvent.clientY,
|
||||
}
|
||||
}
|
||||
|
||||
const date = cell.getAttribute("data-date")
|
||||
if (!date) {
|
||||
return null
|
||||
}
|
||||
if (isHistoricalDay(parseLocalDateTime(`${date} 00:00:00`))) {
|
||||
return {
|
||||
itemId: state.item.id,
|
||||
date,
|
||||
label: t("agentTeamSchedule.historyReadonly"),
|
||||
invalid: true,
|
||||
x: pointerEvent.clientX,
|
||||
y: pointerEvent.clientY,
|
||||
}
|
||||
}
|
||||
|
||||
const point = { x: pointerEvent.clientX, y: pointerEvent.clientY }
|
||||
if (state.type === "move") {
|
||||
return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, t("agentTeamSchedule.cannotMoveHere"))
|
||||
}
|
||||
|
||||
const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell))
|
||||
return buildPreviewFromPayload(state.item.id, date, payload, point, t("agentTeamSchedule.resizeInvalid"))
|
||||
}
|
||||
|
||||
function cleanupPointerInteraction(
|
||||
target: HTMLElement,
|
||||
pointerId: number,
|
||||
handlePointerMove: (moveEvent: PointerEvent) => void,
|
||||
handlePointerUp: (upEvent: PointerEvent) => void,
|
||||
handlePointerCancel: () => void
|
||||
) {
|
||||
if (target.hasPointerCapture(pointerId)) {
|
||||
target.releasePointerCapture(pointerId)
|
||||
}
|
||||
window.removeEventListener("pointermove", handlePointerMove)
|
||||
window.removeEventListener("pointerup", handlePointerUp)
|
||||
window.removeEventListener("pointercancel", handlePointerCancel)
|
||||
setInteractionPreview(null)
|
||||
}
|
||||
|
||||
function handlePointerDown(event: ReactPointerEvent, item: AdminAgentTeamSchedule, type: DragState["type"], edge?: "start" | "end") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (target.isConnected) {
|
||||
try {
|
||||
target.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// Some synthetic/browser edge events do not expose a capturable pointer id.
|
||||
}
|
||||
}
|
||||
const state: DragState =
|
||||
type === "resize"
|
||||
? { type: "resize", edge: edge ?? "end", item, moved: false }
|
||||
: { type: "move", item, startX: event.clientX, startY: event.clientY, moved: false }
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
if (state.type === "move") {
|
||||
if (Math.abs(moveEvent.clientX - state.startX) > 4 || Math.abs(moveEvent.clientY - state.startY) > 4) {
|
||||
state.moved = true
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
state.moved = true
|
||||
}
|
||||
setInteractionPreview(buildInteractionPreview(state, moveEvent))
|
||||
}
|
||||
|
||||
async function handlePointerUp(upEvent: PointerEvent) {
|
||||
cleanupPointerInteraction(target, event.pointerId, handlePointerMove, handlePointerUp, handlePointerCancel)
|
||||
if (!state.moved) {
|
||||
onEdit(item)
|
||||
return
|
||||
}
|
||||
const cell = getDropCell(upEvent)
|
||||
if (!cell) {
|
||||
return
|
||||
}
|
||||
if (state.type === "move") {
|
||||
const date = cell.getAttribute("data-date")
|
||||
if (!date) {
|
||||
return
|
||||
}
|
||||
if (isHistoricalDay(parseLocalDateTime(`${date} 00:00:00`))) {
|
||||
return
|
||||
}
|
||||
await onMove(buildMovePayload(item, date))
|
||||
return
|
||||
}
|
||||
const payload = buildResizePayload(item, state.edge, getPointerDateInCell(upEvent, cell))
|
||||
if (payload) {
|
||||
await onResize(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerCancel() {
|
||||
cleanupPointerInteraction(target, event.pointerId, handlePointerMove, handlePointerUp, handlePointerCancel)
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove)
|
||||
window.addEventListener("pointerup", handlePointerUp)
|
||||
window.addEventListener("pointercancel", handlePointerCancel)
|
||||
}
|
||||
|
||||
if (teams.length === 0 && !loading) {
|
||||
return (
|
||||
<div className="flex min-h-64 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
|
||||
{t("agentTeamSchedule.noTeamsCalendar")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDayCell(day: Date, dayIndex: number, options?: { inMonth?: boolean; className?: string; showFullDate?: boolean }) {
|
||||
const date = formatDate(day)
|
||||
const inMonth = options?.inMonth ?? day.getMonth() === monthStart.getMonth()
|
||||
const historical = isHistoricalDay(day)
|
||||
const today = isSameLocalDay(day, new Date())
|
||||
const daySchedules = schedules
|
||||
.filter((item) => intersectsDay(item, day))
|
||||
.sort((a, b) => parseLocalDateTime(a.startAt).getTime() - parseLocalDateTime(b.startAt).getTime())
|
||||
const dayTimeLayout = buildDayTimeLayout(daySchedules, day)
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
data-schedule-cell
|
||||
data-date={date}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"border-l border-t bg-background p-2 text-left outline-none transition-colors first:border-l-0 hover:bg-muted/20 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
dayIndex % 7 === 0 && "border-l-0",
|
||||
!inMonth && "bg-muted/20 text-muted-foreground",
|
||||
historical && "cursor-not-allowed bg-muted/30 hover:bg-muted/30",
|
||||
interactionPreview?.date === date &&
|
||||
(interactionPreview.invalid ? "bg-destructive/5 ring-2 ring-destructive/30" : "bg-primary/5 ring-2 ring-primary/35"),
|
||||
options?.className
|
||||
)}
|
||||
onClick={(event) => {
|
||||
if ((event.target as HTMLElement).closest("[data-schedule-block]")) {
|
||||
return
|
||||
}
|
||||
if (historical) {
|
||||
return
|
||||
}
|
||||
handleBlankCellClick(day)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (historical) {
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
handleBlankCellClick(day)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className={cn("text-sm font-medium", !inMonth && "text-muted-foreground")}>
|
||||
{options?.showFullDate ? date : day.getDate()}
|
||||
</div>
|
||||
{dayTimeLayout.rangeLabel ? (
|
||||
<div className="mt-0.5 text-[10px] leading-none text-muted-foreground">{dayTimeLayout.rangeLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{today ? (
|
||||
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground">
|
||||
{t("agentTeamSchedule.today")}
|
||||
</span>
|
||||
) : null}
|
||||
{historical ? null : <CalendarPlusIcon className="size-3.5 text-muted-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{daySchedules.slice(0, 5).map((item) => {
|
||||
const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || t("agentTeamSchedule.teamFallback", { id: item.teamId })
|
||||
const busy = savingId === item.id
|
||||
const active = interactionPreview?.itemId === item.id
|
||||
const timeLayout = dayTimeLayout.items.get(item.id)
|
||||
const readonly = historical || isHistoricalDay(parseLocalDateTime(item.startAt))
|
||||
return (
|
||||
<div key={`${item.id}-${date}`} className="relative h-10 rounded-sm bg-muted/25">
|
||||
<div
|
||||
data-schedule-block
|
||||
data-time-left={timeLayout?.leftPercent ?? 0}
|
||||
data-time-width={timeLayout?.widthPercent ?? 100}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute inset-y-0 cursor-grab overflow-hidden rounded-md border border-primary/20 bg-primary/10 px-2 py-1.5 pl-4 pr-4 text-primary shadow-sm outline-none transition active:cursor-grabbing",
|
||||
active && "scale-[0.98] border-primary/50 bg-primary/15 opacity-80 ring-2 ring-primary/30",
|
||||
readonly && "cursor-not-allowed opacity-60",
|
||||
busy && "pointer-events-none opacity-60"
|
||||
)}
|
||||
style={{
|
||||
left: `${timeLayout?.leftPercent ?? 0}%`,
|
||||
width: `${timeLayout?.widthPercent ?? 100}%`,
|
||||
minWidth: 34,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (readonly) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
handlePointerDown(event, item, "move")
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onEdit(item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-3 cursor-ew-resize items-center justify-center bg-primary/15"
|
||||
onPointerDown={(event) => {
|
||||
if (readonly) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
handlePointerDown(event, item, "resize", "start")
|
||||
}}
|
||||
>
|
||||
<GripVerticalIcon className="size-3" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute right-0 top-0 flex h-full w-3 cursor-ew-resize items-center justify-center bg-primary/15"
|
||||
onPointerDown={(event) => {
|
||||
if (readonly) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
handlePointerDown(event, item, "resize", "end")
|
||||
}}
|
||||
>
|
||||
<GripVerticalIcon className="size-3" />
|
||||
</div>
|
||||
<div className="truncate text-xs font-medium">{teamName}</div>
|
||||
<div className="truncate text-xs">
|
||||
{timeLayout ? `${timeLayout.startLabel} - ${timeLayout.endLabel}` : `${formatTime(item.startAt)} - ${formatTime(item.endAt)}`}
|
||||
</div>
|
||||
{item.remark ? <div className="truncate text-[11px] text-primary/80">{item.remark}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{daySchedules.length > 5 ? (
|
||||
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.moreItems", { count: daySchedules.length - 5 })}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === "week") {
|
||||
return (
|
||||
<div className="min-w-[760px] overflow-hidden rounded-lg border bg-background">
|
||||
<div className={cn("divide-y", loading && "opacity-60")}>
|
||||
{days.map((day, dayIndex) => {
|
||||
const date = formatDate(day)
|
||||
return (
|
||||
<div key={date} className="grid grid-cols-[112px_minmax(0,1fr)]">
|
||||
<div className="border-r bg-muted/40 px-3 py-3 text-sm font-medium">
|
||||
<div>{t(`agentTeamSchedule.${weekDayKeys[dayIndex] ?? "weekdayShortMon"}`)}</div>
|
||||
<div className="mt-1 text-xs font-normal text-muted-foreground">{date.slice(5)}</div>
|
||||
</div>
|
||||
{renderDayCell(day, dayIndex, {
|
||||
inMonth: true,
|
||||
className: "min-h-24 border-l-0 border-t-0",
|
||||
showFullDate: false,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{interactionPreview ? (
|
||||
<div
|
||||
data-schedule-preview
|
||||
className={cn(
|
||||
"pointer-events-none fixed z-50 rounded-md border bg-popover px-3 py-2 text-xs font-medium text-popover-foreground shadow-md",
|
||||
interactionPreview.invalid && "border-destructive/40 bg-destructive text-destructive-foreground"
|
||||
)}
|
||||
style={{
|
||||
left: interactionPreview.x + 12,
|
||||
top: interactionPreview.y + 12,
|
||||
}}
|
||||
>
|
||||
{interactionPreview.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[960px] overflow-hidden rounded-lg border bg-background">
|
||||
<div className="grid grid-cols-7 border-b bg-muted/40">
|
||||
{weekDayKeys.map((key) => (
|
||||
<div key={key} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
|
||||
{t(`agentTeamSchedule.${key}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={cn("grid grid-cols-7", loading && "opacity-60")}>
|
||||
{days.map((day, dayIndex) => renderDayCell(day, dayIndex, { className: "min-h-36" }))}
|
||||
</div>
|
||||
{interactionPreview ? (
|
||||
<div
|
||||
data-schedule-preview
|
||||
className={cn(
|
||||
"pointer-events-none fixed z-50 rounded-md border bg-popover px-3 py-2 text-xs font-medium text-popover-foreground shadow-md",
|
||||
interactionPreview.invalid && "border-destructive/40 bg-destructive text-destructive-foreground"
|
||||
)}
|
||||
style={{
|
||||
left: interactionPreview.x + 12,
|
||||
top: interactionPreview.y + 12,
|
||||
}}
|
||||
>
|
||||
{interactionPreview.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
type AdminAgentTeam,
|
||||
type AdminAgentTeamSchedule,
|
||||
type CreateAdminAgentTeamSchedulePayload,
|
||||
fetchAgentTeamSchedule,
|
||||
fetchAgentTeamsAll,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
type ScheduleEditDialogProps = {
|
||||
open: boolean
|
||||
saving: boolean
|
||||
itemId: number | null
|
||||
defaultValues?: Partial<CreateAdminAgentTeamSchedulePayload> | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateAdminAgentTeamSchedulePayload) => Promise<void>
|
||||
onDelete?: (id: number) => Promise<void>
|
||||
}
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
teamId: "",
|
||||
startAt: "",
|
||||
endAt: "",
|
||||
remark: "",
|
||||
}
|
||||
|
||||
type EditForm = {
|
||||
teamId: string
|
||||
startAt: string
|
||||
endAt: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
function createEditFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
teamId: z.string().trim().regex(/^\d+$/, t("agentTeamSchedule.teamRequired")),
|
||||
startAt: z.string().trim().min(1, t("agentTeamSchedule.startRequired")),
|
||||
endAt: z.string().trim().min(1, t("agentTeamSchedule.endRequired")),
|
||||
remark: z.string().trim(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const startAt = parseDateTimeLocal(value.startAt)
|
||||
const endAt = parseDateTimeLocal(value.endAt)
|
||||
if (!startAt || !endAt) {
|
||||
return
|
||||
}
|
||||
if (!endAt || endAt <= startAt) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["endAt"],
|
||||
message: t("agentTeamSchedule.endAfterStart"),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!isSameLocalDay(startAt, endAt)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["endAt"],
|
||||
message: t("agentTeamSchedule.singleDayOnly"),
|
||||
})
|
||||
}
|
||||
if (startAt < startOfLocalDay(new Date())) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["startAt"],
|
||||
message: t("agentTeamSchedule.historyReadonly"),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value?: string) {
|
||||
if (!value) {
|
||||
return ""
|
||||
}
|
||||
return value.replace(" ", "T").slice(0, 16)
|
||||
}
|
||||
|
||||
function parseDateTimeLocal(value: string) {
|
||||
const ret = new Date(value)
|
||||
return Number.isNaN(ret.getTime()) ? null : ret
|
||||
}
|
||||
|
||||
function startOfLocalDay(value: Date) {
|
||||
const ret = new Date(value)
|
||||
ret.setHours(0, 0, 0, 0)
|
||||
return ret
|
||||
}
|
||||
|
||||
function isSameLocalDay(a: Date, b: Date) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
function todayDateTimeLocalMin() {
|
||||
const today = startOfLocalDay(new Date())
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(today.getDate()).padStart(2, "0")
|
||||
return `${today.getFullYear()}-${month}-${day}T00:00`
|
||||
}
|
||||
|
||||
function buildForm(item: AdminAgentTeamSchedule | null, defaultValues?: Partial<CreateAdminAgentTeamSchedulePayload> | null): EditForm {
|
||||
if (!item) {
|
||||
return {
|
||||
teamId: defaultValues?.teamId ? String(defaultValues.teamId) : emptyForm.teamId,
|
||||
startAt: toDateTimeLocal(defaultValues?.startAt),
|
||||
endAt: toDateTimeLocal(defaultValues?.endAt),
|
||||
remark: defaultValues?.remark ?? emptyForm.remark,
|
||||
}
|
||||
}
|
||||
return {
|
||||
teamId: String(item.teamId),
|
||||
startAt: toDateTimeLocal(item.startAt),
|
||||
endAt: toDateTimeLocal(item.endAt),
|
||||
remark: item.remark || "",
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateAdminAgentTeamSchedulePayload {
|
||||
return {
|
||||
teamId: Number(form.teamId),
|
||||
startAt: form.startAt.trim(),
|
||||
endAt: form.endAt.trim(),
|
||||
remark: form.remark.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
defaultValues,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}: ScheduleEditDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{open ? (
|
||||
<ScheduleEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
itemId={itemId}
|
||||
defaultValues={defaultValues}
|
||||
saving={saving}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type ScheduleEditDialogBodyProps = Omit<ScheduleEditDialogProps, "open">
|
||||
|
||||
function ScheduleEditDialogBody({
|
||||
saving,
|
||||
itemId,
|
||||
defaultValues,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}: ScheduleEditDialogBodyProps) {
|
||||
const t = useI18n()
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const loadOptions = useCallback(async () => {
|
||||
try {
|
||||
const teamsData = await fetchAgentTeamsAll()
|
||||
setTeams(teamsData)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadOptionsFailed"))
|
||||
}
|
||||
}, [t])
|
||||
const editFormSchema = useMemo(() => createEditFormSchema(t), [t])
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(editFormSchema) as Resolver<EditForm>,
|
||||
[editFormSchema],
|
||||
)
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form
|
||||
const minDateTime = todayDateTimeLocalMin()
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(buildForm(null, defaultValues))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchAgentTeamSchedule(itemId)
|
||||
reset(buildForm(data))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadDetailFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void loadDetail()
|
||||
}, [defaultValues, itemId, reset, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadOptions()
|
||||
}, [loadOptions])
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
await onSubmit(buildPayload(values))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{itemId ? t("agentTeamSchedule.editTitle") : t("agentTeamSchedule.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("agentTeamSchedule.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Field data-invalid={!!errors.teamId}>
|
||||
<FieldLabel>{t("agentTeamSchedule.team")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="teamId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={teams.map((team) => ({
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
}))}
|
||||
placeholder={t("agentTeamSchedule.teamRequired")}
|
||||
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
|
||||
emptyText={t("agentTeamSchedule.emptyTeam")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.teamId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.startAt}>
|
||||
<FieldLabel htmlFor="agent-team-schedule-start-at">{t("agentTeamSchedule.startTime")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="agent-team-schedule-start-at" type="datetime-local" min={minDateTime} {...register("startAt")} />
|
||||
<FieldError errors={[errors.startAt]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.endAt}>
|
||||
<FieldLabel htmlFor="agent-team-schedule-end-at">{t("agentTeamSchedule.endTime")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="agent-team-schedule-end-at" type="datetime-local" min={minDateTime} {...register("endAt")} />
|
||||
<FieldError errors={[errors.endAt]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="agent-team-schedule-remark">{t("agentTeamSchedule.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea id="agent-team-schedule-remark" rows={4} placeholder={t("agentTeamSchedule.remarkPlaceholder")} {...register("remark")} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
{itemId && onDelete ? (
|
||||
<Button type="button" variant="destructive" onClick={() => void onDelete(itemId)} disabled={saving}>
|
||||
{t("agentTeamSchedule.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("agentTeamSchedule.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving || loading}>
|
||||
{saving ? t("agentTeamSchedule.saving") : t("agentTeamSchedule.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
494
web/app/(dashboard)/dashboard/agent-team-schedules/page.tsx
Normal file
494
web/app/(dashboard)/dashboard/agent-team-schedules/page.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CalendarRangeIcon,
|
||||
CalendarSearchIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
LayersIcon,
|
||||
ListIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ButtonGroup } from "@/components/ui/button-group"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import {
|
||||
createAgentTeamSchedule,
|
||||
deleteAgentTeamSchedule,
|
||||
fetchAgentTeamScheduleCalendar,
|
||||
fetchAgentTeamSchedules,
|
||||
fetchAgentTeamsAll,
|
||||
updateAgentTeamSchedule,
|
||||
type AdminAgentTeam,
|
||||
type AdminAgentTeamSchedule,
|
||||
type CreateAdminAgentTeamSchedulePayload,
|
||||
type UpdateAdminAgentTeamSchedulePayload,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { BatchScheduleDialog } from "./_components/batch-schedule-dialog"
|
||||
import { ScheduleCalendar } from "./_components/calendar"
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
formatDateTimeValue,
|
||||
formatWeekTitle,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfMonthCalendar,
|
||||
startOfWeek,
|
||||
endOfMonthCalendar,
|
||||
} from "./_components/calendar-date-range"
|
||||
import { EditDialog } from "./_components/edit"
|
||||
|
||||
type ViewMode = "month" | "week" | "list"
|
||||
|
||||
function parseLocalDateTime(value: string) {
|
||||
const ret = new Date(value.replace(" ", "T"))
|
||||
return Number.isNaN(ret.getTime()) ? null : ret
|
||||
}
|
||||
|
||||
function isHistoricalSchedule(item: AdminAgentTeamSchedule) {
|
||||
const startAt = parseLocalDateTime(item.startAt)
|
||||
return !!startAt && startAt < startOfDay(new Date())
|
||||
}
|
||||
|
||||
export default function DashboardAgentTeamSchedulesPage() {
|
||||
const t = useI18n()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("month")
|
||||
const [teamFilterInput, setTeamFilterInput] = useState("all")
|
||||
const [teamFilter, setTeamFilter] = useState("all")
|
||||
const [listReloadKey, setListReloadKey] = useState(0)
|
||||
const [monthStart, setMonthStart] = useState(() => startOfMonth(new Date()))
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()))
|
||||
const [calendarLoading, setCalendarLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<AdminAgentTeamSchedule | null>(null)
|
||||
const [dialogDefaults, setDialogDefaults] = useState<Partial<CreateAdminAgentTeamSchedulePayload> | null>(null)
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [calendarItems, setCalendarItems] = useState<AdminAgentTeamSchedule[]>([])
|
||||
|
||||
const visibleTeams = useMemo(() => {
|
||||
if (teamFilter === "all") {
|
||||
return teams
|
||||
}
|
||||
return teams.filter((team) => String(team.id) === teamFilter)
|
||||
}, [teamFilter, teams])
|
||||
|
||||
const refreshing = calendarLoading
|
||||
|
||||
const listFilters = useMemo<DashboardCrudFilter[]>(
|
||||
() => [
|
||||
{
|
||||
name: "teamId",
|
||||
label: t("agentTeamSchedule.filterTeam"),
|
||||
defaultValue: teamFilter,
|
||||
allValue: "all",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "all", label: t("agentTeamSchedule.allTeams") },
|
||||
...teams.map((team) => ({ value: String(team.id), label: team.name })),
|
||||
],
|
||||
},
|
||||
],
|
||||
[t, teamFilter, teams],
|
||||
)
|
||||
|
||||
const listColumns = useMemo<DashboardCrudColumn<AdminAgentTeamSchedule>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "team",
|
||||
label: t("agentTeamSchedule.team"),
|
||||
render: (item) => (
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("agentTeamSchedule.teamId", { id: item.teamId })}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "timeRange",
|
||||
label: t("agentTeamSchedule.timeRange"),
|
||||
render: (item) => (
|
||||
<>
|
||||
<div className="text-sm">{formatDateTime(item.startAt)}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDateTime(item.endAt)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
)
|
||||
|
||||
const loadCalendarData = useCallback(async () => {
|
||||
setCalendarLoading(true)
|
||||
const rangeStart = viewMode === "week" ? weekStart : startOfMonthCalendar(monthStart)
|
||||
const rangeEnd = viewMode === "week" ? addDays(weekStart, 7) : endOfMonthCalendar(monthStart)
|
||||
try {
|
||||
const data = await fetchAgentTeamScheduleCalendar({
|
||||
startAt: formatDateTimeValue(rangeStart),
|
||||
endAt: formatDateTimeValue(rangeEnd),
|
||||
teamId: teamFilter === "all" ? undefined : teamFilter,
|
||||
})
|
||||
setCalendarItems(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadCalendarFailed"))
|
||||
} finally {
|
||||
setCalendarLoading(false)
|
||||
}
|
||||
}, [monthStart, t, teamFilter, viewMode, weekStart])
|
||||
|
||||
const loadTeams = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchAgentTeamsAll()
|
||||
setTeams(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const refreshActiveView = useCallback(async () => {
|
||||
await loadCalendarData()
|
||||
if (viewMode === "list") {
|
||||
setListReloadKey((value) => value + 1)
|
||||
}
|
||||
}, [loadCalendarData, viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
void loadCalendarData()
|
||||
}, [loadCalendarData])
|
||||
|
||||
useEffect(() => {
|
||||
void loadTeams()
|
||||
}, [loadTeams])
|
||||
|
||||
function applyFilters() {
|
||||
setTeamFilter(teamFilterInput)
|
||||
setListReloadKey((value) => value + 1)
|
||||
}
|
||||
|
||||
function openCreateDialog(defaults?: Partial<CreateAdminAgentTeamSchedulePayload>) {
|
||||
setEditingItem(null)
|
||||
setDialogDefaults(defaults ?? null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(item: AdminAgentTeamSchedule) {
|
||||
if (isHistoricalSchedule(item)) {
|
||||
toast.error(t("agentTeamSchedule.historyReadonly"))
|
||||
return
|
||||
}
|
||||
setDialogDefaults(null)
|
||||
setEditingItem(item)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(open: boolean) {
|
||||
if (saving) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setEditingItem(null)
|
||||
setDialogDefaults(null)
|
||||
}
|
||||
setDialogOpen(open)
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: CreateAdminAgentTeamSchedulePayload) {
|
||||
if (saving) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
|
||||
toast.success(t("agentTeamSchedule.updated"))
|
||||
} else {
|
||||
await createAgentTeamSchedule(payload)
|
||||
toast.success(t("agentTeamSchedule.created"))
|
||||
}
|
||||
setDialogOpen(false)
|
||||
setEditingItem(null)
|
||||
setDialogDefaults(null)
|
||||
await refreshActiveView()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchSuccess() {
|
||||
await refreshActiveView()
|
||||
}
|
||||
|
||||
async function handleDeleteById(id: number) {
|
||||
setActionLoadingId(id)
|
||||
try {
|
||||
await deleteAgentTeamSchedule(id)
|
||||
toast.success(t("agentTeamSchedule.deleted"))
|
||||
setDialogOpen(false)
|
||||
setEditingItem(null)
|
||||
setDialogDefaults(null)
|
||||
await refreshActiveView()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.deleteFailed"))
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) {
|
||||
const startAt = parseLocalDateTime(payload.startAt)
|
||||
if (startAt && startAt < startOfDay(new Date())) {
|
||||
toast.error(t("agentTeamSchedule.historyReadonly"))
|
||||
return
|
||||
}
|
||||
setActionLoadingId(payload.id)
|
||||
try {
|
||||
await updateAgentTeamSchedule(payload)
|
||||
toast.success(t("agentTeamSchedule.updated"))
|
||||
await loadCalendarData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.updateFailed"))
|
||||
await loadCalendarData()
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const today = new Date()
|
||||
setMonthStart(startOfMonth(today))
|
||||
setWeekStart(startOfWeek(today))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4 lg:p-6">
|
||||
<div className="shrink-0 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant={viewMode === "month" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("month")}
|
||||
>
|
||||
<CalendarDaysIcon />
|
||||
{t("agentTeamSchedule.month")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "week" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("week")}
|
||||
>
|
||||
<CalendarRangeIcon />
|
||||
{t("agentTeamSchedule.week")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<ListIcon />
|
||||
{t("agentTeamSchedule.list")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
{viewMode === "month" ? (
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label={t("agentTeamSchedule.prevMonth")}>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}>
|
||||
{t("agentTeamSchedule.thisMonth")}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label={t("agentTeamSchedule.nextMonth")}>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
) : null}
|
||||
{viewMode === "week" ? (
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label={t("agentTeamSchedule.prevWeek")}>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
|
||||
{t("agentTeamSchedule.thisWeek")}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label={t("agentTeamSchedule.nextWeek")}>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
) : null}
|
||||
{viewMode === "month" ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("agentTeamSchedule.monthTitle", {
|
||||
year: monthStart.getFullYear(),
|
||||
month: String(monthStart.getMonth() + 1).padStart(2, "0"),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{viewMode === "week" ? (
|
||||
<div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div>
|
||||
) : null}
|
||||
{viewMode !== "list" ? (
|
||||
<Button variant="outline" size="sm" onClick={goToToday}>
|
||||
<CalendarSearchIcon />
|
||||
{t("agentTeamSchedule.today")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:justify-end">
|
||||
<div className="w-full sm:w-48">
|
||||
<OptionCombobox
|
||||
value={teamFilterInput}
|
||||
options={[
|
||||
{ value: "all", label: t("agentTeamSchedule.allTeams") },
|
||||
...teams.map((team) => ({ value: String(team.id), label: team.name })),
|
||||
]}
|
||||
placeholder={t("agentTeamSchedule.filterTeam")}
|
||||
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
|
||||
emptyText={t("agentTeamSchedule.emptyTeam")}
|
||||
onChange={(value) => setTeamFilterInput(value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={refreshing}>
|
||||
<SearchIcon />
|
||||
{t("agentTeamSchedule.query")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void refreshActiveView()}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} />
|
||||
{t("agentTeamSchedule.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setBatchDialogOpen(true)}>
|
||||
<LayersIcon />
|
||||
{t("agentTeamSchedule.batch")}
|
||||
</Button>
|
||||
<Button onClick={() => openCreateDialog()}>
|
||||
<PlusIcon />
|
||||
{t("agentTeamSchedule.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "month" || viewMode === "week" ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<ScheduleCalendar
|
||||
variant={viewMode}
|
||||
monthStart={monthStart}
|
||||
calendarStart={viewMode === "week" ? weekStart : startOfMonthCalendar(monthStart)}
|
||||
calendarEnd={viewMode === "week" ? addDays(weekStart, 7) : endOfMonthCalendar(monthStart)}
|
||||
teams={visibleTeams}
|
||||
schedules={calendarItems}
|
||||
loading={calendarLoading}
|
||||
savingId={actionLoadingId}
|
||||
onCreate={openCreateDialog}
|
||||
onEdit={openEditDialog}
|
||||
onMove={handleCalendarUpdate}
|
||||
onResize={handleCalendarUpdate}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-auto">
|
||||
<DashboardCrudPage<AdminAgentTeamSchedule, CreateAdminAgentTeamSchedulePayload>
|
||||
key={`${teamFilter}-${listReloadKey}`}
|
||||
layout="fragment"
|
||||
showToolbar={false}
|
||||
filters={listFilters}
|
||||
columns={listColumns}
|
||||
fetchList={(query) =>
|
||||
fetchAgentTeamSchedules({
|
||||
teamId:
|
||||
typeof query.teamId === "string" ? query.teamId : undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createAgentTeamSchedule}
|
||||
updateItem={async (item, payload) => {
|
||||
await updateAgentTeamSchedule({ id: item.id, ...payload })
|
||||
await loadCalendarData()
|
||||
}}
|
||||
canEdit={(item) => !isHistoricalSchedule(item)}
|
||||
deleteItem={async (item) => {
|
||||
await deleteAgentTeamSchedule(item.id)
|
||||
await loadCalendarData()
|
||||
}}
|
||||
canDelete={(item) => !isHistoricalSchedule(item)}
|
||||
renderEditDialog={({ open, saving: dialogSaving, itemId, onOpenChange, onSubmit }) => (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={dialogSaving}
|
||||
itemId={itemId}
|
||||
defaultValues={null}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("agentTeamSchedule.refresh"),
|
||||
create: t("agentTeamSchedule.new"),
|
||||
query: t("agentTeamSchedule.query"),
|
||||
loading: t("agentTeamSchedule.loading"),
|
||||
empty: t("agentTeamSchedule.emptyRows"),
|
||||
actions: t("agentTeamSchedule.actions"),
|
||||
edit: t("agentTeamSchedule.edit"),
|
||||
delete: t("agentTeamSchedule.delete"),
|
||||
processing: t("agentTeamSchedule.deleting"),
|
||||
moreActions: (item) =>
|
||||
t("agentTeamSchedule.moreActions", { name: item.startAt }),
|
||||
loadFailed: t("agentTeamSchedule.loadFailed"),
|
||||
saveFailed: t("agentTeamSchedule.saveFailed"),
|
||||
deleteFailed: t("agentTeamSchedule.deleteFailed"),
|
||||
created: () => t("agentTeamSchedule.created"),
|
||||
updated: () => t("agentTeamSchedule.updated"),
|
||||
deleted: () => t("agentTeamSchedule.deleted"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving || actionLoadingId === editingItem?.id}
|
||||
itemId={editingItem?.id ?? null}
|
||||
defaultValues={dialogDefaults}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDeleteById}
|
||||
/>
|
||||
<BatchScheduleDialog
|
||||
open={batchDialogOpen}
|
||||
onOpenChange={setBatchDialogOpen}
|
||||
onSuccess={handleBatchSuccess}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
659
web/app/(dashboard)/dashboard/agents/_components/edit.tsx
Normal file
659
web/app/(dashboard)/dashboard/agents/_components/edit.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ImageInput } from "@/components/image-input";
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EditDialog as TeamEditDialog } from "./team-edit";
|
||||
import {
|
||||
createAgentTeam,
|
||||
fetchAgentProfile,
|
||||
fetchAgentTeamsAll,
|
||||
fetchUsersAll,
|
||||
type AdminAgentProfile,
|
||||
type AdminAgentTeam,
|
||||
type AdminUser,
|
||||
type CreateAdminAgentProfilePayload,
|
||||
type CreateAdminAgentTeamPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
type AgentEditDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
defaultTeamId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
|
||||
};
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
userId: "",
|
||||
teamId: "",
|
||||
agentCode: "",
|
||||
displayName: "",
|
||||
avatar: "",
|
||||
serviceStatus: String(ServiceStatus.Idle) as "0" | "1",
|
||||
maxConcurrentCount: "0",
|
||||
priorityLevel: "0",
|
||||
autoAssignEnabled: true,
|
||||
receiveOfflineMessage: false,
|
||||
remark: "",
|
||||
};
|
||||
|
||||
type EditForm = {
|
||||
userId: string;
|
||||
teamId: string;
|
||||
agentCode: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
serviceStatus: "0" | "1";
|
||||
maxConcurrentCount: string;
|
||||
priorityLevel: string;
|
||||
autoAssignEnabled: boolean;
|
||||
receiveOfflineMessage: boolean;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function createEditFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
userId: z.string().trim().min(1, t("agentProfile.userRequired")),
|
||||
teamId: z.string().trim().min(1, t("agentProfile.teamRequired")),
|
||||
agentCode: z.string().trim().min(1, t("agentProfile.agentCodeRequired")),
|
||||
displayName: z.string().trim().min(1, t("agentProfile.displayNameRequired")),
|
||||
avatar: z.string().trim(),
|
||||
serviceStatus: z.enum(["0", "1"], {
|
||||
message: t("agentProfile.statusRequired"),
|
||||
}),
|
||||
maxConcurrentCount: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d+$/, t("agentProfile.maxConcurrentInvalid")),
|
||||
priorityLevel: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^-?\d+$/, t("agentProfile.priorityInvalid")),
|
||||
autoAssignEnabled: z.boolean(),
|
||||
receiveOfflineMessage: z.boolean(),
|
||||
remark: z.string().trim(),
|
||||
});
|
||||
}
|
||||
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
];
|
||||
}
|
||||
|
||||
function buildForm(item: AdminAgentProfile | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm;
|
||||
}
|
||||
return {
|
||||
userId: String(item.userId),
|
||||
teamId: String(item.teamId),
|
||||
agentCode: item.agentCode,
|
||||
displayName: item.displayName,
|
||||
avatar: item.avatar || "",
|
||||
serviceStatus: String(item.serviceStatus) as EditForm["serviceStatus"],
|
||||
maxConcurrentCount: String(item.maxConcurrentCount),
|
||||
priorityLevel: String(item.priorityLevel),
|
||||
autoAssignEnabled: item.autoAssignEnabled,
|
||||
receiveOfflineMessage: item.receiveOfflineMessage,
|
||||
remark: item.remark || "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildFormWithDefaultTeam(
|
||||
item: AdminAgentProfile | null,
|
||||
defaultTeamId: number | null,
|
||||
): EditForm {
|
||||
const form = buildForm(item);
|
||||
if (!item && defaultTeamId) {
|
||||
return {
|
||||
...form,
|
||||
teamId: String(defaultTeamId),
|
||||
};
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateAdminAgentProfilePayload {
|
||||
return {
|
||||
userId: Number(form.userId),
|
||||
teamId: Number(form.teamId),
|
||||
agentCode: form.agentCode.trim(),
|
||||
displayName: form.displayName.trim(),
|
||||
avatar: form.avatar.trim(),
|
||||
serviceStatus: Number(form.serviceStatus),
|
||||
maxConcurrentCount: Number(form.maxConcurrentCount),
|
||||
priorityLevel: Number(form.priorityLevel),
|
||||
autoAssignEnabled: form.autoAssignEnabled,
|
||||
receiveOfflineMessage: form.receiveOfflineMessage,
|
||||
remark: form.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
defaultTeamId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AgentEditDialogProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
itemId={itemId}
|
||||
defaultTeamId={defaultTeamId}
|
||||
saving={saving}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AgentEditDialogBodyProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
defaultTeamId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
|
||||
};
|
||||
|
||||
function AgentEditDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
defaultTeamId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AgentEditDialogBodyProps) {
|
||||
const t = useI18n();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
|
||||
const [userSelectOpen, setUserSelectOpen] = useState(false);
|
||||
const [teamSelectOpen, setTeamSelectOpen] = useState(false);
|
||||
const [createTeamOpen, setCreateTeamOpen] = useState(false);
|
||||
const [createTeamSaving, setCreateTeamSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCreateTeamSubmit = async (payload: CreateAdminAgentTeamPayload) => {
|
||||
setCreateTeamSaving(true);
|
||||
try {
|
||||
const newTeam = await createAgentTeam(payload);
|
||||
toast.success(t("agentProfile.teamCreated", { name: payload.name }));
|
||||
setTeams((prev) => [...prev, newTeam]);
|
||||
form.setValue("teamId", String(newTeam.id), { shouldValidate: true });
|
||||
setCreateTeamOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("agentProfile.teamSaveFailed"),
|
||||
);
|
||||
} finally {
|
||||
setCreateTeamSaving(false);
|
||||
}
|
||||
};
|
||||
const userOptions = users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: `${user.nickname || user.username} (${user.username})`,
|
||||
}));
|
||||
const teamOptions = teams.map((team) => ({
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
}));
|
||||
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
|
||||
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(editFormSchema) as Resolver<EditForm>,
|
||||
[editFormSchema],
|
||||
);
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: buildFormWithDefaultTeam(null, defaultTeamId),
|
||||
});
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
try {
|
||||
const [usersData, teamsData] = await Promise.all([
|
||||
fetchUsersAll(),
|
||||
fetchAgentTeamsAll(),
|
||||
]);
|
||||
setUsers(usersData);
|
||||
setTeams(teamsData);
|
||||
if (!itemId && teamsData.length > 0) {
|
||||
const current = form.getValues("teamId");
|
||||
if (!current) {
|
||||
form.setValue("teamId", String(defaultTeamId || teamsData[0].id), { shouldValidate: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentProfile.loadOptionsFailed"));
|
||||
}
|
||||
}, [defaultTeamId, form, itemId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(buildFormWithDefaultTeam(null, defaultTeamId));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAgentProfile(itemId);
|
||||
reset(buildForm(data));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentProfile.loadDetailFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadDetail();
|
||||
}, [itemId, defaultTeamId, reset, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
void loadOptions();
|
||||
}
|
||||
}, [loadOptions, open]);
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
await onSubmit(buildPayload(values));
|
||||
}
|
||||
|
||||
const formId = "agent-edit-form";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={itemId ? t("agentProfile.editTitle") : t("agentProfile.createTitle")}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("agentProfile.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("agentProfile.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.userId}>
|
||||
<FieldLabel>{t("agentProfile.linkedUser")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="userId"
|
||||
render={({ field }) => (
|
||||
<Popover
|
||||
open={userSelectOpen}
|
||||
onOpenChange={setUserSelectOpen}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={userSelectOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{userOptions.find(
|
||||
(option) => option.value === field.value,
|
||||
)?.label ?? t("agentProfile.selectUser")}
|
||||
</span>
|
||||
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popper-anchor-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("agentProfile.searchUser")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{userOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
field.onChange(option.value);
|
||||
setUserSelectOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={`mr-2 size-4 ${
|
||||
field.value === option.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.userId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.teamId}>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>{t("agentProfile.linkedTeam")}</FieldLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs text-primary hover:underline"
|
||||
onClick={() => setCreateTeamOpen(true)}
|
||||
>
|
||||
+ {t("agentProfile.createTeamAction")}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="teamId"
|
||||
render={({ field }) => (
|
||||
<Popover
|
||||
open={teamSelectOpen}
|
||||
onOpenChange={setTeamSelectOpen}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={teamSelectOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{teamOptions.find(
|
||||
(option) => option.value === field.value,
|
||||
)?.label ?? t("agentProfile.selectTeam")}
|
||||
</span>
|
||||
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popper-anchor-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("agentProfile.searchTeam")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("agentProfile.emptyTeam")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{teamOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
field.onChange(option.value);
|
||||
setTeamSelectOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={`mr-2 size-4 ${
|
||||
field.value === option.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
{teams.length === 0 ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<span>{t("agentProfile.noTeamsAvailable")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-300"
|
||||
onClick={() => setCreateTeamOpen(true)}
|
||||
>
|
||||
{t("agentProfile.createTeamAction")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<FieldError errors={[errors.teamId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.displayName}>
|
||||
<FieldLabel htmlFor="agent-display-name">{t("agentProfile.displayName")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="agent-display-name"
|
||||
placeholder={t("agentProfile.displayNamePlaceholder")}
|
||||
{...register("displayName")}
|
||||
/>
|
||||
<FieldError errors={[errors.displayName]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.agentCode}>
|
||||
<FieldLabel htmlFor="agent-code">{t("agentProfile.agentCodeLabel")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="agent-code"
|
||||
placeholder={t("agentProfile.agentCodePlaceholder")}
|
||||
{...register("agentCode")}
|
||||
/>
|
||||
<FieldError errors={[errors.agentCode]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.serviceStatus}>
|
||||
<FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="serviceStatus"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
options={serviceStatusOptions}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("agentProfile.selectStatus")}
|
||||
searchPlaceholder={t("agentProfile.searchStatus")}
|
||||
emptyText={t("agentProfile.emptyStatus")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.serviceStatus]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field className="min-h-32">
|
||||
<FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatar"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={saving}
|
||||
prefix="avatar"
|
||||
placeholder={t("agentProfile.avatarUpload")}
|
||||
className="size-16 rounded-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.maxConcurrentCount}>
|
||||
<FieldLabel htmlFor="agent-max-concurrent-count">
|
||||
{t("agentProfile.maxConcurrent")}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="agent-max-concurrent-count"
|
||||
type="number"
|
||||
min={0}
|
||||
{...register("maxConcurrentCount")}
|
||||
/>
|
||||
<FieldError errors={[errors.maxConcurrentCount]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.priorityLevel}>
|
||||
<FieldLabel htmlFor="agent-priority-level">{t("agentProfile.priority")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="agent-priority-level"
|
||||
type="number"
|
||||
step={1}
|
||||
{...register("priorityLevel")}
|
||||
/>
|
||||
<FieldError errors={[errors.priorityLevel]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>{t("agentProfile.autoAssignEnabled")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="autoAssignEnabled"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("agentProfile.receiveOfflineMessage")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="receiveOfflineMessage"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="agent-remark">{t("agentProfile.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="agent-remark"
|
||||
rows={4}
|
||||
placeholder={t("agentProfile.remarkPlaceholder")}
|
||||
{...register("remark")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
<TeamEditDialog
|
||||
open={createTeamOpen}
|
||||
saving={createTeamSaving}
|
||||
itemId={null}
|
||||
onOpenChange={setCreateTeamOpen}
|
||||
onSubmit={handleCreateTeamSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
361
web/app/(dashboard)/dashboard/agents/_components/team-edit.tsx
Normal file
361
web/app/(dashboard)/dashboard/agents/_components/team-edit.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
type AdminAgentTeam,
|
||||
type CreateAdminAgentTeamPayload,
|
||||
fetchAgentTeam,
|
||||
fetchUsersAll,
|
||||
type AdminUser,
|
||||
} from "@/lib/api/admin";
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { Status } from "@/lib/generated/enums";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
type TeamEditDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateAdminAgentTeamPayload) => Promise<void>;
|
||||
};
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
name: "",
|
||||
leaderUserId: "0",
|
||||
status: String(Status.Ok),
|
||||
description: "",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
type EditForm = {
|
||||
name: string;
|
||||
leaderUserId: string;
|
||||
status: string;
|
||||
description: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function createEditFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
name: z.string().trim().min(1, t("agentProfile.teamNameRequired")),
|
||||
leaderUserId: z.string().trim().regex(/^\d+$/, t("agentProfile.leaderInvalid")),
|
||||
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
|
||||
message: t("agentProfile.teamStatusRequired"),
|
||||
}),
|
||||
description: z.string().trim(),
|
||||
remark: z.string().trim(),
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
|
||||
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
|
||||
];
|
||||
}
|
||||
|
||||
function buildForm(item: AdminAgentTeam | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm;
|
||||
}
|
||||
return {
|
||||
name: item.name,
|
||||
leaderUserId: String(item.leaderUserId),
|
||||
status: String(item.status),
|
||||
description: item.description || "",
|
||||
remark: item.remark || "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateAdminAgentTeamPayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
leaderUserId: Number(form.leaderUserId),
|
||||
status: Number(form.status),
|
||||
description: form.description.trim(),
|
||||
remark: form.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TeamEditDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{open ? (
|
||||
<TeamEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
itemId={itemId}
|
||||
saving={saving}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type TeamEditDialogBodyProps = Omit<TeamEditDialogProps, "open">;
|
||||
|
||||
function TeamEditDialogBody({
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TeamEditDialogBodyProps) {
|
||||
const t = useI18n();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [userSelectOpen, setUserSelectOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const userOptions = users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: `${user.nickname || user.username} (${user.username})`,
|
||||
}));
|
||||
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchUsersAll();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentProfile.loadUsersFailed"));
|
||||
}
|
||||
}, [t]);
|
||||
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(editFormSchema) as Resolver<EditForm>,
|
||||
[editFormSchema],
|
||||
);
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
});
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(emptyForm);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAgentTeam(itemId);
|
||||
reset(buildForm(data));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamDetailFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadDetail();
|
||||
}, [itemId, reset, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
await onSubmit(buildPayload(values));
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{itemId ? t("agentProfile.teamEditTitle") : t("agentProfile.teamCreateTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("agentProfile.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="agent-team-name">{t("agentProfile.teamName")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="agent-team-name"
|
||||
placeholder={t("agentProfile.teamNamePlaceholder")}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.leaderUserId}>
|
||||
<FieldLabel>{t("agentProfile.leader")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="leaderUserId"
|
||||
render={({ field }) => (
|
||||
<Popover open={userSelectOpen} onOpenChange={setUserSelectOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={userSelectOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value === "0"
|
||||
? t("agentProfile.noLeader")
|
||||
: userOptions.find((option) => option.value === field.value)?.label ?? t("agentProfile.selectLeader")}
|
||||
</span>
|
||||
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("agentProfile.searchUser")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={t("agentProfile.noLeader")}
|
||||
onSelect={() => {
|
||||
field.onChange("0");
|
||||
setUserSelectOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={`mr-2 size-4 ${field.value === "0" ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
{t("agentProfile.noLeader")}
|
||||
</CommandItem>
|
||||
{userOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
field.onChange(option.value);
|
||||
setUserSelectOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={`mr-2 size-4 ${
|
||||
field.value === option.value ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.leaderUserId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.status}>
|
||||
<FieldLabel>{t("agentProfile.status")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
options={statusOptions}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("agentProfile.selectStatus")}
|
||||
searchPlaceholder={t("agentProfile.searchStatus")}
|
||||
emptyText={t("agentProfile.emptyStatus")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.status]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="agent-team-description">{t("agentProfile.description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="agent-team-description"
|
||||
placeholder={t("agentProfile.descriptionPlaceholder")}
|
||||
{...register("description")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="agent-team-remark">{t("agentProfile.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="agent-team-remark"
|
||||
rows={4}
|
||||
placeholder={t("agentProfile.remarkPlaceholder")}
|
||||
{...register("remark")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("agentProfile.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving || loading}>
|
||||
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
Pencil,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
UsersRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { EditDialog } from "./team-edit";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
createAgentTeam,
|
||||
deleteAgentTeam,
|
||||
fetchAgentTeams,
|
||||
updateAgentTeam,
|
||||
type AdminAgentTeam,
|
||||
type CreateAdminAgentTeamPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { Status } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentTeamSidebarProps = {
|
||||
selectedTeamId: number | null;
|
||||
onSelectTeam: (team: AdminAgentTeam | null) => void;
|
||||
onTeamsChange?: (teams: AdminAgentTeam[]) => void;
|
||||
};
|
||||
|
||||
function getStatusTabs(t: (key: string, values?: Record<string, string | number>) => string) {
|
||||
return [
|
||||
{ value: "all", label: t("agentProfile.all") },
|
||||
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
|
||||
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function AgentTeamSidebar({
|
||||
selectedTeamId,
|
||||
onSelectTeam,
|
||||
onTeamsChange,
|
||||
}: AgentTeamSidebarProps) {
|
||||
const t = useI18n();
|
||||
const statusTabs = getStatusTabs(t);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<(typeof statusTabs)[number]["value"]>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<AdminAgentTeam | null>(null);
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAgentTeams({ page: 1, limit: 200 });
|
||||
setTeams(data);
|
||||
onTeamsChange?.(data);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamsFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onTeamsChange, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTeamId == null) {
|
||||
return;
|
||||
}
|
||||
const matchedTeam =
|
||||
teams.find((item) => item.id === selectedTeamId) ?? null;
|
||||
if (matchedTeam) {
|
||||
onSelectTeam(matchedTeam);
|
||||
return;
|
||||
}
|
||||
if (!loading && teams.length > 0) {
|
||||
onSelectTeam(teams[0]);
|
||||
}
|
||||
}, [loading, onSelectTeam, selectedTeamId, teams]);
|
||||
|
||||
const filteredTeams = useMemo(() => {
|
||||
const output = keyword.trim().toLowerCase();
|
||||
return teams.filter((item) => {
|
||||
const matchedKeyword =
|
||||
output.length === 0 ||
|
||||
item.name.toLowerCase().includes(output) ||
|
||||
item.description.toLowerCase().includes(output);
|
||||
const matchedStatus =
|
||||
statusFilter === "all" || String(item.status) === statusFilter;
|
||||
return matchedKeyword && matchedStatus;
|
||||
});
|
||||
}, [keyword, statusFilter, teams]);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingItem(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(item: AdminAgentTeam) {
|
||||
setEditingItem(item);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(open: boolean) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
setEditingItem(null);
|
||||
}
|
||||
setDialogOpen(open);
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: CreateAdminAgentTeamPayload) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateAgentTeam({ id: editingItem.id, ...payload });
|
||||
toast.success(t("agentProfile.teamUpdated", { name: editingItem.name }));
|
||||
} else {
|
||||
await createAgentTeam(payload);
|
||||
toast.success(t("agentProfile.teamCreated", { name: payload.name }));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setEditingItem(null);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentProfile.teamSaveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: AdminAgentTeam) {
|
||||
setActionLoadingId(item.id);
|
||||
try {
|
||||
await deleteAgentTeam(item.id);
|
||||
toast.success(t("agentProfile.teamDeleted", { name: item.name }));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("agentProfile.teamDeleteFailed"));
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col border-r bg-muted/10">
|
||||
<div className="border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t("agentProfile.teamTitle")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-3">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={t("agentProfile.searchTeams")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{statusTabs.map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant={statusFilter === item.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadData()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn("size-4", loading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button size="icon-sm" onClick={openCreateDialog}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-2 py-2">
|
||||
{filteredTeams.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group mt-1 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors hover:bg-accent",
|
||||
selectedTeamId === item.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={() => onSelectTeam(item)}
|
||||
>
|
||||
<UsersRoundIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{item.name}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
item.status === Status.Ok ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{item.status === Status.Ok ? t("agentProfile.enabled") : t("agentProfile.disabled")}
|
||||
</Badge>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
}
|
||||
aria-label={t("agentProfile.moreActions", { name: item.name })}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||
<Pencil />
|
||||
{t("agentProfile.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon />
|
||||
{actionLoadingId === item.id ? t("agentProfile.deleting") : t("agentProfile.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
{!loading && filteredTeams.length === 0 ? (
|
||||
<div className="px-2 py-10 text-center text-sm text-muted-foreground">
|
||||
{t("agentProfile.noTeams")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<EditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving}
|
||||
itemId={editingItem?.id ?? null}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
299
web/app/(dashboard)/dashboard/agents/page.tsx
Normal file
299
web/app/(dashboard)/dashboard/agents/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
UserCogIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
createAgentProfile,
|
||||
deleteAgentProfile,
|
||||
fetchAgentProfiles,
|
||||
updateAgentProfile,
|
||||
type AdminAgentProfile,
|
||||
type AdminAgentTeam,
|
||||
type CreateAdminAgentProfilePayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { EditDialog } from "./_components/edit";
|
||||
import { AgentTeamSidebar } from "./_components/team-sidebar";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("agentProfile.allStatuses") },
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
];
|
||||
}
|
||||
|
||||
function getStatusLabel(value: number, t: TFunction) {
|
||||
return (
|
||||
getServiceStatusOptions(t).find((item) => item.value === String(value))
|
||||
?.label ?? String(value)
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardAgentsPage() {
|
||||
const t = useI18n();
|
||||
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
|
||||
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
const handleTeamsChange = useCallback((nextTeams: AdminAgentTeam[]) => {
|
||||
setSelectedTeam((current) => {
|
||||
if (nextTeams.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!current) {
|
||||
return nextTeams[0];
|
||||
}
|
||||
return nextTeams.find((item) => item.id === current.id) ?? nextTeams[0];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filters = useMemo<DashboardCrudFilter[]>(
|
||||
() => [
|
||||
{
|
||||
name: "displayName",
|
||||
label: t("agentProfile.filterDisplayName"),
|
||||
placeholder: t("agentProfile.filterDisplayName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
{
|
||||
name: "agentCode",
|
||||
label: t("agentProfile.filterAgentCode"),
|
||||
placeholder: t("agentProfile.filterAgentCode"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-44",
|
||||
},
|
||||
{
|
||||
name: "serviceStatus",
|
||||
label: t("agentProfile.allStatuses"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: serviceStatusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
],
|
||||
[serviceStatusOptions, t],
|
||||
);
|
||||
|
||||
const columns = useMemo<DashboardCrudColumn<AdminAgentProfile>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "agent",
|
||||
label: t("agentProfile.columnAgent"),
|
||||
render: (item) => (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-10 items-center justify-center overflow-hidden rounded-md bg-muted">
|
||||
{item.avatar ? (
|
||||
<img
|
||||
src={item.avatar}
|
||||
alt={item.displayName}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserCogIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{item.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.nickname ||
|
||||
item.username ||
|
||||
t("agentProfile.userFallback", { id: item.userId })}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("agentProfile.agentCode", { code: item.agentCode })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rules",
|
||||
label: t("agentProfile.columnRules"),
|
||||
render: (item) => (
|
||||
<>
|
||||
<Badge variant="outline">
|
||||
{getStatusLabel(item.serviceStatus, t)}
|
||||
</Badge>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{t("agentProfile.capacityPriority", {
|
||||
capacity: item.maxConcurrentCount,
|
||||
priority: item.priorityLevel,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dispatch",
|
||||
label: t("agentProfile.columnDispatch"),
|
||||
render: (item) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant={item.autoAssignEnabled ? "secondary" : "outline"}>
|
||||
{item.autoAssignEnabled
|
||||
? t("agentProfile.autoAssign")
|
||||
: t("agentProfile.noAutoAssign")}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={item.receiveOfflineMessage ? "secondary" : "outline"}
|
||||
>
|
||||
{item.receiveOfflineMessage
|
||||
? t("agentProfile.offlineReceive")
|
||||
: t("agentProfile.noOfflineReceive")}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
label: t("agentProfile.columnRecent"),
|
||||
render: (item) => (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
{t("agentProfile.onlineAt", {
|
||||
time: formatDateTime(item.lastOnlineAt),
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("agentProfile.statusAt", {
|
||||
time: formatDateTime(item.lastStatusAt),
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
<div
|
||||
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
|
||||
sidebarCollapsed ? "w-0" : "w-80"
|
||||
}`}
|
||||
>
|
||||
<AgentTeamSidebar
|
||||
selectedTeamId={selectedTeam?.id ?? null}
|
||||
onSelectTeam={setSelectedTeam}
|
||||
onTeamsChange={handleTeamsChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative shrink-0 bg-background">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
aria-label={
|
||||
sidebarCollapsed
|
||||
? t("agentProfile.expandTeams")
|
||||
: t("agentProfile.collapseTeams")
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeftOpenIcon className="size-3.5" />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 p-4 lg:p-6">
|
||||
<div className="flex h-full flex-col gap-6">
|
||||
<DashboardCrudPage<AdminAgentProfile, CreateAdminAgentProfilePayload>
|
||||
key={selectedTeam?.id ?? "all"}
|
||||
layout="fragment"
|
||||
tableShellClassName="min-h-0"
|
||||
filters={filters}
|
||||
columns={columns}
|
||||
fetchList={(query) =>
|
||||
fetchAgentProfiles({
|
||||
teamId: selectedTeam?.id,
|
||||
displayName:
|
||||
typeof query.displayName === "string"
|
||||
? query.displayName
|
||||
: undefined,
|
||||
agentCode:
|
||||
typeof query.agentCode === "string"
|
||||
? query.agentCode
|
||||
: undefined,
|
||||
serviceStatus:
|
||||
typeof query.serviceStatus === "string"
|
||||
? query.serviceStatus
|
||||
: undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createAgentProfile}
|
||||
updateItem={(item, payload) =>
|
||||
updateAgentProfile({ id: item.id, ...payload })
|
||||
}
|
||||
deleteItem={(item) => deleteAgentProfile(item.id)}
|
||||
renderEditDialog={({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}) => (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
defaultTeamId={selectedTeam?.id ?? null}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("agentProfile.refresh"),
|
||||
create: t("agentProfile.new"),
|
||||
query: t("agentProfile.query"),
|
||||
loading: t("agentProfile.loadingRows"),
|
||||
empty: selectedTeam
|
||||
? t("agentProfile.emptyTeamRows")
|
||||
: t("agentProfile.emptyRows"),
|
||||
actions: t("agentProfile.columnActions"),
|
||||
edit: t("agentProfile.edit"),
|
||||
delete: t("agentProfile.delete"),
|
||||
processing: t("agentProfile.deleting"),
|
||||
moreActions: (item) =>
|
||||
t("agentProfile.moreActions", { name: item.displayName }),
|
||||
loadFailed: t("agentProfile.loadFailed"),
|
||||
saveFailed: t("agentProfile.saveFailed"),
|
||||
deleteFailed: t("agentProfile.deleteFailed"),
|
||||
created: (payload) =>
|
||||
t("agentProfile.created", { name: payload.displayName }),
|
||||
updated: (item) =>
|
||||
t("agentProfile.updated", { name: item.displayName }),
|
||||
deleted: (item) =>
|
||||
t("agentProfile.deleted", { name: item.displayName }),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
const configWorkbenchSource = await readFile(new URL("./config-workbench.tsx", import.meta.url), "utf8")
|
||||
const zhMessagesSource = await readFile(new URL("../../../../../messages/zh-CN.json", import.meta.url), "utf8")
|
||||
const adminApiSource = await readFile(new URL("../../../../../lib/api/admin.ts", import.meta.url), "utf8")
|
||||
const zhMessages = JSON.parse(zhMessagesSource)
|
||||
|
||||
test("AI Agent policy copy separates handoff execution from knowledge fallback", () => {
|
||||
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||
|
||||
assert.match(combinedSource, /转人工执行方式/)
|
||||
assert.match(combinedSource, /知识不足回复策略/)
|
||||
assert.match(combinedSource, /知识不足回复文案/)
|
||||
assert.match(combinedSource, /AI继续接待并提醒人工/)
|
||||
assert.match(combinedSource, /直接说明知识不足/)
|
||||
assert.match(combinedSource, /引导用户补充信息/)
|
||||
|
||||
assert.doesNotMatch(configWorkbenchSource, /转人工模式/)
|
||||
assert.doesNotMatch(configWorkbenchSource, /兜底策略/)
|
||||
assert.doesNotMatch(configWorkbenchSource, /兜底文案/)
|
||||
})
|
||||
|
||||
test("AI Agent config no longer exposes legacy graph tool routing knobs", () => {
|
||||
const aiAgentMessages = JSON.stringify(zhMessages.aiAgent ?? {})
|
||||
|
||||
assert.doesNotMatch(configWorkbenchSource, /graphTools/)
|
||||
assert.doesNotMatch(adminApiSource, /graphTools/)
|
||||
assert.doesNotMatch(aiAgentMessages, /graphTools/)
|
||||
assert.doesNotMatch(aiAgentMessages, /Graph Tool/)
|
||||
assert.doesNotMatch(aiAgentMessages, /内置流程/)
|
||||
})
|
||||
|
||||
test("AI Agent config uses one Agent Loop without a runtime mode selector", () => {
|
||||
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||
assert.doesNotMatch(configWorkbenchSource, /runtimeMode/)
|
||||
assert.doesNotMatch(adminApiSource, /runtimeMode/)
|
||||
assert.doesNotMatch(configWorkbenchSource, /运行方式/)
|
||||
assert.doesNotMatch(configWorkbenchSource, /Workflow 是 Agent 的可选能力/)
|
||||
assert.doesNotMatch(configWorkbenchSource, /管理工作流/)
|
||||
assert.match(combinedSource, /写操作(需确认)/)
|
||||
})
|
||||
|
||||
test("publishing an AI Agent saves the current form before publishing", () => {
|
||||
const publishFunction = configWorkbenchSource.match(
|
||||
/async function publishAgent\(\) \{([\s\S]*?)\n \}/,
|
||||
)?.[1]
|
||||
|
||||
assert.ok(publishFunction)
|
||||
assert.match(publishFunction, /const payload = buildPayload\(\)/)
|
||||
|
||||
const saveIndex = publishFunction.indexOf(
|
||||
"await updateAIAgent({ id: agent.id, ...payload })",
|
||||
)
|
||||
const publishIndex = publishFunction.indexOf("await publishAIAgent(agent.id)")
|
||||
|
||||
assert.ok(saveIndex >= 0)
|
||||
assert.ok(publishIndex > saveIndex)
|
||||
assert.match(publishFunction, /publishedSuccess/)
|
||||
})
|
||||
|
||||
test("saving a published AI Agent keeps the active revision online", () => {
|
||||
const saveFunction = configWorkbenchSource.match(
|
||||
/async function saveAgentSettings\(\) \{([\s\S]*?)\n \}/,
|
||||
)?.[1]
|
||||
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||
|
||||
assert.ok(saveFunction)
|
||||
assert.match(combinedSource, /配置已保存,当前已发布版本继续生效/)
|
||||
assert.match(combinedSource, /已发布版本正在生效;再次发布后应用当前配置/)
|
||||
assert.doesNotMatch(saveFunction, /loadData\(\)/)
|
||||
})
|
||||
|
||||
test("trusted MCP tools use backend risk metadata and cannot be edited", () => {
|
||||
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||
assert.match(adminApiSource, /riskEditable: boolean/)
|
||||
assert.match(configWorkbenchSource, /riskLevel: tool\.riskLevel/)
|
||||
assert.match(configWorkbenchSource, /requireConfirmation: tool\.requireConfirmation/)
|
||||
assert.match(combinedSource, /系统定义/)
|
||||
assert.match(configWorkbenchSource, /!catalogTool\.riskEditable/)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
337
web/app/(dashboard)/dashboard/ai-agents/page.tsx
Normal file
337
web/app/(dashboard)/dashboard/ai-agents/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { BotMessageSquareIcon, GitBranchIcon, PowerIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
type DashboardCrudActionState,
|
||||
createDashboardStatusColumn,
|
||||
createDashboardStatusToggleAction,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
createAIAgent,
|
||||
deleteAIAgent,
|
||||
fetchAIAgents,
|
||||
updateAIAgent,
|
||||
updateAIAgentSort,
|
||||
updateAIAgentStatus,
|
||||
type AIAgent,
|
||||
type CreateAIAgentPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { IMConversationServiceMode, Status } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { AIAgentConfigWorkbench } from "./_components/config-workbench";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function getStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("aiAgent.allStatuses") },
|
||||
{ value: String(Status.Ok), label: t("aiAgent.enabled") },
|
||||
{ value: String(Status.Disabled), label: t("aiAgent.disabled") },
|
||||
{ value: String(Status.Deleted), label: t("status.deleted") },
|
||||
];
|
||||
}
|
||||
|
||||
function getStatusLabel(value: string, t: TFunction) {
|
||||
return (
|
||||
getStatusOptions(t).find((item) => item.value === value)?.label ??
|
||||
t("aiAgent.allStatuses")
|
||||
);
|
||||
}
|
||||
|
||||
function getServiceModeLabel(mode: number, t: TFunction) {
|
||||
switch (mode) {
|
||||
case IMConversationServiceMode.AIOnly:
|
||||
return t("aiAgent.serviceAiOnly");
|
||||
case IMConversationServiceMode.HumanOnly:
|
||||
return t("aiAgent.serviceHumanOnly");
|
||||
case IMConversationServiceMode.AIFirst:
|
||||
return t("aiAgent.serviceAiFirst");
|
||||
default:
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
function getNextStatus(item: AIAgent) {
|
||||
return item.status === Status.Ok ? Status.Disabled : Status.Ok;
|
||||
}
|
||||
|
||||
export default function DashboardAIAgentsPage() {
|
||||
const t = useI18n();
|
||||
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||
const [configAgentId, setConfigAgentId] = useState<number | null>(null);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [crudActions, setCrudActions] = useState<DashboardCrudActionState | null>(null);
|
||||
|
||||
const filters = useMemo<DashboardCrudFilter[]>(
|
||||
() => [
|
||||
{
|
||||
name: "name",
|
||||
label: t("aiAgent.filterName"),
|
||||
placeholder: t("aiAgent.filterName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-56",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("aiAgent.allStatuses"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: statusOptions,
|
||||
className: "w-full sm:w-52",
|
||||
},
|
||||
],
|
||||
[statusOptions, t],
|
||||
);
|
||||
|
||||
const columns = useMemo<DashboardCrudColumn<AIAgent>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "agent",
|
||||
label: "Agent",
|
||||
render: (item) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<BotMessageSquareIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
Agent ID: {item.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "aiConfig",
|
||||
label: t("aiAgent.columnAiConfig"),
|
||||
render: (item) => item.aiConfigName || "-",
|
||||
},
|
||||
{
|
||||
key: "serviceMode",
|
||||
label: t("aiAgent.columnServiceMode"),
|
||||
render: (item) => getServiceModeLabel(item.serviceMode, t),
|
||||
},
|
||||
{
|
||||
key: "publication",
|
||||
label: t("aiAgent.publishStatus"),
|
||||
render: (item) => {
|
||||
const published = item.publishedRevisionId > 0;
|
||||
const workflowCount = item.workflowBindings?.length ?? 0;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant={published ? "default" : "outline"}>
|
||||
{published ? t("aiAgent.published") : t("aiAgent.unpublished")}
|
||||
</Badge>
|
||||
{published ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
Revision #{item.publishedRevisionId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{workflowCount > 0
|
||||
? t("aiAgent.configuredWorkflows", { count: String(workflowCount) })
|
||||
: t("aiAgent.directAutonomous")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: t("aiAgent.columnSkills"),
|
||||
render: (item) => {
|
||||
const skills = item.skills ?? [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skills.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("aiAgent.ragOnly")}
|
||||
</span>
|
||||
) : (
|
||||
skills.map((skill) => (
|
||||
<Badge key={skill.id} variant="outline">
|
||||
{skill.name}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "capabilities",
|
||||
label: t("aiAgent.columnCapabilities"),
|
||||
render: (item) => {
|
||||
const skills = item.skills ?? [];
|
||||
const mcpTools = item.mcpTools ?? [];
|
||||
const mcpServerCodes = Array.from(
|
||||
new Set(mcpTools.map((tool) => tool.serverCode).filter(Boolean)),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="secondary">{skills.length} Skills</Badge>
|
||||
<Badge variant="secondary">{mcpTools.length} MCP Tools</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mcpServerCodes.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("aiAgent.noMcpServer")}
|
||||
</span>
|
||||
) : (
|
||||
mcpServerCodes.map((serverCode) => (
|
||||
<Badge key={serverCode} variant="outline">
|
||||
{serverCode}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
createDashboardStatusColumn<AIAgent, number>({
|
||||
label: t("aiAgent.columnStatus"),
|
||||
getStatus: (item) => item.status,
|
||||
getLabel: (status) => getStatusLabel(String(status), t),
|
||||
getBadgeVariant: (status) =>
|
||||
status === Status.Ok ? "default" : "secondary",
|
||||
isEnabled: (status) => status === Status.Ok,
|
||||
toggle: {
|
||||
getNextStatus,
|
||||
updateStatus: (item, nextStatus) =>
|
||||
updateAIAgentStatus(item.id, nextStatus),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t("aiAgent.statusChanged", {
|
||||
name: item.name,
|
||||
status:
|
||||
nextStatus === Status.Ok
|
||||
? t("aiAgent.enabled")
|
||||
: t("aiAgent.stop"),
|
||||
}),
|
||||
errorMessage: t("aiAgent.statusUpdateFailed"),
|
||||
ariaLabel: (item) => t("aiAgent.toggleStatus", { name: item.name }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardCrudPage<AIAgent, CreateAIAgentPayload>
|
||||
filters={filters}
|
||||
columns={columns}
|
||||
fetchList={(query) =>
|
||||
fetchAIAgents({
|
||||
name: typeof query.name === "string" ? query.name : undefined,
|
||||
status: typeof query.status === "string" ? query.status : undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createAIAgent}
|
||||
updateItem={(item, payload) => updateAIAgent({ id: item.id, ...payload })}
|
||||
onCreateItem={() => {
|
||||
setConfigAgentId(null);
|
||||
setConfigOpen(true);
|
||||
}}
|
||||
onEditItem={(item) => {
|
||||
setConfigAgentId(item.id);
|
||||
setConfigOpen(true);
|
||||
}}
|
||||
deleteItem={(item) => deleteAIAgent(item.id)}
|
||||
rowActions={[
|
||||
{
|
||||
key: "workflow",
|
||||
icon: <GitBranchIcon />,
|
||||
label: t("aiAgent.configure"),
|
||||
run: ({ item }) => {
|
||||
setConfigAgentId(item.id);
|
||||
setConfigOpen(true);
|
||||
},
|
||||
},
|
||||
createDashboardStatusToggleAction<AIAgent, number>({
|
||||
icon: <PowerIcon />,
|
||||
label: (item) =>
|
||||
item.status === Status.Ok ? t("aiAgent.stop") : t("aiAgent.enabled"),
|
||||
getNextStatus,
|
||||
updateStatus: (item, nextStatus) =>
|
||||
updateAIAgentStatus(item.id, nextStatus),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t("aiAgent.statusChanged", {
|
||||
name: item.name,
|
||||
status:
|
||||
nextStatus === Status.Ok
|
||||
? t("aiAgent.enabled")
|
||||
: t("aiAgent.stop"),
|
||||
}),
|
||||
errorMessage: t("aiAgent.statusUpdateFailed"),
|
||||
}),
|
||||
]}
|
||||
sort={{
|
||||
enabled: true,
|
||||
onReorder: (items) => updateAIAgentSort(items.map((item) => item.id)),
|
||||
successMessage: t("aiAgent.sortUpdated"),
|
||||
errorMessage: t("aiAgent.sortUpdateFailed"),
|
||||
handleLabel: t("aiAgent.dragSort", { name: "" }),
|
||||
}}
|
||||
onActionStateChange={setCrudActions}
|
||||
labels={{
|
||||
refresh: t("aiAgent.refresh"),
|
||||
create: t("aiAgent.new"),
|
||||
query: t("aiAgent.query"),
|
||||
loading: t("aiAgent.loadingRows"),
|
||||
empty: t("aiAgent.emptyRows"),
|
||||
actions: t("aiAgent.columnActions"),
|
||||
edit: t("aiAgent.configure"),
|
||||
delete: t("aiAgent.delete"),
|
||||
processing: t("aiAgent.processing"),
|
||||
moreActions: (item) => t("aiAgent.moreActions", { name: item.name }),
|
||||
loadFailed: t("aiAgent.loadFailed"),
|
||||
saveFailed: t("aiAgent.saveFailed"),
|
||||
deleteFailed: t("aiAgent.deleteFailed"),
|
||||
created: (payload) => t("aiAgent.created", { name: payload.name }),
|
||||
updated: (_item, payload) => t("aiAgent.updated", { name: payload.name }),
|
||||
deleted: (item) => t("aiAgent.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
<ProjectDialog
|
||||
open={configOpen}
|
||||
onOpenChange={(open) => {
|
||||
setConfigOpen(open);
|
||||
if (!open) setConfigAgentId(null);
|
||||
}}
|
||||
title={t("aiAgent.configure")}
|
||||
size="xxl"
|
||||
allowFullscreen
|
||||
contentClassName="h-[800px] max-h-[calc(100vh-2rem)]"
|
||||
bodyScrollable={false}
|
||||
headerClassName="sr-only"
|
||||
closeOnEsc={false}
|
||||
>
|
||||
{configOpen ? (
|
||||
<AIAgentConfigWorkbench
|
||||
agentId={configAgentId}
|
||||
onAgentCreated={(agent) => setConfigAgentId(agent.id)}
|
||||
onAgentSaved={() => crudActions?.onRefresh()}
|
||||
onCancel={() => setConfigOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</ProjectDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
479
web/app/(dashboard)/dashboard/ai-configs/_components/edit.tsx
Normal file
479
web/app/(dashboard)/dashboard/ai-configs/_components/edit.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { type AIConfig, type CreateAIConfigPayload, fetchAIConfig } from "@/lib/api/admin"
|
||||
import {
|
||||
AIModelType,
|
||||
AIProvider,
|
||||
} from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
|
||||
type AIConfigEditDialogProps = {
|
||||
open: boolean
|
||||
saving: boolean
|
||||
itemId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateAIConfigPayload) => Promise<void>
|
||||
}
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function getProviderOptions(t: TFunction) {
|
||||
return [{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") }]
|
||||
}
|
||||
|
||||
function getModelTypeOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
|
||||
{ value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") },
|
||||
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
|
||||
]
|
||||
}
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
name: "",
|
||||
provider: AIProvider.OpenAI,
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
modelType: AIModelType.LLM,
|
||||
modelName: "",
|
||||
dimension: "0",
|
||||
maxContextTokens: "0",
|
||||
maxOutputTokens: "0",
|
||||
timeoutMs: "120000",
|
||||
maxRetryCount: "0",
|
||||
rpmLimit: "0",
|
||||
tpmLimit: "0",
|
||||
remark: "",
|
||||
}
|
||||
|
||||
type EditForm = {
|
||||
name: string
|
||||
provider: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
modelType: string
|
||||
modelName: string
|
||||
dimension: string
|
||||
maxContextTokens: string
|
||||
maxOutputTokens: string
|
||||
timeoutMs: string
|
||||
maxRetryCount: string
|
||||
rpmLimit: string
|
||||
tpmLimit: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
function buildForm(item: AIConfig | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
provider: item.provider,
|
||||
baseUrl: item.baseUrl,
|
||||
apiKey: "",
|
||||
modelType: item.modelType,
|
||||
modelName: item.modelName,
|
||||
dimension: String(item.dimension),
|
||||
maxContextTokens: String(item.maxContextTokens),
|
||||
maxOutputTokens: String(item.maxOutputTokens),
|
||||
timeoutMs: String(item.timeoutMs),
|
||||
maxRetryCount: String(item.maxRetryCount),
|
||||
rpmLimit: String(item.rpmLimit),
|
||||
tpmLimit: String(item.tpmLimit),
|
||||
remark: item.remark ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateAIConfigPayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
provider: form.provider,
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
apiKey: form.apiKey.trim(),
|
||||
modelType: form.modelType,
|
||||
modelName: form.modelName.trim(),
|
||||
dimension: Number(form.dimension),
|
||||
maxContextTokens: Number(form.maxContextTokens),
|
||||
maxOutputTokens: Number(form.maxOutputTokens),
|
||||
timeoutMs: Number(form.timeoutMs),
|
||||
maxRetryCount: Number(form.maxRetryCount),
|
||||
rpmLimit: Number(form.rpmLimit),
|
||||
tpmLimit: Number(form.tpmLimit),
|
||||
remark: form.remark.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AIConfigEditDialogProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AIConfigEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type AIConfigEditDialogBodyProps = AIConfigEditDialogProps
|
||||
|
||||
function AIConfigEditDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AIConfigEditDialogBodyProps) {
|
||||
const formId = "ai-config-edit-form"
|
||||
const t = useI18n()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const providerOptions = useMemo(() => getProviderOptions(t), [t])
|
||||
const modelTypeOptions = useMemo(() => getModelTypeOptions(t), [t])
|
||||
const aiConfigFormSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
name: z.string().trim().min(1, t("aiConfig.nameRequired")),
|
||||
provider: z.string().trim().min(1, t("aiConfig.providerRequired")),
|
||||
baseUrl: z.string().trim().min(1, t("aiConfig.baseUrlRequired")),
|
||||
apiKey: z.string().trim(),
|
||||
modelType: z.string().trim().min(1, t("aiConfig.modelTypeRequired")),
|
||||
modelName: z.string().trim().min(1, t("aiConfig.modelNameRequired")),
|
||||
dimension: z.string().trim().regex(/^\d+$/, t("aiConfig.dimensionInvalid")),
|
||||
maxContextTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxContextInvalid")),
|
||||
maxOutputTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxOutputInvalid")),
|
||||
timeoutMs: z.string().trim().regex(/^\d+$/, t("aiConfig.timeoutInvalid")),
|
||||
maxRetryCount: z.string().trim().regex(/^\d+$/, t("aiConfig.retryInvalid")),
|
||||
rpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.rpmInvalid")),
|
||||
tpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.tpmInvalid")),
|
||||
remark: z.string().trim(),
|
||||
}),
|
||||
[t],
|
||||
)
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(aiConfigFormSchema as never) as Resolver<EditForm>,
|
||||
[aiConfigFormSchema],
|
||||
)
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
const modelType = watch("modelType")
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(emptyForm)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchAIConfig(itemId)
|
||||
reset(buildForm(data))
|
||||
} catch (error) {
|
||||
console.error("Failed to load AI config:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void loadDetail()
|
||||
}, [itemId, reset])
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
await onSubmit(buildPayload(values))
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={itemId ? t("aiConfig.editTitle") : t("aiConfig.createTitle")}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("aiConfig.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("aiConfig.saving") : itemId ? t("aiConfig.save") : t("aiConfig.create")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("aiConfig.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="ai-config-name">{t("aiConfig.name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-name"
|
||||
placeholder={t("aiConfig.namePlaceholder")}
|
||||
aria-invalid={!!errors.name}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.provider}>
|
||||
<FieldLabel>{t("aiConfig.provider")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={providerOptions}
|
||||
placeholder={t("aiConfig.selectProvider")}
|
||||
searchPlaceholder={t("aiConfig.searchProvider")}
|
||||
emptyText={t("aiConfig.emptyProvider")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.provider]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.modelType}>
|
||||
<FieldLabel>{t("aiConfig.modelType")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="modelType"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={modelTypeOptions}
|
||||
placeholder={t("aiConfig.selectModelType")}
|
||||
searchPlaceholder={t("aiConfig.searchModelType")}
|
||||
emptyText={t("aiConfig.emptyModelType")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.modelType]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={!!errors.baseUrl}>
|
||||
<FieldLabel htmlFor="ai-config-base-url">{t("aiConfig.baseUrl")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-base-url"
|
||||
placeholder={t("aiConfig.baseUrlPlaceholder")}
|
||||
aria-invalid={!!errors.baseUrl}
|
||||
{...register("baseUrl")}
|
||||
/>
|
||||
<FieldError errors={[errors.baseUrl]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.apiKey}>
|
||||
<FieldLabel htmlFor="ai-config-api-key">API Key</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-api-key"
|
||||
type="password"
|
||||
placeholder={t("aiConfig.apiKeyPlaceholder")}
|
||||
aria-invalid={!!errors.apiKey}
|
||||
{...register("apiKey")}
|
||||
/>
|
||||
<FieldError errors={[errors.apiKey]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.modelName}>
|
||||
<FieldLabel htmlFor="ai-config-model-name">{t("aiConfig.modelName")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-model-name"
|
||||
placeholder={t("aiConfig.modelNamePlaceholder")}
|
||||
aria-invalid={!!errors.modelName}
|
||||
{...register("modelName")}
|
||||
/>
|
||||
<FieldError errors={[errors.modelName]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.dimension}>
|
||||
<FieldLabel htmlFor="ai-config-dimension">{t("aiConfig.dimensionLabel")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-dimension"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={modelType !== AIModelType.Embedding}
|
||||
aria-invalid={!!errors.dimension}
|
||||
{...register("dimension")}
|
||||
/>
|
||||
<FieldError errors={[errors.dimension]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.maxContextTokens}>
|
||||
<FieldLabel htmlFor="ai-config-max-context">{t("aiConfig.maxContextTokens")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-max-context"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
aria-invalid={!!errors.maxContextTokens}
|
||||
{...register("maxContextTokens")}
|
||||
/>
|
||||
<FieldError errors={[errors.maxContextTokens]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.maxOutputTokens}>
|
||||
<FieldLabel htmlFor="ai-config-max-output">{t("aiConfig.maxOutputTokens")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-max-output"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
aria-invalid={!!errors.maxOutputTokens}
|
||||
{...register("maxOutputTokens")}
|
||||
/>
|
||||
<FieldError errors={[errors.maxOutputTokens]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.timeoutMs}>
|
||||
<FieldLabel htmlFor="ai-config-timeout">{t("aiConfig.timeoutMs")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-timeout"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
aria-invalid={!!errors.timeoutMs}
|
||||
{...register("timeoutMs")}
|
||||
/>
|
||||
<FieldError errors={[errors.timeoutMs]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.maxRetryCount}>
|
||||
<FieldLabel htmlFor="ai-config-retry">{t("aiConfig.maxRetryCount")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-retry"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
aria-invalid={!!errors.maxRetryCount}
|
||||
{...register("maxRetryCount")}
|
||||
/>
|
||||
<FieldError errors={[errors.maxRetryCount]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field data-invalid={!!errors.rpmLimit}>
|
||||
<FieldLabel htmlFor="ai-config-rpm">{t("aiConfig.rpmLimit")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-rpm"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
aria-invalid={!!errors.rpmLimit}
|
||||
{...register("rpmLimit")}
|
||||
/>
|
||||
<FieldError errors={[errors.rpmLimit]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.tpmLimit}>
|
||||
<FieldLabel htmlFor="ai-config-tpm">{t("aiConfig.tpmLimit")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ai-config-tpm"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
aria-invalid={!!errors.tpmLimit}
|
||||
{...register("tpmLimit")}
|
||||
/>
|
||||
<FieldError errors={[errors.tpmLimit]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={!!errors.remark}>
|
||||
<FieldLabel htmlFor="ai-config-remark">{t("aiConfig.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="ai-config-remark"
|
||||
placeholder={t("aiConfig.remarkPlaceholder")}
|
||||
rows={3}
|
||||
aria-invalid={!!errors.remark}
|
||||
{...register("remark")}
|
||||
/>
|
||||
<FieldError errors={[errors.remark]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
299
web/app/(dashboard)/dashboard/ai-configs/page.tsx
Normal file
299
web/app/(dashboard)/dashboard/ai-configs/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
createDashboardStatusColumn,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
createAIConfig,
|
||||
deleteAIConfig,
|
||||
fetchAIConfigs,
|
||||
updateAIConfig,
|
||||
updateAIConfigSort,
|
||||
updateAIConfigStatus,
|
||||
type AIConfig,
|
||||
type CreateAIConfigPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { AIModelType, AIProvider, Status } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { EditDialog } from "./_components/edit";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function getStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("aiConfig.allStatuses") },
|
||||
{ value: String(Status.Ok), label: t("aiConfig.enabled") },
|
||||
{ value: String(Status.Disabled), label: t("aiConfig.disabled") },
|
||||
{ value: String(Status.Deleted), label: t("aiConfig.deletedStatus") },
|
||||
];
|
||||
}
|
||||
|
||||
function getProviderOptions(t: TFunction, includeAll = true) {
|
||||
const options = [
|
||||
{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") },
|
||||
];
|
||||
return includeAll
|
||||
? [{ value: "all", label: t("aiConfig.allProviders") }, ...options]
|
||||
: options;
|
||||
}
|
||||
|
||||
function getModelTypeOptions(t: TFunction, includeAll = true) {
|
||||
const options = [
|
||||
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
|
||||
{
|
||||
value: String(AIModelType.Embedding),
|
||||
label: t("aiConfig.modelTypeEmbedding"),
|
||||
},
|
||||
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
|
||||
];
|
||||
return includeAll
|
||||
? [{ value: "all", label: t("aiConfig.allTypes") }, ...options]
|
||||
: options;
|
||||
}
|
||||
|
||||
function getStatusLabel(value: Status, t: TFunction) {
|
||||
return (
|
||||
getStatusOptions(t).find((item) => item.value === String(value))?.label ??
|
||||
String(value)
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderLabel(value: AIProvider, t: TFunction) {
|
||||
return (
|
||||
getProviderOptions(t, false).find((item) => item.value === String(value))
|
||||
?.label ?? String(value)
|
||||
);
|
||||
}
|
||||
|
||||
function getModelTypeLabel(value: AIModelType, t: TFunction) {
|
||||
return (
|
||||
getModelTypeOptions(t, false).find((item) => item.value === String(value))
|
||||
?.label ?? String(value)
|
||||
);
|
||||
}
|
||||
|
||||
function getNextStatus(item: AIConfig) {
|
||||
return item.status === Status.Ok ? Status.Disabled : Status.Ok;
|
||||
}
|
||||
|
||||
export default function DashboardAIConfigsPage() {
|
||||
const t = useI18n();
|
||||
const listStatusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||
const providerFilterOptions = useMemo(() => getProviderOptions(t), [t]);
|
||||
const modelTypeFilterOptions = useMemo(() => getModelTypeOptions(t), [t]);
|
||||
|
||||
const filters = useMemo<DashboardCrudFilter[]>(
|
||||
() => [
|
||||
{
|
||||
name: "name",
|
||||
label: t("aiConfig.filterName"),
|
||||
placeholder: t("aiConfig.filterName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
{
|
||||
name: "modelType",
|
||||
label: t("aiConfig.allTypes"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: modelTypeFilterOptions,
|
||||
className: "w-full sm:w-40",
|
||||
},
|
||||
{
|
||||
name: "provider",
|
||||
label: t("aiConfig.allProviders"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: providerFilterOptions,
|
||||
className: "w-full sm:w-40",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("aiConfig.allStatuses"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: listStatusOptions,
|
||||
className: "w-full sm:w-32",
|
||||
},
|
||||
],
|
||||
[listStatusOptions, modelTypeFilterOptions, providerFilterOptions, t],
|
||||
);
|
||||
|
||||
const columns = useMemo<DashboardCrudColumn<AIConfig>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "config",
|
||||
label: t("aiConfig.columnConfig"),
|
||||
render: (item) => (
|
||||
<div className="space-y-1 text-sm font-medium">{item.name}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
label: t("aiConfig.columnProvider"),
|
||||
render: (item) => (
|
||||
<Badge variant="outline">
|
||||
{getProviderLabel(item.provider as AIProvider, t)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
label: t("aiConfig.columnModel"),
|
||||
render: (item) => (
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary">
|
||||
{getModelTypeLabel(item.modelType as AIModelType, t)}
|
||||
</Badge>
|
||||
<div className="text-sm">{item.modelName}</div>
|
||||
{item.dimension > 0 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("aiConfig.dimension", { count: item.dimension })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "access",
|
||||
label: t("aiConfig.columnAccess"),
|
||||
render: (item) => (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="line-clamp-1">{item.baseUrl}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("aiConfig.apiKey", { key: item.hasApiKey ? "****" : "-" })}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "limits",
|
||||
label: t("aiConfig.columnLimits"),
|
||||
render: (item) => (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div>
|
||||
{t("aiConfig.contextTokens", {
|
||||
count: item.maxContextTokens || 0,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("aiConfig.outputTokens", {
|
||||
count: item.maxOutputTokens || 0,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("aiConfig.timeoutRetry", {
|
||||
timeout: item.timeoutMs,
|
||||
retries: item.maxRetryCount,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
createDashboardStatusColumn<AIConfig, number>({
|
||||
label: t("aiConfig.columnStatus"),
|
||||
getStatus: (item) => item.status,
|
||||
getLabel: (status) => getStatusLabel(status as Status, t),
|
||||
getBadgeVariant: (status) =>
|
||||
status === Status.Ok ? "default" : "outline",
|
||||
isEnabled: (status) => status === Status.Ok,
|
||||
toggle: {
|
||||
getNextStatus,
|
||||
updateStatus: (item, nextStatus) =>
|
||||
updateAIConfigStatus(item.id, nextStatus),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t("aiConfig.statusChanged", {
|
||||
name: item.name,
|
||||
status:
|
||||
nextStatus === Status.Ok
|
||||
? t("aiConfig.enabled")
|
||||
: t("aiConfig.disabled"),
|
||||
}),
|
||||
errorMessage: t("aiConfig.statusUpdateFailed"),
|
||||
ariaLabel: (item) => t("aiConfig.toggleStatus", { name: item.name }),
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardCrudPage<AIConfig, CreateAIConfigPayload>
|
||||
filters={filters}
|
||||
columns={columns}
|
||||
fetchList={(query) =>
|
||||
fetchAIConfigs({
|
||||
name: typeof query.name === "string" ? query.name : undefined,
|
||||
status: typeof query.status === "string" ? query.status : undefined,
|
||||
provider:
|
||||
typeof query.provider === "string" ? query.provider : undefined,
|
||||
modelType:
|
||||
typeof query.modelType === "string" ? query.modelType : undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createAIConfig}
|
||||
updateItem={(item, payload) => updateAIConfig({ id: item.id, ...payload })}
|
||||
deleteItem={(item) => deleteAIConfig(item.id)}
|
||||
canDelete={(item) => item.status !== Status.Ok}
|
||||
deleteConfirm={(item) => ({
|
||||
title: t("aiConfig.confirmDeleteTitle"),
|
||||
description: t("aiConfig.confirmDeleteDescription", {
|
||||
name: item.name,
|
||||
}),
|
||||
confirmText: t("aiConfig.confirmDelete"),
|
||||
cancelText: t("aiConfig.cancel"),
|
||||
variant: "destructive",
|
||||
})}
|
||||
sort={{
|
||||
enabled: true,
|
||||
onReorder: (items) => updateAIConfigSort(items.map((item) => item.id)),
|
||||
successMessage: t("aiConfig.sortUpdated"),
|
||||
errorMessage: t("aiConfig.sortUpdateFailed"),
|
||||
handleLabel: t("aiConfig.dragSort", { name: "" }),
|
||||
}}
|
||||
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("aiConfig.refresh"),
|
||||
create: t("aiConfig.new"),
|
||||
query: t("aiConfig.query"),
|
||||
loading: t("aiConfig.loadingRows"),
|
||||
empty: t("aiConfig.emptyRows"),
|
||||
actions: t("aiConfig.columnActions"),
|
||||
edit: t("aiConfig.edit"),
|
||||
delete: t("aiConfig.delete"),
|
||||
processing: t("aiConfig.deleting"),
|
||||
moreActions: (item) => t("aiConfig.moreActions", { name: item.name }),
|
||||
loadFailed: t("aiConfig.loadFailed"),
|
||||
saveFailed: t("aiConfig.saveFailed"),
|
||||
deleteFailed: t("aiConfig.deleteFailed"),
|
||||
created: (payload) => t("aiConfig.created", { name: payload.name }),
|
||||
updated: (item) => t("aiConfig.updated", { name: item.name }),
|
||||
deleted: (item) => t("aiConfig.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react"
|
||||
|
||||
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { OfficialWorkflowEditor } from "../../ai-workflows/_components/official-workflow-editor"
|
||||
|
||||
export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) {
|
||||
const t = useI18n()
|
||||
const nodeRuns = useMemo(() => run.nodes ?? [], [run.nodes])
|
||||
const firstNodeId = nodeRuns[0]?.nodeId ?? run.definition?.nodes?.[0]?.id ?? ""
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId)
|
||||
const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0]
|
||||
const executedNodeIds = useMemo(() => new Set(nodeRuns.map((item) => item.nodeId)), [nodeRuns])
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
|
||||
<div className="relative min-w-0">
|
||||
<OfficialWorkflowEditor
|
||||
documentKey={`run-${run.id}`}
|
||||
definition={run.definition ?? { nodes: [], edges: [] }}
|
||||
onDefinitionChange={() => undefined}
|
||||
readonly
|
||||
/>
|
||||
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
{t("aiWorkflow.executedCount", { count: String(executedNodeIds.size) })}
|
||||
</Badge>
|
||||
{run.errorMessage ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangleIcon className="size-3" />
|
||||
{t("aiWorkflow.errorStatus")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<aside className="flex min-h-0 flex-col border-l bg-background">
|
||||
<div className="border-b p-3">
|
||||
<div className="text-sm font-medium">{t("aiWorkflow.nodeTraceTitle")}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t("aiWorkflow.nodeTraceHint")}</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-2 p-3">
|
||||
{nodeRuns.map((node) => (
|
||||
<button
|
||||
key={node.id || node.nodeId}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full rounded-md border p-2 text-left text-xs hover:bg-muted",
|
||||
selectedNodeId === node.nodeId ? "border-primary bg-primary/5" : "bg-background"
|
||||
)}
|
||||
onClick={() => setSelectedNodeId(node.nodeId)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">{node.nodeId}</span>
|
||||
<Badge variant={node.errorMessage ? "destructive" : "secondary"}>{node.statusName}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||
<TimerIcon className="size-3" />
|
||||
{node.durationMs ?? 0}ms
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="max-h-72 overflow-auto border-t p-3">
|
||||
<NodeRunPreview nodeRun={selectedNodeRun} t={t} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeRunPreview({ nodeRun, t }: { nodeRun?: AIWorkflowNodeRun; t: (key: string, values?: Record<string, string | number>) => string }) {
|
||||
if (!nodeRun) {
|
||||
return <div className="text-xs text-muted-foreground">{t("aiWorkflow.noNodeRuns")}</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
{nodeRun.errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2 text-destructive">
|
||||
{nodeRun.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<PreviewBlock title={t("aiWorkflow.inputTitle")} value={nodeRun.inputPreview} emptyText={t("aiWorkflow.noneValue")} />
|
||||
<PreviewBlock title={t("aiWorkflow.outputTitle")} value={nodeRun.outputPreview} emptyText={t("aiWorkflow.noneValue")} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewBlock({ title, value, emptyText }: { title: string; value?: string; emptyText?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 font-medium">{title}</div>
|
||||
{value ? <JsonTreeViewer value={parsePreview(value)} /> : <div className="text-muted-foreground">{emptyText || "None"}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parsePreview(value: string) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
523
web/app/(dashboard)/dashboard/ai-workflow-runs/page.tsx
Normal file
523
web/app/(dashboard)/dashboard/ai-workflow-runs/page.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
Clock3Icon,
|
||||
MessageCircleMoreIcon,
|
||||
MessageSquareTextIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ConversationDetailDialog } from "@/app/(dashboard)/dashboard/conversation-monitor/_components/detail"
|
||||
import { DashboardListPage } from "@/components/dashboard/list"
|
||||
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
fetchAIAgentsAll,
|
||||
fetchAIWorkflowRun,
|
||||
fetchAIWorkflowRuns,
|
||||
fetchConversationDetail,
|
||||
fetchConversationMessages,
|
||||
type AdminConversationDetail,
|
||||
type AdminMessage,
|
||||
type AIAgent,
|
||||
type AIWorkflowNodeRun,
|
||||
type AIWorkflowRun,
|
||||
} from "@/lib/api/admin"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { WorkflowRunAuditGraph } from "./_components/workflow-run-audit-graph"
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function getStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("workflowRun.allStatus") },
|
||||
{ value: "1", label: t("workflowRun.completed") },
|
||||
{ value: "2", label: t("workflowRun.interrupted") },
|
||||
{ value: "3", label: t("workflowRun.failed") },
|
||||
]
|
||||
}
|
||||
|
||||
function statusBadgeVariant(statusName?: string) {
|
||||
switch ((statusName || "").trim()) {
|
||||
case "failed":
|
||||
return "destructive" as const
|
||||
case "interrupted":
|
||||
return "outline" as const
|
||||
case "completed":
|
||||
return "default" as const
|
||||
default:
|
||||
return "secondary" as const
|
||||
}
|
||||
}
|
||||
|
||||
function formatRunStatus(item: Pick<AIWorkflowRun, "status" | "statusName">) {
|
||||
return item.statusName || (item.status ? String(item.status) : "-")
|
||||
}
|
||||
|
||||
export default function DashboardAIWorkflowRunsPage() {
|
||||
const t = useI18n()
|
||||
const [agents, setAgents] = useState<AIAgent[]>([])
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [activeRun, setActiveRun] = useState<AIWorkflowRun | null>(null)
|
||||
const [conversationOpen, setConversationOpen] = useState(false)
|
||||
const [conversationLoading, setConversationLoading] = useState(false)
|
||||
const [activeConversation, setActiveConversation] = useState<AdminConversationDetail | null>(null)
|
||||
const [conversationMessages, setConversationMessages] = useState<AdminMessage[]>([])
|
||||
const statusOptions = useMemo(() => getStatusOptions(t), [t])
|
||||
const agentOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: t("workflowRun.allAgents") },
|
||||
...agents.map((agent) => ({
|
||||
value: String(agent.id),
|
||||
label: agent.name,
|
||||
})),
|
||||
],
|
||||
[agents, t]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadAgents() {
|
||||
try {
|
||||
const data = await fetchAIAgentsAll()
|
||||
if (!cancelled) {
|
||||
setAgents(data)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : t("workflowRun.loadAgentsFailed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadAgents()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [t])
|
||||
|
||||
async function openDetail(runId: number) {
|
||||
setDetailOpen(true)
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const data = await fetchAIWorkflowRun(runId)
|
||||
setActiveRun(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("workflowRun.loadDetailFailed"))
|
||||
setDetailOpen(false)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(conversationId: number) {
|
||||
if (!conversationId) {
|
||||
return
|
||||
}
|
||||
setConversationOpen(true)
|
||||
setConversationLoading(true)
|
||||
try {
|
||||
const [detail, messagePage] = await Promise.all([
|
||||
fetchConversationDetail(conversationId),
|
||||
fetchConversationMessages({ conversationId, limit: 20 }),
|
||||
])
|
||||
setActiveConversation(detail)
|
||||
setConversationMessages(messagePage.results ?? [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("workflowRun.loadConversationFailed"))
|
||||
setConversationOpen(false)
|
||||
} finally {
|
||||
setConversationLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardListPage<AIWorkflowRun>
|
||||
filters={[
|
||||
{
|
||||
name: "conversationId",
|
||||
label: t("workflowRun.conversationId"),
|
||||
placeholder: t("workflowRun.conversationId"),
|
||||
defaultValue: "",
|
||||
valueType: "number",
|
||||
className: "w-full sm:w-44",
|
||||
},
|
||||
{
|
||||
name: "messageId",
|
||||
label: t("workflowRun.messageId"),
|
||||
placeholder: t("workflowRun.messageId"),
|
||||
defaultValue: "",
|
||||
valueType: "number",
|
||||
className: "w-full sm:w-40",
|
||||
},
|
||||
{
|
||||
name: "workflowVersionId",
|
||||
label: t("workflowRun.workflowVersionId"),
|
||||
placeholder: t("workflowRun.workflowVersionId"),
|
||||
defaultValue: "",
|
||||
valueType: "number",
|
||||
className: "w-full sm:w-48",
|
||||
},
|
||||
{
|
||||
name: "aiAgentId",
|
||||
label: t("workflowRun.agent"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: agentOptions,
|
||||
placeholder: t("workflowRun.agent"),
|
||||
searchPlaceholder: t("workflowRun.searchAgent"),
|
||||
emptyText: t("workflowRun.emptyAgent"),
|
||||
className: "w-full sm:w-56",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("workflowRun.status"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: statusOptions,
|
||||
placeholder: t("workflowRun.status"),
|
||||
className: "w-full sm:w-44",
|
||||
},
|
||||
]}
|
||||
fetchList={fetchAIWorkflowRuns}
|
||||
getItemId={(item) => item.id}
|
||||
getRowClassName={() => "cursor-pointer"}
|
||||
onRowClick={(item) => void openDetail(item.id)}
|
||||
columns={[
|
||||
{
|
||||
key: "time",
|
||||
label: t("workflowRun.startedAt"),
|
||||
className: "w-42 text-xs text-muted-foreground",
|
||||
render: (item) => formatDateTime(item.startedAt || item.createdAt),
|
||||
},
|
||||
{
|
||||
key: "workflow",
|
||||
label: t("workflowRun.workflow"),
|
||||
render: (item) => (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="truncate font-medium">
|
||||
{item.workflowName || `Workflow #${item.workflowId}`}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>v{item.workflowVersion || "-"}</span>
|
||||
<span>#{item.workflowVersionId || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "agent",
|
||||
label: t("workflowRun.agent"),
|
||||
className: "w-48",
|
||||
render: (item) => item.aiAgentName || `#${item.aiAgentId}`,
|
||||
},
|
||||
{
|
||||
key: "message",
|
||||
label: t("workflowRun.message"),
|
||||
className: "w-48",
|
||||
render: (item) => (
|
||||
<div className="space-y-1 text-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto max-w-full justify-start px-0 py-0 text-sm font-normal hover:bg-transparent hover:text-primary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void openConversation(item.conversationId)
|
||||
}}
|
||||
>
|
||||
<MessageCircleMoreIcon className="size-3.5" />
|
||||
<span className="truncate">
|
||||
{t("workflowRun.conversationShort", { id: item.conversationId || "-" })}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("workflowRun.messageShort", { id: item.messageId || "-" })}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("workflowRun.status"),
|
||||
className: "w-32",
|
||||
render: (item) => (
|
||||
<Badge variant={statusBadgeVariant(item.statusName)}>
|
||||
{formatRunStatus(item)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
label: t("workflowRun.duration"),
|
||||
className: "w-28 text-right",
|
||||
render: (item) => `${item.durationMs || 0} ms`,
|
||||
},
|
||||
{
|
||||
key: "error",
|
||||
label: t("workflowRun.error"),
|
||||
className: "w-72 max-w-72",
|
||||
render: (item) =>
|
||||
item.errorMessage ? (
|
||||
<ErrorMessagePreview message={item.errorMessage} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
labels={{
|
||||
refresh: t("workflowRun.refresh"),
|
||||
query: t("workflowRun.query"),
|
||||
loading: t("workflowRun.loading"),
|
||||
empty: t("workflowRun.empty"),
|
||||
loadFailed: t("workflowRun.loadFailed"),
|
||||
}}
|
||||
/>
|
||||
<WorkflowRunDetailDialog
|
||||
open={detailOpen}
|
||||
loading={detailLoading}
|
||||
run={activeRun}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open)
|
||||
if (!open) {
|
||||
setActiveRun(null)
|
||||
}
|
||||
}}
|
||||
t={t}
|
||||
onOpenConversation={openConversation}
|
||||
/>
|
||||
<ConversationDetailDialog
|
||||
open={conversationOpen}
|
||||
loading={conversationLoading}
|
||||
saving={false}
|
||||
readOnly
|
||||
item={activeConversation}
|
||||
detail={activeConversation}
|
||||
messages={conversationMessages}
|
||||
messagesHasMore={false}
|
||||
loadingMoreMessages={false}
|
||||
onOpenChange={(open) => {
|
||||
setConversationOpen(open)
|
||||
if (!open) {
|
||||
setActiveConversation(null)
|
||||
setConversationMessages([])
|
||||
}
|
||||
}}
|
||||
onOpenAssign={() => undefined}
|
||||
onDispatch={async () => undefined}
|
||||
onOpenTransfer={() => undefined}
|
||||
onRead={async () => undefined}
|
||||
onOpenClose={() => undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessagePreview({ message }: { message: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-1.5 text-left text-xs text-destructive"
|
||||
title={message}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||
<span className="block min-w-0 truncate">{message}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowRunDetailDialog({
|
||||
open,
|
||||
loading,
|
||||
run,
|
||||
onOpenChange,
|
||||
t,
|
||||
onOpenConversation,
|
||||
}: {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
run: AIWorkflowRun | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
t: TFunction
|
||||
onOpenConversation: (conversationId: number) => void | Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<WorkflowIcon className="size-4" />
|
||||
{t("workflowRun.detailTitle")}
|
||||
</span>
|
||||
}
|
||||
description={run ? `Run #${run.id}` : t("workflowRun.detailDescription")}
|
||||
size="xl"
|
||||
allowFullscreen
|
||||
defaultFullscreen
|
||||
bodyClassName="min-h-0"
|
||||
footer={
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("workflowRun.close")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="py-10 text-sm text-muted-foreground">{t("workflowRun.loadingDetail")}</div>
|
||||
) : run ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2 text-xs">
|
||||
<CompactRunMeta label={t("workflowRun.workflow")} value={run.workflowName || `#${run.workflowId}`} />
|
||||
<CompactRunMeta label={t("workflowRun.version")} value={`v${run.workflowVersion || "-"} / #${run.workflowVersionId}`} />
|
||||
<CompactRunMeta label={t("workflowRun.agent")} value={run.aiAgentName || `#${run.aiAgentId}`} />
|
||||
<CompactRunMeta label={t("workflowRun.status")} value={formatRunStatus(run)} />
|
||||
<CompactRunMeta
|
||||
label={t("workflowRun.conversationId")}
|
||||
value={
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto px-0 py-0 text-xs font-medium"
|
||||
onClick={() => void onOpenConversation(run.conversationId)}
|
||||
>
|
||||
#{run.conversationId}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CompactRunMeta label={t("workflowRun.messageId")} value={`#${run.messageId}`} />
|
||||
<CompactRunMeta label={t("workflowRun.startedAt")} value={run.startedAt ? formatDateTime(run.startedAt) : "-"} />
|
||||
<CompactRunMeta label={t("workflowRun.duration")} value={`${run.durationMs || 0} ms`} />
|
||||
{run.interruptNodeId ? <CompactRunMeta label={t("workflowRun.interruptNode")} value={run.interruptNodeId} /> : null}
|
||||
</div>
|
||||
{run.errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{run.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<WorkflowRunAuditGraph run={run} />
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">{t("workflowRun.nodeDetails")}</div>
|
||||
{(run.nodes ?? []).map((node, index) => (
|
||||
<WorkflowNodeRunBlock key={node.id || node.nodeId || index} node={node} t={t} />
|
||||
))}
|
||||
{!run.nodes || run.nodes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("workflowRun.emptyNodes")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-10 text-sm text-muted-foreground">{t("workflowRun.notFound")}</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowNodeRunBlock({ node, t }: { node: AIWorkflowNodeRun; t: TFunction }) {
|
||||
const inputPreview = node.inputPreview || ""
|
||||
const outputPreview = node.outputPreview || ""
|
||||
const inputValue = safeParseJSON(inputPreview)
|
||||
const outputValue = safeParseJSON(outputPreview)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<MessageSquareTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">{node.nodeId || `#${node.id}`}</span>
|
||||
<Badge variant={statusBadgeVariant(node.statusName)}>{node.statusName || node.status || "-"}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{node.nodeType || "unknown"}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock3Icon className="size-3.5" />
|
||||
{node.durationMs} ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{node.errorMessage ? (
|
||||
<div className="flex max-w-xl items-start gap-1.5 text-xs text-destructive">
|
||||
<AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="line-clamp-2 break-all">{node.errorMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<PreviewBlock title={t("workflowRun.input")} raw={inputPreview} value={inputValue} />
|
||||
<PreviewBlock title={t("workflowRun.output")} raw={outputPreview} value={outputValue} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewBlock({
|
||||
title,
|
||||
raw,
|
||||
value,
|
||||
}: {
|
||||
title: string
|
||||
raw: string
|
||||
value: unknown
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">{title}</div>
|
||||
{value !== null ? (
|
||||
<JsonTreeViewer value={value} collapsed={2} />
|
||||
) : raw.trim() ? (
|
||||
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all">
|
||||
{raw}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
-
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate font-medium">{value || "-"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactRunMeta({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-md border bg-background px-2 py-1">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate font-medium">{value || "-"}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function safeParseJSON(raw: string): unknown | null {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||
|
||||
const MESSAGE_SOURCE = "agent-desk"
|
||||
const EMPTY_NODE_SPECS: AIWorkflowNodeSpec[] = []
|
||||
|
||||
function definitionsEqual(
|
||||
left: AIWorkflowDefinition,
|
||||
right: AIWorkflowDefinition
|
||||
) {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
type EditorMessage =
|
||||
| {
|
||||
source: typeof MESSAGE_SOURCE
|
||||
type: "workflow:ready"
|
||||
}
|
||||
| {
|
||||
source: typeof MESSAGE_SOURCE
|
||||
type: "workflow:change"
|
||||
document: AIWorkflowDefinition
|
||||
}
|
||||
|
||||
export function OfficialWorkflowEditor({
|
||||
documentKey,
|
||||
definition,
|
||||
nodeSpecs = EMPTY_NODE_SPECS,
|
||||
onDefinitionChange,
|
||||
readonly = false,
|
||||
}: {
|
||||
documentKey: string
|
||||
definition: AIWorkflowDefinition
|
||||
nodeSpecs?: AIWorkflowNodeSpec[]
|
||||
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
||||
readonly?: boolean
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||
const definitionRef = useRef(definition)
|
||||
|
||||
useEffect(() => {
|
||||
definitionRef.current = definition
|
||||
}, [definition])
|
||||
|
||||
const loadDocument = useCallback(() => {
|
||||
frameRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
source: MESSAGE_SOURCE,
|
||||
type: "workflow:load",
|
||||
documentKey,
|
||||
document: definitionRef.current,
|
||||
nodeSpecs,
|
||||
readonly,
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
}, [documentKey, nodeSpecs, readonly])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<EditorMessage>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== frameRef.current?.contentWindow ||
|
||||
event.data?.source !== MESSAGE_SOURCE
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (event.data.type === "workflow:ready") {
|
||||
loadDocument()
|
||||
} else if (event.data.type === "workflow:change") {
|
||||
if (definitionsEqual(event.data.document, definitionRef.current)) {
|
||||
return
|
||||
}
|
||||
definitionRef.current = event.data.document
|
||||
onDefinitionChange(event.data.document)
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [loadDocument, onDefinitionChange])
|
||||
|
||||
useEffect(() => {
|
||||
loadDocument()
|
||||
}, [loadDocument])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src="/flowgram-editor/index.html"
|
||||
title={t("aiWorkflow.editorTitle")}
|
||||
className="h-full min-h-[560px] w-full border-0 bg-white"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
fetchAIWorkflowUsage,
|
||||
type AIWorkflow,
|
||||
type AIWorkflowUsage,
|
||||
} from "@/lib/api/admin"
|
||||
|
||||
export function WorkflowUsageDialog({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
workflow: AIWorkflow | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const [usage, setUsage] = useState<AIWorkflowUsage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!workflow || !open) return
|
||||
setLoading(true)
|
||||
try {
|
||||
setUsage((await fetchAIWorkflowUsage(workflow.id)) ?? [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [open, t, workflow])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("aiWorkflow.usageTitle")}
|
||||
description={workflow?.name}
|
||||
size="lg"
|
||||
bodyClassName="min-h-[360px]"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.loadingUsage")}
|
||||
</div>
|
||||
) : usage.length ? (
|
||||
<div className="space-y-3">
|
||||
{usage.map((item) => (
|
||||
<div
|
||||
key={`${item.aiAgentId}-${item.workflowVersionId}`}
|
||||
className="flex items-center justify-between rounded-md border p-4"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{item.aiAgentName}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.fixedBoundVersion", { version: String(item.workflowVersion) })}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={item.enabled ? "secondary" : "outline"}>
|
||||
{item.enabled ? t("aiWorkflow.enabledStatus") : t("aiWorkflow.disabledStatus")}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.notUsedByAnyAgent")}
|
||||
</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
fetchAIWorkflowVersions,
|
||||
restoreAIWorkflowVersion,
|
||||
type AIWorkflow,
|
||||
type AIWorkflowVersion,
|
||||
} from "@/lib/api/admin"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
export function WorkflowVersionsDialog({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRestored,
|
||||
}: {
|
||||
workflow: AIWorkflow | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onRestored: () => void
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const [versions, setVersions] = useState<AIWorkflowVersion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [restoringId, setRestoringId] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!workflow || !open) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const page = await fetchAIWorkflowVersions({
|
||||
workflowId: workflow.id,
|
||||
limit: 50,
|
||||
})
|
||||
setVersions(page.results ?? [])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [open, t, workflow])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
async function restore(version: AIWorkflowVersion) {
|
||||
if (!workflow || restoringId !== null) return
|
||||
setRestoringId(version.id)
|
||||
try {
|
||||
await restoreAIWorkflowVersion(workflow.id, version.id)
|
||||
onRestored()
|
||||
toast.success(t("aiWorkflow.restoredDraftSuccess", { version: String(version.version) }))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.restoreFailed"))
|
||||
} finally {
|
||||
setRestoringId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("aiWorkflow.versionsTitle")}
|
||||
description={workflow?.name}
|
||||
size="lg"
|
||||
bodyClassName="min-h-[360px]"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.loadingVersions")}
|
||||
</div>
|
||||
) : versions.length ? (
|
||||
<div className="space-y-3">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="flex items-center gap-4 rounded-md border p-4"
|
||||
>
|
||||
<Badge>v{version.version}</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{formatDateTime(version.publishedAt || version.createdAt)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("aiWorkflow.publisher", { name: version.publishedByName || "-" })}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restoringId !== null}
|
||||
onClick={() => void restore(version)}
|
||||
>
|
||||
{restoringId === version.id ? t("aiWorkflow.restoring") : t("aiWorkflow.restoreToDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.noVersionsYet")}
|
||||
</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { PencilIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
createAIWorkflow,
|
||||
fetchAIWorkflow,
|
||||
fetchAIWorkflowDefaultDefinition,
|
||||
fetchAIWorkflowNodeSpecs,
|
||||
fetchAIWorkflowTemplates,
|
||||
publishAIWorkflow,
|
||||
updateAIWorkflow,
|
||||
type AIWorkflow,
|
||||
type AIWorkflowDefinition,
|
||||
type AIWorkflowNodeSpec,
|
||||
type AIWorkflowTemplate,
|
||||
} from "@/lib/api/admin"
|
||||
|
||||
import { OfficialWorkflowEditor } from "./official-workflow-editor"
|
||||
|
||||
const emptyDefinition: AIWorkflowDefinition = {
|
||||
schemaVersion: 2,
|
||||
nodes: [
|
||||
{
|
||||
id: "start_1",
|
||||
type: "start",
|
||||
meta: { position: { x: 0, y: 80 } },
|
||||
data: { title: "Start", config: {}, inputsValues: {} },
|
||||
},
|
||||
{
|
||||
id: "end_1",
|
||||
type: "end",
|
||||
meta: { position: { x: 260, y: 80 } },
|
||||
data: { title: "End", config: {}, inputsValues: {} },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
sourceNodeID: "start_1",
|
||||
targetNodeID: "end_1",
|
||||
sourcePortID: "edge_start_end",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
type WorkflowWorkbenchProps = {
|
||||
workflowID?: number
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export function WorkflowWorkbench({
|
||||
workflowID,
|
||||
onSaved,
|
||||
}: WorkflowWorkbenchProps) {
|
||||
const t = useI18n()
|
||||
const [active, setActive] = useState<AIWorkflow | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [definition, setDefinition] =
|
||||
useState<AIWorkflowDefinition>(emptyDefinition)
|
||||
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
|
||||
const [templates, setTemplates] = useState<AIWorkflowTemplate[]>([])
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [editingField, setEditingField] = useState<
|
||||
"name" | "description" | null
|
||||
>(null)
|
||||
const editSnapshotRef = useRef({ value: "", dirty: false })
|
||||
const cancellingEditRef = useRef(false)
|
||||
const savingRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoaded(false)
|
||||
const [specs, availableTemplates] = await Promise.all([
|
||||
fetchAIWorkflowNodeSpecs(),
|
||||
fetchAIWorkflowTemplates(),
|
||||
])
|
||||
setNodeSpecs(specs)
|
||||
setTemplates(availableTemplates)
|
||||
if (!workflowID) {
|
||||
setDefinition(await fetchAIWorkflowDefaultDefinition())
|
||||
setLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
const item = await fetchAIWorkflow(workflowID)
|
||||
setActive(item)
|
||||
setName(item.name)
|
||||
setDescription(item.description)
|
||||
setDefinition(item.draftDefinition)
|
||||
setDirty(false)
|
||||
setLoaded(true)
|
||||
}, [workflowID])
|
||||
|
||||
const handleDefinitionChange = useCallback(
|
||||
(next: AIWorkflowDefinition) => {
|
||||
setDefinition(next)
|
||||
setDirty(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch((error) =>
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||
)
|
||||
}, [load, t])
|
||||
|
||||
function beginEditing(field: "name" | "description") {
|
||||
cancellingEditRef.current = false
|
||||
editSnapshotRef.current = {
|
||||
value: field === "name" ? name : description,
|
||||
dirty,
|
||||
}
|
||||
setEditingField(field)
|
||||
}
|
||||
|
||||
async function finishEditing(field: "name" | "description") {
|
||||
if (cancellingEditRef.current) {
|
||||
cancellingEditRef.current = false
|
||||
return
|
||||
}
|
||||
setEditingField(null)
|
||||
const currentValue = field === "name" ? name.trim() : description.trim()
|
||||
if (currentValue === editSnapshotRef.current.value) {
|
||||
setDirty(editSnapshotRef.current.dirty)
|
||||
return
|
||||
}
|
||||
if (field === "name" && !currentValue) {
|
||||
setName(editSnapshotRef.current.value)
|
||||
setDirty(editSnapshotRef.current.dirty)
|
||||
toast.error(t("aiWorkflow.nameRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
const nextName = field === "name" ? currentValue : name.trim()
|
||||
const nextDescription =
|
||||
field === "description" ? currentValue : description.trim()
|
||||
if (savingRef.current) return
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
if (active) {
|
||||
await updateAIWorkflow({
|
||||
id: active.id,
|
||||
name: nextName,
|
||||
description: nextDescription,
|
||||
definition: active.draftDefinition,
|
||||
})
|
||||
setActive((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
name: nextName,
|
||||
description: nextDescription,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: current
|
||||
)
|
||||
} else {
|
||||
const created = await createAIWorkflow({
|
||||
name: nextName,
|
||||
description: nextDescription,
|
||||
definition,
|
||||
})
|
||||
setActive(created)
|
||||
}
|
||||
setName(nextName)
|
||||
setDescription(nextDescription)
|
||||
setDirty(active ? editSnapshotRef.current.dirty : false)
|
||||
onSaved?.()
|
||||
toast.success(field === "name" ? t("aiWorkflow.nameSaved") : t("aiWorkflow.descriptionSaved"))
|
||||
} catch (error) {
|
||||
setDirty(true)
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditing(field: "name" | "description") {
|
||||
cancellingEditRef.current = true
|
||||
if (field === "name") {
|
||||
setName(editSnapshotRef.current.value)
|
||||
} else {
|
||||
setDescription(editSnapshotRef.current.value)
|
||||
}
|
||||
setDirty(editSnapshotRef.current.dirty)
|
||||
setEditingField(null)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (savingRef.current) return
|
||||
if (!name.trim()) {
|
||||
toast.error(t("aiWorkflow.nameRequired"))
|
||||
return
|
||||
}
|
||||
const savedName = name.trim()
|
||||
const savedDescription = description.trim()
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
if (active) {
|
||||
await updateAIWorkflow({
|
||||
id: active.id,
|
||||
name: savedName,
|
||||
description: savedDescription,
|
||||
definition,
|
||||
})
|
||||
setActive((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
name: savedName,
|
||||
description: savedDescription,
|
||||
draftDefinition: definition,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: current
|
||||
)
|
||||
setName(savedName)
|
||||
setDescription(savedDescription)
|
||||
setDirty(false)
|
||||
} else {
|
||||
const created = await createAIWorkflow({
|
||||
name: savedName,
|
||||
description: savedDescription,
|
||||
definition,
|
||||
})
|
||||
setActive(created)
|
||||
setName(created.name)
|
||||
setDescription(created.description)
|
||||
setDirty(false)
|
||||
}
|
||||
onSaved?.()
|
||||
toast.success(t("aiWorkflow.savedSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (savingRef.current) return
|
||||
if (!active) {
|
||||
toast.error(t("aiWorkflow.saveFirst"))
|
||||
return
|
||||
}
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
const version = await publishAIWorkflow(active.id, definition)
|
||||
setActive((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
draftDefinition: definition,
|
||||
publishedVersionId: version.id,
|
||||
updatedAt: version.updatedAt,
|
||||
}
|
||||
: current
|
||||
)
|
||||
setDirty(false)
|
||||
onSaved?.()
|
||||
toast.success(t("aiWorkflow.publishedVersionSuccess", { version: String(version.version) }))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("aiWorkflow.publishFailed"))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<header className="shrink-0 border-b px-4 py-1.5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{editingField === "name" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={t("aiWorkflow.nameLabel")}
|
||||
value={name}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
onBlur={() => void finishEditing("name")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelEditing("name")
|
||||
}
|
||||
}}
|
||||
className="h-7 min-w-48 max-w-xl flex-1 px-2 text-lg font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Workflow Name"
|
||||
onClick={() => beginEditing("name")}
|
||||
className="group flex h-7 min-w-0 max-w-xl items-center gap-1.5 rounded-md px-1 text-left text-lg font-semibold hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">{name || t("aiWorkflow.createTitle")}</span>
|
||||
<PencilIcon className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<Badge
|
||||
variant={active?.publishedVersionId ? "secondary" : "outline"}
|
||||
>
|
||||
{active?.publishedVersionId ? t("aiWorkflow.published") : t("aiWorkflow.draft")}
|
||||
</Badge>
|
||||
{dirty ? (
|
||||
<span className="text-xs text-amber-600">{t("supportHelpWorkbench.unsaved")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{editingField === "description" ? (
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="Workflow Description"
|
||||
value={description}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
onBlur={() => void finishEditing("description")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelEditing("description")
|
||||
}
|
||||
}}
|
||||
placeholder="Workflow description..."
|
||||
className="mt-0.5 h-6 max-w-2xl px-1 text-sm font-normal"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Workflow Description"
|
||||
onClick={() => beginEditing("description")}
|
||||
className="group mt-0.5 flex h-6 max-w-2xl items-center gap-1.5 rounded-md px-1 text-left text-sm text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">
|
||||
{description || t("aiWorkflow.noDescription")}
|
||||
</span>
|
||||
<PencilIcon className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{!active ? (
|
||||
<OptionCombobox
|
||||
value={selectedTemplate}
|
||||
placeholder="Template"
|
||||
searchPlaceholder="Search template..."
|
||||
emptyText="No templates"
|
||||
triggerClassName="w-48"
|
||||
options={templates.map((template) => ({
|
||||
value: template.code,
|
||||
label: template.name,
|
||||
description: template.description,
|
||||
}))}
|
||||
onChange={(code) => {
|
||||
const template = templates.find((item) => item.code === code)
|
||||
if (!template) return
|
||||
setSelectedTemplate(code)
|
||||
setDefinition(structuredClone(template.definition))
|
||||
setName(template.name)
|
||||
setDescription(template.description)
|
||||
setDirty(true)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving || !active}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("aiAgent.publishAgent")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-8 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{loaded ? (
|
||||
<OfficialWorkflowEditor
|
||||
documentKey={
|
||||
workflowID
|
||||
? `workflow-${workflowID}`
|
||||
: `new-${selectedTemplate || "blank"}`
|
||||
}
|
||||
definition={definition}
|
||||
nodeSpecs={nodeSpecs}
|
||||
onDefinitionChange={handleDefinitionChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiWorkflow.loadingNodeCapabilities")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
web/app/(dashboard)/dashboard/ai-workflows/page.tsx
Normal file
168
web/app/(dashboard)/dashboard/ai-workflows/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
GitBranchIcon,
|
||||
HistoryIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { DashboardCrudPage } from "@/components/dashboard/crud"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
deleteAIWorkflow,
|
||||
fetchAIWorkflows,
|
||||
type AIWorkflow,
|
||||
type CreateAIWorkflowPayload,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
import { WorkflowUsageDialog } from "./_components/workflow-usage-dialog"
|
||||
import { WorkflowVersionsDialog } from "./_components/workflow-versions-dialog"
|
||||
import { WorkflowWorkbench } from "./_components/workflow-workbench"
|
||||
|
||||
export default function DashboardAIWorkflowsPage() {
|
||||
const t = useI18n()
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [versionsWorkflow, setVersionsWorkflow] =
|
||||
useState<AIWorkflow | null>(null)
|
||||
const [usageWorkflow, setUsageWorkflow] = useState<AIWorkflow | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardCrudPage<AIWorkflow, CreateAIWorkflowPayload>
|
||||
filters={[
|
||||
{
|
||||
name: "name",
|
||||
label: t("aiWorkflow.filterName"),
|
||||
placeholder: t("aiWorkflow.searchName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
key: "workflow",
|
||||
label: t("aiWorkflow.columnWorkflow"),
|
||||
render: (item) => (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<GitBranchIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{item.description || t("aiWorkflow.noDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("aiWorkflow.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge
|
||||
variant={item.publishedVersionId ? "secondary" : "outline"}
|
||||
>
|
||||
{item.publishedVersionId ? t("aiWorkflow.published") : t("aiWorkflow.draft")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: t("aiWorkflow.currentVersion"),
|
||||
render: (item) =>
|
||||
item.publishedVersionId
|
||||
? t("aiWorkflow.publishedVersion", { version: item.publishedVersionId })
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: t("common.actions"),
|
||||
render: (item) => formatDateTime(item.updatedAt),
|
||||
},
|
||||
]}
|
||||
fetchList={(query) =>
|
||||
fetchAIWorkflows({
|
||||
name: typeof query.name === "string" ? query.name : undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={async () => undefined}
|
||||
updateItem={async () => undefined}
|
||||
deleteItem={(item) => deleteAIWorkflow(item.id)}
|
||||
rowActions={[
|
||||
{
|
||||
key: "versions",
|
||||
label: t("aiWorkflow.versionHistory"),
|
||||
icon: <HistoryIcon />,
|
||||
run: ({ item }) => setVersionsWorkflow(item),
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: t("aiWorkflow.usage"),
|
||||
icon: <UsersIcon />,
|
||||
run: ({ item }) => setUsageWorkflow(item),
|
||||
},
|
||||
]}
|
||||
reloadKey={reloadKey}
|
||||
renderEditDialog={({ open, item, itemId, onOpenChange }) => (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={item ? t("aiWorkflow.editTitle") : t("aiWorkflow.createTitle")}
|
||||
defaultFullscreen
|
||||
bodyScrollable={false}
|
||||
headerClassName="sr-only"
|
||||
>
|
||||
<WorkflowWorkbench
|
||||
workflowID={itemId ?? undefined}
|
||||
onSaved={() => setReloadKey((value) => value + 1)}
|
||||
/>
|
||||
</ProjectDialog>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("aiWorkflow.refresh"),
|
||||
create: t("aiWorkflow.create"),
|
||||
query: t("aiWorkflow.query"),
|
||||
loading: t("aiWorkflow.loading"),
|
||||
empty: t("aiWorkflow.empty"),
|
||||
actions: t("aiWorkflow.actions"),
|
||||
edit: t("aiWorkflow.edit"),
|
||||
delete: t("aiWorkflow.delete"),
|
||||
processing: t("aiWorkflow.processing"),
|
||||
moreActions: (item) => t("aiWorkflow.moreActions", { name: item.name }),
|
||||
loadFailed: t("aiWorkflow.loadFailed"),
|
||||
saveFailed: t("aiWorkflow.saveFailed"),
|
||||
deleteFailed: t("aiWorkflow.deleteFailed"),
|
||||
created: (payload) => t("aiWorkflow.created", { name: payload.name }),
|
||||
updated: (item) => t("aiWorkflow.updated", { name: item.name }),
|
||||
deleted: (item) => t("aiWorkflow.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkflowVersionsDialog
|
||||
workflow={versionsWorkflow}
|
||||
open={versionsWorkflow !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVersionsWorkflow(null)
|
||||
}}
|
||||
onRestored={() => setReloadKey((value) => value + 1)}
|
||||
/>
|
||||
<WorkflowUsageDialog
|
||||
workflow={usageWorkflow}
|
||||
open={usageWorkflow !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setUsageWorkflow(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1514
web/app/(dashboard)/dashboard/channels/_components/edit.tsx
Normal file
1514
web/app/(dashboard)/dashboard/channels/_components/edit.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { PlugZapIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { testChannelEmail } from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type TestEmailConnectionButtonProps = {
|
||||
/** Undefined for a channel that has not been saved yet. */
|
||||
channelId?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the channel's bound mail server is reachable before anyone relies
|
||||
* on it. Saving a channel cannot tell a wrong host from a right one, and the
|
||||
* alternative is finding out by filing a real request and watching the outbox
|
||||
* retry five times.
|
||||
*/
|
||||
export function TestEmailConnectionButton({
|
||||
channelId,
|
||||
}: TestEmailConnectionButtonProps) {
|
||||
const t = useI18n()
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
async function handleTest() {
|
||||
if (testing) {
|
||||
return
|
||||
}
|
||||
if (!channelId) {
|
||||
toast.error(t("channel.smtpTestSaveFirst"))
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
try {
|
||||
const result = await testChannelEmail(channelId)
|
||||
toast.success(
|
||||
t("channel.smtpTestSuccess", {
|
||||
host: `${result.host}:${result.port}`,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("channel.smtpTestFailed")
|
||||
)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={testing}
|
||||
onClick={() => void handleTest()}
|
||||
className="w-full"
|
||||
>
|
||||
<PlugZapIcon className="size-4" />
|
||||
{testing ? t("channel.smtpTesting") : t("channel.smtpTest")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
228
web/app/(dashboard)/dashboard/channels/page.tsx
Normal file
228
web/app/(dashboard)/dashboard/channels/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Building2Icon,
|
||||
Gamepad2Icon,
|
||||
MailIcon,
|
||||
SendIcon,
|
||||
SlackIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
createDashboardStatusColumn,
|
||||
DashboardCrudPage,
|
||||
} from "@/components/dashboard/crud"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
fetchChannels,
|
||||
updateChannel,
|
||||
updateChannelStatus,
|
||||
type AdminChannel,
|
||||
type CreateAdminChannelPayload,
|
||||
} from "@/lib/api/admin"
|
||||
import { getEnumOptions } from "@/lib/enums"
|
||||
import { Status, StatusLabels } from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { EditDialog } from "./_components/edit"
|
||||
|
||||
function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
|
||||
if (channelType === "email") {
|
||||
return t("channel.typeEmail")
|
||||
}
|
||||
if (channelType === "telegram") {
|
||||
return t("channel.typeTelegram")
|
||||
}
|
||||
if (channelType === "zalo_oa") {
|
||||
return t("channel.typeZaloOa")
|
||||
}
|
||||
if (channelType === "slack") {
|
||||
return t("channel.typeSlack")
|
||||
}
|
||||
if (channelType === "discord") {
|
||||
return t("channel.typeDiscord")
|
||||
}
|
||||
return t("channel.typeWeb")
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Status, t: (key: string) => string) {
|
||||
if (status === Status.Disabled) {
|
||||
return t("status.disabled")
|
||||
}
|
||||
if (status === Status.Deleted) {
|
||||
return t("status.deleted")
|
||||
}
|
||||
return t("status.ok")
|
||||
}
|
||||
|
||||
function ChannelIcon({ channelType }: { channelType: string }) {
|
||||
if (channelType === "email") {
|
||||
return <MailIcon className="size-4" />
|
||||
}
|
||||
if (channelType === "telegram" || channelType === "zalo_oa") {
|
||||
return <SendIcon className="size-4" />
|
||||
}
|
||||
if (channelType === "slack") {
|
||||
return <SlackIcon className="size-4" />
|
||||
}
|
||||
if (channelType === "discord") {
|
||||
return <Gamepad2Icon className="size-4" />
|
||||
}
|
||||
return <Building2Icon className="size-4" />
|
||||
}
|
||||
|
||||
export default function DashboardChannelsPage() {
|
||||
const t = useI18n()
|
||||
const statusOptions = [
|
||||
{ value: "all", label: t("status.all") },
|
||||
...getEnumOptions(StatusLabels).map((option) => ({
|
||||
value: String(option.value),
|
||||
label: getStatusLabel(option.value as Status, t),
|
||||
})),
|
||||
]
|
||||
const channelTypeOptions = [
|
||||
{ value: "all", label: t("channel.allTypes") },
|
||||
{ value: "web", label: t("channel.typeWeb") },
|
||||
{ value: "email", label: t("channel.typeEmail") },
|
||||
{ value: "telegram", label: t("channel.typeTelegram") },
|
||||
{ value: "discord", label: t("channel.typeDiscord") },
|
||||
{ value: "slack", label: t("channel.typeSlack") },
|
||||
{ value: "zalo_oa", label: t("channel.typeZaloOa") },
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardCrudPage<AdminChannel, CreateAdminChannelPayload>
|
||||
filters={[
|
||||
{
|
||||
name: "name",
|
||||
label: t("channel.filterName"),
|
||||
placeholder: t("channel.filterName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-56",
|
||||
},
|
||||
{
|
||||
name: "channelId",
|
||||
label: t("channel.filterChannelId"),
|
||||
placeholder: t("channel.filterChannelId"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
{
|
||||
name: "channelType",
|
||||
label: t("channel.allTypes"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: channelTypeOptions,
|
||||
className: "w-full sm:w-40",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("status.all"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: statusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
key: "channel",
|
||||
label: t("channel.columnChannel"),
|
||||
render: (item) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<ChannelIcon channelType={item.channelType} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getChannelTypeLabel(item.channelType, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
label: t("channel.columnType"),
|
||||
render: (item) => (
|
||||
<Badge variant="outline">
|
||||
{getChannelTypeLabel(item.channelType, t)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "channelId",
|
||||
label: "ChannelID",
|
||||
render: (item) => (
|
||||
<span className="font-mono text-xs">{item.channelId || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "agent",
|
||||
label: t("channel.columnAgent"),
|
||||
render: (item) => item.aiAgentName || "-",
|
||||
},
|
||||
createDashboardStatusColumn<AdminChannel, Status>({
|
||||
label: t("channel.columnStatus"),
|
||||
getStatus: (item) => item.status as Status,
|
||||
getLabel: (status) => getStatusLabel(status, t),
|
||||
getBadgeVariant: (status) => (status === Status.Ok ? "default" : "outline"),
|
||||
isEnabled: (status) => status === Status.Ok,
|
||||
toggle: {
|
||||
getNextStatus: (item) =>
|
||||
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||
updateStatus: (item, nextStatus) =>
|
||||
updateChannelStatus(item.id, nextStatus),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t(
|
||||
nextStatus === Status.Ok
|
||||
? "channel.statusEnabled"
|
||||
: "channel.statusDisabled",
|
||||
{ name: item.name }
|
||||
),
|
||||
errorMessage: t("channel.statusUpdateFailed"),
|
||||
ariaLabel: (item) => t("channel.toggleStatus", { name: item.name }),
|
||||
},
|
||||
}),
|
||||
]}
|
||||
fetchList={fetchChannels}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createChannel}
|
||||
updateItem={(item, payload) => updateChannel({ id: item.id, ...payload })}
|
||||
deleteItem={(item) => deleteChannel(item.id)}
|
||||
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("channel.refresh"),
|
||||
create: t("channel.new"),
|
||||
query: t("channel.query"),
|
||||
loading: t("channel.loading"),
|
||||
empty: t("channel.empty"),
|
||||
actions: t("channel.columnActions"),
|
||||
edit: t("channel.edit"),
|
||||
delete: t("channel.delete"),
|
||||
processing: t("channel.processing"),
|
||||
moreActions: (item) => t("channel.moreActions", { name: item.name }),
|
||||
loadFailed: t("channel.loadFailed"),
|
||||
saveFailed: t("channel.saveFailed"),
|
||||
deleteFailed: t("channel.deleteFailed"),
|
||||
created: (payload) => t("channel.created", { name: payload.name }),
|
||||
updated: (_item, payload) => t("channel.updated", { name: payload.name }),
|
||||
deleted: (item) => t("channel.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
211
web/app/(dashboard)/dashboard/companies/page.tsx
Normal file
211
web/app/(dashboard)/dashboard/companies/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client"
|
||||
|
||||
import { BanIcon, CheckCircle2Icon } from "lucide-react"
|
||||
|
||||
import {
|
||||
createDashboardStatusColumn,
|
||||
createDashboardStatusToggleAction,
|
||||
DashboardCrudPage,
|
||||
} from "@/components/dashboard/crud"
|
||||
import {
|
||||
createCompany,
|
||||
deleteCompany,
|
||||
fetchCompanies,
|
||||
fetchCompany,
|
||||
updateCompany,
|
||||
updateCompanyStatus,
|
||||
type AdminCompany,
|
||||
type CreateAdminCompanyPayload,
|
||||
} from "@/lib/api/company"
|
||||
import { getEnumOptions } from "@/lib/enums"
|
||||
import { Status, StatusLabels } from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
function getStatusLabel(status: Status, t: (key: string) => string) {
|
||||
if (status === Status.Disabled) {
|
||||
return t("status.disabled")
|
||||
}
|
||||
if (status === Status.Deleted) {
|
||||
return t("status.deleted")
|
||||
}
|
||||
return t("status.ok")
|
||||
}
|
||||
|
||||
export default function DashboardCompaniesPage() {
|
||||
const t = useI18n()
|
||||
const listStatusOptions = [
|
||||
{ value: "all", label: t("status.all") },
|
||||
...getEnumOptions(StatusLabels)
|
||||
.filter((item) => Number(item.value) !== Status.Deleted)
|
||||
.map((item) => ({
|
||||
value: String(item.value),
|
||||
label: getStatusLabel(item.value as Status, t),
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardCrudPage<AdminCompany, CreateAdminCompanyPayload>
|
||||
filters={[
|
||||
{
|
||||
name: "name",
|
||||
label: t("company.filterName"),
|
||||
placeholder: t("company.filterName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
label: t("company.filterCode"),
|
||||
placeholder: t("company.filterCode"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-44",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("status.all"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: listStatusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
key: "id",
|
||||
label: "ID",
|
||||
className: "w-20",
|
||||
render: (item) => item.id,
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: t("company.columnName"),
|
||||
render: (item) => <span className="font-medium">{item.name}</span>,
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: t("company.columnCode"),
|
||||
render: (item) => (
|
||||
<span className="text-muted-foreground">{item.code || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "customerCount",
|
||||
label: t("company.columnCustomerCount"),
|
||||
className: "w-28",
|
||||
render: (item) => item.customerCount,
|
||||
},
|
||||
createDashboardStatusColumn<AdminCompany, Status>({
|
||||
label: t("company.columnStatus"),
|
||||
className: "w-24",
|
||||
getStatus: (item) => item.status as Status,
|
||||
getLabel: (status) =>
|
||||
StatusLabels[status] ? getStatusLabel(status, t) : t("company.unknownStatus"),
|
||||
getBadgeVariant: (status) =>
|
||||
status === Status.Ok
|
||||
? "default"
|
||||
: status === Status.Deleted
|
||||
? "outline"
|
||||
: "secondary",
|
||||
}),
|
||||
{
|
||||
key: "remark",
|
||||
label: t("company.columnRemark"),
|
||||
render: (item) => (
|
||||
<div className="line-clamp-2 max-w-[320px] text-muted-foreground">
|
||||
{item.remark || "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
fetchList={fetchCompanies}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createCompany}
|
||||
updateItem={(item, payload) => updateCompany({ id: item.id, ...payload })}
|
||||
deleteItem={(item) => deleteCompany(item.id)}
|
||||
canDelete={(item) => item.status !== Status.Deleted}
|
||||
form={{
|
||||
fetchDetail: fetchCompany,
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: t("company.columnName"),
|
||||
placeholder: t("company.namePlaceholder"),
|
||||
required: true,
|
||||
requiredMessage: t("company.nameRequired"),
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
label: t("company.columnCode"),
|
||||
placeholder: t("company.optional"),
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
name: "remark",
|
||||
label: t("company.columnRemark"),
|
||||
placeholder: t("company.remarkPlaceholder"),
|
||||
type: "textarea",
|
||||
rows: 4,
|
||||
trim: true,
|
||||
},
|
||||
],
|
||||
transformSubmitValues: (values) => ({
|
||||
name: String(values.name ?? ""),
|
||||
code: String(values.code ?? ""),
|
||||
remark: String(values.remark ?? ""),
|
||||
}),
|
||||
labels: {
|
||||
createTitle: t("company.createTitle"),
|
||||
editTitle: t("company.editTitle"),
|
||||
create: t("company.create"),
|
||||
save: t("company.save"),
|
||||
saving: t("company.saving"),
|
||||
cancel: t("company.cancel"),
|
||||
loadingDetail: t("company.loadingDetail"),
|
||||
required: t("company.nameRequired"),
|
||||
invalidNumber: t("company.nameRequired"),
|
||||
minValue: () => t("company.nameRequired"),
|
||||
maxValue: () => t("company.nameRequired"),
|
||||
},
|
||||
}}
|
||||
rowActions={[
|
||||
createDashboardStatusToggleAction<AdminCompany, Status>({
|
||||
disabled: (item) => item.status === Status.Deleted,
|
||||
icon: (item) =>
|
||||
item.status === Status.Ok ? <BanIcon /> : <CheckCircle2Icon />,
|
||||
label: (item) =>
|
||||
item.status === Status.Ok ? t("company.disable") : t("company.enable"),
|
||||
getNextStatus: (item) =>
|
||||
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||
updateStatus: (item, nextStatus) => updateCompanyStatus(item.id, nextStatus),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t(nextStatus === Status.Ok ? "company.enabled" : "company.disabled", {
|
||||
name: item.name,
|
||||
}),
|
||||
errorMessage: t("company.statusUpdateFailed"),
|
||||
}),
|
||||
]}
|
||||
labels={{
|
||||
refresh: t("company.refresh"),
|
||||
create: t("company.new"),
|
||||
query: t("company.query"),
|
||||
loading: t("company.loading"),
|
||||
empty: t("company.empty"),
|
||||
actions: t("company.columnActions"),
|
||||
edit: t("company.edit"),
|
||||
delete: t("company.delete"),
|
||||
processing: t("company.processing"),
|
||||
moreActions: (item) => t("company.moreActions", { name: item.name }),
|
||||
loadFailed: t("company.loadFailed"),
|
||||
saveFailed: t("company.saveFailed"),
|
||||
deleteFailed: t("company.deleteFailed"),
|
||||
created: (payload) => t("company.created", { name: payload.name }),
|
||||
updated: (item) => t("company.updated", { name: item.name }),
|
||||
deleted: (item) => t("company.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCheckIcon, EyeIcon, MessageCircleMoreIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble";
|
||||
import {
|
||||
ConversationMessageRow,
|
||||
} from "@/components/chat/conversation-message-row";
|
||||
import {
|
||||
ConversationMessageScroller,
|
||||
ConversationMessageScrollerItem,
|
||||
} from "@/components/chat/conversation-message-scroller";
|
||||
import { ImMessageHTML } from "@/components/im-message-html";
|
||||
import { useImageLightbox } from "@/components/image-lightbox";
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
type AdminConversation,
|
||||
type AdminConversationDetail,
|
||||
type AdminMessage,
|
||||
} from "@/lib/api/admin";
|
||||
import {
|
||||
parseMessageAssetPayload,
|
||||
renderIMMessageHTML,
|
||||
} from "@/lib/im-message";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
type ConversationDetailDialogProps = {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
readOnly?: boolean;
|
||||
item: AdminConversation | null;
|
||||
detail: AdminConversationDetail | null;
|
||||
messages?: AdminMessage[] | null;
|
||||
/** Whether older messages are available through cursor pagination. */
|
||||
messagesHasMore?: boolean;
|
||||
loadingMoreMessages?: boolean;
|
||||
onLoadMoreMessages?: () => void | Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenAssign: () => void;
|
||||
onDispatch: () => Promise<void>;
|
||||
onOpenTransfer: () => void;
|
||||
onRead: () => Promise<void>;
|
||||
onOpenClose: () => void;
|
||||
};
|
||||
|
||||
function getStatusMeta(
|
||||
status: number,
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return { label: t("conversation.filterAiServing"), variant: "secondary" as const };
|
||||
case 2:
|
||||
return { label: t("conversation.filterPending"), variant: "outline" as const };
|
||||
case 3:
|
||||
return { label: t("conversation.filterActive"), variant: "secondary" as const };
|
||||
case 4:
|
||||
return { label: t("conversation.filterClosed"), variant: "outline" as const };
|
||||
default:
|
||||
return { label: t("conversationMonitor.unknown"), variant: "outline" as const };
|
||||
}
|
||||
}
|
||||
|
||||
function getServiceModeLabel(
|
||||
mode: number,
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return t("conversationMonitor.serviceAi");
|
||||
case 2:
|
||||
return t("conversationMonitor.serviceHuman");
|
||||
case 3:
|
||||
return t("conversationMonitor.serviceAiFirst");
|
||||
default:
|
||||
return t("conversationMonitor.serviceUndefined");
|
||||
}
|
||||
}
|
||||
|
||||
function getSenderLabel(
|
||||
message: AdminMessage,
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
switch (message.senderType) {
|
||||
case "agent":
|
||||
return message.senderName || t("conversationMonitor.senderAgent");
|
||||
case "customer":
|
||||
return message.senderName || t("conversationMonitor.senderCustomer");
|
||||
case "ai":
|
||||
return "AI";
|
||||
case "system":
|
||||
return t("conversationMonitor.senderSystem");
|
||||
default:
|
||||
return message.senderType;
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageContent(message: AdminMessage) {
|
||||
return message.content || message.payload || "-";
|
||||
}
|
||||
|
||||
function getImageMessageUrl(message: AdminMessage) {
|
||||
return parseMessageAssetPayload(message.payload)?.url || "";
|
||||
}
|
||||
|
||||
function getParticipantIdentity(
|
||||
participant: NonNullable<AdminConversationDetail["participants"]>[number],
|
||||
) {
|
||||
return participant.participantId || participant.externalParticipantId || "-";
|
||||
}
|
||||
|
||||
function getMessageAlign(message: AdminMessage): "start" | "end" {
|
||||
return message.senderType === "customer" || message.senderType === "system"
|
||||
? "start"
|
||||
: "end";
|
||||
}
|
||||
|
||||
function getMessageVariant(message: AdminMessage) {
|
||||
switch (message.senderType) {
|
||||
case "agent":
|
||||
return "agent" as const;
|
||||
case "ai":
|
||||
return "ai" as const;
|
||||
case "system":
|
||||
return "system" as const;
|
||||
default:
|
||||
return "customer" as const;
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageHtmlClassName(message: AdminMessage, isRecalled: boolean) {
|
||||
if (isRecalled) {
|
||||
return message.senderType === "agent" || message.senderType === "ai"
|
||||
? "text-emerald-800 [&_p]:text-emerald-800"
|
||||
: "text-muted-foreground [&_p]:text-muted-foreground";
|
||||
}
|
||||
if (message.senderType === "agent") {
|
||||
return "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md";
|
||||
}
|
||||
return "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md";
|
||||
}
|
||||
|
||||
function getRecalledBubbleClassName(message: AdminMessage) {
|
||||
return message.senderType === "agent" || message.senderType === "ai"
|
||||
? "border-dashed border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-dashed border-border/70 bg-muted/40 text-muted-foreground";
|
||||
}
|
||||
|
||||
export function ConversationDetailDialog({
|
||||
open,
|
||||
loading,
|
||||
saving,
|
||||
readOnly = false,
|
||||
item,
|
||||
detail,
|
||||
messages: rawMessages,
|
||||
messagesHasMore = false,
|
||||
loadingMoreMessages = false,
|
||||
onLoadMoreMessages,
|
||||
onOpenChange,
|
||||
onOpenAssign,
|
||||
onDispatch,
|
||||
onOpenTransfer,
|
||||
onRead,
|
||||
onOpenClose,
|
||||
}: ConversationDetailDialogProps) {
|
||||
const t = useI18n();
|
||||
const messages = Array.isArray(rawMessages) ? rawMessages : [];
|
||||
const currentConversation = detail ?? item;
|
||||
const isClosedConversation = currentConversation?.status === 4;
|
||||
const isPendingConversation = currentConversation?.status === 2;
|
||||
const statusMeta = currentConversation
|
||||
? getStatusMeta(currentConversation.status, t)
|
||||
: null;
|
||||
const { open: openImageLightbox, close: closeImageLightbox } =
|
||||
useImageLightbox();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
closeImageLightbox();
|
||||
}
|
||||
}, [open, closeImageLightbox]);
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{currentConversation?.customerName || t("conversationMonitor.detailFallbackTitle")}</span>
|
||||
|
||||
<div>
|
||||
{statusMeta ? (
|
||||
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
size="xl"
|
||||
// allowFullscreen
|
||||
defaultFullscreen
|
||||
bodyScrollable={false}
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
|
||||
contentClassName="h-[calc(100vh-40px)] max-h-[calc(100vh-40px)]"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{currentConversation
|
||||
? t("conversationMonitor.lastActivePrefix", {
|
||||
time: formatDateTime(currentConversation.lastMessageAt),
|
||||
})
|
||||
: t("conversationMonitor.noConversationInfo")}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onOpenAssign}
|
||||
disabled={saving || !currentConversation || currentConversation.status !== 2}
|
||||
>
|
||||
<MessageCircleMoreIcon />
|
||||
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.assign")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void onDispatch()}
|
||||
disabled={
|
||||
saving || !currentConversation || !isPendingConversation
|
||||
}
|
||||
>
|
||||
<MessageCircleMoreIcon />
|
||||
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.retryDispatch")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void onRead()}
|
||||
disabled={saving || !currentConversation}
|
||||
>
|
||||
<CheckCheckIcon />
|
||||
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.markRead")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onOpenTransfer}
|
||||
disabled={saving || !currentConversation || currentConversation.status !== 3}
|
||||
>
|
||||
<MessageCircleMoreIcon />
|
||||
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.transfer")}
|
||||
</Button>
|
||||
{!isClosedConversation ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onOpenClose}
|
||||
disabled={saving || !currentConversation}
|
||||
>
|
||||
<EyeIcon />
|
||||
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.close")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("conversationMonitor.loadingDetail")}
|
||||
</div>
|
||||
) : currentConversation ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-t md:flex-row">
|
||||
<aside className="flex max-h-[46%] w-full shrink-0 flex-col overflow-hidden border-b bg-muted/20 md:h-full md:max-h-none md:w-90 md:border-r md:border-b-0">
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<InfoItem
|
||||
label={t("conversationMonitor.columnServiceMode")}
|
||||
value={getServiceModeLabel(currentConversation.serviceMode, t)}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("conversationMonitor.currentAssignee")}
|
||||
value={currentConversation.currentAssigneeName || "-"}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("conversation.channelId")}
|
||||
value={`${currentConversation.channelId || "-"}`}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("conversationMonitor.agentUnread")}
|
||||
value={`${currentConversation.agentUnreadCount}`}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("conversationMonitor.customerUnread")}
|
||||
value={`${currentConversation.customerUnreadCount}`}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("conversationMonitor.columnLastActive")}
|
||||
value={formatDateTime(currentConversation.lastMessageAt)}
|
||||
fullWidth
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("conversationMonitor.closedAt")}
|
||||
value={formatDateTime(currentConversation.closedAt)}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-4 p-6">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
{t("conversationMonitor.participants")}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("conversationMonitor.participantCount", {
|
||||
count: detail?.participants?.length ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{detail?.participants?.length ? (
|
||||
<div className="space-y-3">
|
||||
{detail.participants.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="rounded-lg border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{participant.participantType || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{t("conversationMonitor.participantIdentity", {
|
||||
value: getParticipantIdentity(participant),
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("conversationMonitor.participantJoinedAt", {
|
||||
time: formatDateTime(participant.joinedAt),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||
{t("conversationMonitor.emptyParticipants")}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
<section className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<ConversationMessageScroller
|
||||
key={currentConversation.id}
|
||||
className="min-h-0 flex-1"
|
||||
hasMoreOlder={messagesHasMore}
|
||||
loadingOlder={loadingMoreMessages}
|
||||
onLoadOlder={onLoadMoreMessages}
|
||||
topSlot={
|
||||
messagesHasMore ? (
|
||||
<div className="flex min-h-8 flex-col items-center justify-center py-2 text-xs text-muted-foreground">
|
||||
{loadingMoreMessages
|
||||
? t("conversationMonitor.loadingOlder")
|
||||
: t("conversationMonitor.loadOlderHint")}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{messages.length ? (
|
||||
messages.map((message) => (
|
||||
<ConversationMessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={`${message.id}`}
|
||||
>
|
||||
<ConversationMonitorMessage
|
||||
message={message}
|
||||
onImagePreview={openImageLightbox}
|
||||
/>
|
||||
</ConversationMessageScrollerItem>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full min-h-80 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
|
||||
{t("conversationMonitor.emptyMessages")}
|
||||
</div>
|
||||
)}
|
||||
</ConversationMessageScroller>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("conversationMonitor.emptyDetail")}
|
||||
</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
|
||||
type ConversationMonitorMessageProps = {
|
||||
message: AdminMessage;
|
||||
onImagePreview: (src: string, alt?: string) => void;
|
||||
};
|
||||
|
||||
function ConversationMonitorMessage({
|
||||
message,
|
||||
onImagePreview,
|
||||
}: ConversationMonitorMessageProps) {
|
||||
const t = useI18n();
|
||||
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6;
|
||||
const isHtmlMessage =
|
||||
!isRecalled &&
|
||||
(message.messageType === "html" || message.messageType === "attachment");
|
||||
const isImageMessage = !isRecalled && message.messageType === "image";
|
||||
const isSystem = message.senderType === "system";
|
||||
const align = getMessageAlign(message);
|
||||
const variant = isRecalled ? "recalled" : getMessageVariant(message);
|
||||
const htmlClassName = getMessageHtmlClassName(message, isRecalled);
|
||||
|
||||
return (
|
||||
<ConversationMessageRow
|
||||
align={align}
|
||||
centered={isSystem}
|
||||
header={
|
||||
<>
|
||||
<span>{getSenderLabel(message, t)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(message.sentAt)}</span>
|
||||
{isRecalled ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{t("conversationMonitor.messageRecalled")}</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
footer={t("conversationMonitor.readStatus", {
|
||||
agent: message.agentRead
|
||||
? t("conversationMonitor.read")
|
||||
: t("conversationMonitor.unread"),
|
||||
customer: message.customerRead
|
||||
? t("conversationMonitor.read")
|
||||
: t("conversationMonitor.unread"),
|
||||
})}
|
||||
>
|
||||
<ConversationMessageBubble
|
||||
variant={variant}
|
||||
className={isRecalled ? getRecalledBubbleClassName(message) : undefined}
|
||||
>
|
||||
{isRecalled ? (
|
||||
<div className={htmlClassName}>
|
||||
{t("conversationMonitor.messageRecalledBody")}
|
||||
</div>
|
||||
) : isHtmlMessage ? (
|
||||
<ImMessageHTML
|
||||
html={renderIMMessageHTML(message)}
|
||||
className={`${htmlClassName} [&_img]:max-w-full [&_img]:cursor-zoom-in`}
|
||||
onImageClick={onImagePreview}
|
||||
/>
|
||||
) : isImageMessage ? (
|
||||
<MessageImage
|
||||
src={getImageMessageUrl(message)}
|
||||
alt={getMessageContent(message)}
|
||||
onPreview={onImagePreview}
|
||||
/>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{getMessageContent(message)}
|
||||
</div>
|
||||
)}
|
||||
</ConversationMessageBubble>
|
||||
</ConversationMessageRow>
|
||||
);
|
||||
}
|
||||
|
||||
type InfoItemProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
function InfoItem({ label, value, fullWidth = false }: InfoItemProps) {
|
||||
return (
|
||||
<div className={fullWidth ? "col-span-2" : undefined}>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 break-all font-medium">{value || "-"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageImageProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
onPreview: (src: string, alt?: string) => void;
|
||||
};
|
||||
|
||||
function MessageImage({ src, alt, onPreview }: MessageImageProps) {
|
||||
const t = useI18n();
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{alt || t("conversationMonitor.imageFallback")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="block cursor-zoom-in"
|
||||
onClick={() => onPreview(src, alt)}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt || t("conversationMonitor.messageImageAlt")}
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-h-64 w-auto max-w-full rounded-md object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
927
web/app/(dashboard)/dashboard/conversation-monitor/page.tsx
Normal file
927
web/app/(dashboard)/dashboard/conversation-monitor/page.tsx
Normal file
@@ -0,0 +1,927 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CheckCheckIcon,
|
||||
MessageCircleMoreIcon,
|
||||
MoreHorizontalIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"
|
||||
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardTableStateRow,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
import { ListPagination } from "@/components/list-pagination"
|
||||
import {
|
||||
OptionCombobox,
|
||||
type ComboboxOption,
|
||||
} from "@/components/option-combobox"
|
||||
import { TagSelector } from "@/components/tag-selector"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ButtonGroup } from "@/components/ui/button-group"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
createAdminWebSocketUrl,
|
||||
dispatchConversation,
|
||||
fetchAgentProfilesAll,
|
||||
fetchAgentTeamsAll,
|
||||
fetchConversationDetail,
|
||||
fetchConversationMessages,
|
||||
fetchConversations,
|
||||
fetchTagsAll,
|
||||
markConversationRead,
|
||||
type AdminAgentProfile,
|
||||
type AdminAgentTeam,
|
||||
type AdminConversation,
|
||||
type AdminConversationDetail,
|
||||
type AdminMessage,
|
||||
type PageResult,
|
||||
type TagTree,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { ConversationDetailDialog } from "./_components/detail"
|
||||
|
||||
const RECONNECT_BASE_DELAY = 2000
|
||||
const RECONNECT_MAX_DELAY = 30000
|
||||
|
||||
function getStatusMeta(
|
||||
status: number,
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return { label: t("conversation.filterAiServing"), variant: "secondary" as const }
|
||||
case 2:
|
||||
return { label: t("conversation.filterPending"), variant: "outline" as const }
|
||||
case 3:
|
||||
return { label: t("conversation.filterActive"), variant: "secondary" as const }
|
||||
case 4:
|
||||
return { label: t("conversation.filterClosed"), variant: "outline" as const }
|
||||
default:
|
||||
return { label: t("conversationMonitor.unknown"), variant: "outline" as const }
|
||||
}
|
||||
}
|
||||
|
||||
function getServiceModeLabel(
|
||||
mode: number,
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return t("conversationMonitor.serviceAi")
|
||||
case 2:
|
||||
return t("conversationMonitor.serviceHuman")
|
||||
case 3:
|
||||
return t("conversationMonitor.serviceAiFirst")
|
||||
default:
|
||||
return t("conversationMonitor.serviceUndefined")
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusOptions(
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
return [
|
||||
{ value: "all", label: t("conversationMonitor.allStatuses") },
|
||||
{ value: "1", label: t("conversation.filterAiServing") },
|
||||
{ value: "2", label: t("conversation.filterPending") },
|
||||
{ value: "3", label: t("conversation.filterActive") },
|
||||
{ value: "4", label: t("conversation.filterClosed") },
|
||||
]
|
||||
}
|
||||
|
||||
export default function DashboardConversationsPage() {
|
||||
const t = useI18n()
|
||||
const statusOptions = useMemo(() => getStatusOptions(t), [t])
|
||||
const [keywordInput, setKeywordInput] = useState("")
|
||||
const [statusFilterInput, setStatusFilterInput] = useState("all")
|
||||
const [tagFilterInput, setTagFilterInput] = useState("0")
|
||||
const [assigneeFilterInput, setAssigneeFilterInput] = useState("0")
|
||||
const [agentTeamFilterInput, setAgentTeamFilterInput] = useState("0")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [tagFilter, setTagFilter] = useState("0")
|
||||
const [assigneeFilter, setAssigneeFilter] = useState("0")
|
||||
const [agentTeamFilter, setAgentTeamFilter] = useState("0")
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [tags, setTags] = useState<TagTree[]>([])
|
||||
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([
|
||||
{ value: "0", label: t("conversationMonitor.allAssignees") },
|
||||
])
|
||||
const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([
|
||||
{ value: "0", label: t("conversationMonitor.allTeams") },
|
||||
])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailItem, setDetailItem] = useState<AdminConversation | null>(null)
|
||||
const [detailData, setDetailData] = useState<AdminConversationDetail | null>(null)
|
||||
const [detailMessages, setDetailMessages] = useState<AdminMessage[]>([])
|
||||
const [detailMessagesNextCursor, setDetailMessagesNextCursor] = useState("")
|
||||
const [detailMessagesHasMore, setDetailMessagesHasMore] = useState(false)
|
||||
const [detailMessagesLoadingMore, setDetailMessagesLoadingMore] = useState(false)
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
const [assignItem, setAssignItem] = useState<AdminConversation | null>(null)
|
||||
const [closeOpen, setCloseOpen] = useState(false)
|
||||
const [closeItem, setCloseItem] = useState<AdminConversation | null>(null)
|
||||
const [transferOpen, setTransferOpen] = useState(false)
|
||||
const [transferItem, setTransferItem] = useState<AdminConversation | null>(null)
|
||||
const [result, setResult] = useState<PageResult<AdminConversation>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: 20, total: 0 },
|
||||
})
|
||||
const websocketRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimerRef = useRef<number | null>(null)
|
||||
const pingTimerRef = useRef<number | null>(null)
|
||||
const reconnectAttemptRef = useRef(0)
|
||||
const detailItemRef = useRef<AdminConversation | null>(null)
|
||||
const subscribedConversationIdRef = useRef<number | null>(null)
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchConversations({
|
||||
keyword: keyword.trim() || undefined,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
tagId: tagFilter === "0" ? undefined : tagFilter,
|
||||
currentAssigneeId: assigneeFilter === "0" ? undefined : assigneeFilter,
|
||||
agentTeamId: agentTeamFilter === "0" ? undefined : agentTeamFilter,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
setResult(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadListFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter, t])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
const [tagData, assigneeData, teamData] = await Promise.all([
|
||||
fetchTagsAll(),
|
||||
fetchAgentProfilesAll(),
|
||||
fetchAgentTeamsAll(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setTags(Array.isArray(tagData) ? tagData : [])
|
||||
setAssigneeOptions([
|
||||
{ value: "0", label: t("conversationMonitor.allAssignees") },
|
||||
...assigneeData.map((item: AdminAgentProfile) => ({
|
||||
value: String(item.userId),
|
||||
label: item.displayName || item.nickname || item.username || `#${item.userId}`,
|
||||
})),
|
||||
])
|
||||
setAgentTeamOptions([
|
||||
{ value: "0", label: t("conversationMonitor.allTeams") },
|
||||
...teamData.map((item: AdminAgentTeam) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})),
|
||||
])
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadFiltersFailed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFilterOptions()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
detailItemRef.current = detailItem
|
||||
}, [detailItem])
|
||||
|
||||
useEffect(() => {
|
||||
void loadConversations()
|
||||
}, [loadConversations])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const clearTimers = () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
window.clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
if (pingTimerRef.current) {
|
||||
window.clearInterval(pingTimerRef.current)
|
||||
pingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (cancelled || reconnectTimerRef.current) {
|
||||
return
|
||||
}
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY * 2 ** reconnectAttemptRef.current,
|
||||
RECONNECT_MAX_DELAY
|
||||
)
|
||||
reconnectTimerRef.current = window.setTimeout(() => {
|
||||
reconnectTimerRef.current = null
|
||||
reconnectAttemptRef.current += 1
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
let socket: WebSocket
|
||||
try {
|
||||
socket = new WebSocket(createAdminWebSocketUrl())
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationMonitor.realtimeConnectFailed"))
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
websocketRef.current = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
reconnectAttemptRef.current = 0
|
||||
if (pingTimerRef.current) {
|
||||
window.clearInterval(pingTimerRef.current)
|
||||
}
|
||||
pingTimerRef.current = window.setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }))
|
||||
}
|
||||
}, 20000)
|
||||
|
||||
const conversationId = detailItemRef.current?.id
|
||||
if (conversationId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "subscribe",
|
||||
topics: [`conversation:${conversationId}`],
|
||||
})
|
||||
)
|
||||
subscribedConversationIdRef.current = conversationId
|
||||
} else {
|
||||
subscribedConversationIdRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as {
|
||||
eventId?: string
|
||||
type?: string
|
||||
data?: { conversationId?: number }
|
||||
}
|
||||
const eventType = payload.type ?? ""
|
||||
const conversationId = payload.data?.conversationId ?? 0
|
||||
const eventId = payload.eventId?.trim() ?? ""
|
||||
|
||||
if (
|
||||
eventType === "" ||
|
||||
eventType === "connected" ||
|
||||
eventType === "pong" ||
|
||||
eventType === "subscribed" ||
|
||||
eventType === "unsubscribed"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (eventId && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ack", eventId }))
|
||||
}
|
||||
|
||||
void loadConversations()
|
||||
const currentDetail = detailItemRef.current
|
||||
if (conversationId > 0 && currentDetail?.id === conversationId) {
|
||||
void loadDetail(currentDetail)
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid ws payload
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
if (pingTimerRef.current) {
|
||||
window.clearInterval(pingTimerRef.current)
|
||||
pingTimerRef.current = null
|
||||
}
|
||||
if (websocketRef.current === socket) {
|
||||
websocketRef.current = null
|
||||
}
|
||||
subscribedConversationIdRef.current = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimers()
|
||||
reconnectAttemptRef.current = 0
|
||||
const socket = websocketRef.current
|
||||
websocketRef.current = null
|
||||
if (socket) {
|
||||
socket.close()
|
||||
}
|
||||
subscribedConversationIdRef.current = null
|
||||
}
|
||||
}, [loadConversations, t])
|
||||
|
||||
useEffect(() => {
|
||||
const socket = websocketRef.current
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousConversationId = subscribedConversationIdRef.current
|
||||
const nextConversationId = detailOpen ? detailItem?.id ?? null : null
|
||||
if (previousConversationId && previousConversationId !== nextConversationId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "unsubscribe",
|
||||
topics: [`conversation:${previousConversationId}`],
|
||||
})
|
||||
)
|
||||
}
|
||||
if (nextConversationId && nextConversationId !== previousConversationId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "subscribe",
|
||||
topics: [`conversation:${nextConversationId}`],
|
||||
})
|
||||
)
|
||||
subscribedConversationIdRef.current = nextConversationId
|
||||
return
|
||||
}
|
||||
if (!nextConversationId) {
|
||||
subscribedConversationIdRef.current = null
|
||||
}
|
||||
}, [detailItem, detailOpen])
|
||||
|
||||
function handleStatusFilterChange(value: string | null) {
|
||||
setStatusFilterInput(value ?? "all")
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
setKeyword(keywordInput)
|
||||
setStatusFilter(statusFilterInput)
|
||||
setTagFilter(tagFilterInput)
|
||||
setAssigneeFilter(assigneeFilterInput)
|
||||
setAgentTeamFilter(agentTeamFilterInput)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
if (nextPage < 1 || nextPage === page) {
|
||||
return
|
||||
}
|
||||
setPage(nextPage)
|
||||
}
|
||||
|
||||
function handleLimitChange(nextLimit: number) {
|
||||
if (nextLimit <= 0 || nextLimit === limit) {
|
||||
return
|
||||
}
|
||||
setLimit(nextLimit)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
async function loadDetail(item: AdminConversation) {
|
||||
setDetailLoading(true)
|
||||
setDetailMessagesNextCursor("")
|
||||
setDetailMessagesHasMore(false)
|
||||
try {
|
||||
const [detail, messages] = await Promise.all([
|
||||
fetchConversationDetail(item.id),
|
||||
fetchConversationMessages({ conversationId: item.id, limit: 20 }),
|
||||
])
|
||||
setDetailData(detail)
|
||||
setDetailMessages(Array.isArray(messages.results) ? messages.results : [])
|
||||
setDetailMessagesNextCursor(messages.cursor ?? "")
|
||||
setDetailMessagesHasMore(Boolean(messages.hasMore))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadDetailFailed"))
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreDetailMessages = useCallback(async () => {
|
||||
if (!detailItem || detailMessagesLoadingMore || !detailMessagesHasMore) {
|
||||
return
|
||||
}
|
||||
const cursor = Number.parseInt(detailMessagesNextCursor, 10)
|
||||
if (!detailMessagesNextCursor.trim() || !Number.isFinite(cursor) || cursor <= 0) {
|
||||
return
|
||||
}
|
||||
setDetailMessagesLoadingMore(true)
|
||||
try {
|
||||
const page = await fetchConversationMessages({
|
||||
conversationId: detailItem.id,
|
||||
cursor,
|
||||
limit: 20,
|
||||
})
|
||||
const nextResults = Array.isArray(page.results) ? page.results : []
|
||||
setDetailMessages((prev) => [...nextResults, ...prev])
|
||||
setDetailMessagesNextCursor(page.cursor ?? "")
|
||||
setDetailMessagesHasMore(Boolean(page.hasMore))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadMoreMessagesFailed"))
|
||||
} finally {
|
||||
setDetailMessagesLoadingMore(false)
|
||||
}
|
||||
}, [
|
||||
detailItem,
|
||||
detailMessagesHasMore,
|
||||
detailMessagesLoadingMore,
|
||||
detailMessagesNextCursor,
|
||||
t,
|
||||
])
|
||||
|
||||
async function openDetail(item: AdminConversation) {
|
||||
setDetailItem(item)
|
||||
setDetailData(null)
|
||||
setDetailMessages([])
|
||||
setDetailMessagesNextCursor("")
|
||||
setDetailMessagesHasMore(false)
|
||||
setDetailMessagesLoadingMore(false)
|
||||
setDetailOpen(true)
|
||||
await loadDetail(item)
|
||||
}
|
||||
|
||||
function handleDetailOpenChange(open: boolean) {
|
||||
if (actionLoadingId) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setDetailOpen(false)
|
||||
setDetailItem(null)
|
||||
setDetailData(null)
|
||||
setDetailMessages([])
|
||||
setDetailMessagesNextCursor("")
|
||||
setDetailMessagesHasMore(false)
|
||||
setDetailMessagesLoadingMore(false)
|
||||
return
|
||||
}
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
function openAssign(item: AdminConversation) {
|
||||
setAssignItem(item)
|
||||
setAssignOpen(true)
|
||||
}
|
||||
|
||||
function handleAssignOpenChange(open: boolean) {
|
||||
if (actionLoadingId) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setAssignOpen(false)
|
||||
setAssignItem(null)
|
||||
return
|
||||
}
|
||||
setAssignOpen(true)
|
||||
}
|
||||
|
||||
function openTransfer(item: AdminConversation) {
|
||||
setTransferItem(item)
|
||||
setTransferOpen(true)
|
||||
}
|
||||
|
||||
function openClose(item: AdminConversation) {
|
||||
setCloseItem(item)
|
||||
setCloseOpen(true)
|
||||
}
|
||||
|
||||
function handleCloseOpenChange(open: boolean) {
|
||||
if (actionLoadingId) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setCloseOpen(false)
|
||||
setCloseItem(null)
|
||||
return
|
||||
}
|
||||
setCloseOpen(true)
|
||||
}
|
||||
|
||||
function handleTransferOpenChange(open: boolean) {
|
||||
if (actionLoadingId) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setTransferOpen(false)
|
||||
setTransferItem(null)
|
||||
return
|
||||
}
|
||||
setTransferOpen(true)
|
||||
}
|
||||
|
||||
async function refreshDetail() {
|
||||
if (!detailOpen || !detailItem) {
|
||||
return
|
||||
}
|
||||
await loadDetail(detailItem)
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await loadConversations()
|
||||
await refreshDetail()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRead(item: AdminConversation) {
|
||||
setActionLoadingId(item.id)
|
||||
try {
|
||||
await markConversationRead(item.id)
|
||||
toast.success(t("conversationMonitor.markedRead", { name: item.customerName || `#${item.id}` }))
|
||||
await loadConversations()
|
||||
if (detailItem?.id === item.id) {
|
||||
await refreshDetail()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationMonitor.markReadFailed"))
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDispatch(item: AdminConversation) {
|
||||
setActionLoadingId(item.id)
|
||||
try {
|
||||
await dispatchConversation(item.id)
|
||||
toast.success(t("conversationMonitor.dispatchTriggered", { name: item.customerName || `#${item.id}` }))
|
||||
await loadConversations()
|
||||
if (detailItem?.id === item.id) {
|
||||
await refreshDetail()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationMonitor.dispatchFailed"))
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConversationChanged(conversationId: number) {
|
||||
await loadConversations()
|
||||
if (detailItem?.id === conversationId) {
|
||||
await refreshDetail()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPage>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={loading || refreshing}
|
||||
>
|
||||
<RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} />
|
||||
{t("conversationMonitor.refresh")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="relative w-full sm:w-72">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={t("conversationMonitor.keywordPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-36">
|
||||
<OptionCombobox
|
||||
value={statusFilterInput}
|
||||
options={statusOptions}
|
||||
placeholder={t("conversationMonitor.allStatuses")}
|
||||
onChange={handleStatusFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-64">
|
||||
<TagSelector
|
||||
mode="single"
|
||||
value={Number(tagFilterInput)}
|
||||
onChange={(value) => setTagFilterInput(String(value))}
|
||||
tags={tags}
|
||||
placeholder={t("conversationMonitor.selectTag")}
|
||||
searchPlaceholder={t("conversationMonitor.searchTagPath")}
|
||||
emptyText={t("conversationMonitor.emptyTags")}
|
||||
rootOption={{ value: 0, label: t("conversationMonitor.allTags") }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-56">
|
||||
<OptionCombobox
|
||||
value={assigneeFilterInput}
|
||||
options={assigneeOptions}
|
||||
placeholder={t("conversationMonitor.selectAssignee")}
|
||||
searchPlaceholder={t("conversationMonitor.searchAssignee")}
|
||||
emptyText={t("conversationMonitor.emptyAssignees")}
|
||||
onChange={setAssigneeFilterInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-56">
|
||||
<OptionCombobox
|
||||
value={agentTeamFilterInput}
|
||||
options={agentTeamOptions}
|
||||
placeholder={t("conversationMonitor.selectTeam")}
|
||||
searchPlaceholder={t("conversationMonitor.searchTeam")}
|
||||
emptyText={t("conversationMonitor.emptyTeams")}
|
||||
onChange={setAgentTeamFilterInput}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||
<SearchIcon />
|
||||
{t("conversationMonitor.query")}
|
||||
</Button>
|
||||
</DashboardToolbar>
|
||||
|
||||
<DashboardTableShell
|
||||
pagination={
|
||||
<ListPagination
|
||||
page={result.page.page}
|
||||
total={result.page.total}
|
||||
limit={result.page.limit}
|
||||
loading={loading || refreshing}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead>{t("conversationMonitor.columnConversation")}</TableHead>
|
||||
<TableHead>{t("conversationMonitor.columnStatus")}</TableHead>
|
||||
<TableHead>{t("conversationMonitor.columnServiceMode")}</TableHead>
|
||||
<TableHead>{t("conversationMonitor.columnAssignee")}</TableHead>
|
||||
<TableHead>{t("conversationMonitor.columnUnread")}</TableHead>
|
||||
<TableHead>{t("conversationMonitor.columnLastActive")}</TableHead>
|
||||
<TableHead className="w-28 text-right">
|
||||
{t("conversationMonitor.columnActions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.results.map((item) => {
|
||||
const statusMeta = getStatusMeta(item.status, t)
|
||||
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="max-w-60">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{item.customerName ||
|
||||
t("conversationMonitor.customerFallback", {
|
||||
id: item.customerId || item.id,
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{t("conversationMonitor.channelIdValue", {
|
||||
id: item.channelId || "-",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{item.lastMessageSummary || t("conversationMonitor.noLatestSummary")}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{getServiceModeLabel(item.serviceMode, t)}</TableCell>
|
||||
<TableCell>{item.currentAssigneeName || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{t("conversationMonitor.unreadSummary", {
|
||||
agent: item.agentUnreadCount,
|
||||
customer: item.customerUnreadCount,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(item.lastMessageAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ButtonGroup className="ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openDetail(item)}
|
||||
disabled={actionLoadingId === item.id}
|
||||
>
|
||||
{t("conversationMonitor.view")}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon-sm" />}
|
||||
aria-label={t("conversationMonitor.moreActions", {
|
||||
name: item.customerName || item.id,
|
||||
})}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44 min-w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={() => openAssign(item)}
|
||||
disabled={actionLoadingId === item.id || item.status !== 2}
|
||||
>
|
||||
<MessageCircleMoreIcon />
|
||||
{actionLoadingId === item.id
|
||||
? t("conversationMonitor.processing")
|
||||
: t("conversationMonitor.assign")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleDispatch(item)}
|
||||
disabled={actionLoadingId === item.id || item.status !== 2}
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
{actionLoadingId === item.id
|
||||
? t("conversationMonitor.processing")
|
||||
: t("conversationMonitor.retryDispatch")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleRead(item)}
|
||||
disabled={actionLoadingId === item.id}
|
||||
>
|
||||
<CheckCheckIcon />
|
||||
{actionLoadingId === item.id
|
||||
? t("conversationMonitor.processing")
|
||||
: t("conversationMonitor.markRead")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openTransfer(item)}
|
||||
disabled={actionLoadingId === item.id || item.status !== 3}
|
||||
>
|
||||
<MessageCircleMoreIcon />
|
||||
{actionLoadingId === item.id
|
||||
? t("conversationMonitor.processing")
|
||||
: t("conversationMonitor.transfer")}
|
||||
</DropdownMenuItem>
|
||||
{item.status !== 4 ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => openClose(item)}
|
||||
disabled={actionLoadingId === item.id}
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
{actionLoadingId === item.id
|
||||
? t("conversationMonitor.processing")
|
||||
: t("conversationMonitor.close")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{loading || result.results.length === 0 ? (
|
||||
<DashboardTableStateRow
|
||||
colSpan={7}
|
||||
loading={loading}
|
||||
loadingText={t("conversationMonitor.loadingRows")}
|
||||
emptyText={t("conversationMonitor.emptyRows")}
|
||||
/>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DashboardTableShell>
|
||||
</DashboardPage>
|
||||
|
||||
<ConversationDetailDialog
|
||||
open={detailOpen}
|
||||
loading={detailLoading}
|
||||
saving={actionLoadingId === detailItem?.id}
|
||||
item={detailItem}
|
||||
detail={detailData}
|
||||
messages={detailMessages}
|
||||
messagesHasMore={detailMessagesHasMore}
|
||||
loadingMoreMessages={detailMessagesLoadingMore}
|
||||
onLoadMoreMessages={loadMoreDetailMessages}
|
||||
onOpenChange={handleDetailOpenChange}
|
||||
onOpenAssign={() => {
|
||||
if (!detailItem) {
|
||||
return
|
||||
}
|
||||
openAssign(detailItem)
|
||||
}}
|
||||
onDispatch={async () => {
|
||||
if (!detailItem) {
|
||||
return
|
||||
}
|
||||
await handleDispatch(detailItem)
|
||||
}}
|
||||
onOpenTransfer={() => {
|
||||
if (!detailItem) {
|
||||
return
|
||||
}
|
||||
openTransfer(detailItem)
|
||||
}}
|
||||
onRead={async () => {
|
||||
if (!detailItem) {
|
||||
return
|
||||
}
|
||||
await handleRead(detailItem)
|
||||
}}
|
||||
onOpenClose={() => {
|
||||
if (!detailItem) {
|
||||
return
|
||||
}
|
||||
openClose(detailItem)
|
||||
}}
|
||||
/>
|
||||
<ConversationCloseDialog
|
||||
open={closeOpen}
|
||||
conversationId={closeItem?.id ?? null}
|
||||
onOpenChange={handleCloseOpenChange}
|
||||
onSuccess={async () => {
|
||||
const conversationId = closeItem?.id
|
||||
setCloseOpen(false)
|
||||
setCloseItem(null)
|
||||
if (conversationId) {
|
||||
await handleConversationChanged(conversationId)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConversationTransferDialog
|
||||
open={assignOpen}
|
||||
mode="assign"
|
||||
conversationId={assignItem?.id ?? null}
|
||||
onOpenChange={handleAssignOpenChange}
|
||||
onSuccess={async () => {
|
||||
const conversationId = assignItem?.id
|
||||
setAssignOpen(false)
|
||||
setAssignItem(null)
|
||||
if (conversationId) {
|
||||
await handleConversationChanged(conversationId)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConversationTransferDialog
|
||||
open={transferOpen}
|
||||
mode="transfer"
|
||||
conversationId={transferItem?.id ?? null}
|
||||
onOpenChange={handleTransferOpenChange}
|
||||
onSuccess={async () => {
|
||||
const conversationId = transferItem?.id
|
||||
setTransferOpen(false)
|
||||
setTransferItem(null)
|
||||
if (conversationId) {
|
||||
await handleConversationChanged(conversationId)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
SharedMessageEditor,
|
||||
type UploadedMessageEditorImage,
|
||||
} from "@/components/chat/shared-message-editor"
|
||||
import {
|
||||
sendOperatorPresence,
|
||||
useAgentConversationsStore,
|
||||
} from "@/hooks/use-agent-conversation-realtime"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
|
||||
import { suggestAgentReply } from "@/lib/api/agent"
|
||||
|
||||
type AgentMessageEditorProps = {
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
onSend: (html: string) => Promise<void>
|
||||
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent message editor for dashboard conversations.
|
||||
* Supports quick replies picker and slash command (/) autocomplete.
|
||||
*/
|
||||
export function AgentMessageEditor({
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
onSend,
|
||||
onUploadImage,
|
||||
onSendAttachment,
|
||||
}: AgentMessageEditorProps) {
|
||||
const t = useI18n()
|
||||
const selectedConversationId = useAgentConversationsStore(
|
||||
(state) => state.selectedConversationId
|
||||
)
|
||||
const lastTypingSentAtRef = useRef<number>(0)
|
||||
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
|
||||
const [loadingQuickReplies, setLoadingQuickReplies] = useState(true)
|
||||
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
|
||||
|
||||
const handleTyping = () => {
|
||||
if (!selectedConversationId) return
|
||||
const now = Date.now()
|
||||
if (now - lastTypingSentAtRef.current >= 3000) {
|
||||
lastTypingSentAtRef.current = now
|
||||
sendOperatorPresence(selectedConversationId, "typing")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void fetchQuickReplyListAll()
|
||||
.then((list) => {
|
||||
if (!cancelled) {
|
||||
setQuickReplies(list)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversation.loadQuickRepliesFailed"))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingQuickReplies(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<SharedMessageEditor
|
||||
variant="agent"
|
||||
conversationId={selectedConversationId}
|
||||
disabled={disabled}
|
||||
uploadingAsset={uploadingAsset}
|
||||
quickReplies={{
|
||||
open: quickReplyPickerOpen,
|
||||
loading: loadingQuickReplies,
|
||||
items: quickReplies,
|
||||
onOpenChange: setQuickReplyPickerOpen,
|
||||
}}
|
||||
aiCopilot={
|
||||
selectedConversationId
|
||||
? {
|
||||
onSuggest: async () => {
|
||||
try {
|
||||
const text = await suggestAgentReply(selectedConversationId)
|
||||
if (!text) {
|
||||
toast.info(t("aiCopilot.errorGenerating"))
|
||||
return null
|
||||
}
|
||||
return text
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("aiCopilot.errorGenerating")
|
||||
)
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSend={onSend}
|
||||
onUploadImage={onUploadImage}
|
||||
onSendAttachment={onSendAttachment}
|
||||
onTyping={handleTyping}
|
||||
/>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import { UserIcon } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { IMConversationStatus } from "@/lib/generated/enums";
|
||||
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
type ConversationListProps = {
|
||||
onAfterSelect?: () => void
|
||||
}
|
||||
|
||||
export function ConversationList({ onAfterSelect }: ConversationListProps) {
|
||||
const t = useI18n()
|
||||
const conversations = useAgentConversationsStore((state) => state.conversations)
|
||||
const loading = useAgentConversationsStore((state) => state.conversationsLoading)
|
||||
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
|
||||
const selectConversation = useAgentConversationsStore((state) => state.selectConversation)
|
||||
|
||||
return (
|
||||
<ScrollArea className="overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : conversations.length > 0 ? (
|
||||
conversations.map((conversation) => {
|
||||
const isSelected = selectedId === conversation.id
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={`cursor-pointer border-b border-border/80 px-3 py-2 transition-colors hover:bg-muted/40 ${
|
||||
isSelected ? "bg-accent/70" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
void selectConversation(conversation.id).then(
|
||||
() => {
|
||||
onAfterSelect?.()
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src="" />
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
<UserIcon className="size-3.5 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-sm leading-4">
|
||||
{conversation.customerName ||
|
||||
t("conversation.customerFallback", {
|
||||
id: conversation.customerId || conversation.id,
|
||||
})}
|
||||
</span>
|
||||
{conversation.agentUnreadCount > 0 ? (
|
||||
<div className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||
{conversation.agentUnreadCount > 99
|
||||
? "99+"
|
||||
: conversation.agentUnreadCount}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{conversation.lastMessageAt
|
||||
? formatDateTime(conversation.lastMessageAt)
|
||||
: t("conversation.noTime")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
|
||||
{conversation.lastMessageSummary || t("conversation.noLatestMessage")}
|
||||
</div>
|
||||
{conversation.status === IMConversationStatus.Pending &&
|
||||
conversation.currentTeamName ? (
|
||||
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5">
|
||||
{t("conversation.teamOnDuty", {
|
||||
name: conversation.currentTeamName,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
{t("conversation.empty")}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { CopyIcon, Loader2Icon, SparklesIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { summarizeAgentConversation } from "@/lib/api/agent"
|
||||
|
||||
export type ConversationSummaryDialogProps = {
|
||||
open: boolean
|
||||
conversationId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ConversationSummaryDialog({
|
||||
open,
|
||||
conversationId,
|
||||
onOpenChange,
|
||||
}: ConversationSummaryDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{open ? (
|
||||
<ConversationSummaryDialogBody
|
||||
key={conversationId ? `summary-${conversationId}` : "summary"}
|
||||
conversationId={conversationId}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type ConversationSummaryDialogBodyProps = {
|
||||
conversationId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function ConversationSummaryDialogBody({
|
||||
conversationId,
|
||||
onOpenChange,
|
||||
}: ConversationSummaryDialogBodyProps) {
|
||||
const t = useI18n()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [summary, setSummary] = useState("")
|
||||
const [copying, setCopying] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setSummary("")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setLoading(true)
|
||||
|
||||
summarizeAgentConversation(conversationId)
|
||||
.then((res) => {
|
||||
if (!active) return
|
||||
setSummary(res || "")
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return
|
||||
toast.error(error instanceof Error ? error.message : t("aiCopilot.errorGenerating"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [conversationId, t])
|
||||
|
||||
async function handleCopy() {
|
||||
if (!summary || copying) {
|
||||
return
|
||||
}
|
||||
|
||||
setCopying(true)
|
||||
try {
|
||||
await navigator.clipboard.writeText(summary)
|
||||
toast.success(t("aiCopilot.copied"))
|
||||
} catch {
|
||||
toast.error(t("aiCopilot.errorGenerating"))
|
||||
} finally {
|
||||
setCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-4 text-primary" />
|
||||
{t("aiCopilot.summaryTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t("aiCopilot.summaryTitle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[160px] max-h-[60vh] overflow-y-auto rounded-md border bg-muted/30 p-4">
|
||||
{loading ? (
|
||||
<div className="flex h-36 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2Icon className="size-5 animate-spin text-primary" />
|
||||
<span className="text-sm">{t("aiCopilot.summarizing")}</span>
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="whitespace-pre-wrap text-sm leading-6 select-text text-foreground">
|
||||
{summary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("aiCopilot.noSummary")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={loading || !summary || copying}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
{copying ? t("aiCopilot.copied") : t("aiCopilot.copy")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { TagBadges, TagSelector } from "@/components/tag-selector"
|
||||
import {
|
||||
addConversationTag,
|
||||
removeConversationTag,
|
||||
type AgentConversation,
|
||||
type AgentConversationTag,
|
||||
} from "@/lib/api/agent"
|
||||
import { type TagTree } from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type ConversationTagPickerProps = {
|
||||
conversation: AgentConversation
|
||||
availableTags: TagTree[]
|
||||
loading?: boolean
|
||||
onTagsChange: (tags: AgentConversationTag[]) => void
|
||||
}
|
||||
|
||||
export function ConversationTagPicker({
|
||||
conversation,
|
||||
availableTags,
|
||||
loading = false,
|
||||
onTagsChange,
|
||||
}: ConversationTagPickerProps) {
|
||||
const t = useI18n()
|
||||
const [pendingTagId, setPendingTagId] = useState<number | null>(null)
|
||||
|
||||
const selectedValues = useMemo(
|
||||
() => (conversation.tags ?? []).map((item) => item.id),
|
||||
[conversation.tags]
|
||||
)
|
||||
const selectedTagIds = useMemo(
|
||||
() => new Set(selectedValues),
|
||||
[selectedValues]
|
||||
)
|
||||
|
||||
async function handleChange(nextTagIds: number[]) {
|
||||
if (pendingTagId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const tagId =
|
||||
nextTagIds.find((id) => !selectedTagIds.has(id)) ??
|
||||
selectedValues.find((id) => !nextTagIds.includes(id))
|
||||
|
||||
if (!tagId) {
|
||||
return
|
||||
}
|
||||
|
||||
const exists = selectedTagIds.has(tagId)
|
||||
const currentTags = conversation.tags ?? []
|
||||
const nextTags = exists
|
||||
? currentTags.filter((item) => item.id !== tagId)
|
||||
: [...currentTags, { id: tagId, name: "" }]
|
||||
|
||||
setPendingTagId(tagId)
|
||||
try {
|
||||
if (exists) {
|
||||
await removeConversationTag({
|
||||
conversationId: conversation.id,
|
||||
tagId,
|
||||
})
|
||||
} else {
|
||||
await addConversationTag({
|
||||
conversationId: conversation.id,
|
||||
tagId,
|
||||
})
|
||||
}
|
||||
onTagsChange(nextTags)
|
||||
toast.success(exists ? t("conversation.tagRemoved") : t("conversation.tagAdded"))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversation.tagUpdateFailed"))
|
||||
} finally {
|
||||
setPendingTagId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TagSelector
|
||||
mode="multiple"
|
||||
value={selectedValues}
|
||||
onChange={(value) => void handleChange(value)}
|
||||
tags={availableTags}
|
||||
loading={loading}
|
||||
pendingTagId={pendingTagId}
|
||||
placeholder={t("conversation.edit")}
|
||||
triggerText={t("conversation.edit")}
|
||||
searchPlaceholder={t("conversation.searchTags")}
|
||||
loadingText={t("conversation.loadingTags")}
|
||||
emptyText={t("conversation.emptyTags")}
|
||||
align="end"
|
||||
showSelectedBadges={false}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="sm"
|
||||
triggerClassName="h-7 w-auto shrink-0 justify-start gap-1 px-2 text-xs"
|
||||
contentClassName="w-72"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ConversationTagBadgesProps = {
|
||||
tags?: AgentConversationTag[]
|
||||
availableTags?: TagTree[]
|
||||
}
|
||||
|
||||
export function ConversationTagBadges({
|
||||
tags,
|
||||
availableTags = [],
|
||||
}: ConversationTagBadgesProps) {
|
||||
if (!tags || tags.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TagBadges
|
||||
ids={tags.map((tag) => tag.id)}
|
||||
tags={availableTags}
|
||||
fallbackTags={tags}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRightLeftIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
CircleUserRoundIcon,
|
||||
CircleXIcon,
|
||||
FilePlus2Icon,
|
||||
Menu,
|
||||
MoreHorizontalIcon,
|
||||
SparklesIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationSummaryDialog } from "./conversation-summary-dialog";
|
||||
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog";
|
||||
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import {
|
||||
agentConversationFilterOptions,
|
||||
agentConversationSelectors,
|
||||
type AgentConversationFilterKey,
|
||||
useAgentConversationsStore,
|
||||
} from "@/lib/stores/agent-conversations";
|
||||
import { CreateTicketFromConversationDialog } from "../../tickets/_components/create-ticket-from-conversation-dialog";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
import { ConversationInfoPanel } from "./conversation-info-panel";
|
||||
import { ConversationList } from "./conversation-list";
|
||||
|
||||
const workbenchIconButtonClassName =
|
||||
"size-8 text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
function getCustomerOnlineClassName(online?: boolean) {
|
||||
return online
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300"
|
||||
: "border-border bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
function getCustomerOnlineDotClassName(online?: boolean) {
|
||||
return online ? "bg-emerald-500" : "bg-muted-foreground/70";
|
||||
}
|
||||
|
||||
export function ConversationWorkbench() {
|
||||
const t = useI18n();
|
||||
const conversation = useAgentConversationsStore(
|
||||
agentConversationSelectors.selectedConversation,
|
||||
);
|
||||
const conversationFilter = useAgentConversationsStore(
|
||||
(state) => state.conversationFilter,
|
||||
);
|
||||
const setConversationFilter = useAgentConversationsStore(
|
||||
(state) => state.setConversationFilter,
|
||||
);
|
||||
const loadConversations = useAgentConversationsStore(
|
||||
(state) => state.loadConversations,
|
||||
);
|
||||
const loadMessages = useAgentConversationsStore(
|
||||
(state) => state.loadMessages,
|
||||
);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [infoPanelCollapsed, setInfoPanelCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [mobileCustomerSheetOpen, setMobileCustomerSheetOpen] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [closeOpen, setCloseOpen] = useState(false);
|
||||
const [createTicketOpen, setCreateTicketOpen] = useState(false);
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const sidebarPanelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
const infoPanelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
const filterContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const filterMeasureRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = filterContainerRef.current;
|
||||
const measure = filterMeasureRef.current;
|
||||
if (!container || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFilterMode = () => {
|
||||
setShowFilterDropdown(measure.scrollWidth > container.clientWidth);
|
||||
};
|
||||
|
||||
updateFilterMode();
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateFilterMode();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
observer.observe(measure);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentFilterOption =
|
||||
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
|
||||
agentConversationFilterOptions[0];
|
||||
const getFilterLabel = (labelKey: string) => t(labelKey);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConversations().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("conversation.loadListFailed"));
|
||||
});
|
||||
}, [loadConversations, conversationFilter, t]);
|
||||
|
||||
async function handleConversationChanged(conversationId: number) {
|
||||
await loadConversations();
|
||||
await loadMessages(conversationId, {
|
||||
forceLoading: false,
|
||||
reset: false,
|
||||
});
|
||||
}
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
const panel = sidebarPanelRef.current;
|
||||
if (!panel) {
|
||||
setSidebarCollapsed((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel.isCollapsed()) {
|
||||
panel.expand();
|
||||
setSidebarCollapsed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
panel.collapse();
|
||||
setSidebarCollapsed(true);
|
||||
};
|
||||
|
||||
const handleInfoPanelToggle = () => {
|
||||
const panel = infoPanelRef.current;
|
||||
if (!panel) {
|
||||
setInfoPanelCollapsed((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel.isCollapsed()) {
|
||||
panel.expand();
|
||||
setInfoPanelCollapsed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
panel.collapse();
|
||||
setInfoPanelCollapsed(true);
|
||||
};
|
||||
|
||||
const renderConversationSidebar = (opts?: { onListAfterSelect?: () => void }) => (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col bg-inherit">
|
||||
<div className="flex h-12.5 shrink-0 items-start justify-between gap-2 border-b border-border/80 bg-card px-2 py-2">
|
||||
<div ref={filterContainerRef} className="relative min-w-0 flex-1">
|
||||
{showFilterDropdown ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8.5 w-full min-w-0 justify-between gap-2 px-3 text-xs sm:text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{currentFilterOption
|
||||
? getFilterLabel(currentFilterOption.labelKey)
|
||||
: t("conversation.filterPlaceholder")}
|
||||
</span>
|
||||
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44 min-w-44">
|
||||
<DropdownMenuRadioGroup
|
||||
value={conversationFilter}
|
||||
onValueChange={(value) =>
|
||||
setConversationFilter(value as AgentConversationFilterKey)
|
||||
}
|
||||
>
|
||||
{agentConversationFilterOptions.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
||||
{getFilterLabel(opt.labelKey)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Tabs
|
||||
value={conversationFilter}
|
||||
onValueChange={(value) =>
|
||||
setConversationFilter(value as AgentConversationFilterKey)
|
||||
}
|
||||
className="min-w-0 flex-1 gap-0"
|
||||
>
|
||||
<TabsList
|
||||
className="w-full min-w-0 justify-start"
|
||||
>
|
||||
{agentConversationFilterOptions.map((opt) => (
|
||||
<TabsTrigger
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="shrink-0 px-2.5 text-xs sm:text-sm"
|
||||
>
|
||||
{getFilterLabel(opt.labelKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
<div
|
||||
ref={filterMeasureRef}
|
||||
className="pointer-events-none absolute whitespace-nowrap opacity-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="inline-flex">
|
||||
{agentConversationFilterOptions.map((opt) => (
|
||||
<span
|
||||
key={opt.value}
|
||||
className="shrink-0 px-2.5 text-xs sm:text-sm"
|
||||
>
|
||||
{getFilterLabel(opt.labelKey)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${workbenchIconButtonClassName} mt-0.5 shrink-0 lg:hidden`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationList onAfterSelect={opts?.onListAfterSelect} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const workspaceContent = (
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-card text-card-foreground">
|
||||
<div className="flex h-12.5 shrink-0 items-center justify-between gap-3 border-b border-border/80 bg-card px-3 py-1">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${workbenchIconButtonClassName} lg:hidden`}
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${workbenchIconButtonClassName} hidden lg:flex`}
|
||||
onClick={handleSidebarToggle}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="size-4" />
|
||||
) : (
|
||||
<ChevronLeft className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{conversation ? (
|
||||
<>
|
||||
<Avatar className="size-8 shrink-0 lg:size-9">
|
||||
<AvatarImage src="" />
|
||||
<AvatarFallback className="bg-primary/10 text-sm text-primary">
|
||||
{t("conversation.customerAvatar")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium leading-tight">
|
||||
{conversation.customerName ||
|
||||
t("conversation.customerFallback", {
|
||||
id: conversation.customerId || conversation.id,
|
||||
})}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] leading-none ${getCustomerOnlineClassName(
|
||||
conversation.customerOnline,
|
||||
)}`}
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${getCustomerOnlineDotClassName(
|
||||
conversation.customerOnline,
|
||||
)}`}
|
||||
/>
|
||||
{conversation.customerOnline
|
||||
? t("conversation.customerOnline")
|
||||
: t("conversation.customerOffline")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
<span>{t("conversation.channelNumber", { id: conversation.channelId || "-" })}</span>
|
||||
{conversation.customerId ? (
|
||||
<>
|
||||
<span className="text-muted-foreground/60"> / </span>
|
||||
<span>{t("conversation.linkedCustomer")}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-[14px] leading-tight">
|
||||
{t("conversation.workbenchTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden">
|
||||
{t("conversation.openMenuSelectConversation")}
|
||||
</p>
|
||||
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
|
||||
{t("conversation.selectConversationFromSidebar")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${workbenchIconButtonClassName} lg:hidden`}
|
||||
disabled={!conversation}
|
||||
aria-label={t("conversation.conversationInfo")}
|
||||
onClick={() => setMobileCustomerSheetOpen(true)}
|
||||
>
|
||||
<CircleUserRoundIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={workbenchIconButtonClassName}
|
||||
disabled={!conversation}
|
||||
title={t("aiCopilot.summarize")}
|
||||
aria-label={t("aiCopilot.summarize")}
|
||||
onClick={() => setSummaryOpen(true)}
|
||||
>
|
||||
<SparklesIcon className="size-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={workbenchIconButtonClassName}
|
||||
disabled={!conversation}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44 min-w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSummaryOpen(true)}
|
||||
disabled={!conversation}
|
||||
>
|
||||
<SparklesIcon className="size-4" />
|
||||
{t("aiCopilot.summarize")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCreateTicketOpen(true)}
|
||||
disabled={!conversation}
|
||||
>
|
||||
<FilePlus2Icon />
|
||||
{t("conversation.createTicket")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setTransferOpen(true)}
|
||||
disabled={!conversation || conversation.status !== 3}
|
||||
>
|
||||
<ArrowRightLeftIcon />
|
||||
{t("conversation.transferConversation")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCloseOpen(true)}
|
||||
disabled={!conversation || conversation.status === 4}
|
||||
>
|
||||
<CircleXIcon />
|
||||
{t("conversation.closeConversation")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${workbenchIconButtonClassName} hidden lg:flex`}
|
||||
onClick={handleInfoPanelToggle}
|
||||
aria-label={
|
||||
infoPanelCollapsed
|
||||
? t("conversation.expandConversationInfo")
|
||||
: t("conversation.collapseConversationInfo")
|
||||
}
|
||||
>
|
||||
{infoPanelCollapsed ? (
|
||||
<ChevronLeft className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 w-full flex-1 overflow-hidden">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-var(--header-height))] min-h-0 w-full min-w-0 flex-col overflow-hidden lg:h-full">
|
||||
{mobileMenuOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("conversation.closeConversationList")}
|
||||
className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`fixed top-12 bottom-0 left-0 z-40 flex w-[min(22rem,calc(100vw-0.75rem))] max-w-[min(22rem,calc(100vw-0.75rem))] flex-col overflow-hidden border-r border-border/80 bg-card text-card-foreground shadow-xl transition-transform duration-300 ease-out will-change-transform touch-manipulation overscroll-contain supports-[padding:max(0px)]:pb-[env(safe-area-inset-bottom)] lg:hidden ${
|
||||
mobileMenuOpen ? "translate-x-0" : "-translate-x-full pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={!mobileMenuOpen}
|
||||
>
|
||||
{renderConversationSidebar({
|
||||
onListAfterSelect: () => setMobileMenuOpen(false),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden lg:hidden">
|
||||
{workspaceContent}
|
||||
</div>
|
||||
<div className="hidden min-h-0 w-full flex-1 overflow-hidden lg:flex">
|
||||
<ResizablePanelGroup orientation="horizontal">
|
||||
<ResizablePanel
|
||||
panelRef={sidebarPanelRef}
|
||||
defaultSize="20%"
|
||||
minSize="10%"
|
||||
maxSize="40%"
|
||||
collapsedSize="0%"
|
||||
collapsible
|
||||
onResize={(panelSize: { asPercentage: number }) => {
|
||||
setSidebarCollapsed(panelSize.asPercentage <= 1);
|
||||
}}
|
||||
className="min-h-0 bg-card"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
|
||||
{renderConversationSidebar()}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize="50%" minSize="32%" className="min-h-0 bg-card">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
{workspaceContent}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel
|
||||
panelRef={infoPanelRef}
|
||||
defaultSize="500px"
|
||||
minSize="20%"
|
||||
maxSize="40%"
|
||||
collapsedSize="0%"
|
||||
collapsible
|
||||
onResize={(panelSize: { asPercentage: number }) => {
|
||||
setInfoPanelCollapsed(panelSize.asPercentage <= 1);
|
||||
}}
|
||||
className="min-h-0 bg-card"
|
||||
>
|
||||
<ConversationInfoPanel conversation={conversation} className="h-full" />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
<ConversationTransferDialog
|
||||
open={transferOpen}
|
||||
mode="transfer"
|
||||
conversationId={conversation?.id ?? null}
|
||||
onOpenChange={setTransferOpen}
|
||||
onSuccess={async () => {
|
||||
setTransferOpen(false);
|
||||
if (conversation?.id) {
|
||||
await handleConversationChanged(conversation.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConversationCloseDialog
|
||||
open={closeOpen}
|
||||
conversationId={conversation?.id ?? null}
|
||||
onOpenChange={setCloseOpen}
|
||||
onSuccess={async () => {
|
||||
setCloseOpen(false);
|
||||
if (conversation?.id) {
|
||||
await handleConversationChanged(conversation.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CreateTicketFromConversationDialog
|
||||
open={createTicketOpen}
|
||||
onOpenChange={setCreateTicketOpen}
|
||||
conversation={
|
||||
conversation
|
||||
? {
|
||||
id: conversation.id,
|
||||
customerName: conversation.customerName,
|
||||
customerId: conversation.customerId ?? 0,
|
||||
lastMessageSummary: conversation.lastMessageSummary,
|
||||
currentAssigneeId: conversation.currentAssigneeId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSuccess={() => {
|
||||
setCreateTicketOpen(false);
|
||||
}}
|
||||
/>
|
||||
<ConversationSummaryDialog
|
||||
open={summaryOpen}
|
||||
conversationId={conversation?.id ?? null}
|
||||
onOpenChange={setSummaryOpen}
|
||||
/>
|
||||
|
||||
<Sheet open={mobileCustomerSheetOpen} onOpenChange={setMobileCustomerSheetOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
|
||||
showCloseButton
|
||||
>
|
||||
<ConversationInfoPanel
|
||||
conversation={conversation}
|
||||
variant="embedded"
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
web/app/(dashboard)/dashboard/conversations/page.tsx
Normal file
12
web/app/(dashboard)/dashboard/conversations/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AgentRealtimeProvider } from "@/components/agent-realtime-provider";
|
||||
|
||||
import { ConversationWorkbench } from "./_components/conversation-workbench";
|
||||
|
||||
export default function ConversationsPage() {
|
||||
return (
|
||||
<>
|
||||
<AgentRealtimeProvider />
|
||||
<ConversationWorkbench />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"use client"
|
||||
|
||||
export { CustomerFormDialog, type CustomerFormDialogProps } from "@/components/customer-form-dialog"
|
||||
export { CustomerFormDialog as EditDialog } from "@/components/customer-form-dialog"
|
||||
266
web/app/(dashboard)/dashboard/customers/page.tsx
Normal file
266
web/app/(dashboard)/dashboard/customers/page.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { BanIcon, CheckCircle2Icon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { type CustomerFormSavePayload } from "@/components/customer-form";
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
createDashboardStatusColumn,
|
||||
createDashboardStatusToggleAction,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { type ComboboxOption } from "@/components/option-combobox";
|
||||
import { fetchCompanies, type AdminCompany } from "@/lib/api/company";
|
||||
import {
|
||||
deleteCustomer,
|
||||
fetchCustomers,
|
||||
saveCustomerProfile,
|
||||
updateCustomerStatus,
|
||||
type AdminCustomer,
|
||||
} from "@/lib/api/customer";
|
||||
import { Gender, Status } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { EditDialog } from "./_components/edit";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function getGenderText(gender: number, t: TFunction) {
|
||||
if (gender === Gender.Male) return t("customerForm.genderMale");
|
||||
if (gender === Gender.Female) return t("customerForm.genderFemale");
|
||||
return t("customerForm.genderUnknown");
|
||||
}
|
||||
|
||||
export default function DashboardCustomersPage() {
|
||||
const t = useI18n();
|
||||
const [companyOptions, setCompanyOptions] = useState<ComboboxOption[]>([
|
||||
{ value: "0", label: t("customer.allCompanies") },
|
||||
]);
|
||||
const [companyNameMap, setCompanyNameMap] = useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const listStatusOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: t("status.all") },
|
||||
{ value: String(Status.Ok), label: t("status.ok") },
|
||||
{ value: String(Status.Disabled), label: t("status.disabled") },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const genderOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: t("customer.allGenders") },
|
||||
{ value: String(Gender.Unknown), label: t("customerForm.genderUnknown") },
|
||||
{ value: String(Gender.Male), label: t("customerForm.genderMale") },
|
||||
{ value: String(Gender.Female), label: t("customerForm.genderFemale") },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCompanies() {
|
||||
try {
|
||||
const data = await fetchCompanies({ status: 0, page: 1, limit: 500 });
|
||||
setCompanyOptions([
|
||||
{ value: "0", label: t("customer.allCompanies") },
|
||||
...data.results.map((item) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})),
|
||||
]);
|
||||
const map: Record<number, string> = {};
|
||||
data.results.forEach((item: AdminCompany) => {
|
||||
map[item.id] = item.name;
|
||||
});
|
||||
setCompanyNameMap(map);
|
||||
} catch {
|
||||
// Company names are optional display enrichment for this list.
|
||||
}
|
||||
}
|
||||
void loadCompanies();
|
||||
}, [t]);
|
||||
|
||||
const filters = useMemo<DashboardCrudFilter[]>(
|
||||
() => [
|
||||
{
|
||||
name: "keyword",
|
||||
label: t("customer.columnName"),
|
||||
placeholder: t("customer.keywordPlaceholder"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
{
|
||||
name: "gender",
|
||||
label: t("customer.columnGender"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: genderOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
{
|
||||
name: "companyId",
|
||||
label: t("customer.columnCompany"),
|
||||
type: "select",
|
||||
defaultValue: "0",
|
||||
allValue: "0",
|
||||
valueType: "number",
|
||||
options: companyOptions,
|
||||
className: "w-full sm:w-56",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("customer.columnStatus"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: listStatusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
],
|
||||
[companyOptions, genderOptions, listStatusOptions, t],
|
||||
);
|
||||
|
||||
const columns = useMemo<DashboardCrudColumn<AdminCustomer>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "id",
|
||||
label: "ID",
|
||||
className: "w-20",
|
||||
render: (item) => item.id,
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: t("customer.columnName"),
|
||||
render: (item) => <span className="font-medium">{item.name}</span>,
|
||||
},
|
||||
{
|
||||
key: "gender",
|
||||
label: t("customer.columnGender"),
|
||||
className: "w-20",
|
||||
render: (item) => (
|
||||
<span className="text-muted-foreground">
|
||||
{getGenderText(item.gender, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "company",
|
||||
label: t("customer.columnCompany"),
|
||||
render: (item) => (
|
||||
<span className="text-muted-foreground">
|
||||
{item.companyId > 0
|
||||
? (companyNameMap[item.companyId] ?? String(item.companyId))
|
||||
: "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mobile",
|
||||
label: t("customer.columnMobile"),
|
||||
render: (item) => (
|
||||
<span className="text-muted-foreground">
|
||||
{item.primaryMobile || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: t("customer.columnEmail"),
|
||||
render: (item) => (
|
||||
<span className="text-muted-foreground">
|
||||
{item.primaryEmail || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
createDashboardStatusColumn<AdminCustomer, number>({
|
||||
label: t("customer.columnStatus"),
|
||||
className: "w-24",
|
||||
getStatus: (item) => item.status,
|
||||
getLabel: (status) =>
|
||||
status === Status.Ok ? t("status.ok") : t("status.disabled"),
|
||||
getBadgeVariant: (status) =>
|
||||
status === Status.Ok ? "default" : "secondary",
|
||||
}),
|
||||
],
|
||||
[companyNameMap, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardCrudPage<AdminCustomer, CustomerFormSavePayload>
|
||||
filters={filters}
|
||||
columns={columns}
|
||||
fetchList={(query) =>
|
||||
fetchCustomers({
|
||||
keyword:
|
||||
typeof query.keyword === "string" ? query.keyword : undefined,
|
||||
status:
|
||||
typeof query.status === "number" ? query.status : undefined,
|
||||
gender:
|
||||
typeof query.gender === "number" ? query.gender : undefined,
|
||||
companyId:
|
||||
typeof query.companyId === "number" ? query.companyId : undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={saveCustomerProfile}
|
||||
updateItem={(_item, payload) => saveCustomerProfile(payload)}
|
||||
deleteItem={(item) => deleteCustomer(item.id)}
|
||||
canDelete={(item) => item.status !== Status.Deleted}
|
||||
rowActions={[
|
||||
createDashboardStatusToggleAction<AdminCustomer, number>({
|
||||
icon: (item) =>
|
||||
item.status === Status.Ok ? <BanIcon /> : <CheckCircle2Icon />,
|
||||
label: (item) =>
|
||||
item.status === Status.Ok
|
||||
? t("customer.disable")
|
||||
: t("customer.enable"),
|
||||
disabled: (item) => item.status === Status.Deleted,
|
||||
getNextStatus: (item) =>
|
||||
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||
updateStatus: (item, nextStatus) =>
|
||||
updateCustomerStatus(item.id, nextStatus),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t(nextStatus === Status.Ok ? "customer.enabled" : "customer.disabled", {
|
||||
name: item.name,
|
||||
}),
|
||||
errorMessage: t("customer.statusUpdateFailed"),
|
||||
}),
|
||||
]}
|
||||
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSave={onSubmit}
|
||||
/>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("customer.refresh"),
|
||||
create: t("customer.new"),
|
||||
query: t("customer.query"),
|
||||
loading: t("customer.loading"),
|
||||
empty: t("customer.empty"),
|
||||
actions: t("customer.columnActions"),
|
||||
edit: t("customer.edit"),
|
||||
delete: t("customer.delete"),
|
||||
processing: t("customer.processing"),
|
||||
moreActions: (item) => t("customer.moreActions", { name: item.name }),
|
||||
loadFailed: t("customer.loadFailed"),
|
||||
saveFailed: t("customer.saveFailed"),
|
||||
deleteFailed: t("customer.deleteFailed"),
|
||||
created: (payload) => t("customer.created", { name: payload.name }),
|
||||
updated: (item) => t("customer.updated", { name: item.name }),
|
||||
deleted: (item) => t("customer.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
web/app/(dashboard)/dashboard/docs/page.tsx
Normal file
21
web/app/(dashboard)/dashboard/docs/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export default function DashboardDocsPage() {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<DashboardPlaceholder
|
||||
eyebrow="Docs"
|
||||
title={t("docs.title")}
|
||||
description={t("docs.description")}
|
||||
nextSteps={[
|
||||
t("docs.step1"),
|
||||
t("docs.step2"),
|
||||
t("docs.step3"),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BotIcon, SearchIcon, SparklesIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
debugKnowledgeAnswer,
|
||||
debugKnowledgeSearch,
|
||||
type KnowledgeAnswerResponse,
|
||||
type KnowledgeSearchResponse,
|
||||
} from "@/lib/api/admin";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { getKnowledgeAnswerStatusLabel } from "@/lib/knowledge-i18n";
|
||||
|
||||
type DebugPanelProps = {
|
||||
knowledgeBaseId: number | null;
|
||||
};
|
||||
|
||||
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
const t = useI18n();
|
||||
const [question, setQuestion] = useState("");
|
||||
const [topK, setTopK] = useState("5");
|
||||
const [scoreThreshold, setScoreThreshold] = useState("0.2");
|
||||
const [rerankLimit, setRerankLimit] = useState("5");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [answering, setAnswering] = useState(false);
|
||||
const [searchResult, setSearchResult] = useState<KnowledgeSearchResponse | null>(null);
|
||||
const [answerResult, setAnswerResult] = useState<KnowledgeAnswerResponse | null>(null);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!knowledgeBaseId) {
|
||||
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
|
||||
return;
|
||||
}
|
||||
if (!question.trim()) {
|
||||
toast.error(t("knowledge.debugQuestionRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const data = await debugKnowledgeSearch({
|
||||
knowledgeBaseIds: [knowledgeBaseId],
|
||||
question: question.trim(),
|
||||
topK: Number(topK) || undefined,
|
||||
scoreThreshold: Number(scoreThreshold) || undefined,
|
||||
rerankLimit: Number(rerankLimit) || undefined,
|
||||
});
|
||||
setSearchResult(data);
|
||||
toast.success(t("knowledge.searchCompleted", { count: data.hitCount }));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.searchFailed"));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnswer() {
|
||||
if (!knowledgeBaseId) {
|
||||
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
|
||||
return;
|
||||
}
|
||||
if (!question.trim()) {
|
||||
toast.error(t("knowledge.debugQuestionRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setAnswering(true);
|
||||
try {
|
||||
const data = await debugKnowledgeAnswer({
|
||||
knowledgeBaseIds: [knowledgeBaseId],
|
||||
question: question.trim(),
|
||||
topK: Number(topK) || undefined,
|
||||
scoreThreshold: Number(scoreThreshold) || undefined,
|
||||
rerankLimit: Number(rerankLimit) || undefined,
|
||||
});
|
||||
setAnswerResult(data);
|
||||
toast.success(t("knowledge.answerCompleted", {
|
||||
status: getKnowledgeAnswerStatusLabel(data.answerStatus, data.answerStatusName, t),
|
||||
}));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.answerFailed"));
|
||||
} finally {
|
||||
setAnswering(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={question}
|
||||
onChange={(event) => setQuestion(event.target.value)}
|
||||
placeholder={t("knowledge.debugQuestionPlaceholder")}
|
||||
rows={5}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="topk" className="text-xs">TopK</Label>
|
||||
<Input id="topk" value={topK} onChange={(event) => setTopK(event.target.value)} placeholder={t("knowledge.topKPlaceholder")} className="h-8" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="threshold" className="text-xs">{t("knowledge.scoreThreshold")}</Label>
|
||||
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder={t("knowledge.scoreThresholdPlaceholder")} className="h-8" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rerank" className="text-xs">{t("knowledge.rerankLimitLabel")}</Label>
|
||||
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder={t("knowledge.rerankLimitPlaceholder")} className="h-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" variant="outline" onClick={() => void handleSearch()} disabled={searching}>
|
||||
<SearchIcon className="mr-2 size-4" />
|
||||
{searching ? t("knowledge.searching") : t("knowledge.debugSearch")}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
|
||||
<SparklesIcon className="mr-2 size-4" />
|
||||
{answering ? t("knowledge.generating") : t("knowledge.debugAnswer")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-3">
|
||||
{answerResult ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<BotIcon className="size-4" />
|
||||
{t("knowledge.answerResult")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-3 whitespace-pre-wrap">
|
||||
{answerResult.answer}
|
||||
</div>
|
||||
{answerResult.citations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">{t("knowledge.citationSources")}</div>
|
||||
{answerResult.citations.map((citation) => (
|
||||
<div
|
||||
key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`}
|
||||
className="rounded-md border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{getSearchResultLabel(citation)}
|
||||
</div>
|
||||
<Badge variant="outline">{citation.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{citation.sectionPath || citation.title || `Chunk #${citation.chunkNo}`}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{citation.snippet}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{searchResult ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t("knowledge.searchHits")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("knowledge.hitsSummary", { count: searchResult.hitCount, latency: searchResult.latencyMs })}
|
||||
</div>
|
||||
{searchResult.results.map((item) => (
|
||||
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{getSearchResultLabel(item)}
|
||||
</div>
|
||||
<Badge variant="outline">{item.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || `Chunk #${item.chunkNo}`}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{item.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSearchResultLabel(item: {
|
||||
faqQuestion?: string
|
||||
faqId?: number
|
||||
documentTitle?: string
|
||||
documentId?: number
|
||||
}) {
|
||||
if (item.faqQuestion) {
|
||||
return item.faqQuestion
|
||||
}
|
||||
if (item.documentTitle) {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId && item.faqId > 0) {
|
||||
return `FAQ ${item.faqId}`
|
||||
}
|
||||
return `Document ${item.documentId ?? 0}`
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
type KnowledgeDocument,
|
||||
type CreateKnowledgeDocumentPayload,
|
||||
type KnowledgeDirectory,
|
||||
fetchKnowledgeDocument,
|
||||
fetchKnowledgeDirectories,
|
||||
} from "@/lib/api/admin"
|
||||
import {
|
||||
KnowledgeDocumentContentType,
|
||||
} from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type DocumentEditDialogProps = {
|
||||
open: boolean
|
||||
saving: boolean
|
||||
itemId: number | null
|
||||
knowledgeBaseId: number | null
|
||||
initialDirectoryId?: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
|
||||
}
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
directoryId: "0",
|
||||
title: "",
|
||||
contentType: KnowledgeDocumentContentType.Markdown,
|
||||
content: "",
|
||||
}
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function createKnowledgeDocumentFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
directoryId: z.string().trim(),
|
||||
title: z.string().trim().min(1, t("knowledge.documentTitleRequired")).max(255, t("knowledge.documentTitleMax")),
|
||||
contentType: z.string().trim().min(1, t("knowledge.contentTypeRequired")),
|
||||
content: z.string().trim().min(1, t("knowledge.contentRequired")),
|
||||
})
|
||||
}
|
||||
|
||||
type EditForm = {
|
||||
directoryId: string
|
||||
title: string
|
||||
contentType: string
|
||||
content: string
|
||||
}
|
||||
|
||||
type DirectoryOption = { value: string; label: string }
|
||||
|
||||
function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] {
|
||||
return items.flatMap((item) => [
|
||||
{ value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` },
|
||||
...flattenDirectoryOptions(item.children || [], depth + 1),
|
||||
])
|
||||
}
|
||||
|
||||
function buildForm(item: KnowledgeDocument | null, initialDirectoryId = 0): EditForm {
|
||||
if (!item) {
|
||||
return { ...emptyForm, directoryId: String(initialDirectoryId) }
|
||||
}
|
||||
|
||||
return {
|
||||
directoryId: String(item.directoryId || 0),
|
||||
title: item.title,
|
||||
contentType: item.contentType || KnowledgeDocumentContentType.Markdown,
|
||||
content: item.content || "",
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeDocumentPayload {
|
||||
return {
|
||||
knowledgeBaseId,
|
||||
directoryId: Number(form.directoryId),
|
||||
title: form.title.trim(),
|
||||
contentType: form.contentType,
|
||||
content: form.content.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function DocumentEditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
knowledgeBaseId,
|
||||
initialDirectoryId = 0,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: DocumentEditDialogProps) {
|
||||
if (!open || !knowledgeBaseId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentFormDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
itemId={itemId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
initialDirectoryId={initialDirectoryId}
|
||||
saving={saving}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DocumentFormDialogBodyProps = {
|
||||
saving: boolean
|
||||
itemId: number | null
|
||||
knowledgeBaseId: number
|
||||
initialDirectoryId: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
|
||||
}
|
||||
|
||||
function DocumentFormDialogBody({
|
||||
saving,
|
||||
itemId,
|
||||
knowledgeBaseId,
|
||||
initialDirectoryId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: DocumentFormDialogBodyProps) {
|
||||
const t = useI18n()
|
||||
const formId = "knowledge-document-edit-form"
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([])
|
||||
const knowledgeDocumentFormSchema = useMemo(() => createKnowledgeDocumentFormSchema(t), [t])
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(knowledgeDocumentFormSchema) as Resolver<EditForm>,
|
||||
[knowledgeDocumentFormSchema],
|
||||
)
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
const contentType = watch("contentType")
|
||||
const content = watch("content")
|
||||
const directoryOptions = useMemo(
|
||||
() => [
|
||||
{ value: "0", label: t("knowledge.rootContent") },
|
||||
...flattenDirectoryOptions(directories),
|
||||
],
|
||||
[directories, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(buildForm(null, initialDirectoryId))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchKnowledgeDocument(itemId)
|
||||
reset(buildForm(data))
|
||||
} catch (error) {
|
||||
console.error("Failed to load knowledge document:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void loadDetail()
|
||||
}, [itemId, initialDirectoryId, reset])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const data = await fetchKnowledgeDirectories(knowledgeBaseId)
|
||||
if (!cancelled) {
|
||||
setDirectories(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load knowledge directories:", error)
|
||||
}
|
||||
}
|
||||
void loadDirectories()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [knowledgeBaseId])
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
const payload = buildPayload({ ...values, contentType, content }, knowledgeBaseId)
|
||||
await onSubmit(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
title={itemId ? t("knowledge.editDocumentTitle") : t("knowledge.createDocumentTitle")}
|
||||
size="xl"
|
||||
allowFullscreen
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("knowledge.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("knowledge.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<Field data-invalid={!!errors.directoryId}>
|
||||
<FieldLabel>{t("knowledge.directory")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="directoryId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value ?? "0")}
|
||||
options={directoryOptions}
|
||||
placeholder={t("knowledge.selectDirectory")}
|
||||
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||
emptyText={t("knowledge.emptyDirectory")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.directoryId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.title}>
|
||||
<FieldLabel htmlFor="doc-title">{t("knowledge.documentTitle")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="doc-title"
|
||||
placeholder={t("knowledge.documentTitlePlaceholder")}
|
||||
aria-invalid={!!errors.title}
|
||||
{...register("title")}
|
||||
/>
|
||||
<FieldError errors={[errors.title]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.content}>
|
||||
<FieldLabel htmlFor="doc-content">{t("knowledge.content")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<ContentEditor
|
||||
value={{
|
||||
mode:
|
||||
contentType === KnowledgeDocumentContentType.HTML
|
||||
? KnowledgeDocumentContentType.HTML
|
||||
: KnowledgeDocumentContentType.Markdown,
|
||||
raw: field.value ?? "",
|
||||
}}
|
||||
onChange={(next) => {
|
||||
field.onChange(next.raw)
|
||||
setValue("contentType", next.mode, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
placeholder={t("knowledge.contentPlaceholder")}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.content]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FileTextIcon,
|
||||
FolderInputIcon,
|
||||
EyeIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useConfirm } from "@/components/confirm-provider";
|
||||
import {
|
||||
useDashboardPagedList,
|
||||
type DashboardPagedListFilter,
|
||||
} from "@/components/dashboard/list";
|
||||
import { ListPagination } from "@/components/list-pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
batchDeleteKnowledgeDocuments,
|
||||
batchMoveKnowledgeDocuments,
|
||||
buildKnowledgeDocumentIndex,
|
||||
createKnowledgeDocument,
|
||||
deleteKnowledgeDocument,
|
||||
fetchKnowledgeDocuments,
|
||||
updateKnowledgeDocument,
|
||||
type CreateKnowledgeDocumentPayload,
|
||||
type KnowledgeDocumentListItem,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import {
|
||||
KnowledgeDocumentIndexStatus,
|
||||
Status,
|
||||
} from "@/lib/generated/enums";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { DocumentEditDialog } from "./document-edit";
|
||||
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
|
||||
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
|
||||
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
|
||||
import { SelectionCheckbox } from "./selection-checkbox";
|
||||
|
||||
type DocumentListProps = {
|
||||
knowledgeBaseId: number | null;
|
||||
onActionStateChange?: (state: DocumentListActionState) => void;
|
||||
};
|
||||
|
||||
export type DocumentListActionState = {
|
||||
onRefresh: () => void;
|
||||
onChangeViewMode: (mode: "list" | "grid") => void;
|
||||
onCreate: () => void;
|
||||
viewMode: "list" | "grid";
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function getStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allStatus") },
|
||||
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
|
||||
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
|
||||
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
|
||||
];
|
||||
}
|
||||
|
||||
function getIndexStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allIndexStatus") },
|
||||
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
|
||||
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
|
||||
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
|
||||
];
|
||||
}
|
||||
|
||||
function getIndexStatusLabel(status: string, t: TFunction) {
|
||||
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
|
||||
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
|
||||
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
|
||||
return status || "-";
|
||||
}
|
||||
|
||||
function getIndexStatusBadgeVariant(status: string) {
|
||||
switch (status) {
|
||||
case KnowledgeDocumentIndexStatus.Indexed:
|
||||
return "secondary" as const;
|
||||
case KnowledgeDocumentIndexStatus.Failed:
|
||||
return "destructive" as const;
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
|
||||
const badge = (
|
||||
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
|
||||
{getIndexStatusLabel(item.indexStatus, t)}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
if (
|
||||
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
|
||||
!item.indexError
|
||||
) {
|
||||
return badge
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{item.indexError}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
|
||||
|
||||
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
|
||||
const t = useI18n();
|
||||
const confirm = useConfirm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [moveTargetIds, setMoveTargetIds] = useState<number[]>([]);
|
||||
const [contextMenuDocumentId, setContextMenuDocumentId] = useState<number | null>(null);
|
||||
const [detailItemId, setDetailItemId] = useState<number | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
|
||||
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<KnowledgeDocumentListItem | null>(
|
||||
null,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">(() => {
|
||||
if (typeof window === "undefined") return "grid";
|
||||
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY);
|
||||
return saved === "list" || saved === "grid" ? saved : "grid";
|
||||
});
|
||||
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDirectoryId(null);
|
||||
setSelectedIds([]);
|
||||
}, [knowledgeBaseId]);
|
||||
|
||||
const filters = useMemo<DashboardPagedListFilter[]>(() => [
|
||||
{
|
||||
name: "title",
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
},
|
||||
{
|
||||
name: "indexStatus",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
},
|
||||
], []);
|
||||
|
||||
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
|
||||
return fetchKnowledgeDocuments({
|
||||
title: typeof query.title === "string" ? query.title : undefined,
|
||||
status: typeof query.status === "string" ? query.status : undefined,
|
||||
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
|
||||
knowledgeBaseId: knowledgeBaseId ?? 0,
|
||||
directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId,
|
||||
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
|
||||
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
|
||||
});
|
||||
}, [knowledgeBaseId, selectedDirectoryId]);
|
||||
|
||||
const {
|
||||
draftFilters,
|
||||
setDraftFilter,
|
||||
applyFilter,
|
||||
applyFilters: applyDraftFilters,
|
||||
loading,
|
||||
result: documents,
|
||||
loadData,
|
||||
handlePageChange,
|
||||
handleLimitChange,
|
||||
} = useDashboardPagedList<KnowledgeDocumentListItem>({
|
||||
filters,
|
||||
fetchList,
|
||||
enabled: Boolean(knowledgeBaseId),
|
||||
reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`,
|
||||
loadFailed: t("knowledge.loadDocumentsFailed"),
|
||||
});
|
||||
|
||||
const currentPageIds = useMemo(
|
||||
() => documents.results.map((item) => item.id),
|
||||
[documents.results],
|
||||
);
|
||||
const currentPageSelectedCount = useMemo(
|
||||
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
|
||||
[currentPageIds, selectedIds],
|
||||
);
|
||||
const allCurrentPageSelected =
|
||||
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
|
||||
}, [currentPageIds]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
function handleStatusFilterChange(value: string | null) {
|
||||
applyFilter("status", value ?? "all");
|
||||
}
|
||||
|
||||
function handleIndexStatusFilterChange(value: string | null) {
|
||||
applyFilter("indexStatus", value ?? "all");
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
applyDraftFilters();
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function toggleSelected(id: number, checked: boolean) {
|
||||
setSelectedIds((prev) => {
|
||||
if (checked) {
|
||||
return prev.includes(id) ? prev : [...prev, id];
|
||||
}
|
||||
return prev.filter((currentId) => currentId !== id);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCurrentPage(checked: boolean) {
|
||||
setSelectedIds(checked ? currentPageIds : []);
|
||||
}
|
||||
|
||||
function openMoveDialog(ids: number[]) {
|
||||
setMoveTargetIds(ids);
|
||||
setBulkMoveOpen(true);
|
||||
}
|
||||
|
||||
const openCreateDialog = useCallback(() => {
|
||||
setEditingItem(null);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onActionStateChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
onActionStateChange({
|
||||
onRefresh: () => void loadData(),
|
||||
onChangeViewMode: setViewMode,
|
||||
onCreate: openCreateDialog,
|
||||
viewMode,
|
||||
loading,
|
||||
});
|
||||
}, [onActionStateChange, loadData, openCreateDialog, viewMode, loading]);
|
||||
|
||||
function openEditDialog(item: KnowledgeDocumentListItem) {
|
||||
setEditingItem(item);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openDetailDialog(item: KnowledgeDocumentListItem) {
|
||||
setDetailItemId(item.id);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(open: boolean) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
setEditingItem(null);
|
||||
}
|
||||
setDialogOpen(open);
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: CreateKnowledgeDocumentPayload) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateKnowledgeDocument({
|
||||
id: editingItem.id,
|
||||
...payload,
|
||||
});
|
||||
toast.success(t("knowledge.documentUpdated", { title: editingItem.title }));
|
||||
} else {
|
||||
await createKnowledgeDocument(payload);
|
||||
toast.success(t("knowledge.documentCreated", { title: payload.title }));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setEditingItem(null);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.documentSaveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: KnowledgeDocumentListItem) {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
|
||||
try {
|
||||
await deleteKnowledgeDocument(item.id);
|
||||
toast.success(t("knowledge.documentDeleted", { title: item.title }));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.documentDeleteFailed"));
|
||||
} finally {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirm({
|
||||
title: t("knowledge.batchDelete"),
|
||||
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
|
||||
confirmText: t("knowledge.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await batchDeleteKnowledgeDocuments(selectedIds);
|
||||
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
|
||||
setSelectedIds([]);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchMove(directoryId: number) {
|
||||
const ids = moveTargetIds.length > 0 ? moveTargetIds : selectedIds;
|
||||
if (!knowledgeBaseId || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
setMoving(true);
|
||||
try {
|
||||
await batchMoveKnowledgeDocuments({
|
||||
knowledgeBaseId,
|
||||
directoryId,
|
||||
ids,
|
||||
});
|
||||
toast.success(t("knowledge.batchMoved", { count: ids.length }));
|
||||
setBulkMoveOpen(false);
|
||||
setMoveTargetIds([]);
|
||||
setSelectedIds([]);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
|
||||
} finally {
|
||||
setMoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuildIndex(item: KnowledgeDocumentListItem) {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
|
||||
try {
|
||||
await buildKnowledgeDocumentIndex(item.id);
|
||||
toast.success(t("knowledge.documentIndexRebuilt", { title: item.title }));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.documentIndexRebuildFailed"));
|
||||
} finally {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!knowledgeBaseId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<FileTextIcon className="mb-2 size-12 opacity-50" />
|
||||
<p>{t("knowledge.selectKnowledgeBaseForDocuments")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0">
|
||||
<KnowledgeDirectoryPanel
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onSelectDirectory={setSelectedDirectoryId}
|
||||
onChanged={() => void loadData()}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={String(draftFilters.title ?? "")}
|
||||
onChange={(event) => setDraftFilter("title", event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={t("knowledge.searchDocumentTitle")}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28 shrink-0">
|
||||
<OptionCombobox
|
||||
value={String(draftFilters.status ?? "all")}
|
||||
onChange={handleStatusFilterChange}
|
||||
options={statusOptions}
|
||||
placeholder={t("knowledge.allStatus")}
|
||||
searchPlaceholder={t("knowledge.searchStatus")}
|
||||
emptyText={t("knowledge.emptyStatus")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<OptionCombobox
|
||||
value={String(draftFilters.indexStatus ?? "all")}
|
||||
onChange={handleIndexStatusFilterChange}
|
||||
options={indexStatusOptions}
|
||||
placeholder={t("knowledge.allIndexStatus")}
|
||||
searchPlaceholder={t("knowledge.searchStatus")}
|
||||
emptyText={t("knowledge.emptyStatus")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
>
|
||||
<SearchIcon className="size-3.5" />
|
||||
{t("knowledge.query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className={viewMode === "grid" ? "p-2 space-y-1" : "p-2 space-y-0.5"}>
|
||||
{documents.results.length > 0 ? (
|
||||
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
|
||||
<Checkbox
|
||||
checked={allCurrentPageSelected}
|
||||
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
|
||||
aria-label={t("knowledge.selectCurrentPage")}
|
||||
/>
|
||||
<span>{t("knowledge.selectCurrentPage")}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{documents.results.map((item) => (
|
||||
viewMode === "grid" ? (
|
||||
<ContextMenu
|
||||
key={item.id}
|
||||
onOpenChange={(open) => setContextMenuDocumentId(open ? item.id : null)}
|
||||
>
|
||||
<ContextMenuTrigger className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background p-3 transition-colors hover:bg-accent w-full cursor-pointer",
|
||||
contextMenuDocumentId === item.id && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => openDetailDialog(item)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<SelectionCheckbox
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
|
||||
aria-label={t("knowledge.selectItem", { name: item.title })}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
{/* <FileTextIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
{renderIndexStatusBadge(item, t)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{item.createUserName || "-"}</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
<span className={cn(item.indexStatus === KnowledgeDocumentIndexStatus.Failed && "text-destructive")}>
|
||||
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
|
||||
? t("knowledge.indexedAt", { time: formatDateTime(item.indexedAt) })
|
||||
: getIndexStatusLabel(item.indexStatus, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
/>
|
||||
}
|
||||
aria-label={t("knowledge.moreActions", { name: item.title })}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32 min-w-32">
|
||||
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
|
||||
<EyeIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.view")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||
<FolderInputIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
|
||||
<WrenchIcon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem onClick={() => openDetailDialog(item)}>
|
||||
<EyeIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.view")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openEditDialog(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||
<FolderInputIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
|
||||
<WrenchIcon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
variant="destructive"
|
||||
disabled={actionLoadingMap[item.id]?.delete}
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<ContextMenu
|
||||
key={item.id}
|
||||
onOpenChange={(open) => setContextMenuDocumentId(open ? item.id : null)}
|
||||
>
|
||||
<ContextMenuTrigger className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full cursor-pointer",
|
||||
contextMenuDocumentId === item.id && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => openDetailDialog(item)}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
|
||||
aria-label={t("knowledge.selectItem", { name: item.title })}
|
||||
/>
|
||||
{/* <FileTextIcon className="size-4 shrink-0 text-muted-foreground" /> */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||
{renderIndexStatusBadge(item, t)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
|
||||
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
|
||||
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
/>
|
||||
}
|
||||
aria-label={t("knowledge.moreActions", { name: item.title })}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32 min-w-32">
|
||||
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
|
||||
<EyeIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.view")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||
<FolderInputIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
|
||||
<WrenchIcon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem onClick={() => openDetailDialog(item)}>
|
||||
<EyeIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.view")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openEditDialog(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||
<FolderInputIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
|
||||
<WrenchIcon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
variant="destructive"
|
||||
disabled={actionLoadingMap[item.id]?.delete}
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
))}
|
||||
{!loading && documents.results.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("knowledge.emptyDocuments")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{selectedIds.length > 0 ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
|
||||
<span className="text-muted-foreground">
|
||||
{t("knowledge.selectedCount", { count: selectedIds.length })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => openMoveDialog(selectedIds)}
|
||||
disabled={saving || moving}
|
||||
>
|
||||
<FolderInputIcon className="size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-destructive hover:text-destructive"
|
||||
onClick={() => void handleBatchDelete()}
|
||||
disabled={saving || moving}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
{t("knowledge.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={saving || moving}
|
||||
aria-label={t("knowledge.clearSelection")}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-t px-6 py-3">
|
||||
<ListPagination
|
||||
page={documents.page.page}
|
||||
limit={documents.page.limit}
|
||||
total={documents.page.total}
|
||||
loading={loading}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentEditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving}
|
||||
itemId={editingItem?.id ?? null}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
initialDirectoryId={selectedDirectoryId ?? 0}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<KnowledgeContentDetailDialog
|
||||
open={detailOpen}
|
||||
type="document"
|
||||
itemId={detailItemId}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open);
|
||||
if (!open) {
|
||||
setDetailItemId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<KnowledgeBulkMoveDialog
|
||||
open={bulkMoveOpen}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
moving={moving}
|
||||
selectedCount={moveTargetIds.length || selectedIds.length}
|
||||
onOpenChange={(open) => {
|
||||
setBulkMoveOpen(open);
|
||||
if (!open) {
|
||||
setMoveTargetIds([]);
|
||||
}
|
||||
}}
|
||||
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
289
web/app/(dashboard)/dashboard/knowledge/_components/faq-edit.tsx
Normal file
289
web/app/(dashboard)/dashboard/knowledge/_components/faq-edit.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
fetchKnowledgeFAQ,
|
||||
fetchKnowledgeDirectories,
|
||||
type CreateKnowledgeFAQPayload,
|
||||
type KnowledgeDirectory,
|
||||
type KnowledgeFAQ,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
type FAQEditDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
knowledgeBaseId: number | null;
|
||||
initialDirectoryId?: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
|
||||
};
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function createFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
directoryId: z.string().trim(),
|
||||
question: z.string().trim().min(1, t("knowledge.faqQuestionRequired")).max(500, t("knowledge.faqQuestionMax")),
|
||||
answer: z.string().trim().min(1, t("knowledge.faqAnswerRequired")),
|
||||
similarQuestionsText: z.string(),
|
||||
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
|
||||
});
|
||||
}
|
||||
|
||||
type EditForm = {
|
||||
directoryId: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
similarQuestionsText: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
directoryId: "0",
|
||||
question: "",
|
||||
answer: "",
|
||||
similarQuestionsText: "",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
type DirectoryOption = { value: string; label: string };
|
||||
|
||||
function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] {
|
||||
return items.flatMap((item) => [
|
||||
{ value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` },
|
||||
...flattenDirectoryOptions(item.children || [], depth + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildForm(item: KnowledgeFAQ | null, initialDirectoryId = 0): EditForm {
|
||||
if (!item) {
|
||||
return { ...emptyForm, directoryId: String(initialDirectoryId) };
|
||||
}
|
||||
return {
|
||||
directoryId: String(item.directoryId || 0),
|
||||
question: item.question,
|
||||
answer: item.answer,
|
||||
similarQuestionsText: (item.similarQuestions ?? []).join("\n"),
|
||||
remark: item.remark ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
|
||||
return {
|
||||
knowledgeBaseId,
|
||||
directoryId: Number(form.directoryId),
|
||||
question: form.question.trim(),
|
||||
answer: form.answer.trim(),
|
||||
similarQuestions: form.similarQuestionsText
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
remark: form.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function FAQEditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
knowledgeBaseId,
|
||||
initialDirectoryId = 0,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: FAQEditDialogProps) {
|
||||
if (!open || !knowledgeBaseId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FAQEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
initialDirectoryId={initialDirectoryId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FAQEditDialogBodyProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
knowledgeBaseId: number;
|
||||
initialDirectoryId: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
|
||||
};
|
||||
|
||||
function FAQEditDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
knowledgeBaseId,
|
||||
initialDirectoryId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: FAQEditDialogBodyProps) {
|
||||
const t = useI18n();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||
const formId = "knowledge-faq-edit-form";
|
||||
const formSchema = useMemo(() => createFormSchema(t), [t]);
|
||||
const resolver = useMemo(
|
||||
() => zodResolver(formSchema) as Resolver<EditForm>,
|
||||
[formSchema],
|
||||
);
|
||||
const form = useForm<EditForm>({
|
||||
resolver,
|
||||
defaultValues: emptyForm,
|
||||
});
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
const directoryOptions = useMemo(
|
||||
() => [
|
||||
{ value: "0", label: t("knowledge.rootContent") },
|
||||
...flattenDirectoryOptions(directories),
|
||||
],
|
||||
[directories, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(buildForm(null, initialDirectoryId));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchKnowledgeFAQ(itemId);
|
||||
reset(buildForm(data));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
void loadDetail();
|
||||
}
|
||||
}, [itemId, initialDirectoryId, open, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const data = await fetchKnowledgeDirectories(knowledgeBaseId);
|
||||
if (!cancelled) {
|
||||
setDirectories(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load knowledge directories:", error);
|
||||
}
|
||||
}
|
||||
void loadDirectories();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [knowledgeBaseId, open]);
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
await onSubmit(buildPayload(values, knowledgeBaseId));
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={itemId ? t("knowledge.editFAQTitle") : t("knowledge.createFAQTitle")}
|
||||
allowFullscreen
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("knowledge.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">{t("knowledge.loading")}</div>
|
||||
) : (
|
||||
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<Field data-invalid={!!errors.directoryId}>
|
||||
<FieldLabel>{t("knowledge.directory")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<OptionCombobox
|
||||
value={form.watch("directoryId")}
|
||||
onChange={(value) => form.setValue("directoryId", value ?? "0", { shouldDirty: true })}
|
||||
options={directoryOptions}
|
||||
placeholder={t("knowledge.selectDirectory")}
|
||||
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||
emptyText={t("knowledge.emptyDirectory")}
|
||||
/>
|
||||
<FieldError errors={[errors.directoryId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.question}>
|
||||
<FieldLabel htmlFor="faq-question">{t("knowledge.standardQuestion")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="faq-question" placeholder={t("knowledge.questionPlaceholder")} {...register("question")} />
|
||||
<FieldError errors={[errors.question]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.answer}>
|
||||
<FieldLabel htmlFor="faq-answer">{t("knowledge.answer")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea id="faq-answer" rows={8} placeholder={t("knowledge.answerPlaceholder")} {...register("answer")} />
|
||||
<FieldError errors={[errors.answer]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="faq-similar-questions">{t("knowledge.similarQuestions")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="faq-similar-questions"
|
||||
rows={5}
|
||||
placeholder={t("knowledge.similarQuestionsPlaceholder")}
|
||||
{...register("similarQuestionsText")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.remark}>
|
||||
<FieldLabel htmlFor="faq-remark">{t("knowledge.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea id="faq-remark" rows={3} placeholder={t("knowledge.remarkPlaceholder")} {...register("remark")} />
|
||||
<FieldError errors={[errors.remark]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client"
|
||||
|
||||
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
downloadKnowledgeFAQImportTemplate,
|
||||
importKnowledgeFAQs,
|
||||
type KnowledgeFAQImportMode,
|
||||
type KnowledgeFAQImportResult,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type FAQImportDialogProps = {
|
||||
open: boolean
|
||||
knowledgeBaseId: number | null
|
||||
importing: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onImportingChange: (importing: boolean) => void
|
||||
onImported: () => Promise<void>
|
||||
}
|
||||
|
||||
export function FAQImportDialog({
|
||||
open,
|
||||
knowledgeBaseId,
|
||||
importing,
|
||||
onOpenChange,
|
||||
onImportingChange,
|
||||
onImported,
|
||||
}: FAQImportDialogProps) {
|
||||
const t = useI18n()
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [mode, setMode] = useState<KnowledgeFAQImportMode>("append")
|
||||
const [result, setResult] = useState<KnowledgeFAQImportResult | null>(null)
|
||||
|
||||
const modeOptions = useMemo(
|
||||
() => [
|
||||
{ value: "append", label: t("knowledge.importModeAppend") },
|
||||
{ value: "overwrite", label: t("knowledge.importModeOverwrite") },
|
||||
],
|
||||
[t],
|
||||
)
|
||||
|
||||
function resetState() {
|
||||
setFile(null)
|
||||
setMode("append")
|
||||
setResult(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const nextFile = event.target.files?.[0] ?? null
|
||||
setResult(null)
|
||||
if (!nextFile) {
|
||||
setFile(null)
|
||||
return
|
||||
}
|
||||
if (!nextFile.name.toLowerCase().endsWith(".xlsx")) {
|
||||
setFile(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
toast.error(t("knowledge.importXlsxOnly"))
|
||||
return
|
||||
}
|
||||
setFile(nextFile)
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await downloadKnowledgeFAQImportTemplate()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!knowledgeBaseId || !file || importing) {
|
||||
return
|
||||
}
|
||||
|
||||
onImportingChange(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const data = await importKnowledgeFAQs({
|
||||
knowledgeBaseId,
|
||||
mode,
|
||||
file,
|
||||
})
|
||||
setResult(data)
|
||||
await onImported()
|
||||
toast.success(
|
||||
t("knowledge.importFAQResultToast", {
|
||||
created: data.created,
|
||||
updated: data.updated,
|
||||
skipped: data.skipped,
|
||||
failed: data.failed,
|
||||
}),
|
||||
)
|
||||
if (data.failed === 0) {
|
||||
resetState()
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.importFailed"))
|
||||
} finally {
|
||||
onImportingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !importing) {
|
||||
resetState()
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
title={t("knowledge.importFAQTitle")}
|
||||
description={t("knowledge.importFAQDescription")}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => void handleDownloadTemplate()}>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t("knowledge.downloadTemplate")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
|
||||
{t("knowledge.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleImport()} disabled={importing || !file}>
|
||||
{importing ? t("knowledge.importing") : t("knowledge.startImport")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>{t("knowledge.importMode")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<OptionCombobox
|
||||
value={mode}
|
||||
options={modeOptions}
|
||||
placeholder={t("knowledge.selectImportMode")}
|
||||
searchPlaceholder={t("knowledge.searchImportMode")}
|
||||
emptyText={t("knowledge.emptyImportMode")}
|
||||
disabled={importing}
|
||||
onChange={(value) => setMode(value as KnowledgeFAQImportMode)}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{mode === "overwrite"
|
||||
? t("knowledge.importModeOverwriteDescription")
|
||||
: t("knowledge.importModeAppendDescription")}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="faq-import-file"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
disabled={importing}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={importing}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileUpIcon className="size-4" />
|
||||
{t("knowledge.chooseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldDescription>{t("knowledge.importFileDescription")}</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{file ? (
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||
{t("knowledge.currentFile", { name: file.name })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||
<InfoIcon className="size-4" />
|
||||
{t("knowledge.importHint")}
|
||||
</div>
|
||||
<div>{t("knowledge.importXlsxHint")}</div>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">
|
||||
{t("knowledge.importResult")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 p-4 text-sm sm:grid-cols-5">
|
||||
<ImportMetric label={t("knowledge.importTotal")} value={result.total} />
|
||||
<ImportMetric label={t("knowledge.importCreated")} value={result.created} />
|
||||
<ImportMetric label={t("knowledge.importUpdated")} value={result.updated} />
|
||||
<ImportMetric label={t("knowledge.importSkipped")} value={result.skipped} />
|
||||
<ImportMetric label={t("knowledge.importFailedCount")} value={result.failed} />
|
||||
</div>
|
||||
{result.errors.length > 0 ? (
|
||||
<ScrollArea className="max-h-64 border-t">
|
||||
<ul className="divide-y text-sm">
|
||||
{result.errors.map((item, index) => (
|
||||
<li key={`${item.row}-${index}`} className="px-4 py-2">
|
||||
{t("knowledge.importRowFailed", {
|
||||
row: item.row,
|
||||
message: item.message,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportMetric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
653
web/app/(dashboard)/dashboard/knowledge/_components/faq-list.tsx
Normal file
653
web/app/(dashboard)/dashboard/knowledge/_components/faq-list.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
EyeIcon,
|
||||
FolderInputIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useConfirm } from "@/components/confirm-provider";
|
||||
import {
|
||||
useDashboardPagedList,
|
||||
type DashboardPagedListFilter,
|
||||
} from "@/components/dashboard/list";
|
||||
import { ListPagination } from "@/components/list-pagination";
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
batchDeleteKnowledgeFAQs,
|
||||
batchMoveKnowledgeFAQs,
|
||||
buildKnowledgeFAQIndex,
|
||||
createKnowledgeFAQ,
|
||||
deleteKnowledgeFAQ,
|
||||
fetchKnowledgeFAQs,
|
||||
updateKnowledgeFAQ,
|
||||
type CreateKnowledgeFAQPayload,
|
||||
type KnowledgeFAQ,
|
||||
} from "@/lib/api/admin";
|
||||
import { KnowledgeDocumentIndexStatus } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { FAQEditDialog } from "./faq-edit";
|
||||
import { FAQImportDialog } from "./faq-import-dialog";
|
||||
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
|
||||
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
|
||||
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
|
||||
import { SelectionCheckbox } from "./selection-checkbox";
|
||||
|
||||
type FAQListProps = {
|
||||
knowledgeBaseId: number | null;
|
||||
onActionStateChange?: (state: FAQListActionState) => void;
|
||||
};
|
||||
|
||||
export type FAQListActionState = {
|
||||
onRefresh: () => void;
|
||||
onCreate: () => void;
|
||||
onImport: () => void;
|
||||
loading: boolean;
|
||||
importing: boolean;
|
||||
};
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function getIndexStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allIndexStatus") },
|
||||
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
|
||||
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
|
||||
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
|
||||
];
|
||||
}
|
||||
|
||||
function getIndexStatusLabel(status: string, t: TFunction) {
|
||||
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
|
||||
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
|
||||
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
|
||||
return status || "-";
|
||||
}
|
||||
|
||||
function getIndexStatusBadgeVariant(status: string) {
|
||||
switch (status) {
|
||||
case KnowledgeDocumentIndexStatus.Indexed:
|
||||
return "secondary" as const;
|
||||
case KnowledgeDocumentIndexStatus.Failed:
|
||||
return "destructive" as const;
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
|
||||
const badge = (
|
||||
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
|
||||
{getIndexStatusLabel(item.indexStatus, t)}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (
|
||||
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
|
||||
!item.indexError
|
||||
) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{item.indexError}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function FAQList({
|
||||
knowledgeBaseId,
|
||||
onActionStateChange,
|
||||
}: FAQListProps) {
|
||||
const t = useI18n();
|
||||
const confirm = useConfirm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [moveTargetIds, setMoveTargetIds] = useState<number[]>([]);
|
||||
const [contextMenuFAQId, setContextMenuFAQId] = useState<number | null>(null);
|
||||
const [detailItemId, setDetailItemId] = useState<number | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<KnowledgeFAQ | null>(null);
|
||||
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
|
||||
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDirectoryId(null);
|
||||
setSelectedIds([]);
|
||||
}, [knowledgeBaseId]);
|
||||
|
||||
const filters = useMemo<DashboardPagedListFilter[]>(() => [
|
||||
{
|
||||
name: "question",
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
name: "indexStatus",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
},
|
||||
], []);
|
||||
|
||||
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
|
||||
return fetchKnowledgeFAQs({
|
||||
knowledgeBaseId: knowledgeBaseId ?? 0,
|
||||
directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId,
|
||||
question: typeof query.question === "string" ? query.question : undefined,
|
||||
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
|
||||
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
|
||||
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
|
||||
});
|
||||
}, [knowledgeBaseId, selectedDirectoryId]);
|
||||
|
||||
const {
|
||||
draftFilters,
|
||||
setDraftFilter,
|
||||
applyFilter,
|
||||
applyFilters,
|
||||
loading,
|
||||
result: faqs,
|
||||
loadData,
|
||||
handlePageChange,
|
||||
handleLimitChange,
|
||||
} = useDashboardPagedList<KnowledgeFAQ>({
|
||||
filters,
|
||||
fetchList,
|
||||
enabled: Boolean(knowledgeBaseId),
|
||||
reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`,
|
||||
loadFailed: t("knowledge.loadFAQFailed"),
|
||||
});
|
||||
|
||||
const currentPageIds = useMemo(
|
||||
() => faqs.results.map((item) => item.id),
|
||||
[faqs.results],
|
||||
);
|
||||
const currentPageSelectedCount = useMemo(
|
||||
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
|
||||
[currentPageIds, selectedIds],
|
||||
);
|
||||
const allCurrentPageSelected =
|
||||
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
|
||||
}, [currentPageIds]);
|
||||
|
||||
const openCreateDialog = useCallback(() => {
|
||||
setEditingItem(null);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onActionStateChange?.({
|
||||
onRefresh: () => void loadData(),
|
||||
onCreate: openCreateDialog,
|
||||
onImport: () => setImportDialogOpen(true),
|
||||
loading,
|
||||
importing,
|
||||
});
|
||||
}, [loadData, loading, importing, onActionStateChange, openCreateDialog]);
|
||||
|
||||
function handleIndexStatusFilterChange(value: string | null) {
|
||||
applyFilter("indexStatus", value ?? "all");
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function toggleSelected(id: number, checked: boolean) {
|
||||
setSelectedIds((prev) => {
|
||||
if (checked) {
|
||||
return prev.includes(id) ? prev : [...prev, id];
|
||||
}
|
||||
return prev.filter((currentId) => currentId !== id);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCurrentPage(checked: boolean) {
|
||||
setSelectedIds(checked ? currentPageIds : []);
|
||||
}
|
||||
|
||||
function openMoveDialog(ids: number[]) {
|
||||
setMoveTargetIds(ids);
|
||||
setBulkMoveOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(item: KnowledgeFAQ) {
|
||||
setEditingItem(item);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openDetailDialog(item: KnowledgeFAQ) {
|
||||
setDetailItemId(item.id);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(open: boolean) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
setEditingItem(null);
|
||||
}
|
||||
setDialogOpen(open);
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: CreateKnowledgeFAQPayload) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateKnowledgeFAQ({ id: editingItem.id, ...payload });
|
||||
toast.success(t("knowledge.faqSaved"));
|
||||
} else {
|
||||
await createKnowledgeFAQ(payload);
|
||||
toast.success(t("knowledge.faqSaved"));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setEditingItem(null);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: KnowledgeFAQ) {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
|
||||
try {
|
||||
await deleteKnowledgeFAQ(item.id);
|
||||
toast.success(t("knowledge.faqDeleted"));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
|
||||
} finally {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirm({
|
||||
title: t("knowledge.batchDelete"),
|
||||
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
|
||||
confirmText: t("knowledge.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await batchDeleteKnowledgeFAQs(selectedIds);
|
||||
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
|
||||
setSelectedIds([]);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchMove(directoryId: number) {
|
||||
const ids = moveTargetIds.length > 0 ? moveTargetIds : selectedIds;
|
||||
if (!knowledgeBaseId || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
setMoving(true);
|
||||
try {
|
||||
await batchMoveKnowledgeFAQs({
|
||||
knowledgeBaseId,
|
||||
directoryId,
|
||||
ids,
|
||||
});
|
||||
toast.success(t("knowledge.batchMoved", { count: ids.length }));
|
||||
setBulkMoveOpen(false);
|
||||
setMoveTargetIds([]);
|
||||
setSelectedIds([]);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
|
||||
} finally {
|
||||
setMoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuildIndex(item: KnowledgeFAQ) {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
|
||||
try {
|
||||
await buildKnowledgeFAQIndex(item.id);
|
||||
toast.success(t("knowledge.faqIndexRebuilt"));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
|
||||
} finally {
|
||||
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!knowledgeBaseId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("knowledge.selectFAQBase")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0">
|
||||
<KnowledgeDirectoryPanel
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onSelectDirectory={setSelectedDirectoryId}
|
||||
onChanged={() => void loadData()}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={String(draftFilters.question ?? "")}
|
||||
onChange={(event) => setDraftFilter("question", event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={t("knowledge.searchFAQ")}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<OptionCombobox
|
||||
value={String(draftFilters.indexStatus ?? "all")}
|
||||
onChange={handleIndexStatusFilterChange}
|
||||
options={indexStatusOptions}
|
||||
placeholder={t("knowledge.allIndexStatus")}
|
||||
searchPlaceholder={t("knowledge.searchStatus")}
|
||||
emptyText={t("knowledge.emptyStatus")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
>
|
||||
<SearchIcon className="size-3.5" />
|
||||
{t("knowledge.query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{faqs.results.length > 0 ? (
|
||||
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
|
||||
<Checkbox
|
||||
checked={allCurrentPageSelected}
|
||||
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
|
||||
aria-label={t("knowledge.selectCurrentPage")}
|
||||
/>
|
||||
<span>{t("knowledge.selectCurrentPage")}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{faqs.results.map((item) => (
|
||||
<ContextMenu
|
||||
key={item.id}
|
||||
onOpenChange={(open) => setContextMenuFAQId(open ? item.id : null)}
|
||||
>
|
||||
<ContextMenuTrigger className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full cursor-pointer",
|
||||
contextMenuFAQId === item.id && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => openDetailDialog(item)}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
|
||||
aria-label={t("knowledge.selectItem", { name: item.question })}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-sm font-medium">{item.question}</div>
|
||||
{renderIndexStatusBadge(item, t)}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||
{item.answer}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 text-xs text-muted-foreground sm:block">
|
||||
{formatDateTime(item.updatedAt)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
render={<Button variant="ghost" size="icon" className="size-6" />}
|
||||
aria-label={t("knowledge.moreActions", { name: item.question })}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32 min-w-32">
|
||||
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
|
||||
<EyeIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.view")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||
<FolderInputIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
|
||||
<WrenchIcon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem onClick={() => openDetailDialog(item)}>
|
||||
<EyeIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.view")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openEditDialog(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||
<FolderInputIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
|
||||
<WrenchIcon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
variant="destructive"
|
||||
disabled={actionLoadingMap[item.id]?.delete}
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
{!loading && faqs.results.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("knowledge.emptyFAQ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{selectedIds.length > 0 ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
|
||||
<span className="text-muted-foreground">
|
||||
{t("knowledge.selectedCount", { count: selectedIds.length })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => openMoveDialog(selectedIds)}
|
||||
disabled={saving || moving}
|
||||
>
|
||||
<FolderInputIcon className="size-3.5" />
|
||||
{t("knowledge.move")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-destructive hover:text-destructive"
|
||||
onClick={() => void handleBatchDelete()}
|
||||
disabled={saving || moving}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
{t("knowledge.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={saving || moving}
|
||||
aria-label={t("knowledge.clearSelection")}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-t px-6 py-3">
|
||||
<ListPagination
|
||||
page={faqs.page.page}
|
||||
limit={faqs.page.limit}
|
||||
total={faqs.page.total}
|
||||
loading={loading}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FAQEditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving}
|
||||
itemId={editingItem?.id ?? null}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
initialDirectoryId={selectedDirectoryId ?? 0}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<KnowledgeContentDetailDialog
|
||||
open={detailOpen}
|
||||
type="faq"
|
||||
itemId={detailItemId}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open);
|
||||
if (!open) {
|
||||
setDetailItemId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FAQImportDialog
|
||||
open={importDialogOpen}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
importing={importing}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
onImportingChange={setImporting}
|
||||
onImported={async () => {
|
||||
await loadData();
|
||||
}}
|
||||
/>
|
||||
<KnowledgeBulkMoveDialog
|
||||
open={bulkMoveOpen}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
moving={moving}
|
||||
selectedCount={moveTargetIds.length || selectedIds.length}
|
||||
onOpenChange={(open) => {
|
||||
setBulkMoveOpen(open);
|
||||
if (!open) {
|
||||
setMoveTargetIds([]);
|
||||
}
|
||||
}}
|
||||
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
fetchKnowledgeBase,
|
||||
type CreateKnowledgeBasePayload,
|
||||
type KnowledgeBase,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import {
|
||||
KnowledgeAnswerMode,
|
||||
KnowledgeBaseType,
|
||||
KnowledgeChunkProvider,
|
||||
} from "@/lib/generated/enums";
|
||||
|
||||
type KnowledgeBaseEditDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
|
||||
};
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
knowledgeType: KnowledgeBaseType.Document,
|
||||
defaultTopK: "5",
|
||||
defaultScoreThreshold: "0.2",
|
||||
defaultRerankLimit: "10",
|
||||
chunkProvider: KnowledgeChunkProvider.Structured,
|
||||
chunkTargetTokens: "300",
|
||||
chunkMaxTokens: "400",
|
||||
chunkOverlapTokens: "40",
|
||||
answerMode: String(KnowledgeAnswerMode.Strict),
|
||||
remark: "",
|
||||
};
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function createKnowledgeBaseFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
name: z.string().trim().min(1, t("knowledge.nameRequired")).max(100, t("knowledge.nameMax")),
|
||||
description: z.string().trim().max(500, t("knowledge.descriptionMax")),
|
||||
knowledgeType: z.string().trim().min(1, t("knowledge.typeRequired")),
|
||||
defaultTopK: z.string().trim().min(1, t("knowledge.topKRequired")),
|
||||
defaultScoreThreshold: z.string().trim().min(1, t("knowledge.scoreRequired")),
|
||||
defaultRerankLimit: z.string().trim().min(1, t("knowledge.rerankRequired")),
|
||||
chunkProvider: z.string().trim().min(1, t("knowledge.chunkProviderRequired")),
|
||||
chunkTargetTokens: z.string().trim().min(1, t("knowledge.targetTokensRequired")),
|
||||
chunkMaxTokens: z.string().trim().min(1, t("knowledge.maxTokensRequired")),
|
||||
chunkOverlapTokens: z.string().trim().min(1, t("knowledge.overlapTokensRequired")),
|
||||
answerMode: z.string().trim().min(1, t("knowledge.answerModeRequired")),
|
||||
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
|
||||
});
|
||||
}
|
||||
|
||||
type EditForm = {
|
||||
name: string;
|
||||
description: string;
|
||||
knowledgeType: string;
|
||||
defaultTopK: string;
|
||||
defaultScoreThreshold: string;
|
||||
defaultRerankLimit: string;
|
||||
chunkProvider: string;
|
||||
chunkTargetTokens: string;
|
||||
chunkMaxTokens: string;
|
||||
chunkOverlapTokens: string;
|
||||
answerMode: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function getKnowledgeTypeOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: KnowledgeBaseType.Document, label: t("knowledge.typeDocument") },
|
||||
{ value: KnowledgeBaseType.FAQ, label: t("knowledge.typeFAQ") },
|
||||
];
|
||||
}
|
||||
|
||||
function getChunkProviderOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: KnowledgeChunkProvider.Fixed, label: t("knowledge.chunkFixed") },
|
||||
{ value: KnowledgeChunkProvider.Structured, label: t("knowledge.chunkStructured") },
|
||||
{ value: KnowledgeChunkProvider.Semantic, label: t("knowledge.chunkSemantic") },
|
||||
];
|
||||
}
|
||||
|
||||
function getAnswerModeOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: String(KnowledgeAnswerMode.Strict), label: t("knowledge.answerStrict") },
|
||||
{ value: String(KnowledgeAnswerMode.Assist), label: t("knowledge.answerAssist") },
|
||||
];
|
||||
}
|
||||
|
||||
function buildForm(item: KnowledgeBase | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm;
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
description: item.description || "",
|
||||
knowledgeType: item.knowledgeType || KnowledgeBaseType.Document,
|
||||
defaultTopK: String(item.defaultTopK),
|
||||
defaultScoreThreshold: String(item.defaultScoreThreshold),
|
||||
defaultRerankLimit: String(item.defaultRerankLimit),
|
||||
chunkProvider: item.chunkProvider,
|
||||
chunkTargetTokens: String(item.chunkTargetTokens),
|
||||
chunkMaxTokens: String(item.chunkMaxTokens),
|
||||
chunkOverlapTokens: String(item.chunkOverlapTokens),
|
||||
answerMode: String(item.answerMode),
|
||||
remark: item.remark || "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateKnowledgeBasePayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
knowledgeType: form.knowledgeType,
|
||||
defaultTopK: Number(form.defaultTopK),
|
||||
defaultScoreThreshold: Number(form.defaultScoreThreshold),
|
||||
defaultRerankLimit: Number(form.defaultRerankLimit),
|
||||
chunkProvider: form.chunkProvider,
|
||||
chunkTargetTokens: Number(form.chunkTargetTokens),
|
||||
chunkMaxTokens: Number(form.chunkMaxTokens),
|
||||
chunkOverlapTokens: Number(form.chunkOverlapTokens),
|
||||
answerMode: Number(form.answerMode),
|
||||
remark: form.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: KnowledgeBaseEditDialogProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<KnowledgeBaseFormDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
itemId={itemId}
|
||||
saving={saving}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type KnowledgeBaseFormDialogBodyProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
|
||||
};
|
||||
|
||||
function KnowledgeBaseFormDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: KnowledgeBaseFormDialogBodyProps) {
|
||||
const t = useI18n();
|
||||
const formId = "knowledge-base-edit-form";
|
||||
const [loading, setLoading] = useState(false);
|
||||
const knowledgeBaseFormSchema = useMemo(() => createKnowledgeBaseFormSchema(t), [t]);
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(knowledgeBaseFormSchema) as Resolver<EditForm>,
|
||||
[knowledgeBaseFormSchema],
|
||||
);
|
||||
const knowledgeTypeOptions = useMemo(() => getKnowledgeTypeOptions(t), [t]);
|
||||
const chunkProviderOptions = useMemo(() => getChunkProviderOptions(t), [t]);
|
||||
const answerModeOptions = useMemo(() => getAnswerModeOptions(t), [t]);
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: buildForm(null),
|
||||
});
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
const knowledgeType = watch("knowledgeType");
|
||||
const isFAQKnowledgeBase = knowledgeType === KnowledgeBaseType.FAQ;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemId === null) {
|
||||
reset(buildForm(null));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchKnowledgeBase(itemId!);
|
||||
if (!cancelled) {
|
||||
reset(buildForm(data));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load knowledge base:", error);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadItem();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, itemId, reset]);
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
const payload = buildPayload(values);
|
||||
await onSubmit(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={itemId ? t("knowledge.editBaseTitle") : t("knowledge.createBaseTitle")}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t("knowledge.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">{t("knowledge.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field data-invalid={!!errors.knowledgeType}>
|
||||
<FieldLabel htmlFor="kb-knowledge-type">{t("knowledge.knowledgeType")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="knowledgeType"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={knowledgeTypeOptions}
|
||||
placeholder={t("knowledge.selectKnowledgeType")}
|
||||
searchPlaceholder={t("knowledge.searchKnowledgeType")}
|
||||
emptyText={t("knowledge.emptyKnowledgeType")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.knowledgeType]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="kb-name">{t("knowledge.name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="kb-name"
|
||||
placeholder={t("knowledge.namePlaceholder")}
|
||||
aria-invalid={!!errors.name}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.description}>
|
||||
<FieldLabel htmlFor="kb-description">{t("knowledge.description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="kb-description"
|
||||
placeholder={t("knowledge.descriptionPlaceholder")}
|
||||
rows={3}
|
||||
aria-invalid={!!errors.description}
|
||||
{...register("description")}
|
||||
/>
|
||||
<FieldError errors={[errors.description]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!isFAQKnowledgeBase ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Field data-invalid={!!errors.chunkProvider}>
|
||||
<FieldLabel htmlFor="kb-chunk-provider">{t("knowledge.chunkProvider")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="chunkProvider"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={chunkProviderOptions}
|
||||
placeholder={t("knowledge.selectChunkProvider")}
|
||||
searchPlaceholder={t("knowledge.searchChunkProvider")}
|
||||
emptyText={t("knowledge.emptyChunkProvider")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.chunkProvider]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.chunkTargetTokens}>
|
||||
<FieldLabel htmlFor="kb-chunk-target-tokens">
|
||||
{t("knowledge.targetToken")}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="kb-chunk-target-tokens"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2000"
|
||||
aria-invalid={!!errors.chunkTargetTokens}
|
||||
{...register("chunkTargetTokens")}
|
||||
/>
|
||||
<FieldError errors={[errors.chunkTargetTokens]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.chunkMaxTokens}>
|
||||
<FieldLabel htmlFor="kb-chunk-max-tokens">{t("knowledge.maxToken")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="kb-chunk-max-tokens"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4000"
|
||||
aria-invalid={!!errors.chunkMaxTokens}
|
||||
{...register("chunkMaxTokens")}
|
||||
/>
|
||||
<FieldError errors={[errors.chunkMaxTokens]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.chunkOverlapTokens}>
|
||||
<FieldLabel htmlFor="kb-chunk-overlap-tokens">
|
||||
{t("knowledge.overlapToken")}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="kb-chunk-overlap-tokens"
|
||||
type="number"
|
||||
min="0"
|
||||
max="500"
|
||||
aria-invalid={!!errors.chunkOverlapTokens}
|
||||
{...register("chunkOverlapTokens")}
|
||||
/>
|
||||
<FieldError errors={[errors.chunkOverlapTokens]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Field data-invalid={!!errors.defaultTopK}>
|
||||
<FieldLabel htmlFor="kb-default-top-k">{t("knowledge.defaultTopK")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="kb-default-top-k"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
aria-invalid={!!errors.defaultTopK}
|
||||
{...register("defaultTopK")}
|
||||
/>
|
||||
<FieldError errors={[errors.defaultTopK]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.defaultScoreThreshold}>
|
||||
<FieldLabel htmlFor="kb-default-score-threshold">
|
||||
{t("knowledge.defaultScoreThreshold")}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="kb-default-score-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
aria-invalid={!!errors.defaultScoreThreshold}
|
||||
{...register("defaultScoreThreshold")}
|
||||
/>
|
||||
<FieldError errors={[errors.defaultScoreThreshold]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.defaultRerankLimit}>
|
||||
<FieldLabel htmlFor="kb-default-rerank-limit">
|
||||
{t("knowledge.defaultRerankLimit")}
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="kb-default-rerank-limit"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
aria-invalid={!!errors.defaultRerankLimit}
|
||||
{...register("defaultRerankLimit")}
|
||||
/>
|
||||
<FieldError errors={[errors.defaultRerankLimit]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.answerMode}>
|
||||
<FieldLabel htmlFor="kb-answer-mode">{t("knowledge.answerMode")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="answerMode"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
options={answerModeOptions}
|
||||
placeholder={t("knowledge.selectAnswerMode")}
|
||||
searchPlaceholder={t("knowledge.searchAnswerMode")}
|
||||
emptyText={t("knowledge.emptyAnswerMode")}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.answerMode]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={!!errors.remark}>
|
||||
<FieldLabel htmlFor="kb-remark">{t("knowledge.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="kb-remark"
|
||||
placeholder={t("knowledge.remarkPlaceholder")}
|
||||
rows={2}
|
||||
aria-invalid={!!errors.remark}
|
||||
{...register("remark")}
|
||||
/>
|
||||
<FieldError errors={[errors.remark]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
fetchKnowledgeDirectories,
|
||||
type KnowledgeDirectory,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
type KnowledgeBulkMoveDialogProps = {
|
||||
open: boolean;
|
||||
knowledgeBaseId: number;
|
||||
moving: boolean;
|
||||
selectedCount: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (directoryId: number) => void;
|
||||
};
|
||||
|
||||
function flattenDirectoryOptions(
|
||||
directories: KnowledgeDirectory[],
|
||||
): Array<{ value: string; label: string }> {
|
||||
const result: Array<{ value: string; label: string }> = [];
|
||||
for (const item of directories) {
|
||||
result.push({ value: String(item.id), label: item.name });
|
||||
for (const child of item.children ?? []) {
|
||||
result.push({ value: String(child.id), label: `${item.name} / ${child.name}` });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function KnowledgeBulkMoveDialog({
|
||||
open,
|
||||
knowledgeBaseId,
|
||||
moving,
|
||||
selectedCount,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: KnowledgeBulkMoveDialogProps) {
|
||||
const t = useI18n();
|
||||
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||
const [targetDirectoryId, setTargetDirectoryId] = useState("0");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setTargetDirectoryId("0");
|
||||
fetchKnowledgeDirectories(knowledgeBaseId)
|
||||
.then(setDirectories)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setDirectories([]);
|
||||
});
|
||||
}, [knowledgeBaseId, open]);
|
||||
|
||||
const directoryOptions = useMemo(() => [
|
||||
{ value: "0", label: t("knowledge.rootContent") },
|
||||
...flattenDirectoryOptions(directories),
|
||||
], [directories, t]);
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("knowledge.batchMove")}
|
||||
description={t("knowledge.batchMoveDescription", { count: selectedCount })}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={moving}
|
||||
>
|
||||
{t("knowledge.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onSubmit(Number(targetDirectoryId))}
|
||||
disabled={moving || selectedCount <= 0}
|
||||
>
|
||||
{moving ? t("knowledge.moving") : t("knowledge.move")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("knowledge.targetDirectory")}</div>
|
||||
<OptionCombobox
|
||||
value={targetDirectoryId}
|
||||
onChange={(value) => setTargetDirectoryId(value ?? "0")}
|
||||
options={directoryOptions}
|
||||
placeholder={t("knowledge.selectDirectory")}
|
||||
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||
emptyText={t("knowledge.emptyDirectory")}
|
||||
/>
|
||||
</div>
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { SafeRichHTML } from "@/components/safe-rich-html";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
fetchKnowledgeDocument,
|
||||
fetchKnowledgeFAQ,
|
||||
type KnowledgeDocument,
|
||||
type KnowledgeFAQ,
|
||||
} from "@/lib/api/admin";
|
||||
import { markdownToHtml } from "@/components/content-editor/convert";
|
||||
import { KnowledgeDocumentContentType } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
type KnowledgeContentDetailDialogProps = {
|
||||
open: boolean;
|
||||
type: "document" | "faq";
|
||||
itemId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type DetailFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function DetailField({ label, value }: DetailFieldProps) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="truncate text-sm">{value || "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDirectoryLabel(item: KnowledgeDocument | KnowledgeFAQ, rootLabel: string) {
|
||||
return item.directoryPath || item.directoryName || (item.directoryId === 0 ? rootLabel : "-");
|
||||
}
|
||||
|
||||
function renderDocumentContent(item: KnowledgeDocument) {
|
||||
if (item.contentType === KnowledgeDocumentContentType.Markdown) {
|
||||
return markdownToHtml(item.content || "");
|
||||
}
|
||||
return item.content || "";
|
||||
}
|
||||
|
||||
export function KnowledgeContentDetailDialog({
|
||||
open,
|
||||
type,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
}: KnowledgeContentDetailDialogProps) {
|
||||
const t = useI18n();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [documentDetail, setDocumentDetail] = useState<KnowledgeDocument | null>(null);
|
||||
const [faqDetail, setFAQDetail] = useState<KnowledgeFAQ | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !itemId) {
|
||||
return;
|
||||
}
|
||||
const id = itemId;
|
||||
let cancelled = false;
|
||||
async function loadDetail() {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (type === "document") {
|
||||
const data = await fetchKnowledgeDocument(id);
|
||||
if (!cancelled) {
|
||||
setDocumentDetail(data);
|
||||
setFAQDetail(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await fetchKnowledgeFAQ(id);
|
||||
if (!cancelled) {
|
||||
setFAQDetail(data);
|
||||
setDocumentDetail(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadDetail();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [itemId, open, type]);
|
||||
|
||||
const item = type === "document" ? documentDetail : faqDetail;
|
||||
const title =
|
||||
type === "document"
|
||||
? documentDetail?.title || t("knowledge.viewDocumentTitle")
|
||||
: faqDetail?.question || t("knowledge.viewFAQTitle");
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
size="xl"
|
||||
allowFullscreen
|
||||
closeOnEsc
|
||||
defaultFullscreen
|
||||
footer={
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading || !item ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("knowledge.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<DetailField
|
||||
label={t("knowledge.directory")}
|
||||
value={getDirectoryLabel(item, t("knowledge.rootContent"))}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.status")}</div>
|
||||
<Badge variant="outline">{item.statusName || item.status}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.indexStatus")}</div>
|
||||
<Badge variant="secondary">{item.indexStatusName || item.indexStatus}</Badge>
|
||||
</div>
|
||||
<DetailField
|
||||
label={t("knowledge.createdAt")}
|
||||
value={formatDateTime(item.createdAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("knowledge.updatedAt")}
|
||||
value={formatDateTime(item.updatedAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("knowledge.createUser")}
|
||||
value={item.createUserName || "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("knowledge.updateUser")}
|
||||
value={item.updateUserName || "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("knowledge.indexedAtLabel")}
|
||||
value={item.indexedAt ? formatDateTime(item.indexedAt) : "-"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.indexError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{item.indexError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === "document" && documentDetail ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium">{t("knowledge.content")}</div>
|
||||
<Badge variant="outline">{documentDetail.contentType || "-"}</Badge>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-4">
|
||||
<SafeRichHTML html={renderDocumentContent(documentDetail)} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === "faq" && faqDetail ? (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("knowledge.standardQuestion")}</div>
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-sm">{faqDetail.question}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("knowledge.answer")}</div>
|
||||
<pre className="whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6">
|
||||
{faqDetail.answer || "-"}
|
||||
</pre>
|
||||
</div>
|
||||
{faqDetail.similarQuestions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("knowledge.similarQuestions")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{faqDetail.similarQuestions.map((question) => (
|
||||
<Badge key={question} variant="outline">
|
||||
{question}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{faqDetail.remark ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t("knowledge.remark")}</div>
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-sm">{faqDetail.remark}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
FolderPlusIcon,
|
||||
LayersIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createKnowledgeDirectory,
|
||||
deleteKnowledgeDirectory,
|
||||
fetchKnowledgeDirectories,
|
||||
updateKnowledgeDirectory,
|
||||
updateKnowledgeDirectorySort,
|
||||
type KnowledgeDirectory,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
findDirectoryParentId,
|
||||
moveDirectoryToParent,
|
||||
moveDirectoryWithinParent,
|
||||
} from "./knowledge-directory-sort";
|
||||
|
||||
type KnowledgeDirectoryPanelProps = {
|
||||
knowledgeBaseId: number;
|
||||
selectedDirectoryId: number | null;
|
||||
onSelectDirectory: (directoryId: number | null) => void;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
type DirectoryDialogState = {
|
||||
open: boolean;
|
||||
id: number | null;
|
||||
parentId: number;
|
||||
name: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
type DirectoryOption = { value: string; label: string };
|
||||
|
||||
const DIRECTORY_PANEL_WIDTH_STORAGE_KEY = "knowledge-directory-panel-width";
|
||||
const DIRECTORY_PANEL_MIN_WIDTH = 180;
|
||||
const DIRECTORY_PANEL_MAX_WIDTH = 360;
|
||||
const DIRECTORY_PANEL_DEFAULT_WIDTH = 224;
|
||||
const ROOT_DIRECTORY_DROP_ID = "knowledge-directory-root";
|
||||
|
||||
function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] {
|
||||
return items.map((item) => ({ value: String(item.id), label: item.name }));
|
||||
}
|
||||
|
||||
function collectExpandedIds(items: KnowledgeDirectory[]) {
|
||||
const ids = new Set<number>();
|
||||
const walk = (nodes: KnowledgeDirectory[]) => {
|
||||
for (const node of nodes) {
|
||||
ids.add(node.id);
|
||||
walk(node.children || []);
|
||||
}
|
||||
};
|
||||
walk(items);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function KnowledgeDirectoryPanel({
|
||||
knowledgeBaseId,
|
||||
selectedDirectoryId,
|
||||
onSelectDirectory,
|
||||
onChanged,
|
||||
}: KnowledgeDirectoryPanelProps) {
|
||||
const t = useI18n();
|
||||
const directoryNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const directoryNameFocusTimerRef = useRef<number | null>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return DIRECTORY_PANEL_DEFAULT_WIDTH;
|
||||
}
|
||||
const saved = Number(localStorage.getItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY));
|
||||
if (!Number.isFinite(saved)) {
|
||||
return DIRECTORY_PANEL_DEFAULT_WIDTH;
|
||||
}
|
||||
return Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, saved));
|
||||
});
|
||||
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sorting, setSorting] = useState(false);
|
||||
const [dialog, setDialog] = useState<DirectoryDialogState>({
|
||||
open: false,
|
||||
id: null,
|
||||
parentId: 0,
|
||||
name: "",
|
||||
remark: "",
|
||||
});
|
||||
const parentOptions = useMemo(
|
||||
() => [
|
||||
{ value: "0", label: t("knowledge.rootDirectory") },
|
||||
...rootDirectoryOptions(directories).filter((item) => item.value !== String(dialog.id ?? "")),
|
||||
],
|
||||
[directories, dialog.id, t],
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const loadDirectories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchKnowledgeDirectories(knowledgeBaseId);
|
||||
setDirectories(data);
|
||||
setExpandedIds(collectExpandedIds(data));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.loadDirectoriesFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [knowledgeBaseId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDirectories();
|
||||
}, [loadDirectories]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY, String(panelWidth));
|
||||
}, [panelWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialog.open) {
|
||||
return;
|
||||
}
|
||||
const frame = requestAnimationFrame(() => {
|
||||
directoryNameFocusTimerRef.current = window.setTimeout(() => {
|
||||
const input =
|
||||
directoryNameInputRef.current ??
|
||||
document.querySelector<HTMLInputElement>("[data-knowledge-directory-name-input='true']");
|
||||
input?.focus({ preventScroll: true });
|
||||
input?.select();
|
||||
}, 80);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
if (directoryNameFocusTimerRef.current !== null) {
|
||||
window.clearTimeout(directoryNameFocusTimerRef.current);
|
||||
directoryNameFocusTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dialog.open]);
|
||||
|
||||
function handleResizePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = panelWidth;
|
||||
|
||||
function handlePointerMove(moveEvent: globalThis.PointerEvent) {
|
||||
const nextWidth = startWidth + moveEvent.clientX - startX;
|
||||
setPanelWidth(
|
||||
Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, nextWidth)),
|
||||
);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
function toggleDirectory(id: number) {
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreate(parentId = 0) {
|
||||
setDialog({ open: true, id: null, parentId, name: "", remark: "" });
|
||||
}
|
||||
|
||||
function openEdit(item: KnowledgeDirectory) {
|
||||
setDialog({
|
||||
open: true,
|
||||
id: item.id,
|
||||
parentId: item.parentId,
|
||||
name: item.name,
|
||||
remark: item.remark || "",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const name = dialog.name.trim();
|
||||
if (!name) {
|
||||
toast.error(t("knowledge.directoryNameRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (dialog.id) {
|
||||
await updateKnowledgeDirectory({
|
||||
id: dialog.id,
|
||||
knowledgeBaseId,
|
||||
parentId: dialog.parentId,
|
||||
name,
|
||||
remark: dialog.remark.trim(),
|
||||
});
|
||||
toast.success(t("knowledge.directoryUpdated", { name }));
|
||||
} else {
|
||||
await createKnowledgeDirectory({
|
||||
knowledgeBaseId,
|
||||
parentId: dialog.parentId,
|
||||
name,
|
||||
remark: dialog.remark.trim(),
|
||||
});
|
||||
toast.success(t("knowledge.directoryCreated", { name }));
|
||||
}
|
||||
setDialog((current) => ({ ...current, open: false }));
|
||||
await loadDirectories();
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.directorySaveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: KnowledgeDirectory) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteKnowledgeDirectory(item.id);
|
||||
if (selectedDirectoryId === item.id) {
|
||||
onSelectDirectory(null);
|
||||
}
|
||||
toast.success(t("knowledge.directoryDeleted", { name: item.name }));
|
||||
await loadDirectories();
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.directoryDeleteFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDirectoryDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || sorting) {
|
||||
return;
|
||||
}
|
||||
const activeId = Number(active.id);
|
||||
if (!Number.isFinite(activeId)) {
|
||||
return;
|
||||
}
|
||||
if (over.id === ROOT_DIRECTORY_DROP_ID) {
|
||||
await handleDirectoryMoveToParent(activeId, 0);
|
||||
return;
|
||||
}
|
||||
const overId = Number(over.id);
|
||||
if (!Number.isFinite(overId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeParentId = findDirectoryParentId(directories, activeId);
|
||||
const overParentId = findDirectoryParentId(directories, overId);
|
||||
if (activeParentId === null || overParentId === null) {
|
||||
return;
|
||||
}
|
||||
if (activeParentId !== overParentId) {
|
||||
await handleDirectoryMoveToParent(activeId, overId);
|
||||
return;
|
||||
}
|
||||
if (activeParentId === 0 && overParentId === 0 && event.delta.x > 24) {
|
||||
await handleDirectoryMoveToParent(activeId, overId);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousDirectories = directories;
|
||||
const moved = moveDirectoryWithinParent(previousDirectories, activeParentId, activeId, overId);
|
||||
if (!moved.changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectories(moved.items);
|
||||
setSorting(true);
|
||||
try {
|
||||
await updateKnowledgeDirectorySort({
|
||||
knowledgeBaseId,
|
||||
parentId: moved.parentId,
|
||||
ids: moved.orderedIds,
|
||||
});
|
||||
toast.success(t("knowledge.directorySortUpdated"));
|
||||
await loadDirectories();
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
setDirectories(previousDirectories);
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.directorySortUpdateFailed"));
|
||||
} finally {
|
||||
setSorting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDirectoryMoveToParent(activeId: number, targetParentId: number) {
|
||||
const previousDirectories = directories;
|
||||
const moved = moveDirectoryToParent(previousDirectories, activeId, targetParentId);
|
||||
if (!moved.changed || !moved.item) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectories(moved.items);
|
||||
if (moved.parentId > 0) {
|
||||
setExpandedIds((current) => new Set(current).add(moved.parentId));
|
||||
}
|
||||
setSorting(true);
|
||||
try {
|
||||
await updateKnowledgeDirectory({
|
||||
id: moved.item.id,
|
||||
knowledgeBaseId,
|
||||
parentId: moved.parentId,
|
||||
name: moved.item.name,
|
||||
remark: moved.item.remark || "",
|
||||
});
|
||||
await updateKnowledgeDirectorySort({
|
||||
knowledgeBaseId,
|
||||
parentId: moved.parentId,
|
||||
ids: moved.orderedIds,
|
||||
});
|
||||
toast.success(t("knowledge.directoryMoved"));
|
||||
await loadDirectories();
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
setDirectories(previousDirectories);
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.directoryMoveFailed"));
|
||||
} finally {
|
||||
setSorting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="relative flex h-full min-h-0 shrink-0 flex-col border-r bg-muted/20"
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
<div className="flex h-[49px] items-center justify-between border-b bg-background px-3">
|
||||
<div className="text-sm font-medium">{t("knowledge.directory")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
disabled={loading || saving}
|
||||
onClick={() => openCreate(0)}
|
||||
aria-label={t("knowledge.createDirectory")}
|
||||
>
|
||||
<FolderPlusIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="py-1">
|
||||
<DirectoryStaticRow
|
||||
icon={<LayersIcon className="size-4 text-muted-foreground" />}
|
||||
label={t("knowledge.allContent")}
|
||||
selected={selectedDirectoryId === null}
|
||||
onClick={() => onSelectDirectory(null)}
|
||||
/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
|
||||
>
|
||||
<RootDirectoryRow
|
||||
selected={selectedDirectoryId === 0}
|
||||
onClick={() => onSelectDirectory(0)}
|
||||
t={t}
|
||||
/>
|
||||
<SortableContext
|
||||
items={directories.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{directories.map((item) => (
|
||||
<DirectoryNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
depth={0}
|
||||
expandedIds={expandedIds}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||
saving={saving || sorting}
|
||||
onToggle={toggleDirectory}
|
||||
onSelect={onSelectDirectory}
|
||||
onContextMenuOpenChange={(directoryId, open) =>
|
||||
setContextMenuDirectoryId(open ? directoryId : null)
|
||||
}
|
||||
onCreate={openCreate}
|
||||
onEdit={openEdit}
|
||||
onDelete={(directory) => void handleDelete(directory)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div
|
||||
className="absolute top-0 right-[-3px] z-10 h-full w-1.5 cursor-col-resize transition-colors hover:bg-primary/30"
|
||||
onPointerDown={handleResizePointerDown}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("knowledge.resizeDirectoryPanel")}
|
||||
/>
|
||||
</div>
|
||||
<ProjectDialog
|
||||
open={dialog.open}
|
||||
onOpenChange={(open) => setDialog((current) => ({ ...current, open }))}
|
||||
title={dialog.id ? t("knowledge.editDirectory") : t("knowledge.createDirectory")}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialog((current) => ({ ...current, open: false }))}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("knowledge.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSubmit()} disabled={saving}>
|
||||
{saving ? t("knowledge.saving") : t("knowledge.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel>{t("knowledge.parentDirectory")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<OptionCombobox
|
||||
value={String(dialog.parentId)}
|
||||
onChange={(value) =>
|
||||
setDialog((current) => ({ ...current, parentId: Number(value ?? 0) }))
|
||||
}
|
||||
options={parentOptions}
|
||||
placeholder={t("knowledge.selectDirectory")}
|
||||
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||
emptyText={t("knowledge.emptyDirectory")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("knowledge.directoryName")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
ref={directoryNameInputRef}
|
||||
autoFocus
|
||||
data-knowledge-directory-name-input="true"
|
||||
value={dialog.name}
|
||||
onChange={(event) =>
|
||||
setDialog((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
placeholder={t("knowledge.directoryNamePlaceholder")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("knowledge.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
value={dialog.remark}
|
||||
onChange={(event) =>
|
||||
setDialog((current) => ({ ...current, remark: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
placeholder={t("knowledge.remarkPlaceholder")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
</ProjectDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DirectoryStaticRowProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
draggingOver?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function DirectoryStaticRow({ icon, label, selected, draggingOver, onClick }: DirectoryStaticRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 px-2 py-1.5 text-left text-sm hover:bg-accent",
|
||||
selected && "bg-accent text-accent-foreground",
|
||||
draggingOver && "bg-primary/10 text-accent-foreground",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="size-5" />
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type RootDirectoryRowProps = {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
function RootDirectoryRow({ selected, onClick, t }: RootDirectoryRowProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: ROOT_DIRECTORY_DROP_ID,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<DirectoryStaticRow
|
||||
icon={<FolderIcon className="size-4 text-muted-foreground" />}
|
||||
label={t("knowledge.rootContent")}
|
||||
selected={selected}
|
||||
draggingOver={isOver}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DirectoryNodeProps = {
|
||||
item: KnowledgeDirectory;
|
||||
depth: number;
|
||||
expandedIds: Set<number>;
|
||||
selectedDirectoryId: number | null;
|
||||
contextMenuDirectoryId: number | null;
|
||||
saving: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
onSelect: (id: number) => void;
|
||||
onContextMenuOpenChange: (id: number, open: boolean) => void;
|
||||
onCreate: (parentId: number) => void;
|
||||
onEdit: (item: KnowledgeDirectory) => void;
|
||||
onDelete: (item: KnowledgeDirectory) => void;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function DirectoryNode({
|
||||
item,
|
||||
depth,
|
||||
expandedIds,
|
||||
selectedDirectoryId,
|
||||
contextMenuDirectoryId,
|
||||
saving,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onContextMenuOpenChange,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
t,
|
||||
}: DirectoryNodeProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: item.id,
|
||||
disabled: saving,
|
||||
});
|
||||
const expanded = expandedIds.has(item.id);
|
||||
const hasChildren = (item.children || []).length > 0;
|
||||
const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id;
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<ContextMenu onOpenChange={(open) => onContextMenuOpenChange(item.id, open)}>
|
||||
<ContextMenuTrigger className="block">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center gap-1 px-2 py-1.5 text-sm hover:bg-accent",
|
||||
active && "bg-accent text-accent-foreground",
|
||||
isDragging && "bg-muted/60 shadow-sm opacity-80",
|
||||
)}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
onClick={() => onSelect(item.id)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 shrink-0"
|
||||
disabled={!hasChildren}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle(item.id);
|
||||
}}
|
||||
aria-label={expanded ? t("knowledge.collapseDirectory") : t("knowledge.expandDirectory")}
|
||||
>
|
||||
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||
</Button>
|
||||
<FolderIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{item.name}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 opacity-0 group-hover:opacity-100"
|
||||
disabled={saving}
|
||||
/>
|
||||
}
|
||||
aria-label={t("knowledge.moreActions", { name: item.name })}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||
{item.parentId === 0 ? (
|
||||
<DropdownMenuItem onClick={() => onCreate(item.id)}>
|
||||
<PlusIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.createSubDirectory")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={() => onEdit(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(item)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{t("knowledge.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
{item.parentId === 0 ? (
|
||||
<ContextMenuItem onClick={() => onCreate(item.id)}>
|
||||
<PlusIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.createSubDirectory")}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={() => onEdit(item)}>
|
||||
<PencilIcon className="mr-2 size-3.5" />
|
||||
{t("knowledge.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDelete(item)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-3.5" />
|
||||
{t("knowledge.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{expanded
|
||||
? (
|
||||
<SortableContext
|
||||
items={(item.children || []).map((child) => child.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(item.children || []).map((child) => (
|
||||
<DirectoryNode
|
||||
key={child.id}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
expandedIds={expandedIds}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||
saving={saving}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
function plain(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
async function loadModule() {
|
||||
const source = await readFile(
|
||||
new URL("./knowledge-directory-sort.ts", import.meta.url),
|
||||
"utf8"
|
||||
)
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "knowledge-directory-sort.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
const tree = [
|
||||
{ id: 1, parentId: 0, name: "A", children: [
|
||||
{ id: 11, parentId: 1, name: "A-1", children: [] },
|
||||
{ id: 12, parentId: 1, name: "A-2", children: [] },
|
||||
] },
|
||||
{ id: 2, parentId: 0, name: "B", children: [] },
|
||||
{ id: 3, parentId: 0, name: "C", children: [] },
|
||||
]
|
||||
|
||||
describe("knowledge directory sorting", () => {
|
||||
it("moves root directories within the same parent", async () => {
|
||||
const { moveDirectoryWithinParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryWithinParent(tree, 0, 3, 1)
|
||||
|
||||
assert.deepEqual(plain(next.items).map((item) => item.id), [3, 1, 2])
|
||||
assert.equal(next.changed, true)
|
||||
assert.equal(next.parentId, 0)
|
||||
assert.deepEqual(plain(next.orderedIds), [3, 1, 2])
|
||||
})
|
||||
|
||||
it("moves child directories within their parent", async () => {
|
||||
const { moveDirectoryWithinParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryWithinParent(tree, 1, 12, 11)
|
||||
|
||||
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12, 11])
|
||||
assert.equal(next.changed, true)
|
||||
assert.equal(next.parentId, 1)
|
||||
assert.deepEqual(plain(next.orderedIds), [12, 11])
|
||||
})
|
||||
|
||||
it("does not move directories across parents", async () => {
|
||||
const { findDirectoryParentId, moveDirectoryWithinParent } = await loadModule()
|
||||
|
||||
assert.equal(findDirectoryParentId(tree, 11), 1)
|
||||
assert.equal(findDirectoryParentId(tree, 2), 0)
|
||||
|
||||
const next = moveDirectoryWithinParent(tree, 1, 11, 2)
|
||||
|
||||
assert.equal(next.changed, false)
|
||||
assert.equal(next.items, tree)
|
||||
assert.deepEqual(plain(next.orderedIds), [])
|
||||
})
|
||||
|
||||
it("moves a child directory back to the root level", async () => {
|
||||
const { moveDirectoryToParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryToParent(tree, 11, 0)
|
||||
|
||||
assert.equal(next.changed, true)
|
||||
assert.equal(next.parentId, 0)
|
||||
assert.equal(next.item?.parentId, 0)
|
||||
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2, 3, 11])
|
||||
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12])
|
||||
assert.deepEqual(plain(next.orderedIds), [1, 2, 3, 11])
|
||||
})
|
||||
|
||||
it("moves a root directory without children under another root directory", async () => {
|
||||
const { moveDirectoryToParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryToParent(tree, 3, 2)
|
||||
|
||||
assert.equal(next.changed, true)
|
||||
assert.equal(next.parentId, 2)
|
||||
assert.equal(next.item?.parentId, 2)
|
||||
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2])
|
||||
assert.deepEqual(plain(next.items[1].children).map((item) => item.id), [3])
|
||||
assert.deepEqual(plain(next.orderedIds), [3])
|
||||
})
|
||||
|
||||
it("rejects moving a directory with children under another directory", async () => {
|
||||
const { moveDirectoryToParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryToParent(tree, 1, 2)
|
||||
|
||||
assert.equal(next.changed, false)
|
||||
assert.equal(next.items, tree)
|
||||
})
|
||||
|
||||
it("rejects moving a directory under a child directory", async () => {
|
||||
const { moveDirectoryToParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryToParent(tree, 3, 11)
|
||||
|
||||
assert.equal(next.changed, false)
|
||||
assert.equal(next.items, tree)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
export type SortableKnowledgeDirectory = {
|
||||
id: number
|
||||
parentId: number
|
||||
children?: SortableKnowledgeDirectory[]
|
||||
}
|
||||
|
||||
export type MoveDirectoryResult<T extends SortableKnowledgeDirectory> = {
|
||||
items: T[]
|
||||
changed: boolean
|
||||
parentId: number
|
||||
orderedIds: number[]
|
||||
}
|
||||
|
||||
export type MoveDirectoryToParentResult<T extends SortableKnowledgeDirectory> =
|
||||
MoveDirectoryResult<T> & {
|
||||
item: T | null
|
||||
}
|
||||
|
||||
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
|
||||
const next = [...items]
|
||||
const [item] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
export function findDirectoryParentId<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
id: number,
|
||||
): number | null {
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
return item.parentId
|
||||
}
|
||||
const childParentId = findDirectoryParentId(item.children as T[] | undefined ?? [], id)
|
||||
if (childParentId !== null) {
|
||||
return childParentId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function findDirectory<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
id: number,
|
||||
): T | null {
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
return item
|
||||
}
|
||||
const child = findDirectory(item.children as T[] | undefined ?? [], id)
|
||||
if (child !== null) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function moveDirectoryToParent<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
activeId: number,
|
||||
targetParentId: number,
|
||||
): MoveDirectoryToParentResult<T> {
|
||||
if (activeId === targetParentId) {
|
||||
return unchangedParentMove(items, targetParentId)
|
||||
}
|
||||
|
||||
const activeItem = findDirectory(items, activeId)
|
||||
const activeParentId = findDirectoryParentId(items, activeId)
|
||||
if (!activeItem || activeParentId === null || activeParentId === targetParentId) {
|
||||
return unchangedParentMove(items, targetParentId)
|
||||
}
|
||||
|
||||
if (targetParentId > 0) {
|
||||
const targetParent = findDirectory(items, targetParentId)
|
||||
if (!targetParent || targetParent.parentId !== 0 || (activeItem.children?.length ?? 0) > 0) {
|
||||
return unchangedParentMove(items, targetParentId)
|
||||
}
|
||||
}
|
||||
|
||||
const removed = removeDirectory(items, activeId)
|
||||
if (!removed.item) {
|
||||
return unchangedParentMove(items, targetParentId)
|
||||
}
|
||||
|
||||
const movedItem = { ...removed.item, parentId: targetParentId } as T
|
||||
const appended = appendDirectoryToParent(removed.items, targetParentId, movedItem)
|
||||
if (!appended.changed) {
|
||||
return unchangedParentMove(items, targetParentId)
|
||||
}
|
||||
|
||||
return {
|
||||
items: appended.items,
|
||||
changed: true,
|
||||
parentId: targetParentId,
|
||||
orderedIds: appended.siblings.map((item) => item.id),
|
||||
item: movedItem,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
parentId: number,
|
||||
activeId: number,
|
||||
overId: number,
|
||||
): MoveDirectoryResult<T> {
|
||||
if (activeId === overId) {
|
||||
return { items, changed: false, parentId, orderedIds: [] }
|
||||
}
|
||||
|
||||
if (parentId === 0) {
|
||||
return moveSiblingList(items, parentId, activeId, overId)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
let orderedIds: number[] = []
|
||||
const nextItems = items.map((item) => {
|
||||
if (item.id === parentId) {
|
||||
const moved = moveSiblingList((item.children as T[] | undefined) ?? [], parentId, activeId, overId)
|
||||
changed = moved.changed
|
||||
orderedIds = moved.orderedIds
|
||||
return { ...item, children: moved.items }
|
||||
}
|
||||
if (item.children?.length) {
|
||||
const moved = moveDirectoryWithinParent(item.children as T[], parentId, activeId, overId)
|
||||
if (moved.changed) {
|
||||
changed = true
|
||||
orderedIds = moved.orderedIds
|
||||
return { ...item, children: moved.items }
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
return {
|
||||
items: changed ? nextItems : items,
|
||||
changed,
|
||||
parentId,
|
||||
orderedIds,
|
||||
}
|
||||
}
|
||||
|
||||
function unchangedParentMove<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
parentId: number,
|
||||
): MoveDirectoryToParentResult<T> {
|
||||
return { items, changed: false, parentId, orderedIds: [], item: null }
|
||||
}
|
||||
|
||||
function removeDirectory<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
id: number,
|
||||
): { items: T[]; item: T | null } {
|
||||
let removedItem: T | null = null
|
||||
const nextItems: T[] = []
|
||||
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
removedItem = item
|
||||
continue
|
||||
}
|
||||
if (item.children?.length) {
|
||||
const removed = removeDirectory(item.children as T[], id)
|
||||
if (removed.item) {
|
||||
removedItem = removed.item
|
||||
nextItems.push({ ...item, children: removed.items } as T)
|
||||
continue
|
||||
}
|
||||
}
|
||||
nextItems.push(item)
|
||||
}
|
||||
|
||||
return {
|
||||
items: removedItem ? nextItems : items,
|
||||
item: removedItem,
|
||||
}
|
||||
}
|
||||
|
||||
function appendDirectoryToParent<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
parentId: number,
|
||||
item: T,
|
||||
): { items: T[]; changed: boolean; siblings: T[] } {
|
||||
if (parentId === 0) {
|
||||
const siblings = [...items, item]
|
||||
return { items: siblings, changed: true, siblings }
|
||||
}
|
||||
|
||||
let changed = false
|
||||
let siblings: T[] = []
|
||||
const nextItems = items.map((current) => {
|
||||
if (current.id === parentId) {
|
||||
siblings = [...((current.children as T[] | undefined) ?? []), item]
|
||||
changed = true
|
||||
return { ...current, children: siblings } as T
|
||||
}
|
||||
if (current.children?.length) {
|
||||
const appended = appendDirectoryToParent(current.children as T[], parentId, item)
|
||||
if (appended.changed) {
|
||||
changed = true
|
||||
siblings = appended.siblings
|
||||
return { ...current, children: appended.items } as T
|
||||
}
|
||||
}
|
||||
return current
|
||||
})
|
||||
|
||||
return {
|
||||
items: changed ? nextItems : items,
|
||||
changed,
|
||||
siblings,
|
||||
}
|
||||
}
|
||||
|
||||
function moveSiblingList<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
parentId: number,
|
||||
activeId: number,
|
||||
overId: number,
|
||||
): MoveDirectoryResult<T> {
|
||||
const oldIndex = items.findIndex((item) => item.id === activeId)
|
||||
const newIndex = items.findIndex((item) => item.id === overId)
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return { items, changed: false, parentId, orderedIds: [] }
|
||||
}
|
||||
|
||||
const nextItems = arrayMove(items, oldIndex, newIndex)
|
||||
return {
|
||||
items: nextItems,
|
||||
changed: true,
|
||||
parentId,
|
||||
orderedIds: nextItems.map((item) => item.id),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
fetchKnowledgeRetrieveLog,
|
||||
type KnowledgeRetrieveHit,
|
||||
type KnowledgeRetrieveLogDetail,
|
||||
} from "@/lib/api/admin"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
getKnowledgeAnswerStatusLabel,
|
||||
getKnowledgeChunkProviderLabel,
|
||||
getKnowledgeRetrieveChannelLabel,
|
||||
getKnowledgeRetrieveSceneLabel,
|
||||
} from "@/lib/knowledge-i18n"
|
||||
|
||||
type RetrieveLogDetailDrawerProps = {
|
||||
open: boolean
|
||||
retrieveLogId: number | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function safeParseJSON(value: string) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction }) {
|
||||
const citations = hits.filter((item) => item.isCitation)
|
||||
if (citations.length === 0) {
|
||||
return <div className="text-sm text-muted-foreground">{t("knowledge.noCitations")}</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{citations.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
|
||||
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90">
|
||||
{item.snippet || "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RetrieveLogDetailDrawer({
|
||||
open,
|
||||
retrieveLogId,
|
||||
onOpenChange,
|
||||
}: RetrieveLogDetailDrawerProps) {
|
||||
const t = useI18n()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null)
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!retrieveLogId) {
|
||||
setDetail(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchKnowledgeRetrieveLog(retrieveLogId)
|
||||
setDetail(data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogDetailFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [retrieveLogId, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && retrieveLogId) {
|
||||
void loadDetail()
|
||||
}
|
||||
}, [open, retrieveLogId, loadDetail])
|
||||
|
||||
const traceData = useMemo(() => safeParseJSON(detail?.log.traceData ?? ""), [detail?.log.traceData])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
|
||||
<DrawerContent className="max-w-3xl">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t("knowledge.retrieveLogDetail")}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{detail?.log.question || (loading ? t("knowledge.loading") : t("knowledge.retrieveLogNotFound"))}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
|
||||
{!detail ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
{loading ? t("knowledge.loadingDetail") : t("knowledge.emptyDetail")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 pb-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.title")}</div>
|
||||
<div className="mt-1 text-sm">{detail.log.knowledgeBaseName || `#${detail.log.knowledgeBaseId}`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.createdAt")}</div>
|
||||
<div className="mt-1 text-sm">{formatDateTime(detail.log.createdAt)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.channelScene")}</div>
|
||||
<div className="mt-1 text-sm">
|
||||
{getKnowledgeRetrieveChannelLabel(detail.log.channel, detail.log.channelName, t)} /{" "}
|
||||
{getKnowledgeRetrieveSceneLabel(detail.log.scene, detail.log.sceneName, t)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Request ID</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.originalQuestion")}</div>
|
||||
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.question || "-"}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.rewriteQuestion")}</div>
|
||||
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.rewriteQuestion || "-"}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.answerContent")}</div>
|
||||
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.answer || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.retrieveStrategy")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
|
||||
<Metric
|
||||
label="Chunk Provider"
|
||||
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
|
||||
mono
|
||||
/>
|
||||
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
|
||||
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
|
||||
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
|
||||
<Metric label="Rerank" value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.resultSummary")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-4">
|
||||
<Metric
|
||||
label={t("knowledge.answerStatus")}
|
||||
value={getKnowledgeAnswerStatusLabel(detail.log.answerStatus, detail.log.answerStatusName, t)}
|
||||
/>
|
||||
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
|
||||
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
|
||||
<Metric label={t("knowledge.contextChunks")} value={detail.log.usedChunkCount} />
|
||||
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
|
||||
<Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
|
||||
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
|
||||
<Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
|
||||
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
|
||||
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
|
||||
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
|
||||
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.citationSources")}</h3>
|
||||
<CitationList hits={detail.hits} t={t} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.hitDetails")}</h3>
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.itemsCount", { count: detail.hits.length })}</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{detail.hits.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">#{item.rankNo}</Badge>
|
||||
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
|
||||
<Badge variant={item.usedInAnswer ? "default" : "secondary"}>
|
||||
{item.usedInAnswer ? t("knowledge.usedInContext") : t("knowledge.notUsedInContext")}
|
||||
</Badge>
|
||||
{item.isCitation ? <Badge>{t("knowledge.citations")}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("knowledge.section", { section: item.sectionPath || item.title || "-" })}</span>
|
||||
<span>Chunk #{item.chunkNo}</span>
|
||||
<span>Provider:{item.provider || "-"}</span>
|
||||
<span>Score:{item.score.toFixed(4)}</span>
|
||||
<span>Rerank:{item.rerankScore ? item.rerankScore.toFixed(4) : "-"}</span>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="text-sm leading-6 whitespace-pre-wrap text-foreground/90">
|
||||
{item.snippet || "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">TraceData</h3>
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<pre className="overflow-x-auto text-xs leading-6 text-muted-foreground">
|
||||
{traceData ? JSON.stringify(traceData, null, 2) : detail.log.traceData || "-"}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function getHitSourceLabel(item: KnowledgeRetrieveHit, t: TFunction) {
|
||||
if (item.faqQuestion) {
|
||||
return item.faqQuestion
|
||||
}
|
||||
if (item.documentTitle) {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId > 0) {
|
||||
return `FAQ #${item.faqId}`
|
||||
}
|
||||
return `${t("knowledge.document")} #${item.documentId}`
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className={`mt-1 text-sm ${mono ? "font-mono" : ""}`}>{String(value || value === 0 ? value : "-")}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
fetchKnowledgeRetrieveLogs,
|
||||
type KnowledgeRetrieveLog,
|
||||
} from "@/lib/api/admin"
|
||||
import {
|
||||
KnowledgeAnswerStatus,
|
||||
KnowledgeRetrieveChannel,
|
||||
KnowledgeRetrieveScene,
|
||||
} from "@/lib/generated/enums"
|
||||
import {
|
||||
getKnowledgeAnswerStatusLabel,
|
||||
getKnowledgeChunkProviderLabel,
|
||||
getKnowledgeRetrieveChannelLabel,
|
||||
getKnowledgeRetrieveSceneLabel,
|
||||
} from "@/lib/knowledge-i18n"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { DashboardListPage } from "@/components/dashboard/list"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { RetrieveLogDetailDrawer } from "./retrieve-log-detail"
|
||||
|
||||
type RetrieveLogListProps = {
|
||||
knowledgeBaseId: number | null
|
||||
}
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function getChannelOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allChannels") },
|
||||
{ value: KnowledgeRetrieveChannel.IM, label: t("knowledge.channelIM") },
|
||||
{ value: KnowledgeRetrieveChannel.AgentAssist, label: t("knowledge.channelAgentAssist") },
|
||||
{ value: KnowledgeRetrieveChannel.API, label: t("knowledge.channelAPI") },
|
||||
{ value: KnowledgeRetrieveChannel.Debug, label: t("knowledge.channelDebug") },
|
||||
]
|
||||
}
|
||||
|
||||
function getSceneOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allScenes") },
|
||||
{ value: KnowledgeRetrieveScene.FirstResponse, label: t("knowledge.sceneFirstResponse") },
|
||||
{ value: KnowledgeRetrieveScene.Assist, label: t("knowledge.sceneAssist") },
|
||||
{ value: KnowledgeRetrieveScene.QA, label: t("knowledge.sceneQA") },
|
||||
]
|
||||
}
|
||||
|
||||
function getAnswerStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allAnswerStatus") },
|
||||
{ value: String(KnowledgeAnswerStatus.Normal), label: t("knowledge.answerNormal") },
|
||||
{ value: String(KnowledgeAnswerStatus.NoAnswer), label: t("knowledge.answerNoAnswer") },
|
||||
{ value: String(KnowledgeAnswerStatus.Fallback), label: t("knowledge.answerFallback") },
|
||||
{ value: String(KnowledgeAnswerStatus.Blocked), label: t("knowledge.answerBlocked") },
|
||||
]
|
||||
}
|
||||
|
||||
function getProviderOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allChunkProviders") },
|
||||
{ value: "fixed", label: t("knowledge.chunkFixed") },
|
||||
{ value: "structured", label: t("knowledge.chunkStructured") },
|
||||
{ value: "faq", label: t("knowledge.chunkFAQ") },
|
||||
{ value: "semantic", label: t("knowledge.chunkSemantic") },
|
||||
]
|
||||
}
|
||||
|
||||
function getRerankOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("knowledge.allRerank") },
|
||||
{ value: "1", label: t("knowledge.rerankEnabled") },
|
||||
{ value: "0", label: t("knowledge.rerankDisabled") },
|
||||
]
|
||||
}
|
||||
|
||||
function channelLabel(value: string, fallback: string, t: TFunction) {
|
||||
return getKnowledgeRetrieveChannelLabel(value, fallback, t)
|
||||
}
|
||||
|
||||
function sceneLabel(value: string, fallback: string, t: TFunction) {
|
||||
return getKnowledgeRetrieveSceneLabel(value, fallback, t)
|
||||
}
|
||||
|
||||
function answerStatusLabel(value: number, fallback: string, t: TFunction) {
|
||||
return getKnowledgeAnswerStatusLabel(value, fallback, t)
|
||||
}
|
||||
|
||||
function providerLabel(value: string, t: TFunction) {
|
||||
return getKnowledgeChunkProviderLabel(value, t)
|
||||
}
|
||||
|
||||
function getAnswerStatusVariant(status: number): "default" | "secondary" | "outline" | "destructive" {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "default"
|
||||
case 2:
|
||||
return "secondary"
|
||||
case 3:
|
||||
return "outline"
|
||||
case 4:
|
||||
return "destructive"
|
||||
default:
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
export function RetrieveLogList({
|
||||
knowledgeBaseId,
|
||||
}: RetrieveLogListProps) {
|
||||
const t = useI18n()
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [selectedLogId, setSelectedLogId] = useState<number | null>(null)
|
||||
const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState<number | null>(null)
|
||||
const channelOptions = useMemo(() => getChannelOptions(t), [t])
|
||||
const sceneOptions = useMemo(() => getSceneOptions(t), [t])
|
||||
const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t])
|
||||
const providerOptions = useMemo(() => getProviderOptions(t), [t])
|
||||
const rerankOptions = useMemo(() => getRerankOptions(t), [t])
|
||||
|
||||
function handleOpenDetail(logId: number) {
|
||||
setSelectedLogId(logId)
|
||||
setSelectedKnowledgeBaseId(knowledgeBaseId)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
if (!knowledgeBaseId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("knowledge.selectBaseForLogs")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col gap-4 px-6 py-4">
|
||||
<DashboardListPage<KnowledgeRetrieveLog>
|
||||
layout="fragment"
|
||||
filters={[
|
||||
{
|
||||
name: "question",
|
||||
label: t("knowledge.filterQuestion"),
|
||||
placeholder: t("knowledge.filterQuestion"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "min-w-0 xl:min-w-[280px]",
|
||||
inputClassName: "pl-9",
|
||||
icon: <SearchIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
name: "channel",
|
||||
label: t("knowledge.selectChannel"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: channelOptions,
|
||||
placeholder: t("knowledge.selectChannel"),
|
||||
},
|
||||
{
|
||||
name: "scene",
|
||||
label: t("knowledge.selectScene"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: sceneOptions,
|
||||
placeholder: t("knowledge.selectScene"),
|
||||
},
|
||||
{
|
||||
name: "answerStatus",
|
||||
label: t("knowledge.answerStatus"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: answerStatusOptions,
|
||||
placeholder: t("knowledge.answerStatus"),
|
||||
},
|
||||
{
|
||||
name: "chunkProvider",
|
||||
label: t("knowledge.chunkStrategy"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: providerOptions,
|
||||
placeholder: t("knowledge.chunkStrategy"),
|
||||
},
|
||||
{
|
||||
name: "rerankEnabled",
|
||||
label: "Rerank",
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: rerankOptions,
|
||||
placeholder: "Rerank",
|
||||
},
|
||||
]}
|
||||
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
|
||||
reloadKey={knowledgeBaseId}
|
||||
getItemId={(item) => item.id}
|
||||
getRowClassName={() => "cursor-pointer"}
|
||||
onRowClick={(item) => handleOpenDetail(item.id)}
|
||||
columns={[
|
||||
{
|
||||
key: "time",
|
||||
label: t("knowledge.time"),
|
||||
className: "w-42 text-xs text-muted-foreground",
|
||||
render: (item) => formatDateTime(item.createdAt),
|
||||
},
|
||||
{
|
||||
key: "question",
|
||||
label: t("knowledge.question"),
|
||||
render: (item) => (
|
||||
<div className="space-y-1">
|
||||
<div className="line-clamp-2 font-medium">{item.question || "-"}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{channelLabel(item.channel, item.channelName, t)}</span>
|
||||
<span>{sceneLabel(item.scene, item.sceneName, t)}</span>
|
||||
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "answerStatus",
|
||||
label: t("knowledge.answerStatus"),
|
||||
className: "w-28",
|
||||
render: (item) => (
|
||||
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
|
||||
{answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "hitCount",
|
||||
label: t("knowledge.hitCount"),
|
||||
className: "w-24 text-right",
|
||||
render: (item) => item.hitCount,
|
||||
},
|
||||
{
|
||||
key: "topScore",
|
||||
label: "TopScore",
|
||||
className: "w-24 text-right font-mono text-xs",
|
||||
render: (item) => item.topScore.toFixed(4),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
className: "w-28",
|
||||
render: (item) =>
|
||||
item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
|
||||
},
|
||||
{
|
||||
key: "rerank",
|
||||
label: "Rerank",
|
||||
className: "w-24",
|
||||
render: (item) =>
|
||||
item.rerankEnabled
|
||||
? `${t("knowledge.yes")} (${item.rerankLimit})`
|
||||
: t("knowledge.no"),
|
||||
},
|
||||
{
|
||||
key: "citations",
|
||||
label: t("knowledge.citations"),
|
||||
className: "w-24 text-right",
|
||||
render: (item) => item.citationCount,
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
label: t("knowledge.duration"),
|
||||
className: "w-28 text-right",
|
||||
render: (item) => `${item.latencyMs} ms`,
|
||||
},
|
||||
]}
|
||||
labels={{
|
||||
query: t("knowledge.filter"),
|
||||
loading: t("knowledge.loadingRetrieveLogs"),
|
||||
empty: t("knowledge.emptyRetrieveLogs"),
|
||||
loadFailed: t("knowledge.loadRetrieveLogsFailed"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RetrieveLogDetailDrawer
|
||||
open={detailOpen && selectedKnowledgeBaseId === knowledgeBaseId}
|
||||
retrieveLogId={selectedLogId}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open)
|
||||
if (!open) {
|
||||
setSelectedLogId(null)
|
||||
setSelectedKnowledgeBaseId(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SelectionCheckboxProps = ComponentProps<typeof Checkbox> & {
|
||||
wrapperClassName?: string;
|
||||
};
|
||||
|
||||
export function SelectionCheckbox({
|
||||
className,
|
||||
wrapperClassName,
|
||||
...props
|
||||
}: SelectionCheckboxProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex shrink-0", wrapperClassName)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Checkbox className={className} {...props} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
163
web/app/(dashboard)/dashboard/knowledge/detail/page.tsx
Normal file
163
web/app/(dashboard)/dashboard/knowledge/detail/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { exportKnowledgeFAQs, fetchKnowledgeBase, type KnowledgeBase } from "@/lib/api/admin"
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BugIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
LayoutGridIcon,
|
||||
LayoutListIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { DebugPanel } from "../_components/debug-panel"
|
||||
import { DocumentList, type DocumentListActionState } from "../_components/document-list"
|
||||
import { FAQList, type FAQListActionState } from "../_components/faq-list"
|
||||
import { RetrieveLogList } from "../_components/retrieve-log-list"
|
||||
|
||||
export default function KnowledgeBaseDetailPage() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const knowledgeBaseId = Number(searchParams.get("id"))
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState("documents")
|
||||
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
|
||||
const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null)
|
||||
const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null)
|
||||
const [exportingFAQ, setExportingFAQ] = useState(false)
|
||||
|
||||
const loadKnowledgeBase = useCallback(async () => {
|
||||
if (!Number.isInteger(knowledgeBaseId) || knowledgeBaseId <= 0) {
|
||||
setKnowledgeBase(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
setKnowledgeBase(await fetchKnowledgeBase(knowledgeBaseId))
|
||||
} catch (error) {
|
||||
setKnowledgeBase(null)
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.loadBaseFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [knowledgeBaseId, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadKnowledgeBase()
|
||||
}, [loadKnowledgeBase])
|
||||
|
||||
async function handleExportFAQ() {
|
||||
if (!knowledgeBase || exportingFAQ) {
|
||||
return
|
||||
}
|
||||
setExportingFAQ(true)
|
||||
try {
|
||||
await exportKnowledgeFAQs(knowledgeBase.id)
|
||||
toast.success(t("knowledge.exportFAQSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.exportFAQFailed"))
|
||||
} finally {
|
||||
setExportingFAQ(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!loading && !knowledgeBase) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<FileTextIcon className="size-10 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{t("knowledge.baseNotFound")}</p>
|
||||
<Button variant="outline" onClick={() => router.push("/dashboard/knowledge")}>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
{t("knowledge.backToList")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isFAQKnowledgeBase = knowledgeBase?.knowledgeType === "faq"
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="min-h-0 flex-1 gap-0">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b bg-background px-4 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => router.push("/dashboard/knowledge")}
|
||||
aria-label={t("knowledge.backToList")}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h1 className="shrink-0 truncate text-sm font-semibold">{knowledgeBase?.name ?? t("knowledge.title")}</h1>
|
||||
{knowledgeBase?.description ? (
|
||||
<>
|
||||
<span className="h-3 w-px shrink-0 bg-border" aria-hidden="true" />
|
||||
<p className="min-w-0 truncate text-xs text-muted-foreground">{knowledgeBase.description}</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{activeTab === "documents" && !isFAQKnowledgeBase && documentActionState ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={documentActionState.onRefresh} disabled={documentActionState.loading} aria-label={t("knowledge.refreshDocuments")}>
|
||||
<RefreshCwIcon className={documentActionState.loading ? "size-4 animate-spin" : "size-4"} />
|
||||
</Button>
|
||||
<Button variant={documentActionState.viewMode === "list" ? "secondary" : "ghost"} size="icon" className="size-7" onClick={() => documentActionState.onChangeViewMode("list")} aria-label={t("knowledge.listLayout")}><LayoutListIcon className="size-4" /></Button>
|
||||
<Button variant={documentActionState.viewMode === "grid" ? "secondary" : "ghost"} size="icon" className="size-7" onClick={() => documentActionState.onChangeViewMode("grid")} aria-label={t("knowledge.gridLayout")}><LayoutGridIcon className="size-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={() => setDebugPanelOpen(true)} aria-label={t("knowledge.openDebugPanel")}><BugIcon className="size-4" /></Button>
|
||||
<Button size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={documentActionState.onCreate}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("knowledge.newDocument")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab === "documents" && isFAQKnowledgeBase && faqActionState ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={faqActionState.onRefresh} disabled={faqActionState.loading} aria-label={t("knowledge.refreshFAQ")}><RefreshCwIcon className={faqActionState.loading ? "size-4 animate-spin" : "size-4"} /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={faqActionState.onImport} disabled={faqActionState.importing} aria-label={t("knowledge.importFAQ")}><UploadIcon className="size-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={() => void handleExportFAQ()} disabled={exportingFAQ} aria-label={t("knowledge.exportFAQ")}><DownloadIcon className={exportingFAQ ? "size-4 animate-pulse" : "size-4"} /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={() => setDebugPanelOpen(true)} aria-label={t("knowledge.openDebugPanel")}><BugIcon className="size-4" /></Button>
|
||||
<Button size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={faqActionState.onCreate}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("knowledge.newFAQ")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<TabsList>
|
||||
<TabsTrigger value="documents">
|
||||
{isFAQKnowledgeBase ? t("knowledge.faq") : t("knowledge.document")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="retrieveLogs">
|
||||
{t("knowledge.retrieveLogs")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="documents" className="min-h-0 flex-1">
|
||||
{isFAQKnowledgeBase ? <FAQList knowledgeBaseId={knowledgeBase?.id ?? null} onActionStateChange={setFAQActionState} /> : <DocumentList knowledgeBaseId={knowledgeBase?.id ?? null} onActionStateChange={setDocumentActionState} />}
|
||||
</TabsContent>
|
||||
<TabsContent value="retrieveLogs" className="min-h-0 flex-1">
|
||||
<RetrieveLogList knowledgeBaseId={knowledgeBase?.id ?? null} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Sheet open={debugPanelOpen} onOpenChange={setDebugPanelOpen}>
|
||||
<SheetContent side="right" className="min-w-170">
|
||||
<SheetHeader><SheetTitle>{t("knowledge.ragDebug")}</SheetTitle></SheetHeader>
|
||||
<DebugPanel knowledgeBaseId={knowledgeBase?.id ?? null} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
web/app/(dashboard)/dashboard/knowledge/page.tsx
Normal file
170
web/app/(dashboard)/dashboard/knowledge/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import { CircleHelpIcon, FileTextIcon, RefreshCwIcon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardCrudPage } from "@/components/dashboard/crud"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { EditDialog } from "./_components/knowledge-base-edit"
|
||||
import {
|
||||
createKnowledgeBase,
|
||||
deleteKnowledgeBase,
|
||||
fetchKnowledgeBases,
|
||||
rebuildKnowledgeBaseIndex,
|
||||
updateKnowledgeBase,
|
||||
updateKnowledgeBaseSort,
|
||||
type CreateKnowledgeBasePayload,
|
||||
type KnowledgeBase,
|
||||
} from "@/lib/api/admin"
|
||||
import { KnowledgeBaseType, Status } from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
export default function DashboardKnowledgePage() {
|
||||
const t = useI18n()
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: t("knowledge.allStatus") },
|
||||
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
|
||||
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
|
||||
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardCrudPage<KnowledgeBase, CreateKnowledgeBasePayload>
|
||||
filters={[
|
||||
{
|
||||
name: "name",
|
||||
label: t("knowledge.searchBase"),
|
||||
placeholder: t("knowledge.searchBase"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-80",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("knowledge.allStatus"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: statusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
key: "knowledgeBase",
|
||||
label: t("knowledge.columnBase"),
|
||||
className: "w-[36rem] max-w-[36rem]",
|
||||
render: (item) => {
|
||||
const isFAQ = item.knowledgeType === KnowledgeBaseType.FAQ
|
||||
const Icon = isFAQ ? CircleHelpIcon : FileTextIcon
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/dashboard/knowledge/detail?id=${item.id}`}
|
||||
className="block truncate font-medium hover:underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{item.description || t("knowledge.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
label: t("knowledge.columnType"),
|
||||
render: (item) => <Badge variant="outline">{item.knowledgeTypeName}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "contentCount",
|
||||
label: t("knowledge.columnContent"),
|
||||
render: (item) => (
|
||||
<span className="text-muted-foreground">
|
||||
{item.knowledgeType === KnowledgeBaseType.FAQ
|
||||
? `${item.faqCount} ${t("knowledge.faq")}`
|
||||
: `${item.documentCount} ${t("knowledge.document")}`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("knowledge.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||
{item.statusName}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: t("knowledge.columnUpdatedAt"),
|
||||
className: "whitespace-nowrap",
|
||||
render: (item) => (
|
||||
<span className="text-sm text-muted-foreground">{formatDateTime(item.updatedAt)}</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
fetchList={fetchKnowledgeBases}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createKnowledgeBase}
|
||||
updateItem={(item, payload) => updateKnowledgeBase({ id: item.id, ...payload })}
|
||||
deleteItem={(item) => deleteKnowledgeBase(item.id)}
|
||||
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
rowActions={[
|
||||
{
|
||||
key: "rebuild-index",
|
||||
label: (item) => t("knowledge.rebuildIndex"),
|
||||
icon: <RefreshCwIcon />,
|
||||
run: async ({ item }) => {
|
||||
await rebuildKnowledgeBaseIndex(item.id)
|
||||
toast.success(t("knowledge.rebuildStarted", { name: item.name }))
|
||||
},
|
||||
},
|
||||
]}
|
||||
sort={{
|
||||
enabled: true,
|
||||
onReorder: (items) => updateKnowledgeBaseSort(items.map((item) => item.id)),
|
||||
successMessage: t("knowledge.sortUpdated"),
|
||||
errorMessage: t("knowledge.sortUpdateFailed"),
|
||||
handleLabel: t("knowledge.sortHandle"),
|
||||
}}
|
||||
pageSize={1000}
|
||||
labels={{
|
||||
refresh: t("knowledge.refreshBases"),
|
||||
create: t("knowledge.createBaseTitle"),
|
||||
query: t("knowledge.query"),
|
||||
loading: t("knowledge.loading"),
|
||||
empty: t("knowledge.emptyBases"),
|
||||
actions: t("knowledge.columnActions"),
|
||||
edit: t("knowledge.edit"),
|
||||
delete: t("knowledge.delete"),
|
||||
processing: t("knowledge.processing"),
|
||||
moreActions: (item) => t("knowledge.moreActions", { name: item.name }),
|
||||
loadFailed: t("knowledge.loadBasesFailed"),
|
||||
saveFailed: t("knowledge.baseSaveFailed"),
|
||||
deleteFailed: t("knowledge.baseDeleteFailed"),
|
||||
created: (payload) => t("knowledge.baseCreated", { name: payload.name }),
|
||||
updated: (item, payload) => t("knowledge.baseUpdated", { name: payload.name || item.name }),
|
||||
deleted: (item) => t("knowledge.baseDeleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
75
web/app/(dashboard)/dashboard/layout.tsx
Normal file
75
web/app/(dashboard)/dashboard/layout.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { NotificationProvider } from "@/components/notification-provider"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const { ready, session } = useAuth()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isLoginRoute = pathname?.startsWith("/dashboard/login") ?? false
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !session && !isLoginRoute) {
|
||||
router.replace("/dashboard/login")
|
||||
}
|
||||
}, [isLoginRoute, ready, router, session])
|
||||
|
||||
if (isLoginRoute) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
if (!ready || !session) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Loader2Icon className="size-5 animate-spin" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-medium">{t("auth.checkingSession")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.syncingProfile")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-svh min-h-0 overflow-hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 54)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<NotificationProvider>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset className="overflow-hidden border-l border-border/70 bg-background">
|
||||
<SiteHeader />
|
||||
<div className="@container/main flex min-h-0 flex-1 flex-col overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</NotificationProvider>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
67
web/app/(dashboard)/dashboard/login/oidc/callback/page.tsx
Normal file
67
web/app/(dashboard)/dashboard/login/oidc/callback/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { exchangeOIDCTicket } from "@/lib/api/auth"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export default function OIDCLoginCallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={<OIDCLoginCallbackFallback />}>
|
||||
<OIDCLoginCallbackContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function OIDCLoginCallbackContent() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const ranRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) {
|
||||
return
|
||||
}
|
||||
ranRef.current = true
|
||||
|
||||
const ticket = searchParams.get("ticket")?.trim() ?? ""
|
||||
const next = searchParams.get("next")
|
||||
const nextPath = next && next.startsWith("/") ? next : "/dashboard"
|
||||
|
||||
if (!ticket) {
|
||||
toast.error(t("auth.oidcMissingTicket"))
|
||||
router.replace("/dashboard/login")
|
||||
return
|
||||
}
|
||||
|
||||
void exchangeOIDCTicket(ticket)
|
||||
.then(() => {
|
||||
toast.success(t("auth.loginSuccess"))
|
||||
router.replace(nextPath)
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("auth.oidcFailed"))
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
}, [router, searchParams, t])
|
||||
|
||||
return <OIDCLoginCallbackFallback />
|
||||
}
|
||||
|
||||
function OIDCLoginCallbackFallback() {
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6">
|
||||
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.oidcSigningIn")}</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{t("auth.checkingTicket")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
web/app/(dashboard)/dashboard/login/page.tsx
Normal file
14
web/app/(dashboard)/dashboard/login/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { LoginForm } from "@/components/login-form"
|
||||
import { Suspense } from "react"
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<Suspense fallback={<div className="min-h-96" />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
382
web/app/(dashboard)/dashboard/mcp/page.tsx
Normal file
382
web/app/(dashboard)/dashboard/mcp/page.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, PlugZapIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page";
|
||||
import { JsonCodeEditor } from "@/components/json-code-editor";
|
||||
import { JsonViewer } from "@/components/json-viewer";
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
callMCPTool,
|
||||
listMCPServers,
|
||||
listMCPTools,
|
||||
testMCPConnection,
|
||||
type MCPConnectionResult,
|
||||
type MCPServerInfo,
|
||||
type MCPToolCallResult,
|
||||
type MCPToolInfo,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
const defaultServerCode = "";
|
||||
|
||||
export default function MCPDashboardPage() {
|
||||
const t = useI18n();
|
||||
const [serverCode, setServerCode] = useState(defaultServerCode);
|
||||
const [servers, setServers] = useState<MCPServerInfo[]>([]);
|
||||
const [connection, setConnection] = useState<MCPConnectionResult | null>(
|
||||
null,
|
||||
);
|
||||
const [tools, setTools] = useState<MCPToolInfo[]>([]);
|
||||
const [loadingServers, setLoadingServers] = useState(true);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [loadingTools, setLoadingTools] = useState(false);
|
||||
const [callingTool, setCallingTool] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTool, setActiveTool] = useState<MCPToolInfo | null>(null);
|
||||
const [argumentsText, setArgumentsText] = useState("{}");
|
||||
const [toolResult, setToolResult] = useState<MCPToolCallResult | null>(null);
|
||||
const [argumentsError, setArgumentsError] = useState<string | null>(null);
|
||||
|
||||
const serverOptions = useMemo(
|
||||
() =>
|
||||
servers.map((server) => ({
|
||||
value: server.code,
|
||||
label: server.enabled
|
||||
? `${server.code} (${server.endpoint})`
|
||||
: `${server.code} (${t("mcp.disabled")})`,
|
||||
})),
|
||||
[servers, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadServers() {
|
||||
setLoadingServers(true);
|
||||
try {
|
||||
const result = await listMCPServers();
|
||||
setServers(result);
|
||||
const firstServer = result[0];
|
||||
if (firstServer) {
|
||||
setServerCode((current) => current || firstServer.code);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("mcp.loadServersFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoadingServers(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadServers();
|
||||
}, [t]);
|
||||
|
||||
async function handleTestConnection() {
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testMCPConnection(serverCode.trim());
|
||||
setConnection(result);
|
||||
toast.success(t("mcp.connectSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("mcp.connectFailed"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListTools() {
|
||||
setLoadingTools(true);
|
||||
try {
|
||||
const result = await listMCPTools(serverCode.trim());
|
||||
setTools(result);
|
||||
toast.success(t("mcp.toolsLoaded", { count: result.length }));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("mcp.loadToolsFailed"));
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCallTool() {
|
||||
if (!activeTool) {
|
||||
toast.error(t("mcp.selectToolFirst"));
|
||||
return;
|
||||
}
|
||||
if (argumentsError) {
|
||||
toast.error(t("mcp.argumentsInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedArguments: Record<string, unknown> = {};
|
||||
try {
|
||||
parsedArguments = argumentsText.trim()
|
||||
? (JSON.parse(argumentsText) as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
toast.error(t("mcp.argumentsMustBeJson"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCallingTool(true);
|
||||
try {
|
||||
const result = await callMCPTool({
|
||||
serverCode: serverCode.trim(),
|
||||
toolName: activeTool.name,
|
||||
arguments: parsedArguments,
|
||||
});
|
||||
setToolResult(result);
|
||||
toast.success(result.isError ? t("mcp.toolReturnedError") : t("mcp.toolCallSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("mcp.toolCallFailed"));
|
||||
} finally {
|
||||
setCallingTool(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openToolDrawer(tool: MCPToolInfo) {
|
||||
setActiveTool(tool);
|
||||
setToolResult(null);
|
||||
if (tool.name === "lorem") {
|
||||
setArgumentsText('{\n "wordCount": 8\n}');
|
||||
} else if (tool.name === "ping") {
|
||||
setArgumentsText('{\n "message": "hello from dashboard"\n}');
|
||||
} else {
|
||||
setArgumentsText("{}");
|
||||
}
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPage className="gap-6">
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={testing || loadingServers || !serverCode}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<PlugZapIcon className="mr-2 size-4" />
|
||||
)}
|
||||
{t("mcp.testConnection")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleListTools()}
|
||||
disabled={loadingTools || loadingServers || !serverCode}
|
||||
>
|
||||
{loadingTools ? (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<WrenchIcon className="mr-2 size-4" />
|
||||
)}
|
||||
{t("mcp.listTools")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="shrink-0 text-sm font-medium">{t("mcp.serverConfig")}</span>
|
||||
<div className="min-w-[280px] max-w-[520px] flex-1">
|
||||
<OptionCombobox
|
||||
value={serverCode}
|
||||
options={serverOptions}
|
||||
placeholder={t("mcp.selectServer")}
|
||||
searchPlaceholder={t("mcp.searchServer")}
|
||||
emptyText={t("mcp.emptyServer")}
|
||||
disabled={loadingServers}
|
||||
onChange={(value) => {
|
||||
setServerCode(value);
|
||||
setConnection(null);
|
||||
setTools([]);
|
||||
setToolResult(null);
|
||||
setActiveTool(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{connection ? (
|
||||
<div className="w-full rounded-lg border bg-muted/30 p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{t("mcp.connected")}</Badge>
|
||||
<span className="font-medium">
|
||||
{connection.serverName || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-muted-foreground grid-cols-1 md:grid-cols-5">
|
||||
<div>serverCode: {connection.serverCode}</div>
|
||||
<div>protocol: {connection.protocol || "-"}</div>
|
||||
<div className="break-all md:col-span-2">
|
||||
endpoint: {connection.endpoint}
|
||||
</div>
|
||||
<div>version: {connection.version || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DashboardToolbar>
|
||||
|
||||
{tools.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||
{t("mcp.emptyToolsHint")}
|
||||
</div>
|
||||
) : (
|
||||
<DashboardTableShell>
|
||||
<div>
|
||||
<div className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<div>{t("mcp.toolName")}</div>
|
||||
<div>{t("mcp.description")}</div>
|
||||
<div className="text-right">{t("mcp.actions")}</div>
|
||||
</div>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b px-4 py-4 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="font-medium">{tool.name}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{tool.description || "-"}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openToolDrawer(tool)}
|
||||
>
|
||||
{t("mcp.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DashboardTableShell>
|
||||
)}
|
||||
</DashboardPage>
|
||||
|
||||
<Drawer open={drawerOpen} direction="right" onOpenChange={setDrawerOpen}>
|
||||
<DrawerContent className="min-w-3xl">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{activeTool?.name || t("mcp.toolDetail")}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{t("mcp.detailDescription")}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4">
|
||||
{activeTool ? (
|
||||
<>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{activeTool.name}</Badge>
|
||||
{activeTool.title ? (
|
||||
<span className="text-sm font-medium">
|
||||
{activeTool.title}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{activeTool.description || t("mcp.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Input Schema
|
||||
</p>
|
||||
<JsonViewer value={activeTool.inputSchema} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Output Schema
|
||||
</p>
|
||||
<JsonViewer value={activeTool.outputSchema} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tool-arguments">Arguments JSON</Label>
|
||||
<JsonCodeEditor
|
||||
value={argumentsText}
|
||||
onChange={setArgumentsText}
|
||||
onValidationChange={setArgumentsError}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void handleCallTool()}
|
||||
disabled={
|
||||
callingTool ||
|
||||
loadingServers ||
|
||||
!serverCode ||
|
||||
!!argumentsError
|
||||
}
|
||||
>
|
||||
{callingTool ? (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<WrenchIcon className="mr-2 size-4" />
|
||||
)}
|
||||
{t("mcp.testTool")}
|
||||
</Button>
|
||||
{toolResult ? (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
toolResult.isError ? "destructive" : "default"
|
||||
}
|
||||
>
|
||||
{toolResult.isError ? t("mcp.returnedError") : t("mcp.callSuccess")}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{toolResult.toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Content
|
||||
</p>
|
||||
<JsonViewer value={toolResult.content} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Structured Content
|
||||
</p>
|
||||
<JsonViewer value={toolResult.structuredContent} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button variant="outline" onClick={() => setDrawerOpen(false)}>
|
||||
{t("mcp.close")}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
web/app/(dashboard)/dashboard/notifications/page.tsx
Normal file
128
web/app/(dashboard)/dashboard/notifications/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { BellIcon, CheckCheckIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardListPage } from "@/components/dashboard/list"
|
||||
import { useNotifications } from "@/components/notification-provider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem,
|
||||
type NotificationReadStatus,
|
||||
} from "@/lib/api/notification"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export default function DashboardNotificationsPage() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const { refreshUnreadCount } = useNotifications()
|
||||
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
|
||||
{ value: "all", label: t("notification.all") },
|
||||
{ value: "unread", label: t("notification.unread") },
|
||||
{ value: "read", label: t("notification.read") },
|
||||
]
|
||||
|
||||
async function openNotification(item: NotificationItem) {
|
||||
try {
|
||||
if (!item.readAt) {
|
||||
await markNotificationRead(item.id)
|
||||
await refreshUnreadCount()
|
||||
}
|
||||
if (item.actionUrl) {
|
||||
router.push(item.actionUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("notification.openFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead(reload: () => Promise<void>) {
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
await refreshUnreadCount()
|
||||
await reload()
|
||||
toast.success(t("notification.markAllReadSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("notification.markAllReadFailed")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardListPage<NotificationItem>
|
||||
filters={[
|
||||
{
|
||||
name: "readStatus",
|
||||
label: t("notification.all"),
|
||||
type: "segment",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: readStatusOptions,
|
||||
},
|
||||
]}
|
||||
fetchList={fetchNotifications}
|
||||
renderToolbarActions={({ result, reload }) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void markAllRead(reload)}
|
||||
disabled={result.page.total === 0}
|
||||
>
|
||||
<CheckCheckIcon />
|
||||
{t("notification.markAllRead")}
|
||||
</Button>
|
||||
)}
|
||||
renderContent={({ result, loading }) =>
|
||||
result.results.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{result.results.map((item) => {
|
||||
const unread = !item.readAt
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => void openNotification(item)}
|
||||
className="grid w-full gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BellIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{item.title || t("notification.fallbackTitle")}
|
||||
</span>
|
||||
{unread ? (
|
||||
<Badge>{t("notification.unread")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("notification.read")}</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-line text-sm text-muted-foreground">
|
||||
{item.content || "-"}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
{loading ? t("notification.loading") : t("notification.empty")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
labels={{
|
||||
refresh: t("notification.refresh"),
|
||||
loading: t("notification.loading"),
|
||||
empty: t("notification.empty"),
|
||||
loadFailed: t("notification.loadFailed"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardHome } from "./_components/dashboard-home"
|
||||
|
||||
export default function DashboardHomePage() {
|
||||
return <DashboardHome />
|
||||
}
|
||||
169
web/app/(dashboard)/dashboard/permissions/page.tsx
Normal file
169
web/app/(dashboard)/dashboard/permissions/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { KeyRoundIcon, RouteIcon, SearchIcon, ShieldCheckIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { DashboardListPage } from "@/components/dashboard/list"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { fetchPermissions, syncPermissions, type AdminPermission } from "@/lib/api/admin"
|
||||
import { Status } from "@/lib/generated/enums"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n"
|
||||
|
||||
export default function DashboardPermissionsPage() {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const confirm = useConfirm()
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const listStatusOptions = [
|
||||
{ value: "all", label: t("status.all") },
|
||||
{ value: String(Status.Ok), label: t("status.ok") },
|
||||
{ value: String(Status.Disabled), label: t("status.disabled") },
|
||||
{ value: String(Status.Deleted), label: t("status.deleted") },
|
||||
]
|
||||
|
||||
async function handleSync(reload: () => Promise<void>) {
|
||||
const confirmed = await confirm({
|
||||
title: t("permission.syncTitle"),
|
||||
description: t("permission.syncDescription"),
|
||||
confirmText: t("permission.sync"),
|
||||
})
|
||||
if (!confirmed || syncing) return
|
||||
|
||||
setSyncing(true)
|
||||
try {
|
||||
const result = await syncPermissions()
|
||||
toast.success(t("permission.syncSuccess", {
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
rolePermissionsAdded: result.rolePermissionsAdded,
|
||||
}))
|
||||
await reload()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("permission.syncFailed"))
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardListPage<AdminPermission>
|
||||
filters={[
|
||||
{
|
||||
name: "keyword",
|
||||
label: t("permission.filterKeyword"),
|
||||
placeholder: t("permission.filterKeyword"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
inputClassName: "pl-9",
|
||||
icon: <SearchIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
name: "groupName",
|
||||
label: t("permission.filterGroup"),
|
||||
placeholder: t("permission.filterGroup"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-44",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("status.all"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: listStatusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
]}
|
||||
fetchList={fetchPermissions}
|
||||
renderToolbarActions={({ reload }) => (
|
||||
<Button onClick={() => void handleSync(reload)} disabled={syncing}>
|
||||
<ShieldCheckIcon className={syncing ? "animate-pulse" : undefined} />
|
||||
{syncing ? t("permission.syncing") : t("permission.sync")}
|
||||
</Button>
|
||||
)}
|
||||
getItemId={(item) => item.id}
|
||||
columns={[
|
||||
{
|
||||
key: "permission",
|
||||
label: t("permission.columnPermission"),
|
||||
render: (item) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<KeyRoundIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{getPermissionDisplayName(item.code, item.name, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{item.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: t("permission.columnCode"),
|
||||
render: (item) => <Badge variant="outline">{item.code}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "group",
|
||||
label: t("permission.columnGroup"),
|
||||
render: (item) => getPermissionGroupName(item.groupName, locale),
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
label: t("permission.columnApi"),
|
||||
render: (item) => (
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge variant="secondary">{item.method || "ANY"}</Badge>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<RouteIcon className="size-3.5" />
|
||||
{item.apiPath || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("permission.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||
{getStatusLabel(item.status, t)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
labels={{
|
||||
refresh: t("permission.refresh"),
|
||||
query: t("permission.query"),
|
||||
loading: t("permission.loading"),
|
||||
empty: t("permission.empty"),
|
||||
loadFailed: t("permission.loadFailed"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusLabel(
|
||||
status: number,
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
if (status === Status.Ok) {
|
||||
return t("status.ok")
|
||||
}
|
||||
if (status === Status.Disabled) {
|
||||
return t("status.disabled")
|
||||
}
|
||||
if (status === Status.Deleted) {
|
||||
return t("status.deleted")
|
||||
}
|
||||
return String(status)
|
||||
}
|
||||
244
web/app/(dashboard)/dashboard/quick-replies/page.tsx
Normal file
244
web/app/(dashboard)/dashboard/quick-replies/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client"
|
||||
|
||||
import { FileTextIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
createDashboardStatusColumn,
|
||||
createDashboardStatusToggleAction,
|
||||
DashboardCrudPage,
|
||||
} from "@/components/dashboard/crud"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
createQuickReply,
|
||||
deleteQuickReply,
|
||||
fetchQuickReply,
|
||||
fetchQuickReplies,
|
||||
updateQuickReply,
|
||||
type AdminQuickReply,
|
||||
type CreateAdminQuickReplyPayload,
|
||||
} from "@/lib/api/admin"
|
||||
import { getEnumOptions } from "@/lib/enums"
|
||||
import { Status, StatusLabels } from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
function getStatusLabel(status: Status, t: (key: string) => string) {
|
||||
if (status === Status.Disabled) {
|
||||
return t("status.disabled")
|
||||
}
|
||||
if (status === Status.Deleted) {
|
||||
return t("status.deleted")
|
||||
}
|
||||
return t("status.ok")
|
||||
}
|
||||
|
||||
export default function DashboardQuickRepliesPage() {
|
||||
const t = useI18n()
|
||||
const listStatusOptions = [
|
||||
{ value: "all", label: t("status.all") },
|
||||
...getEnumOptions(StatusLabels)
|
||||
.filter((item) => Number(item.value) !== Status.Deleted)
|
||||
.map((item) => ({
|
||||
value: String(item.value),
|
||||
label: getStatusLabel(item.value as Status, t),
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardCrudPage<AdminQuickReply, CreateAdminQuickReplyPayload>
|
||||
filters={[
|
||||
{
|
||||
name: "title",
|
||||
label: t("quickReply.filterTitle"),
|
||||
placeholder: t("quickReply.filterTitle"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
{
|
||||
name: "groupName",
|
||||
label: t("quickReply.filterGroup"),
|
||||
placeholder: t("quickReply.filterGroup"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-44",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("status.all"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
options: listStatusOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
key: "quickReply",
|
||||
label: t("quickReply.columnQuickReply"),
|
||||
render: (item) => (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<FileTextIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{item.title}</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{item.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "groupName",
|
||||
label: t("quickReply.columnGroup"),
|
||||
render: (item) => <Badge variant="outline">{item.groupName}</Badge>,
|
||||
},
|
||||
createDashboardStatusColumn<AdminQuickReply, Status>({
|
||||
label: t("quickReply.columnStatus"),
|
||||
getStatus: (item) => item.status as Status,
|
||||
getLabel: (status) => getStatusLabel(status, t),
|
||||
getBadgeVariant: (status) => (status === Status.Ok ? "default" : "outline"),
|
||||
}),
|
||||
{
|
||||
key: "sortNo",
|
||||
label: t("quickReply.columnSort"),
|
||||
render: (item) => item.sortNo,
|
||||
},
|
||||
{
|
||||
key: "createdBy",
|
||||
label: t("quickReply.columnCreator"),
|
||||
render: (item) => item.createdBy || "-",
|
||||
},
|
||||
]}
|
||||
fetchList={fetchQuickReplies}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createQuickReply}
|
||||
updateItem={(item, payload) => updateQuickReply({ id: item.id, ...payload })}
|
||||
deleteItem={(item) => deleteQuickReply(item.id)}
|
||||
form={{
|
||||
fetchDetail: fetchQuickReply,
|
||||
fields: [
|
||||
{
|
||||
name: "groupName",
|
||||
label: t("quickReply.groupName"),
|
||||
placeholder: t("quickReply.groupNamePlaceholder"),
|
||||
required: true,
|
||||
requiredMessage: t("quickReply.groupNameRequired"),
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
label: t("quickReply.title"),
|
||||
placeholder: t("quickReply.titlePlaceholder"),
|
||||
required: true,
|
||||
requiredMessage: t("quickReply.titleRequired"),
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
label: t("quickReply.content"),
|
||||
placeholder: t("quickReply.contentPlaceholder"),
|
||||
type: "textarea",
|
||||
rows: 6,
|
||||
required: true,
|
||||
requiredMessage: t("quickReply.contentRequired"),
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("quickReply.columnStatus"),
|
||||
type: "select",
|
||||
defaultValue: String(Status.Ok),
|
||||
valueType: "number",
|
||||
required: true,
|
||||
requiredMessage: t("quickReply.statusRequired"),
|
||||
options: listStatusOptions.filter((item) => item.value !== "all"),
|
||||
valueFromItem: (item) => String(item.status),
|
||||
},
|
||||
{
|
||||
name: "sortNo",
|
||||
label: t("quickReply.columnSort"),
|
||||
placeholder: t("quickReply.sortPlaceholder"),
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
min: 0,
|
||||
step: 1,
|
||||
required: true,
|
||||
requiredMessage: t("quickReply.sortRequired"),
|
||||
pattern: /^\d+$/,
|
||||
patternMessage: t("quickReply.sortInvalid"),
|
||||
},
|
||||
],
|
||||
transformSubmitValues: (values) => ({
|
||||
groupName: String(values.groupName ?? ""),
|
||||
title: String(values.title ?? ""),
|
||||
content: String(values.content ?? ""),
|
||||
status: Number(values.status),
|
||||
sortNo: Number(values.sortNo),
|
||||
}),
|
||||
labels: {
|
||||
createTitle: t("quickReply.createTitle"),
|
||||
editTitle: t("quickReply.editTitle"),
|
||||
create: t("quickReply.create"),
|
||||
save: t("quickReply.save"),
|
||||
saving: t("quickReply.saving"),
|
||||
cancel: t("quickReply.cancel"),
|
||||
loadingDetail: t("quickReply.loadingDetail"),
|
||||
required: t("quickReply.titleRequired"),
|
||||
invalidNumber: t("quickReply.sortInvalid"),
|
||||
minValue: () => t("quickReply.sortInvalid"),
|
||||
maxValue: () => t("quickReply.sortInvalid"),
|
||||
},
|
||||
}}
|
||||
rowActions={[
|
||||
createDashboardStatusToggleAction<AdminQuickReply, Status>({
|
||||
icon: <RefreshCwIcon />,
|
||||
label: (item) =>
|
||||
item.status === Status.Ok
|
||||
? t("quickReply.disable")
|
||||
: t("quickReply.enable"),
|
||||
getNextStatus: (item) =>
|
||||
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||
updateStatus: (item, nextStatus) =>
|
||||
updateQuickReply({
|
||||
id: item.id,
|
||||
groupName: item.groupName,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
sortNo: item.sortNo,
|
||||
status: nextStatus,
|
||||
}),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t(
|
||||
nextStatus === Status.Ok
|
||||
? "quickReply.enabled"
|
||||
: "quickReply.disabled",
|
||||
{ title: item.title }
|
||||
),
|
||||
errorMessage: t("quickReply.statusUpdateFailed"),
|
||||
}),
|
||||
]}
|
||||
labels={{
|
||||
refresh: t("quickReply.refresh"),
|
||||
create: t("quickReply.new"),
|
||||
query: t("quickReply.query"),
|
||||
loading: t("quickReply.loading"),
|
||||
empty: t("quickReply.empty"),
|
||||
actions: t("quickReply.columnActions"),
|
||||
edit: t("quickReply.edit"),
|
||||
delete: t("quickReply.delete"),
|
||||
processing: t("quickReply.processing"),
|
||||
moreActions: (item) =>
|
||||
t("quickReply.moreActions", { title: item.title }),
|
||||
loadFailed: t("quickReply.loadFailed"),
|
||||
saveFailed: t("quickReply.saveFailed"),
|
||||
deleteFailed: t("quickReply.deleteFailed"),
|
||||
created: (payload) => t("quickReply.created", { title: payload.title }),
|
||||
updated: (item) => t("quickReply.updated", { title: item.title }),
|
||||
deleted: (item) => t("quickReply.deleted", { title: item.title }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, Resolver, useForm } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { AdminPermission, AdminRole } from "@/lib/api/admin";
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider";
|
||||
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n";
|
||||
import { getRoleDisplayName } from "@/lib/role-i18n";
|
||||
|
||||
type AssignPermissionsDrawerProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
loading: boolean;
|
||||
item: AdminRole | null;
|
||||
permissions: AdminPermission[];
|
||||
selectedPermissionIds: number[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (permissionIds: number[]) => Promise<void>;
|
||||
};
|
||||
|
||||
const assignPermissionsSchema = z.object({
|
||||
permissionIds: z.array(z.number().int().positive()),
|
||||
});
|
||||
|
||||
type AssignPermissionsForm = z.infer<typeof assignPermissionsSchema>;
|
||||
|
||||
const assignPermissionsResolver = zodResolver(
|
||||
assignPermissionsSchema as never,
|
||||
) as Resolver<
|
||||
z.input<typeof assignPermissionsSchema>,
|
||||
undefined,
|
||||
z.output<typeof assignPermissionsSchema>
|
||||
>;
|
||||
|
||||
function buildForm(selectedPermissionIds: number[]): AssignPermissionsForm {
|
||||
return {
|
||||
permissionIds: selectedPermissionIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function AssignPermissionsDrawer({
|
||||
open,
|
||||
saving,
|
||||
loading,
|
||||
item,
|
||||
permissions,
|
||||
selectedPermissionIds,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AssignPermissionsDrawerProps) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
|
||||
{open ? (
|
||||
<AssignPermissionsDrawerBody
|
||||
key={item ? `assign-permissions-${item.id}` : "assign-permissions"}
|
||||
saving={saving}
|
||||
loading={loading}
|
||||
item={item}
|
||||
permissions={permissions}
|
||||
selectedPermissionIds={selectedPermissionIds}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
type AssignPermissionsDrawerBodyProps = {
|
||||
saving: boolean;
|
||||
loading: boolean;
|
||||
item: AdminRole | null;
|
||||
permissions: AdminPermission[];
|
||||
selectedPermissionIds: number[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (permissionIds: number[]) => Promise<void>;
|
||||
};
|
||||
|
||||
function AssignPermissionsDrawerBody({
|
||||
saving,
|
||||
loading,
|
||||
item,
|
||||
permissions,
|
||||
selectedPermissionIds,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AssignPermissionsDrawerBodyProps) {
|
||||
const t = useI18n();
|
||||
const { locale } = useAppLocale();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const form = useForm<
|
||||
z.input<typeof assignPermissionsSchema>,
|
||||
undefined,
|
||||
z.output<typeof assignPermissionsSchema>
|
||||
>({
|
||||
resolver: assignPermissionsResolver,
|
||||
defaultValues: buildForm(selectedPermissionIds),
|
||||
});
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
useEffect(() => {
|
||||
reset(buildForm(selectedPermissionIds));
|
||||
}, [reset, selectedPermissionIds]);
|
||||
|
||||
const groupedPermissions = useMemo(() => {
|
||||
const output = keyword.trim().toLowerCase();
|
||||
const filtered = output
|
||||
? permissions.filter((permission) =>
|
||||
`${permission.name} ${getPermissionDisplayName(permission.code, permission.name, locale)} ${permission.code} ${permission.groupName} ${getPermissionGroupName(permission.groupName, locale)} ${permission.apiPath}`
|
||||
.toLowerCase()
|
||||
.includes(output),
|
||||
)
|
||||
: permissions;
|
||||
|
||||
const groups = new Map<string, AdminPermission[]>();
|
||||
filtered.forEach((permission) => {
|
||||
const groupName = permission.groupName || "default";
|
||||
const list = groups.get(groupName) ?? [];
|
||||
list.push(permission);
|
||||
groups.set(groupName, list);
|
||||
});
|
||||
return Array.from(groups.entries()).sort(([left], [right]) =>
|
||||
getPermissionGroupName(left, locale).localeCompare(getPermissionGroupName(right, locale)),
|
||||
);
|
||||
}, [keyword, locale, permissions]);
|
||||
|
||||
async function onFormSubmit(values: AssignPermissionsForm) {
|
||||
await onSubmit(values.permissionIds);
|
||||
}
|
||||
|
||||
return (
|
||||
<DrawerContent className="min-w-3xl">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t("role.assignTitle")}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{t("role.currentRole", {
|
||||
name: item ? getRoleDisplayName(item.code, item.name, locale) : "-",
|
||||
code: item?.code ? `(${item.code})` : "",
|
||||
})}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<form
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div className="flex-1 flex flex-col min-h-0 space-y-4 px-4 pb-4">
|
||||
<Field data-invalid={!!errors.permissionIds} className="flex-1 flex flex-col min-h-0">
|
||||
<FieldLabel>{t("role.permissionList")}</FieldLabel>
|
||||
<FieldContent className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="relative mb-2">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={t("role.searchPermission")}
|
||||
className="pl-9"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="permissionIds"
|
||||
render={({ field }) => {
|
||||
const value = field.value || [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 space-y-4 overflow-y-auto rounded-md border p-3">
|
||||
{loading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("role.loadingPermissions")}
|
||||
</div>
|
||||
) : groupedPermissions.length > 0 ? (
|
||||
groupedPermissions.map(([groupName, list]) => (
|
||||
<section key={groupName} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{getPermissionGroupName(groupName, locale)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("role.permissionCount", { count: list.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{list.map((permission) => {
|
||||
const checked = value.includes(permission.id);
|
||||
return (
|
||||
<label
|
||||
key={permission.id}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg border border-transparent px-3 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
if (nextChecked) {
|
||||
field.onChange([
|
||||
...value,
|
||||
permission.id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
field.onChange(
|
||||
value.filter(
|
||||
(currentId) =>
|
||||
currentId !== permission.id,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">
|
||||
{getPermissionDisplayName(permission.code, permission.name, locale)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{permission.code}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{permission.method || "ANY"}{" "}
|
||||
{permission.apiPath || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
permission.status === 0
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{permission.status === 1
|
||||
? t("status.disabled")
|
||||
: t("status.ok")}
|
||||
</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("role.noPermissions")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FieldError errors={[errors.permissionIds]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<Button type="submit" disabled={saving || loading || !item}>
|
||||
{saving ? t("role.saving") : t("role.savePermissions")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("role.cancel")}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
);
|
||||
}
|
||||
192
web/app/(dashboard)/dashboard/roles/_components/create.tsx
Normal file
192
web/app/(dashboard)/dashboard/roles/_components/create.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { Resolver, useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { type CreateAdminRolePayload } from "@/lib/api/admin"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type CreateRoleDrawerProps = {
|
||||
open: boolean
|
||||
saving: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateAdminRolePayload) => Promise<void>
|
||||
}
|
||||
|
||||
type CreateForm = {
|
||||
name: string
|
||||
code: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
const emptyForm: CreateForm = {
|
||||
name: "",
|
||||
code: "",
|
||||
remark: "",
|
||||
}
|
||||
|
||||
function buildEmptyForm(): CreateForm {
|
||||
return {
|
||||
...emptyForm,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(form: CreateForm): CreateAdminRolePayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
remark: form.remark.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateRoleDrawer({
|
||||
open,
|
||||
saving,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: CreateRoleDrawerProps) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
|
||||
{open ? (
|
||||
<CreateRoleDrawerBody
|
||||
key="create-role"
|
||||
saving={saving}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
type CreateRoleDrawerBodyProps = {
|
||||
saving: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateAdminRolePayload) => Promise<void>
|
||||
}
|
||||
|
||||
function CreateRoleDrawerBody({
|
||||
saving,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: CreateRoleDrawerBodyProps) {
|
||||
const t = useI18n()
|
||||
const createFormSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
name: z.string().trim().min(1, t("role.nameRequired")),
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t("role.codeRequired"))
|
||||
.regex(/^[A-Za-z][A-Za-z0-9:_-]*$/, t("role.codeInvalid")),
|
||||
remark: z.string().trim(),
|
||||
}),
|
||||
[t]
|
||||
)
|
||||
const createFormResolver = useMemo(
|
||||
() => zodResolver(createFormSchema as never) as Resolver<CreateForm>,
|
||||
[createFormSchema]
|
||||
)
|
||||
const form = useForm<CreateForm>({
|
||||
resolver: createFormResolver,
|
||||
defaultValues: buildEmptyForm(),
|
||||
})
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
async function onFormSubmit(values: CreateForm) {
|
||||
await onSubmit(buildPayload(values))
|
||||
reset(buildEmptyForm())
|
||||
}
|
||||
|
||||
return (
|
||||
<DrawerContent className="min-w-2xl">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t("role.createTitle")}</DrawerTitle>
|
||||
<DrawerDescription>{t("role.createDescription")}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<form
|
||||
className="flex h-full flex-col"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div className="space-y-4 overflow-y-auto px-4 pb-4">
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="create-role-name">{t("role.name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-role-name"
|
||||
placeholder={t("role.namePlaceholder")}
|
||||
autoComplete="off"
|
||||
aria-invalid={!!errors.name}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.code}>
|
||||
<FieldLabel htmlFor="create-role-code">{t("role.code")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-role-code"
|
||||
placeholder={t("role.codePlaceholder")}
|
||||
autoComplete="off"
|
||||
aria-invalid={!!errors.code}
|
||||
{...register("code")}
|
||||
/>
|
||||
<FieldError errors={[errors.code]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.remark}>
|
||||
<FieldLabel htmlFor="create-role-remark">{t("role.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="create-role-remark"
|
||||
placeholder={t("role.optional")}
|
||||
aria-invalid={!!errors.remark}
|
||||
{...register("remark")}
|
||||
/>
|
||||
<FieldError errors={[errors.remark]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? t("role.creating") : t("role.create")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("role.cancel")}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
)
|
||||
}
|
||||
427
web/app/(dashboard)/dashboard/roles/page.tsx
Normal file
427
web/app/(dashboard)/dashboard/roles/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import type { CSSProperties } from "react"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
GripVerticalIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldIcon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
assignRolePermissions,
|
||||
createRole,
|
||||
fetchPermissions,
|
||||
fetchRoleDetail,
|
||||
fetchRoles,
|
||||
type AdminPermission,
|
||||
type AdminRole,
|
||||
type CreateAdminRolePayload,
|
||||
type PageResult,
|
||||
updateRoleSort,
|
||||
} from "@/lib/api/admin"
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardTableStateRow,
|
||||
DashboardTableSummary,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AssignPermissionsDrawer } from "./_components/assign-permissions"
|
||||
import { CreateRoleDrawer } from "./_components/create"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Status } from "@/lib/generated/enums"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { getRoleDisplayName } from "@/lib/role-i18n"
|
||||
|
||||
type SortableRoleRowProps = {
|
||||
item: AdminRole
|
||||
disabled: boolean
|
||||
actionLoading: boolean
|
||||
onAssignPermissions: (item: AdminRole) => void
|
||||
}
|
||||
|
||||
function SortableRoleRow({
|
||||
item,
|
||||
disabled,
|
||||
actionLoading,
|
||||
onAssignPermissions,
|
||||
}: SortableRoleRowProps) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const displayName = getRoleDisplayName(item.code, item.name, locale)
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: item.id,
|
||||
disabled,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
isDragging && "relative z-10 bg-muted/60 shadow-sm",
|
||||
!disabled && "cursor-move"
|
||||
)}
|
||||
>
|
||||
<TableCell className="w-14">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 cursor-grab active:cursor-grabbing"
|
||||
disabled={disabled}
|
||||
aria-label={t("role.dragSort", { name: displayName })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<ShieldCheckIcon className="size-4" />
|
||||
</div>
|
||||
<div className="font-medium">{displayName}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
<ShieldIcon className="size-3" />
|
||||
{item.code}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||
{item.status === Status.Ok ? t("status.ok") : t("status.disabled")}
|
||||
</Badge>
|
||||
{item.isSystem ? (
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{t("role.system")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{item.sortNo}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAssignPermissions(item)}
|
||||
disabled={disabled || actionLoading}
|
||||
>
|
||||
{actionLoading ? t("role.processing") : t("role.assignPermissions")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardRolesPage() {
|
||||
const t = useI18n()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sorting, setSorting] = useState(false)
|
||||
const [creatingOpen, setCreatingOpen] = useState(false)
|
||||
const [savingCreate, setSavingCreate] = useState(false)
|
||||
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||
const [assignPermissionsLoading, setAssignPermissionsLoading] = useState(false)
|
||||
const [assigningRole, setAssigningRole] = useState<AdminRole | null>(null)
|
||||
const [assignPermissionOptions, setAssignPermissionOptions] = useState<
|
||||
AdminPermission[]
|
||||
>([])
|
||||
const [assignPermissionIds, setAssignPermissionIds] = useState<number[]>([])
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const [result, setResult] = useState<PageResult<AdminRole>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: 20, total: 0 },
|
||||
})
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
async function loadRoles() {
|
||||
setLoading(true)
|
||||
try {
|
||||
setResult(await fetchRoles({ limit: 200 }))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("role.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateDrawerOpenChange(open: boolean) {
|
||||
if (savingCreate) {
|
||||
return
|
||||
}
|
||||
setCreatingOpen(open)
|
||||
}
|
||||
|
||||
async function handleCreateRole(payload: CreateAdminRolePayload) {
|
||||
if (savingCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
setSavingCreate(true)
|
||||
try {
|
||||
const role = await createRole(payload)
|
||||
toast.success(t("role.created", { name: role.name }))
|
||||
setCreatingOpen(false)
|
||||
await loadRoles()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("role.createFailed"))
|
||||
} finally {
|
||||
setSavingCreate(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openAssignPermissionsDrawer(role: AdminRole) {
|
||||
setActionLoadingId(role.id)
|
||||
setAssigningRole(role)
|
||||
setAssignPermissionsLoading(true)
|
||||
try {
|
||||
const [permissionsResult, roleDetail] = await Promise.all([
|
||||
fetchPermissions({ limit: 500 }),
|
||||
fetchRoleDetail(role.id),
|
||||
])
|
||||
const permissionCodeSet = new Set(roleDetail.permissions || [])
|
||||
setAssignPermissionOptions(permissionsResult.results)
|
||||
setAssignPermissionIds(
|
||||
permissionsResult.results
|
||||
.filter((permission) => permissionCodeSet.has(permission.code))
|
||||
.map((permission) => permission.id)
|
||||
)
|
||||
} catch (error) {
|
||||
setAssigningRole(null)
|
||||
toast.error(error instanceof Error ? error.message : t("role.loadAssignFailed"))
|
||||
} finally {
|
||||
setAssignPermissionsLoading(false)
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || sorting) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousResults = result.results
|
||||
const oldIndex = previousResults.findIndex((item) => item.id === active.id)
|
||||
const newIndex = previousResults.findIndex((item) => item.id === over.id)
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextResults = arrayMove(previousResults, oldIndex, newIndex)
|
||||
setResult((current) => ({
|
||||
...current,
|
||||
results: nextResults,
|
||||
}))
|
||||
setSorting(true)
|
||||
|
||||
try {
|
||||
await updateRoleSort(nextResults.map((item) => item.id))
|
||||
toast.success(t("role.sortUpdated"))
|
||||
await loadRoles()
|
||||
} catch (error) {
|
||||
setResult((current) => ({
|
||||
...current,
|
||||
results: previousResults,
|
||||
}))
|
||||
toast.error(error instanceof Error ? error.message : t("role.sortUpdateFailed"))
|
||||
} finally {
|
||||
setSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAssignPermissionsOpenChange(open: boolean) {
|
||||
if (savingPermissions) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setAssigningRole(null)
|
||||
setAssignPermissionOptions([])
|
||||
setAssignPermissionIds([])
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignPermissions(permissionIds: number[]) {
|
||||
if (!assigningRole || savingPermissions) {
|
||||
return
|
||||
}
|
||||
|
||||
setSavingPermissions(true)
|
||||
try {
|
||||
await assignRolePermissions(assigningRole.id, permissionIds)
|
||||
toast.success(t("role.permissionsUpdated", { name: assigningRole.name }))
|
||||
setAssigningRole(null)
|
||||
setAssignPermissionOptions([])
|
||||
setAssignPermissionIds([])
|
||||
await loadRoles()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("role.savePermissionsFailed"))
|
||||
} finally {
|
||||
setSavingPermissions(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoles()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreatingOpen(true)}
|
||||
disabled={loading || sorting}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{t("role.add")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void loadRoles()}
|
||||
disabled={loading || sorting}
|
||||
>
|
||||
<RefreshCwIcon className={cn((loading || sorting) && "animate-spin")} />
|
||||
{t("role.refresh")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("role.dragHint")}
|
||||
</div>
|
||||
</DashboardToolbar>
|
||||
<DashboardTableShell
|
||||
pagination={
|
||||
<DashboardTableSummary>
|
||||
<span>
|
||||
{t("role.pageSummary", { page: result.page.page, limit: result.page.limit })}
|
||||
</span>
|
||||
<span>{t("pagination.total", { total: result.page.total })}</span>
|
||||
</DashboardTableSummary>
|
||||
}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => void handleDragEnd(event)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead className="w-14"></TableHead>
|
||||
<TableHead>{t("role.columnRole")}</TableHead>
|
||||
<TableHead>{t("role.columnCode")}</TableHead>
|
||||
<TableHead>{t("role.columnStatus")}</TableHead>
|
||||
<TableHead>{t("role.columnSort")}</TableHead>
|
||||
<TableHead className="text-right">{t("role.columnActions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={result.results.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{result.results.map((item) => (
|
||||
<SortableRoleRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
disabled={loading || sorting}
|
||||
actionLoading={actionLoadingId === item.id}
|
||||
onAssignPermissions={(current) =>
|
||||
void openAssignPermissionsDrawer(current)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{loading || result.results.length === 0 ? (
|
||||
<DashboardTableStateRow
|
||||
colSpan={6}
|
||||
loading={loading}
|
||||
loadingText={t("role.loading")}
|
||||
emptyText={t("role.empty")}
|
||||
/>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</DashboardTableShell>
|
||||
<AssignPermissionsDrawer
|
||||
open={!!assigningRole}
|
||||
saving={savingPermissions}
|
||||
loading={assignPermissionsLoading}
|
||||
item={assigningRole}
|
||||
permissions={assignPermissionOptions}
|
||||
selectedPermissionIds={assignPermissionIds}
|
||||
onOpenChange={handleAssignPermissionsOpenChange}
|
||||
onSubmit={handleAssignPermissions}
|
||||
/>
|
||||
<CreateRoleDrawer
|
||||
open={creatingOpen}
|
||||
saving={savingCreate}
|
||||
onOpenChange={handleCreateDrawerOpenChange}
|
||||
onSubmit={handleCreateRole}
|
||||
/>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
156
web/app/(dashboard)/dashboard/settings/page.tsx
Normal file
156
web/app/(dashboard)/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
fetchDashboardBranding,
|
||||
saveDashboardBranding,
|
||||
type DashboardBranding,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
/**
|
||||
* Renames the deployment.
|
||||
*
|
||||
* The name applies to the visitor portal, the dashboard chrome and the widget,
|
||||
* because all three read the same resolved branding. Clearing a field hands
|
||||
* control back to configuration rather than blanking the name, so a deployment
|
||||
* that sets COMPANY_NAME in its environment never ends up nameless.
|
||||
*/
|
||||
export default function DashboardSettingsPage() {
|
||||
const t = useI18n()
|
||||
const [branding, setBranding] = useState<DashboardBranding | null>(null)
|
||||
const [companyName, setCompanyName] = useState("")
|
||||
const [companyLogoUrl, setCompanyLogoUrl] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadError, setLoadError] = useState("")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError("")
|
||||
try {
|
||||
const result = await fetchDashboardBranding()
|
||||
setBranding(result)
|
||||
setCompanyName(result.companyName ?? "")
|
||||
setCompanyLogoUrl(result.companyLogoUrl ?? "")
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : t("settings.brandingLoadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
async function handleSave() {
|
||||
if (saving) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await saveDashboardBranding({
|
||||
companyName,
|
||||
companyLogoUrl,
|
||||
})
|
||||
setBranding(result)
|
||||
setCompanyName(result.companyName ?? "")
|
||||
setCompanyLogoUrl(result.companyLogoUrl ?? "")
|
||||
toast.success(t("settings.brandingSaved"))
|
||||
// The name appears in the portal header too, which caches the public config
|
||||
// for the session, so a reload is the honest way to show the new value.
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("settings.brandingSaveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.brandingTitle")}</CardTitle>
|
||||
<CardDescription>{t("settings.brandingDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{loadError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{loadError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="branding-company-name">
|
||||
{t("systemConfig.branding.companyName.title")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="branding-company-name"
|
||||
value={companyName}
|
||||
maxLength={120}
|
||||
disabled={loading || saving}
|
||||
placeholder={t("app.brand")}
|
||||
onChange={(event) => setCompanyName(event.target.value)}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{t("systemConfig.branding.companyName.description")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="branding-company-logo">
|
||||
{t("systemConfig.branding.companyLogoUrl.title")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="branding-company-logo"
|
||||
value={companyLogoUrl}
|
||||
maxLength={500}
|
||||
disabled={loading || saving}
|
||||
placeholder="/images/logo.svg"
|
||||
onChange={(event) => setCompanyLogoUrl(event.target.value)}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{t("systemConfig.branding.companyLogoUrl.description")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{branding?.usesConfiguredFallback ? (
|
||||
<p className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("settings.brandingConfiguredFallback", {
|
||||
name: branding.configuredCompanyName,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
<CardFooter className="gap-2">
|
||||
<Button type="button" disabled={loading || saving} onClick={() => void handleSave()}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={loading || saving}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
ExternalLinkIcon,
|
||||
Loader2Icon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
SparklesIcon,
|
||||
UserCheckIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardPage } from "@/components/dashboard-page"
|
||||
import {
|
||||
notifySetupReadinessUpdated,
|
||||
SETUP_READINESS_UPDATED_EVENT,
|
||||
} from "@/components/operator-readiness-indicator"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
simulateSetupRoute,
|
||||
type SetupQuickFixAction,
|
||||
type SetupReadinessResponse,
|
||||
type SetupSimulateRouteResponse,
|
||||
type SetupStepStatus,
|
||||
} from "@/lib/api/setup-readiness"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function StatusIcon({ status }: { status: SetupStepStatus }) {
|
||||
if (status === "ok") {
|
||||
return (
|
||||
<CheckCircle2Icon className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)
|
||||
}
|
||||
if (status === "warning") {
|
||||
return (
|
||||
<AlertTriangleIcon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<XCircleIcon className="size-4 shrink-0 text-destructive" />
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: SetupStepStatus }) {
|
||||
const t = useI18n()
|
||||
if (status === "ok") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-md border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
{t("setupWizard.stepBadgeOk")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (status === "warning") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-md border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{t("setupWizard.stepBadgeWarning")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-md border-destructive/40 bg-destructive/10 text-destructive"
|
||||
>
|
||||
{t("setupWizard.stepBadgeError")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function SetupWizardView() {
|
||||
const t = useI18n()
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fixingAction, setFixingAction] = useState<string | null>(null)
|
||||
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>("")
|
||||
const [simulation, setSimulation] =
|
||||
useState<SetupSimulateRouteResponse | null>(null)
|
||||
const [simulating, setSimulating] = useState(false)
|
||||
|
||||
const runSimulation = useCallback(async (channelIdNum?: number) => {
|
||||
setSimulating(true)
|
||||
try {
|
||||
const sim = await simulateSetupRoute({
|
||||
channel_id: channelIdNum && channelIdNum > 0 ? channelIdNum : undefined,
|
||||
bypass_ai: true,
|
||||
})
|
||||
setSimulation(sim)
|
||||
if (sim.channel_id > 0) {
|
||||
setSelectedChannelId(String(sim.channel_id))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setSimulating(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchSetupReadiness()
|
||||
setReadiness(data)
|
||||
const currentNum = Number(selectedChannelId)
|
||||
const targetChannelId =
|
||||
currentNum > 0
|
||||
? currentNum
|
||||
: data.channels.find((ch) => ch.status === 0)?.id ??
|
||||
data.channels[0]?.id ??
|
||||
0
|
||||
if (targetChannelId > 0) {
|
||||
setSelectedChannelId(String(targetChannelId))
|
||||
}
|
||||
await runSimulation(targetChannelId)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.loadFailed"),
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [runSimulation, selectedChannelId, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll()
|
||||
const handleUpdated = () => {
|
||||
void loadAll()
|
||||
}
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
}
|
||||
}, [loadAll])
|
||||
|
||||
async function handleQuickFix(
|
||||
action: SetupQuickFixAction,
|
||||
entityId?: number,
|
||||
) {
|
||||
const key = entityId ? `${action}:${entityId}` : action
|
||||
setFixingAction(key)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action, entity_id: entityId })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
const targetChannelId =
|
||||
Number(selectedChannelId) ||
|
||||
res.readiness.channels.find((ch) => ch.status === 0)?.id ||
|
||||
0
|
||||
await runSimulation(targetChannelId)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setFixingAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const canQuickSetupAll = useMemo(() => {
|
||||
return (
|
||||
readiness?.steps.some((s) =>
|
||||
s.quick_fix_actions.includes("quick_setup_all"),
|
||||
) ?? false
|
||||
)
|
||||
}, [readiness])
|
||||
|
||||
const canEnableMyProfile = useMemo(() => {
|
||||
return (
|
||||
readiness?.steps.some((s) =>
|
||||
s.quick_fix_actions.includes("enable_my_operator_profile"),
|
||||
) ?? false
|
||||
)
|
||||
}, [readiness])
|
||||
|
||||
const channelOptions = useMemo(() => {
|
||||
if (!readiness) {
|
||||
return []
|
||||
}
|
||||
return readiness.channels.map((ch) => ({
|
||||
value: String(ch.id),
|
||||
label: `${ch.name} (${ch.channel_type})`,
|
||||
}))
|
||||
}, [readiness])
|
||||
|
||||
if (loading && !readiness) {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<div className="flex min-h-64 items-center justify-center rounded-md border border-border/70 bg-card p-8">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>{t("common.loadingData")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
if (!readiness) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progressPercent = Math.round(
|
||||
(readiness.ready_steps / Math.max(readiness.total_steps, 1)) * 100,
|
||||
)
|
||||
|
||||
return (
|
||||
<DashboardPage className="space-y-5">
|
||||
{/* Top Overview Header */}
|
||||
<div className="rounded-md border border-border/70 bg-card p-4 lg:p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{t("setupWizard.title")}
|
||||
</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-md text-xs",
|
||||
readiness.can_receive_requests_now
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
{readiness.can_receive_requests_now
|
||||
? t("setupWizard.statusReady")
|
||||
: t("setupWizard.statusNeedsSetup", {
|
||||
ready: readiness.ready_steps,
|
||||
total: readiness.total_steps,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setupWizard.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-md"
|
||||
disabled={loading || fixingAction !== null}
|
||||
onClick={() => void loadAll()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn("size-4", loading && "animate-spin")}
|
||||
/>
|
||||
<span>{t("setupWizard.refresh")}</span>
|
||||
</Button>
|
||||
{canQuickSetupAll ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-md"
|
||||
disabled={fixingAction !== null}
|
||||
onClick={() => void handleQuickFix("quick_setup_all")}
|
||||
>
|
||||
{fixingAction === "quick_setup_all" ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{fixingAction === "quick_setup_all"
|
||||
? t("setupWizard.quickSetupRunning")
|
||||
: t("setupWizard.quickSetupAll")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("setupWizard.stepsProgress", {
|
||||
ready: readiness.ready_steps,
|
||||
total: readiness.total_steps,
|
||||
})}
|
||||
</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-md bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300",
|
||||
readiness.can_receive_requests_now
|
||||
? "bg-emerald-500"
|
||||
: "bg-amber-500",
|
||||
)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Operator Personal Diagnostic Card */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border p-4",
|
||||
readiness.current_operator.can_receive_now
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-amber-500/40 bg-amber-500/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<UserCheckIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
readiness.current_operator.can_receive_now
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
/>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{t("setupWizard.currentOperatorTitle")}:{" "}
|
||||
{readiness.current_operator.nickname ||
|
||||
readiness.current_operator.username}
|
||||
</h2>
|
||||
<Badge variant="outline" className="rounded-md text-xs">
|
||||
{t("setupWizard.currentOperatorTeam", {
|
||||
team:
|
||||
readiness.current_operator.team_name ||
|
||||
t("setupWizard.currentOperatorNoTeam"),
|
||||
})}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-md text-xs">
|
||||
{t("setupWizard.currentOperatorLoad", {
|
||||
active: readiness.current_operator.active_conversation_count,
|
||||
max: readiness.current_operator.max_concurrent_count,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{readiness.current_operator.can_receive_now ? (
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-300">
|
||||
{t("setupWizard.currentOperatorReady")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
{t("setupWizard.currentOperatorBlocked")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{readiness.current_operator.blocker_codes.map((code) => (
|
||||
<Badge
|
||||
key={code}
|
||||
variant="secondary"
|
||||
className="gap-1 rounded-md text-xs font-normal"
|
||||
>
|
||||
<ShieldAlertIcon className="size-3 text-amber-600 dark:text-amber-400" />
|
||||
<span>{t(`setupWizard.operatorBlockers.${code}`)}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readiness.current_operator.can_receive_now && canEnableMyProfile ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-md"
|
||||
disabled={fixingAction !== null}
|
||||
onClick={() =>
|
||||
void handleQuickFix("enable_my_operator_profile")
|
||||
}
|
||||
>
|
||||
{fixingAction === "enable_my_operator_profile" ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
<span>{t("setupWizard.enableMyReceptionBtn")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6-Step Checklist Grid */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{t("setupWizard.checklistSectionTitle")}
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{readiness.steps.map((step) => {
|
||||
const primaryQuickFix = step.quick_fix_actions[0]
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className="flex flex-col justify-between rounded-md border border-border/70 bg-card p-4"
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={step.status} />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{step.step_number}.{" "}
|
||||
{t(`setupWizard.steps.${step.key}.title`)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-md text-xs font-normal"
|
||||
>
|
||||
{t("setupWizard.readyCountLabel", {
|
||||
ready: step.ready_count,
|
||||
total: step.total_count,
|
||||
})}
|
||||
</Badge>
|
||||
<StatusBadge status={step.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`setupWizard.steps.${step.key}.description`)}
|
||||
</p>
|
||||
|
||||
{step.issues.length > 0 ? (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/30 p-2.5">
|
||||
{step.issues.map((issue, idx) => (
|
||||
<div
|
||||
key={`${issue.code}-${issue.entity_id}-${idx}`}
|
||||
className="flex items-start justify-between gap-2 text-xs"
|
||||
>
|
||||
<div className="flex items-start gap-1.5 text-foreground">
|
||||
<AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span>
|
||||
{issue.entity_name ? (
|
||||
<strong className="font-medium">
|
||||
[{issue.entity_name}]{" "}
|
||||
</strong>
|
||||
) : null}
|
||||
{t(`setupWizard.issues.${issue.code}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/50 pt-3">
|
||||
{step.key !== "end_to_end" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 rounded-md text-xs"
|
||||
render={
|
||||
<Link
|
||||
href={
|
||||
step.key === "schedules"
|
||||
? "/dashboard/agent-team-schedules"
|
||||
: step.key === "ai_agents"
|
||||
? "/dashboard/ai-agents"
|
||||
: step.key === "channels"
|
||||
? "/dashboard/channels"
|
||||
: "/dashboard/agents"
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{t("setupWizard.openSettings")}</span>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{primaryQuickFix ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 rounded-md text-xs"
|
||||
disabled={fixingAction !== null}
|
||||
onClick={() => void handleQuickFix(primaryQuickFix)}
|
||||
>
|
||||
{fixingAction === primaryQuickFix ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{t(`setupWizard.quickFixLabels.${primaryQuickFix}`)}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Route Simulator */}
|
||||
<div className="space-y-4 rounded-md border border-border/70 bg-card p-4 lg:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{t("setupWizard.simulator.title")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("setupWizard.simulator.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{channelOptions.length > 0 ? (
|
||||
<OptionCombobox
|
||||
options={channelOptions}
|
||||
value={selectedChannelId}
|
||||
onChange={(val) => {
|
||||
setSelectedChannelId(val)
|
||||
void runSimulation(Number(val))
|
||||
}}
|
||||
placeholder={t("setupWizard.simulator.selectChannelPlaceholder")}
|
||||
triggerClassName="w-64 rounded-md"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 rounded-md"
|
||||
disabled={simulating}
|
||||
onClick={() => void runSimulation(Number(selectedChannelId))}
|
||||
>
|
||||
{simulating ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{simulating
|
||||
? t("setupWizard.simulator.running")
|
||||
: t("setupWizard.simulator.runSimulation")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{simulation ? (
|
||||
<div className="space-y-4">
|
||||
{/* Outcome Banner */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between",
|
||||
simulation.will_reach_operator
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{simulation.will_reach_operator ? (
|
||||
<CheckCircle2Icon className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
) : (
|
||||
<XCircleIcon className="size-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span>
|
||||
{simulation.will_reach_operator
|
||||
? t("setupWizard.simulator.outcomeSuccess", {
|
||||
operator:
|
||||
simulation.matched_operator?.display_name ?? "",
|
||||
})
|
||||
: t("setupWizard.simulator.outcomeBlocked")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs opacity-90">
|
||||
{t(
|
||||
`setupWizard.simulator.outcomes.${simulation.final_outcome_code}`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5-Hop Visual Trace Pipeline */}
|
||||
<div className="grid gap-2.5 lg:grid-cols-5">
|
||||
{simulation.hops.map((hop, idx) => (
|
||||
<div
|
||||
key={`${hop.hop_key}-${idx}`}
|
||||
className={cn(
|
||||
"flex flex-col justify-between rounded-md border p-3 text-xs",
|
||||
hop.status === "ok"
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: hop.status === "warning"
|
||||
? "border-amber-500/40 bg-amber-500/5"
|
||||
: "border-destructive/40 bg-destructive/5",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<span className="font-semibold text-foreground">
|
||||
{idx + 1}.{" "}
|
||||
{t(`setupWizard.simulator.hops.${hop.hop_key}`)}
|
||||
</span>
|
||||
<StatusIcon status={hop.status} />
|
||||
</div>
|
||||
{hop.entity_name ? (
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{hop.entity_name}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground">
|
||||
{t(`setupWizard.simulator.hopReasons.${hop.reason_code}`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hop.quick_fix_action && hop.status !== "ok" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2.5 h-7 w-full gap-1 rounded-md text-[11px]"
|
||||
disabled={fixingAction !== null}
|
||||
onClick={() =>
|
||||
void handleQuickFix(
|
||||
hop.quick_fix_action!,
|
||||
hop.entity_id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{t(
|
||||
`setupWizard.quickFixLabels.${hop.quick_fix_action}`,
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Candidate Operators Evaluation Table */}
|
||||
{simulation.candidate_operators.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("setupWizard.simulator.candidateTableTitle")}
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-border/70">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("setupWizard.simulator.colOperator")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("setupWizard.simulator.colTeam")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("setupWizard.simulator.colStatus")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("setupWizard.simulator.colAutoAssign")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("setupWizard.simulator.colLoad")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("setupWizard.simulator.colResult")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{simulation.candidate_operators.map((cand) => (
|
||||
<TableRow key={cand.user_id}>
|
||||
<TableCell className="font-medium">
|
||||
{cand.display_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cand.team_name ||
|
||||
t("setupWizard.currentOperatorNoTeam")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-md text-xs"
|
||||
>
|
||||
{cand.service_status === 0
|
||||
? t("setupWizard.simulator.statusIdle")
|
||||
: t("setupWizard.simulator.statusBusy")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cand.auto_assign_enabled
|
||||
? t("common.enabled")
|
||||
: t("common.disabled")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cand.active_count} / {cand.max_concurrent_count}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cand.eligible ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-md border-emerald-500/30 bg-emerald-500/10 text-xs text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
{t("setupWizard.simulator.eligibleBadge")}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{t(
|
||||
`setupWizard.simulator.skipReasons.${cand.skip_reason_code}`,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
export { ArrowRightIcon }
|
||||
5
web/app/(dashboard)/dashboard/setup/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/setup/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetupWizardView } from "./_components/setup-wizard-view"
|
||||
|
||||
export default function SetupWizardPage() {
|
||||
return <SetupWizardView />
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { type Resolver, useForm } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
import { LoaderCircleIcon, PlayIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
debugResumeSkillDefinition,
|
||||
debugRunSkillDefinition,
|
||||
fetchAIAgentsAll,
|
||||
type AIAgent,
|
||||
type SkillDebugResumePayload,
|
||||
type SkillDebugRunPayload,
|
||||
type SkillDebugRunResult,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type DebugDialogProps = {
|
||||
open: boolean
|
||||
skillDefinitionId: number
|
||||
skillName: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function createDebugFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
aiAgentId: z.string().trim().min(1, t("skillDefinition.agentRequired")),
|
||||
conversationId: z.string().trim(),
|
||||
userMessage: z.string().trim().min(1, t("skillDefinition.messageRequired")),
|
||||
})
|
||||
}
|
||||
|
||||
type DebugForm = {
|
||||
aiAgentId: string
|
||||
conversationId: string
|
||||
userMessage: string
|
||||
}
|
||||
|
||||
const emptyForm: DebugForm = {
|
||||
aiAgentId: "",
|
||||
conversationId: "",
|
||||
userMessage: "",
|
||||
}
|
||||
|
||||
function getQuickResumeActions(t: TFunction) {
|
||||
return [
|
||||
{ label: t("skillDefinition.confirm"), value: t("skillDefinition.confirm") },
|
||||
{ label: t("skillDefinition.reject"), value: t("skillDefinition.reject") },
|
||||
]
|
||||
}
|
||||
|
||||
function ResultBlock({
|
||||
title,
|
||||
value,
|
||||
emptyText,
|
||||
}: {
|
||||
title: string
|
||||
value?: string
|
||||
emptyText?: string
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{value ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-muted/50 p-3 text-xs leading-5">
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{emptyText}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugDialog({
|
||||
open,
|
||||
skillDefinitionId,
|
||||
skillName,
|
||||
onOpenChange,
|
||||
}: DebugDialogProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DebugDialogBody
|
||||
key={skillDefinitionId}
|
||||
open={open}
|
||||
skillDefinitionId={skillDefinitionId}
|
||||
skillName={skillName}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DebugDialogBody({
|
||||
open,
|
||||
skillDefinitionId,
|
||||
skillName,
|
||||
onOpenChange,
|
||||
}: DebugDialogProps) {
|
||||
const t = useI18n()
|
||||
const formId = `skill-debug-form-${skillDefinitionId}`
|
||||
const [running, setRunning] = useState(false)
|
||||
const [resuming, setResuming] = useState(false)
|
||||
const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
|
||||
const [result, setResult] = useState<SkillDebugRunResult | null>(null)
|
||||
const [resumeResult, setResumeResult] = useState<SkillDebugRunResult | null>(null)
|
||||
const [resumeMessage, setResumeMessage] = useState("")
|
||||
const debugFormSchema = useMemo(() => createDebugFormSchema(t), [t])
|
||||
const debugFormResolver = useMemo(
|
||||
() => zodResolver(debugFormSchema) as Resolver<DebugForm>,
|
||||
[debugFormSchema],
|
||||
)
|
||||
const form = useForm<DebugForm>({
|
||||
resolver: debugFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
const selectedAgentId = watch("aiAgentId")
|
||||
const quickResumeActions = useMemo(() => getQuickResumeActions(t), [t])
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAIAgents() {
|
||||
try {
|
||||
const data = await fetchAIAgentsAll({ status: 1 })
|
||||
setAiAgents(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to load AI agents:", error)
|
||||
}
|
||||
}
|
||||
|
||||
void loadAIAgents()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
reset(emptyForm)
|
||||
setResult(null)
|
||||
setResumeResult(null)
|
||||
setResumeMessage("")
|
||||
}, [open, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || aiAgents.length === 0 || selectedAgentId) {
|
||||
return
|
||||
}
|
||||
setValue("aiAgentId", String(aiAgents[0].id), { shouldValidate: true })
|
||||
}, [aiAgents, open, selectedAgentId, setValue])
|
||||
|
||||
const aiAgentOptions = useMemo(
|
||||
() =>
|
||||
aiAgents.map((item) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})),
|
||||
[aiAgents],
|
||||
)
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => aiAgents.find((item) => String(item.id) === selectedAgentId) ?? null,
|
||||
[aiAgents, selectedAgentId],
|
||||
)
|
||||
|
||||
async function onSubmit(values: DebugForm) {
|
||||
const payload: SkillDebugRunPayload = {
|
||||
aiAgentId: Number(values.aiAgentId),
|
||||
skillDefinitionId,
|
||||
userMessage: values.userMessage.trim(),
|
||||
}
|
||||
const conversationId = Number(values.conversationId)
|
||||
if (conversationId > 0) {
|
||||
payload.conversationId = conversationId
|
||||
}
|
||||
|
||||
setRunning(true)
|
||||
try {
|
||||
const data = await debugRunSkillDefinition(payload)
|
||||
setResult(data)
|
||||
setResumeResult(null)
|
||||
setResumeMessage("")
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("skillDefinition.debugFailed"))
|
||||
setResult(null)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeDebug(messageText?: string) {
|
||||
const nextMessage = (messageText ?? resumeMessage).trim()
|
||||
if (!result?.checkPointId || !result.interrupted) {
|
||||
return
|
||||
}
|
||||
if (!nextMessage) {
|
||||
toast.error(t("skillDefinition.resumeMessageRequired"))
|
||||
return
|
||||
}
|
||||
const payload: SkillDebugResumePayload = {
|
||||
aiAgentId: Number(selectedAgentId || result.aiAgentId),
|
||||
checkPointId: result.checkPointId,
|
||||
userMessage: nextMessage,
|
||||
}
|
||||
const conversationId = result.conversationId || Number(watch("conversationId"))
|
||||
if (conversationId > 0) {
|
||||
payload.conversationId = conversationId
|
||||
}
|
||||
|
||||
setResuming(true)
|
||||
try {
|
||||
const data = await debugResumeSkillDefinition(payload)
|
||||
setResumeResult(data)
|
||||
setResumeMessage(nextMessage)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("skillDefinition.resumeFailed"))
|
||||
setResumeResult(null)
|
||||
} finally {
|
||||
setResuming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("skillDefinition.debugTitle", { name: skillName })}
|
||||
description={t("skillDefinition.debugDescription")}
|
||||
size="xl"
|
||||
allowFullscreen
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={running}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("skillDefinition.close")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={running}>
|
||||
{running ? <LoaderCircleIcon className="animate-spin" /> : <PlayIcon />}
|
||||
{running ? t("skillDefinition.debugging") : t("skillDefinition.startDebug")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t("skillDefinition.debugInput")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Field data-invalid={!!errors.aiAgentId}>
|
||||
<FieldLabel>AI Agent</FieldLabel>
|
||||
<FieldContent>
|
||||
<OptionCombobox
|
||||
value={selectedAgentId}
|
||||
options={aiAgentOptions}
|
||||
placeholder={t("skillDefinition.selectAgent")}
|
||||
searchPlaceholder={t("skillDefinition.searchAgent")}
|
||||
emptyText={t("skillDefinition.emptyAgent")}
|
||||
onChange={(value) =>
|
||||
setValue("aiAgentId", value, { shouldValidate: true })
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[errors.aiAgentId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.conversationId}>
|
||||
<FieldLabel htmlFor="skill-debug-conversation-id">
|
||||
Conversation ID
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="skill-debug-conversation-id"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("skillDefinition.conversationPlaceholder")}
|
||||
aria-invalid={!!errors.conversationId}
|
||||
{...register("conversationId")}
|
||||
/>
|
||||
<FieldError errors={[errors.conversationId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Skill</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input value={skillName} disabled />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("skillDefinition.matchedAgent")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
value={selectedAgent?.name || t("skillDefinition.noAgentSelected")}
|
||||
disabled
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={!!errors.userMessage}>
|
||||
<FieldLabel htmlFor="skill-debug-user-message">{t("skillDefinition.userMessage")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="skill-debug-user-message"
|
||||
rows={5}
|
||||
placeholder={t("skillDefinition.userMessagePlaceholder")}
|
||||
aria-invalid={!!errors.userMessage}
|
||||
{...register("userMessage")}
|
||||
/>
|
||||
<FieldError errors={[errors.userMessage]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t("skillDefinition.debugSummary")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{result?.skillName || skillName}</Badge>
|
||||
{result?.interruptType ? (
|
||||
<Badge variant="secondary">{result.interruptType}</Badge>
|
||||
) : null}
|
||||
{result?.interrupted ? (
|
||||
<Badge>{t("skillDefinition.interrupted")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("skillDefinition.notInterrupted")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("skillDefinition.skillName")}</div>
|
||||
<div className="mt-1 font-medium">{result?.skillName || skillName}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">Reply</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||
{result?.replyText || t("skillDefinition.none")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">Checkpoint</div>
|
||||
<div className="mt-1 break-all">
|
||||
{result?.checkPointId || t("skillDefinition.none")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("skillDefinition.errorMessage")}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||
{result?.errorMessage || t("skillDefinition.none")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t("skillDefinition.toolView")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("skillDefinition.skillToolWhitelist")}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{(result?.toolWhitelist ?? []).length > 0 ? (
|
||||
result?.toolWhitelist.map((toolCode) => (
|
||||
<Badge key={toolCode} variant="outline">
|
||||
{toolCode}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("skillDefinition.invokedTools")}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{(result?.invokedToolCodes ?? []).length > 0 ? (
|
||||
result?.invokedToolCodes.map((toolCode) => (
|
||||
<Badge key={toolCode} variant="secondary">
|
||||
{toolCode}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<ResultBlock title={t("skillDefinition.traceData")} value={result?.traceData} emptyText={t("skillDefinition.emptyData")} />
|
||||
</div>
|
||||
|
||||
{result?.interrupted && result.checkPointId ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t("skillDefinition.resumeDebug")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-sm">
|
||||
<div className="text-xs text-muted-foreground">{t("skillDefinition.currentCheckpoint")}</div>
|
||||
<div className="mt-1 break-all">{result.checkPointId}</div>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="skill-debug-resume-message">{t("skillDefinition.resumeMessage")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="skill-debug-resume-message"
|
||||
rows={3}
|
||||
placeholder={t("skillDefinition.resumePlaceholder")}
|
||||
value={resumeMessage}
|
||||
onChange={(event) => setResumeMessage(event.target.value)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickResumeActions.map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={resuming}
|
||||
onClick={() => void handleResumeDebug(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={resuming}
|
||||
onClick={() => void handleResumeDebug()}
|
||||
>
|
||||
{resuming ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
{resuming ? t("skillDefinition.resuming") : t("skillDefinition.resumeDebugAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{resumeResult ? (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t("skillDefinition.resumeResult")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{resumeResult.skillName || skillName}</Badge>
|
||||
{resumeResult.interruptType ? (
|
||||
<Badge variant="secondary">{resumeResult.interruptType}</Badge>
|
||||
) : null}
|
||||
{resumeResult.interrupted ? (
|
||||
<Badge>{t("skillDefinition.stillWaiting")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("skillDefinition.resumeCompleted")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("skillDefinition.resumeMessage")}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||
{resumeMessage || t("skillDefinition.none")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("skillDefinition.resumeReply")}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||
{resumeResult.replyText || t("skillDefinition.none")}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResultBlock title={t("skillDefinition.resumeTraceData")} value={resumeResult.traceData} emptyText={t("skillDefinition.emptyData")} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ContentEditor } from "@/components/content-editor";
|
||||
import { OptionCombobox } from "@/components/option-combobox";
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
fetchMCPCatalog,
|
||||
fetchSkillDefinition,
|
||||
type CreateSkillDefinitionPayload,
|
||||
type MCPToolCatalogItem,
|
||||
type SkillDefinition,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
type SkillEditDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateSkillDefinitionPayload) => Promise<void>;
|
||||
};
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
instruction: "",
|
||||
examplesText: "",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
function createSkillFormSchema(t: TFunction) {
|
||||
return z.object({
|
||||
name: z.string().trim().min(1, t("skillDefinition.nameRequired")),
|
||||
description: z.string().trim(),
|
||||
instruction: z.string().trim().min(1, t("skillDefinition.instructionRequired")),
|
||||
examplesText: z.string().trim(),
|
||||
remark: z.string().trim(),
|
||||
});
|
||||
}
|
||||
|
||||
type EditForm = {
|
||||
name: string;
|
||||
description: string;
|
||||
instruction: string;
|
||||
examplesText: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function buildForm(item: SkillDefinition | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm;
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
instruction: item.instruction ?? "",
|
||||
examplesText: (item.examples ?? []).join("\n"),
|
||||
remark: item.remark ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(
|
||||
form: EditForm,
|
||||
toolWhitelist: string[],
|
||||
): CreateSkillDefinitionPayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
instruction: form.instruction.trim(),
|
||||
examples: form.examplesText
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
toolWhitelist,
|
||||
remark: form.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: SkillEditDialogProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SkillEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SkillEditDialogBodyProps = SkillEditDialogProps;
|
||||
|
||||
function SkillEditDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: SkillEditDialogBodyProps) {
|
||||
const t = useI18n();
|
||||
const formId = "skill-definition-edit-form";
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
|
||||
const [selectedToolWhitelist, setSelectedToolWhitelist] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [toolCodeToAdd, setToolCodeToAdd] = useState("");
|
||||
const skillFormSchema = useMemo(() => createSkillFormSchema(t), [t]);
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(skillFormSchema) as Resolver<EditForm>,
|
||||
[skillFormSchema],
|
||||
);
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(emptyForm);
|
||||
setSelectedToolWhitelist([]);
|
||||
setToolCodeToAdd("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchSkillDefinition(itemId);
|
||||
reset(buildForm(data));
|
||||
setSelectedToolWhitelist(data.toolWhitelist ?? []);
|
||||
setToolCodeToAdd("");
|
||||
} catch (error) {
|
||||
console.error("Failed to load skill definition:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadDetail();
|
||||
}, [itemId, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadToolCatalog() {
|
||||
try {
|
||||
const data = await fetchMCPCatalog();
|
||||
setToolCatalog(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP tool catalog:", error);
|
||||
}
|
||||
}
|
||||
|
||||
void loadToolCatalog();
|
||||
}, []);
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
await onSubmit(buildPayload(values, selectedToolWhitelist));
|
||||
}
|
||||
|
||||
const toolOptions = useMemo(
|
||||
() =>
|
||||
toolCatalog.map((item) => ({
|
||||
value: item.toolCode,
|
||||
label: `${item.title || item.toolName} · ${item.toolCode}`,
|
||||
})),
|
||||
[toolCatalog],
|
||||
);
|
||||
|
||||
const addableToolOptions = useMemo(
|
||||
() =>
|
||||
toolOptions.filter(
|
||||
(option) => !selectedToolWhitelist.includes(option.value),
|
||||
),
|
||||
[selectedToolWhitelist, toolOptions],
|
||||
);
|
||||
|
||||
const selectedToolOptions = useMemo(
|
||||
() =>
|
||||
selectedToolWhitelist
|
||||
.map((toolCode) => toolOptions.find((option) => option.value === toolCode))
|
||||
.filter(
|
||||
(option): option is { value: string; label: string } => !!option,
|
||||
),
|
||||
[selectedToolWhitelist, toolOptions],
|
||||
);
|
||||
|
||||
function handleAddToolWhitelist(toolCode: string) {
|
||||
if (!toolCode || selectedToolWhitelist.includes(toolCode)) {
|
||||
return;
|
||||
}
|
||||
setSelectedToolWhitelist((prev) => [...prev, toolCode]);
|
||||
setToolCodeToAdd("");
|
||||
}
|
||||
|
||||
function handleRemoveToolWhitelist(toolCode: string) {
|
||||
setSelectedToolWhitelist((prev) =>
|
||||
prev.filter((item) => item !== toolCode),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={itemId ? t("skillDefinition.editTitle") : t("skillDefinition.createTitle")}
|
||||
size="xl"
|
||||
allowFullscreen
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("skillDefinition.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("skillDefinition.saving") : itemId ? t("skillDefinition.save") : t("skillDefinition.create")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("skillDefinition.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="skill-name">{t("skillDefinition.name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="skill-name"
|
||||
placeholder={t("skillDefinition.namePlaceholder")}
|
||||
aria-invalid={!!errors.name}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.description}>
|
||||
<FieldLabel htmlFor="skill-description">{t("skillDefinition.description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="skill-description"
|
||||
rows={3}
|
||||
placeholder={t("skillDefinition.descriptionPlaceholder")}
|
||||
aria-invalid={!!errors.description}
|
||||
{...register("description")}
|
||||
/>
|
||||
<FieldError errors={[errors.description]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.instruction}>
|
||||
<FieldLabel>{t("skillDefinition.instruction")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="instruction"
|
||||
render={({ field }) => (
|
||||
<ContentEditor
|
||||
value={{ mode: "markdown", raw: field.value ?? "" }}
|
||||
onChange={(next) => field.onChange(next.raw)}
|
||||
placeholder={t("skillDefinition.instructionPlaceholder")}
|
||||
disabled={saving || loading}
|
||||
allowedModes={["markdown"]}
|
||||
height={360}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.instruction]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.examplesText}>
|
||||
<FieldLabel htmlFor="skill-examples">{t("skillDefinition.examples")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="skill-examples"
|
||||
rows={5}
|
||||
placeholder={t("skillDefinition.examplesPlaceholder")}
|
||||
aria-invalid={!!errors.examplesText}
|
||||
{...register("examplesText")}
|
||||
/>
|
||||
<FieldError errors={[errors.examplesText]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("skillDefinition.toolWhitelist")}</FieldLabel>
|
||||
<FieldContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<OptionCombobox
|
||||
value={toolCodeToAdd}
|
||||
options={addableToolOptions}
|
||||
placeholder={t("skillDefinition.selectTool")}
|
||||
searchPlaceholder={t("skillDefinition.searchTool")}
|
||||
emptyText={t("skillDefinition.emptyTool")}
|
||||
onChange={handleAddToolWhitelist}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!toolCodeToAdd}
|
||||
onClick={() => handleAddToolWhitelist(toolCodeToAdd)}
|
||||
>
|
||||
{t("skillDefinition.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedToolOptions.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("skillDefinition.inheritAgentTools")}
|
||||
</span>
|
||||
) : (
|
||||
selectedToolOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveToolWhitelist(option.value)}
|
||||
className="justify-start"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.remark}>
|
||||
<FieldLabel htmlFor="skill-remark">{t("skillDefinition.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="skill-remark"
|
||||
rows={3}
|
||||
placeholder={t("skillDefinition.remarkPlaceholder")}
|
||||
aria-invalid={!!errors.remark}
|
||||
{...register("remark")}
|
||||
/>
|
||||
<FieldError errors={[errors.remark]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
254
web/app/(dashboard)/dashboard/skill-definition/page.tsx
Normal file
254
web/app/(dashboard)/dashboard/skill-definition/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { BrainCircuitIcon, BugIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
createDashboardStatusColumn,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
createSkillDefinition,
|
||||
deleteSkillDefinition,
|
||||
fetchSkillDefinitions,
|
||||
restoreSkillDefinition,
|
||||
updateSkillDefinition,
|
||||
updateSkillDefinitionStatus,
|
||||
type CreateSkillDefinitionPayload,
|
||||
type SkillDefinition,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { Status } from "@/lib/generated/enums";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { EditDialog } from "./_components/edit";
|
||||
import { DebugDialog } from "./_components/debug-dialog";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function statusLabel(status: number, t: TFunction) {
|
||||
if (status === Status.Ok) return t("skillDefinition.statusOk");
|
||||
if (status === Status.Disabled) return t("skillDefinition.statusDisabled");
|
||||
if (status === Status.Deleted) return t("skillDefinition.statusDeleted");
|
||||
return String(status);
|
||||
}
|
||||
|
||||
function getStatusFilterOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("skillDefinition.allStatus") },
|
||||
{ value: String(Status.Ok), label: t("skillDefinition.statusOk") },
|
||||
{ value: String(Status.Disabled), label: t("skillDefinition.statusDisabled") },
|
||||
{ value: String(Status.Deleted), label: t("skillDefinition.statusDeleted") },
|
||||
];
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: number) {
|
||||
if (status === Status.Deleted) return "destructive";
|
||||
if (status === Status.Ok) return "default";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function getNextStatus(item: SkillDefinition) {
|
||||
return item.status === Status.Ok ? Status.Disabled : Status.Ok;
|
||||
}
|
||||
|
||||
export default function DashboardSkillsPage() {
|
||||
const t = useI18n();
|
||||
const [debugDialogOpen, setDebugDialogOpen] = useState(false);
|
||||
const [debuggingItem, setDebuggingItem] = useState<SkillDefinition | null>(
|
||||
null,
|
||||
);
|
||||
const statusFilterOptions = useMemo(() => getStatusFilterOptions(t), [t]);
|
||||
|
||||
const filters = useMemo<DashboardCrudFilter[]>(
|
||||
() => [
|
||||
{
|
||||
name: "name",
|
||||
label: t("skillDefinition.filterName"),
|
||||
placeholder: t("skillDefinition.filterName"),
|
||||
defaultValue: "",
|
||||
trim: true,
|
||||
className: "w-full sm:w-72",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: t("skillDefinition.allStatus"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: statusFilterOptions,
|
||||
className: "w-full sm:w-36",
|
||||
},
|
||||
],
|
||||
[statusFilterOptions, t],
|
||||
);
|
||||
|
||||
const columns = useMemo<DashboardCrudColumn<SkillDefinition>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "skill",
|
||||
label: "Skill",
|
||||
render: (item) => (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<BrainCircuitIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<Badge variant="secondary">
|
||||
{t("skillDefinition.whitelistCount", {
|
||||
count: item.toolWhitelist.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{t("skillDefinition.exampleCount", {
|
||||
count: item.examples.length,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
||||
{item.description || t("skillDefinition.noDescription")}
|
||||
</div>
|
||||
{item.toolWhitelist.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{item.toolWhitelist.slice(0, 3).map((toolCode) => (
|
||||
<Badge key={toolCode} variant="outline">
|
||||
{toolCode}
|
||||
</Badge>
|
||||
))}
|
||||
{item.toolWhitelist.length > 3 ? (
|
||||
<Badge variant="outline">
|
||||
+{item.toolWhitelist.length - 3}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
createDashboardStatusColumn<SkillDefinition, number>({
|
||||
label: t("skillDefinition.status"),
|
||||
getStatus: (item) => item.status,
|
||||
getLabel: (status) => statusLabel(status, t),
|
||||
getBadgeVariant: statusBadgeVariant,
|
||||
isEnabled: (status) => status === Status.Ok,
|
||||
toggle: {
|
||||
disabled: (item) => item.status === Status.Deleted,
|
||||
getNextStatus,
|
||||
updateStatus: (item, nextStatus) =>
|
||||
updateSkillDefinitionStatus(item.id, nextStatus),
|
||||
successMessage: (item, nextStatus) =>
|
||||
t(nextStatus === Status.Ok ? "skillDefinition.enabled" : "skillDefinition.disabled", {
|
||||
name: item.name,
|
||||
}),
|
||||
errorMessage: t("skillDefinition.statusUpdateFailed"),
|
||||
ariaLabel: (item) =>
|
||||
t("skillDefinition.toggleStatus", { name: item.name }),
|
||||
},
|
||||
}),
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: t("skillDefinition.updatedAt"),
|
||||
render: (item) => (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatDateTime(item.updatedAt)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.updateUserName || "-"}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardCrudPage<SkillDefinition, CreateSkillDefinitionPayload>
|
||||
filters={filters}
|
||||
columns={columns}
|
||||
fetchList={(query) =>
|
||||
fetchSkillDefinitions({
|
||||
name: typeof query.name === "string" ? query.name : undefined,
|
||||
status: typeof query.status === "number" ? query.status : undefined,
|
||||
page: Number(query.page),
|
||||
limit: Number(query.limit),
|
||||
})
|
||||
}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={createSkillDefinition}
|
||||
updateItem={(item, payload) =>
|
||||
updateSkillDefinition({ id: item.id, ...payload })
|
||||
}
|
||||
deleteItem={(item) => deleteSkillDefinition(item.id)}
|
||||
canDelete={(item) => item.status !== Status.Deleted}
|
||||
rowActions={[
|
||||
{
|
||||
key: "debug",
|
||||
icon: <BugIcon />,
|
||||
label: t("skillDefinition.debug"),
|
||||
run: ({ item }) => {
|
||||
setDebuggingItem(item);
|
||||
setDebugDialogOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
icon: <RotateCcwIcon />,
|
||||
label: t("skillDefinition.restore"),
|
||||
visible: (item) => item.status === Status.Deleted,
|
||||
run: async ({ item, reload }) => {
|
||||
await restoreSkillDefinition(item.id);
|
||||
toast.success(t("skillDefinition.restored", { name: item.name }));
|
||||
await reload();
|
||||
},
|
||||
},
|
||||
]}
|
||||
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
labels={{
|
||||
refresh: t("skillDefinition.refresh"),
|
||||
create: t("skillDefinition.new"),
|
||||
query: t("skillDefinition.query"),
|
||||
loading: t("skillDefinition.loadingRows"),
|
||||
empty: t("skillDefinition.emptyRows"),
|
||||
actions: t("skillDefinition.actions"),
|
||||
edit: t("skillDefinition.edit"),
|
||||
delete: t("skillDefinition.delete"),
|
||||
processing: t("skillDefinition.processing"),
|
||||
moreActions: (item) =>
|
||||
t("skillDefinition.moreActions", { name: item.name }),
|
||||
loadFailed: t("skillDefinition.loadFailed"),
|
||||
saveFailed: t("skillDefinition.saveFailed"),
|
||||
deleteFailed: t("skillDefinition.deleteFailed"),
|
||||
created: (payload) =>
|
||||
t("skillDefinition.created", { name: payload.name }),
|
||||
updated: (item) => t("skillDefinition.updated", { name: item.name }),
|
||||
deleted: (item) => t("skillDefinition.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
<DebugDialog
|
||||
open={debugDialogOpen}
|
||||
skillDefinitionId={debuggingItem?.id ?? 0}
|
||||
skillName={debuggingItem?.name ?? ""}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDebuggingItem(null);
|
||||
setDebugDialogOpen(open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeOffIcon,
|
||||
FilePlus2Icon,
|
||||
GripVerticalIcon,
|
||||
MoreHorizontalIcon,
|
||||
RocketIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
Settings2Icon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useApiErrorHandler } from "@/components/api-error-provider"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "@/components/ui/context-menu"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { normalizeSupportSlug } from "@/lib/support-slug"
|
||||
import {
|
||||
changeDocPageStatusAdmin,
|
||||
deleteDocPageAdmin,
|
||||
fetchDocPageAdmin,
|
||||
fetchDocPagesAllAdmin,
|
||||
saveDocPageAdmin,
|
||||
updateDocPageSettingsAdmin,
|
||||
updateDocPageSortAdmin,
|
||||
uploadAsset,
|
||||
type AdminDocPage,
|
||||
} from "@/lib/api/admin"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type PageDraft = Pick<AdminDocPage, "parentId" | "title" | "slug" | "summary" | "content" | "contentType" | "tags">
|
||||
type PageSettingsDraft = Pick<PageDraft, "parentId" | "slug" | "summary">
|
||||
type CreateState = { open: boolean; parentId: number }
|
||||
|
||||
const blankCreate: CreateState = { open: false, parentId: 0 }
|
||||
|
||||
function toDraft(page: AdminDocPage): PageDraft {
|
||||
return {
|
||||
parentId: page.parentId ?? 0,
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
summary: page.summary ?? "",
|
||||
content: page.content ?? "",
|
||||
contentType: page.contentType || "markdown",
|
||||
tags: page.tags ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function descendantIds(pages: AdminDocPage[], id: number) {
|
||||
const result = new Set<number>()
|
||||
const visit = (parentId: number) => pages.filter((page) => page.parentId === parentId).forEach((page) => {
|
||||
result.add(page.id)
|
||||
visit(page.id)
|
||||
})
|
||||
visit(id)
|
||||
return result
|
||||
}
|
||||
|
||||
function childPages(pages: AdminDocPage[], parentId: number) {
|
||||
return pages
|
||||
.filter((page) => page.parentId === parentId)
|
||||
.sort((left, right) => left.sortNo - right.sortNo || left.id - right.id)
|
||||
}
|
||||
|
||||
function reorderSiblings(pages: AdminDocPage[], parentId: number, activeId: number, overId: number) {
|
||||
const siblings = childPages(pages, parentId)
|
||||
const oldIndex = siblings.findIndex((page) => page.id === activeId)
|
||||
const newIndex = siblings.findIndex((page) => page.id === overId)
|
||||
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null
|
||||
const ordered = arrayMove(siblings, oldIndex, newIndex)
|
||||
const sortByID = new Map(ordered.map((page, index) => [page.id, index]))
|
||||
return {
|
||||
ids: ordered.map((page) => page.id),
|
||||
pages: pages.map((page) => page.parentId === parentId ? { ...page, sortNo: sortByID.get(page.id) ?? page.sortNo } : page),
|
||||
}
|
||||
}
|
||||
|
||||
function pagePath(pages: AdminDocPage[], page: AdminDocPage | null) {
|
||||
if (!page) return []
|
||||
const path: Array<Pick<AdminDocPage, "id" | "title">> = []
|
||||
let parentId = page.parentId
|
||||
while (parentId) {
|
||||
const parent = pages.find((item) => item.id === parentId)
|
||||
if (!parent) break
|
||||
path.unshift({ id: parent.id, title: parent.title })
|
||||
parentId = parent.parentId
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function SupportDocsWorkbench() {
|
||||
const t = useI18n()
|
||||
const handleApiError = useApiErrorHandler()
|
||||
const confirm = useConfirm()
|
||||
const [pages, setPages] = useState<AdminDocPage[]>([])
|
||||
const [selected, setSelected] = useState<AdminDocPage | null>(null)
|
||||
const [draft, setDraft] = useState<PageDraft | null>(null)
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||
const [query, setQuery] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [sorting, setSorting] = useState(false)
|
||||
const [activeDragId, setActiveDragId] = useState<number | null>(null)
|
||||
const [createState, setCreateState] = useState<CreateState>(blankCreate)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const selectionRequest = useRef(0)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const dirty = Boolean(selected && draft && JSON.stringify(toDraft(selected)) !== JSON.stringify(draft))
|
||||
const sortingDisabled = Boolean(query.trim()) || loading || sorting
|
||||
const activeDragPage = activeDragId ? pages.find((page) => page.id === activeDragId) : undefined
|
||||
|
||||
const siblingCollisionDetection = useCallback<CollisionDetection>((args) => {
|
||||
const activePage = pages.find((page) => page.id === Number(args.active.id))
|
||||
if (!activePage) return []
|
||||
const siblingIDs = new Set(childPages(pages, activePage.parentId).map((page) => page.id))
|
||||
return closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter((container) => siblingIDs.has(Number(container.id))),
|
||||
})
|
||||
}, [pages])
|
||||
|
||||
const statusOptions = useMemo(() => [
|
||||
{ value: "draft", label: t("supportHelpWorkbench.statusDraft") },
|
||||
{ value: "published", label: t("supportHelpWorkbench.statusPublished") },
|
||||
{ value: "hidden", label: t("supportHelpWorkbench.statusHidden") },
|
||||
], [t])
|
||||
|
||||
const load = useCallback(async (preferredId?: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchDocPagesAllAdmin()
|
||||
setPages(result)
|
||||
const requestedId = preferredId ?? selected?.id
|
||||
const id = requestedId && result.some((page) => page.id === requestedId)
|
||||
? requestedId
|
||||
: result[0]?.id
|
||||
if (!id) {
|
||||
setSelected(null)
|
||||
setDraft(null)
|
||||
return
|
||||
}
|
||||
const detail = await fetchDocPageAdmin(id)
|
||||
setSelected(detail)
|
||||
setDraft(toDraft(detail))
|
||||
const ancestors = new Set<number>()
|
||||
let parentId = detail.parentId
|
||||
while (parentId) {
|
||||
ancestors.add(parentId)
|
||||
parentId = result.find((page) => page.id === parentId)?.parentId ?? 0
|
||||
}
|
||||
setExpanded((current) => new Set([...current, ...ancestors]))
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleApiError, selected?.id])
|
||||
|
||||
useEffect(() => { void load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
||||
event.preventDefault()
|
||||
void save()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handler)
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
})
|
||||
|
||||
async function selectPage(page: AdminDocPage) {
|
||||
if (page.id === selected?.id) return
|
||||
if (dirty) {
|
||||
const leave = await confirm({
|
||||
title: t("supportHelpWorkbench.unsavedLeaveTitle"),
|
||||
description: t("supportHelpWorkbench.unsavedLeaveConfirm"),
|
||||
confirmText: t("supportHelpWorkbench.discardChanges"),
|
||||
cancelText: t("supportHelpWorkbench.continueEditing"),
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!leave) return
|
||||
}
|
||||
const requestId = ++selectionRequest.current
|
||||
try {
|
||||
const detail = await fetchDocPageAdmin(page.id)
|
||||
if (requestId !== selectionRequest.current) return
|
||||
setSelected(detail)
|
||||
setDraft(toDraft(detail))
|
||||
} catch (error) {
|
||||
if (requestId === selectionRequest.current) handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!selected || !draft || !draft.title.trim() || !draft.slug.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await saveDocPageAdmin({
|
||||
id: selected.id,
|
||||
...draft,
|
||||
status: selected.status,
|
||||
sortNo: draft.parentId === selected.parentId
|
||||
? pages.find((page) => page.id === selected.id)?.sortNo ?? selected.sortNo
|
||||
: childPages(pages, draft.parentId).length,
|
||||
title: draft.title.trim(),
|
||||
slug: draft.slug.trim(),
|
||||
summary: draft.summary.trim(),
|
||||
})
|
||||
setSelected(saved)
|
||||
setDraft(toDraft(saved))
|
||||
setPages((items) => items.map((item) => item.id === saved.id ? saved : item))
|
||||
toast.success(t("supportHelpWorkbench.saved"))
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(settings: PageSettingsDraft) {
|
||||
if (!selected || !draft || !settings.slug.trim() || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await updateDocPageSettingsAdmin({
|
||||
id: selected.id,
|
||||
parentId: settings.parentId,
|
||||
slug: settings.slug.trim(),
|
||||
summary: settings.summary.trim(),
|
||||
})
|
||||
setSelected(saved)
|
||||
setDraft({ ...draft, parentId: saved.parentId, slug: saved.slug, summary: saved.summary })
|
||||
setPages((items) => items.map((item) => item.id === saved.id ? saved : item))
|
||||
setSettingsOpen(false)
|
||||
toast.success(t("supportHelpWorkbench.settingsSaved"))
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus(status: "draft" | "published" | "hidden") {
|
||||
if (!selected || saving) return
|
||||
if (dirty) {
|
||||
toast.info(t("supportHelpWorkbench.saveBeforeStatusChange"))
|
||||
return
|
||||
}
|
||||
const confirmed = await confirm({
|
||||
title: t(`supportHelpWorkbench.${status}ConfirmTitle`, { title: selected.title }),
|
||||
description: t(`supportHelpWorkbench.${status}ConfirmDescription`),
|
||||
confirmText: t(`supportHelpWorkbench.${status}Action`),
|
||||
cancelText: t("supportHelpWorkbench.cancel"),
|
||||
variant: status === "hidden" ? "destructive" : "default",
|
||||
})
|
||||
if (!confirmed) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const changed = await changeDocPageStatusAdmin(selected.id, status)
|
||||
setSelected(changed)
|
||||
setDraft(toDraft(changed))
|
||||
setPages((items) => items.map((item) => item.id === changed.id ? changed : item))
|
||||
toast.success(t(`supportHelpWorkbench.${status}Success`))
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(page: AdminDocPage) {
|
||||
const confirmed = await confirm({
|
||||
title: t("supportHelpWorkbench.deleteConfirmTitle", { title: page.title }),
|
||||
description: t("supportHelpWorkbench.deleteConfirm", { title: page.title }),
|
||||
confirmText: t("supportHelpWorkbench.deletePage"),
|
||||
cancelText: t("supportHelpWorkbench.cancel"),
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await deleteDocPageAdmin(page.id)
|
||||
selectionRequest.current += 1
|
||||
toast.success(t("supportHelpWorkbench.deleted"))
|
||||
await load(selected?.id === page.id ? undefined : selected?.id)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveDragId(null)
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || sortingDisabled) return
|
||||
const activePage = pages.find((page) => page.id === Number(active.id))
|
||||
const overPage = pages.find((page) => page.id === Number(over.id))
|
||||
if (!activePage || !overPage) return
|
||||
if (activePage.parentId !== overPage.parentId) {
|
||||
toast.info(t("supportHelpWorkbench.sameLevelOnly"))
|
||||
return
|
||||
}
|
||||
const reordered = reorderSiblings(pages, activePage.parentId, activePage.id, overPage.id)
|
||||
if (!reordered) return
|
||||
const previous = pages
|
||||
setPages(reordered.pages)
|
||||
setSorting(true)
|
||||
try {
|
||||
await updateDocPageSortAdmin({ parentId: activePage.parentId, ids: reordered.ids })
|
||||
toast.success(t("supportHelpWorkbench.sortSaved"))
|
||||
} catch (error) {
|
||||
setPages(previous)
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleIds = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
if (!keyword) return null
|
||||
const ids = new Set<number>()
|
||||
for (const page of pages) {
|
||||
if (keyword && !`${page.title} ${page.slug} ${page.summary}`.toLowerCase().includes(keyword)) continue
|
||||
ids.add(page.id)
|
||||
let parentId = page.parentId
|
||||
while (parentId) {
|
||||
ids.add(parentId)
|
||||
parentId = pages.find((item) => item.id === parentId)?.parentId ?? 0
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [pages, query])
|
||||
|
||||
const roots = childPages(pages, 0).filter((page) => !visibleIds || visibleIds.has(page.id))
|
||||
const path = pagePath(pages, selected)
|
||||
const statusLabel = statusOptions.find((item) => item.value === selected?.status)?.label ?? selected?.status
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden bg-background">
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r bg-muted/20">
|
||||
<div className="flex h-14 items-center justify-between border-b px-3">
|
||||
<span className="font-semibold">{t("supportHelpWorkbench.title")}</span>
|
||||
<Button size="icon" variant="ghost" title={t("supportHelpWorkbench.createPage")} aria-label={t("supportHelpWorkbench.createPage")} onClick={() => setCreateState({ open: true, parentId: 0 })}>
|
||||
<FilePlus2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} className="pl-9" placeholder={t("supportHelpWorkbench.searchPlaceholder")} />
|
||||
</div>
|
||||
{query.trim() ? <p className="mt-2 text-xs text-muted-foreground">{t("supportHelpWorkbench.sortDisabledDuringSearch")}</p> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={siblingCollisionDetection}
|
||||
onDragStart={(event) => setActiveDragId(Number(event.active.id))}
|
||||
onDragCancel={() => setActiveDragId(null)}
|
||||
onDragEnd={(event) => void handleDragEnd(event)}
|
||||
>
|
||||
<SortableContext items={roots.map((page) => page.id)} strategy={verticalListSortingStrategy}>
|
||||
{roots.map((page) => (
|
||||
<PageTreeNode
|
||||
key={page.id}
|
||||
page={page}
|
||||
pages={pages}
|
||||
depth={0}
|
||||
selectedId={selected?.id}
|
||||
expanded={expanded}
|
||||
forceExpanded={Boolean(query)}
|
||||
visibleIds={visibleIds}
|
||||
sortingDisabled={sortingDisabled}
|
||||
onToggle={(id) => setExpanded((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})}
|
||||
onSelect={selectPage}
|
||||
onCreate={(parentId) => setCreateState({ open: true, parentId })}
|
||||
onDelete={remove}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragPage ? <PageDragOverlay page={activeDragPage} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{!loading && pages.length === 0 ? <div className="px-3 py-10 text-center text-sm text-muted-foreground">{t("supportHelpWorkbench.empty")}</div> : null}
|
||||
{loading ? <div className="px-3 py-10 text-center text-sm text-muted-foreground">{t("supportHelpWorkbench.loading")}</div> : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main key={selected?.id ?? "empty"} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{draft && selected ? <>
|
||||
<div className="sticky top-0 z-20 flex h-14 items-center justify-between gap-4 border-b bg-background px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
|
||||
{path.map((item, index) => <span key={item.id} className="flex min-w-0 items-center gap-2">{index > 0 ? <ChevronRightIcon className="size-3.5 shrink-0" /> : null}<span className="truncate">{item.title}</span></span>)}
|
||||
{path.length > 0 ? <ChevronRightIcon className="size-3.5 shrink-0" /> : null}
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
className="h-8 min-w-32 max-w-xl flex-1 border-border/60 bg-muted/30 px-2 text-sm font-medium text-foreground shadow-none hover:border-border hover:bg-muted/50 focus-visible:bg-background"
|
||||
placeholder={t("supportHelpWorkbench.pageTitle")}
|
||||
aria-label={t("supportHelpWorkbench.pageTitle")}
|
||||
/>
|
||||
<Badge variant="outline" className="ml-1 shrink-0">{statusLabel}</Badge>
|
||||
{selected.status === "published" && dirty ? <span className="hidden text-xs text-amber-600 xl:inline dark:text-amber-400">{t("supportHelpWorkbench.publishedSaveWarning")}</span> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className={cn("hidden text-xs sm:inline", dirty ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")}>{dirty ? t("supportHelpWorkbench.unsaved") : t("supportHelpWorkbench.savedState")}</span>
|
||||
<Button size="icon" variant="ghost" title={t("supportHelpWorkbench.pageSettings")} aria-label={t("supportHelpWorkbench.pageSettings")} onClick={() => setSettingsOpen(true)}><Settings2Icon /></Button>
|
||||
{selected.status === "published" ? <Button size="icon" variant="ghost" nativeButton={false} title={t("supportHelpWorkbench.openPublicPage")} aria-label={t("supportHelpWorkbench.openPublicPage")} render={<a href={`/support/docs/${selected.slug}`} target="_blank" rel="noreferrer" />}><ExternalLinkIcon /></Button> : null}
|
||||
<Button variant={selected.status === "published" ? "default" : "outline"} onClick={() => void save()} disabled={!dirty || saving} title={t("supportHelpWorkbench.save")}><SaveIcon /><span className="hidden xl:inline">{saving ? t("supportHelpWorkbench.saving") : t("supportHelpWorkbench.save")}</span></Button>
|
||||
{selected.status === "published" ? <Button variant="outline" onClick={() => void changeStatus("draft")} disabled={saving} title={t("supportHelpWorkbench.withdrawAction")}><EyeOffIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.withdrawAction")}</span></Button> : selected.status !== "hidden" ? <Button variant="outline" onClick={() => void changeStatus("hidden")} disabled={saving} title={t("supportHelpWorkbench.hiddenAction")}><EyeOffIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.hiddenAction")}</span></Button> : null}
|
||||
{selected.status !== "published" ? <Button onClick={() => void changeStatus("published")} disabled={saving || dirty} title={t("supportHelpWorkbench.publishedAction")}><RocketIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.publishedAction")}</span></Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-card">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ContentEditor
|
||||
value={{ mode: draft.contentType === "html" ? "html" : "markdown", raw: draft.content }}
|
||||
onChange={(content) => setDraft({ ...draft, content: content.raw, contentType: content.mode })}
|
||||
onUploadImage={async (file) => {
|
||||
const asset = await uploadAsset(file, "support/docs")
|
||||
return { url: asset.url, alt: asset.filename, title: asset.filename }
|
||||
}}
|
||||
placeholder={t("supportHelpWorkbench.contentPlaceholder")}
|
||||
height="100%"
|
||||
className="min-h-0 flex-1 [&>div]:h-full [&>div]:rounded-none [&>div]:border-0 [&>div]:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</> : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("supportHelpWorkbench.selectPrompt")}</div>}
|
||||
</main>
|
||||
|
||||
<CreatePageDialog key={`${createState.open}-${createState.parentId}`} state={createState} pages={pages} onOpenChange={(open) => setCreateState((current) => ({ ...current, open }))} onCreated={async (id) => { setCreateState(blankCreate); await load(id) }} />
|
||||
<PageSettingsDialog key={`${selected?.id ?? 0}-${settingsOpen}`} open={settingsOpen} onOpenChange={setSettingsOpen} pages={pages} selected={selected} draft={draft} saving={saving} onSave={saveSettings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PageTreeNodeProps = {
|
||||
page: AdminDocPage
|
||||
pages: AdminDocPage[]
|
||||
depth: number
|
||||
selectedId?: number
|
||||
expanded: Set<number>
|
||||
forceExpanded: boolean
|
||||
visibleIds: Set<number> | null
|
||||
sortingDisabled: boolean
|
||||
onToggle: (id: number) => void
|
||||
onSelect: (page: AdminDocPage) => void
|
||||
onCreate: (parentId: number) => void
|
||||
onDelete: (page: AdminDocPage) => void
|
||||
}
|
||||
|
||||
function PageTreeNode(props: PageTreeNodeProps) {
|
||||
const { page, pages, depth, selectedId, expanded, forceExpanded, visibleIds, sortingDisabled, onToggle, onSelect, onCreate, onDelete } = props
|
||||
const t = useI18n()
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: page.id, disabled: sortingDisabled })
|
||||
const children = childPages(pages, page.id).filter((item) => !visibleIds || visibleIds.has(item.id))
|
||||
const open = forceExpanded || expanded.has(page.id)
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition: isDragging ? undefined : transition }
|
||||
|
||||
return <div ref={setNodeRef} style={style} className={cn(isDragging && "relative z-10 opacity-30")}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger className="block">
|
||||
<div className={cn("group flex h-9 items-center rounded-md pr-1 text-sm", selectedId === page.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/60", isDragging && "shadow-sm")} style={{ paddingLeft: 4 + depth * 16 }}>
|
||||
<button type="button" className="flex size-7 shrink-0 items-center justify-center" onClick={() => children.length && onToggle(page.id)} aria-label={open ? t("supportHelpWorkbench.collapse") : t("supportHelpWorkbench.expand")}>
|
||||
<ChevronRightIcon className={cn("size-4 transition-transform", !children.length && "opacity-0", open && "rotate-90")} />
|
||||
</button>
|
||||
<button type="button" className="min-w-0 flex-1 truncate text-left" onClick={() => void onSelect(page)}>{page.title}</button>
|
||||
<span className={cn("mr-1 size-1.5 rounded-full", page.status === "published" ? "bg-emerald-500" : page.status === "hidden" ? "bg-amber-500" : "bg-muted-foreground/40")} />
|
||||
<button type="button" className="flex size-7 shrink-0 cursor-grab items-center justify-center text-muted-foreground touch-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-35" disabled={sortingDisabled} aria-label={t("supportHelpWorkbench.dragPage", { title: page.title })} title={sortingDisabled ? t("supportHelpWorkbench.dragDisabled") : t("supportHelpWorkbench.dragPage", { title: page.title })} {...attributes} {...listeners}>
|
||||
<GripVerticalIcon className="size-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button size="icon-sm" variant="ghost" className="opacity-0 group-hover:opacity-100 focus:opacity-100" aria-label={t("supportHelpWorkbench.pageActions", { title: page.title })} />}><MoreHorizontalIcon /></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => onCreate(page.id)}><FilePlus2Icon />{t("supportHelpWorkbench.createChildPage")}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => void onDelete(page)}><Trash2Icon />{t("supportHelpWorkbench.deletePage")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem onClick={() => onCreate(page.id)}><FilePlus2Icon />{t("supportHelpWorkbench.createChildPage")}</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive" onClick={() => void onDelete(page)}><Trash2Icon />{t("supportHelpWorkbench.deletePage")}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{open ? <SortableContext items={children.map((child) => child.id)} strategy={verticalListSortingStrategy}>
|
||||
{children.map((child) => <PageTreeNode key={child.id} {...props} page={child} depth={depth + 1} />)}
|
||||
</SortableContext> : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PageDragOverlay({ page }: { page: AdminDocPage }) {
|
||||
return <div className="flex h-9 w-64 items-center rounded-md border bg-background px-3 text-sm shadow-lg">
|
||||
<GripVerticalIcon className="mr-2 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{page.title}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
function CreatePageDialog({ state, pages, onOpenChange, onCreated }: { state: CreateState; pages: AdminDocPage[]; onOpenChange: (open: boolean) => void; onCreated: (id: number) => void }) {
|
||||
const t = useI18n()
|
||||
const handleApiError = useApiErrorHandler()
|
||||
const [title, setTitle] = useState("")
|
||||
const [slug, setSlug] = useState("")
|
||||
const [parentId, setParentId] = useState(state.parentId)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
async function create() {
|
||||
if (!title.trim() || !slug.trim() || creating) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const page = await saveDocPageAdmin({
|
||||
parentId,
|
||||
title: title.trim(),
|
||||
slug: slug.trim(),
|
||||
summary: "",
|
||||
contentType: "markdown",
|
||||
content: "",
|
||||
tags: [],
|
||||
status: "draft",
|
||||
sortNo: childPages(pages, parentId).length,
|
||||
})
|
||||
toast.success(t("supportHelpWorkbench.created"))
|
||||
onCreated(page.id)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <Dialog open={state.open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t("supportHelpWorkbench.createPage")}</DialogTitle><DialogDescription>{t("supportHelpWorkbench.createDescription")}</DialogDescription></DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.pageTitle")}</Label><Input autoFocus value={title} onChange={(event) => { setTitle(event.target.value); if (!slug) setSlug(normalizeSupportSlug(event.target.value)) }} /></div>
|
||||
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.slug")}</Label><Input value={slug} onChange={(event) => setSlug(normalizeSupportSlug(event.target.value))} placeholder="getting-started" /><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugFormatHint")}</p></div>
|
||||
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.parentPage")}</Label><OptionCombobox value={String(parentId)} onChange={(value) => setParentId(Number(value))} placeholder={t("supportHelpWorkbench.selectParentPage")} options={[{ value: "0", label: t("supportHelpWorkbench.rootDirectory") }, ...pages.map((page) => ({ value: String(page.id), label: page.title }))]} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" disabled={creating} onClick={() => onOpenChange(false)}>{t("supportHelpWorkbench.cancel")}</Button><Button disabled={!title.trim() || !slug.trim() || creating} onClick={() => void create()}>{creating ? t("supportHelpWorkbench.creating") : t("supportHelpWorkbench.createAndEdit")}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
function PageSettingsDialog({ open, onOpenChange, pages, selected, draft, saving, onSave }: { open: boolean; onOpenChange: (open: boolean) => void; pages: AdminDocPage[]; selected: AdminDocPage | null; draft: PageDraft | null; saving: boolean; onSave: (settings: PageSettingsDraft) => void }) {
|
||||
const t = useI18n()
|
||||
const [settings, setSettings] = useState<PageSettingsDraft>(() => ({ parentId: draft?.parentId ?? 0, slug: draft?.slug ?? "", summary: draft?.summary ?? "" }))
|
||||
if (!draft || !selected) return null
|
||||
const excluded = descendantIds(pages, selected.id)
|
||||
excluded.add(selected.id)
|
||||
const options = [{ value: "0", label: t("supportHelpWorkbench.rootDirectory") }, ...pages.filter((page) => !excluded.has(page.id)).map((page) => ({ value: String(page.id), label: page.title }))]
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t("supportHelpWorkbench.pageSettings")}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.parentPage")}</Label><OptionCombobox value={String(settings.parentId)} onChange={(value) => setSettings({ ...settings, parentId: Number(value) })} placeholder={t("supportHelpWorkbench.selectParentPage")} options={options} /></div>
|
||||
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.slug")}</Label><Input value={settings.slug} onChange={(event) => setSettings({ ...settings, slug: normalizeSupportSlug(event.target.value) })} /><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugFormatHint")}</p><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugChangeWarning")}</p></div>
|
||||
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.summary")}</Label><Textarea value={settings.summary} onChange={(event) => setSettings({ ...settings, summary: event.target.value })} rows={4} placeholder={t("supportHelpWorkbench.summaryPlaceholder")} /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" disabled={saving} onClick={() => onOpenChange(false)}>{t("supportHelpWorkbench.cancel")}</Button><Button disabled={!settings.slug.trim() || saving} onClick={() => onSave(settings)}>{saving ? t("supportHelpWorkbench.saving") : t("supportHelpWorkbench.save")}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { MessageSquareTextIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardCrudPage } from "@/components/dashboard/crud"
|
||||
import { DashboardPage } from "@/components/dashboard-page"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
acceptCommunityCommentAdmin,
|
||||
createCommunityCommentAdmin,
|
||||
deleteCommunityCategoryAdmin,
|
||||
fetchCommunityPostAdmin,
|
||||
fetchCommunityCategoriesAllAdmin,
|
||||
fetchCommunityCategoriesAdmin,
|
||||
fetchCommunityPostsAdmin,
|
||||
moderateCommunityPostAdmin,
|
||||
saveCommunityCategoryAdmin,
|
||||
updateCommunityCategorySortAdmin,
|
||||
type AdminCategory,
|
||||
type AdminPost,
|
||||
type AdminPostDetail,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { normalizeSupportSlug, supportSlugPattern } from "@/lib/support-slug"
|
||||
|
||||
function getPostStatusOptions(t: (key: string) => string) {
|
||||
return [
|
||||
{ value: "all", label: t("supportCommunityAdmin.allStatuses") },
|
||||
{ value: "pending", label: t("supportCommunityAdmin.statusPending") },
|
||||
{ value: "normal", label: t("supportCommunityAdmin.statusNormal") },
|
||||
{ value: "resolved", label: t("supportCommunityAdmin.statusResolved") },
|
||||
{ value: "closed", label: t("supportCommunityAdmin.statusClosed") },
|
||||
{ value: "hidden", label: t("supportCommunityAdmin.statusHidden") },
|
||||
]
|
||||
}
|
||||
|
||||
type CategoryPayload = Pick<
|
||||
AdminCategory,
|
||||
"name" | "slug" | "description" | "status" | "remark"
|
||||
>
|
||||
|
||||
function postStatusLabel(status: string, t: (key: string) => string) {
|
||||
return getPostStatusOptions(t).find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function DashboardSupportCommunityCategoryAdmin() {
|
||||
const t = useI18n()
|
||||
const categoryStatusOptions = [
|
||||
{ value: "all", label: t("supportCommunityCategory.allStatuses") },
|
||||
{ value: "0", label: t("supportCommunityCategory.enabled") },
|
||||
{ value: "1", label: t("supportCommunityCategory.disabled") },
|
||||
]
|
||||
const crudFormLabels = {
|
||||
create: t("supportCommunityCategory.create"),
|
||||
save: t("supportCommunityCategory.save"),
|
||||
saving: t("supportCommunityCategory.saving"),
|
||||
cancel: t("supportCommunityCategory.cancel"),
|
||||
loadingDetail: t("supportCommunityCategory.loadingDetail"),
|
||||
required: t("supportCommunityCategory.required"),
|
||||
invalidNumber: t("supportCommunityCategory.invalidNumber"),
|
||||
minValue: (min: number) => t("supportCommunityCategory.minValue", { min }),
|
||||
maxValue: (max: number) => t("supportCommunityCategory.maxValue", { max }),
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardCrudPage<AdminCategory, CategoryPayload>
|
||||
filters={[
|
||||
{ name: "name", label: t("supportCommunityCategory.name"), placeholder: t("supportCommunityCategory.searchName"), defaultValue: "", trim: true, className: "w-full sm:w-72" },
|
||||
{ name: "status", label: t("supportCommunityCategory.allStatuses"), placeholder: t("supportCommunityCategory.allStatuses"), type: "select", defaultValue: "all", allValue: "all", valueType: "number", options: categoryStatusOptions, className: "w-full sm:w-36" },
|
||||
]}
|
||||
columns={[
|
||||
{ key: "name", label: t("supportCommunityCategory.name"), render: (item) => <div><div className="font-medium">{item.name}</div><div className="mt-1 text-sm text-muted-foreground">{item.description || t("supportCommunityCategory.noDescription")}</div></div> },
|
||||
{ key: "slug", label: "Slug", className: "w-48", render: (item) => <span className="font-mono text-sm">{item.slug}</span> },
|
||||
{ key: "status", label: t("supportCommunityCategory.status"), className: "w-28", render: (item) => <Badge variant={item.status === 0 ? "default" : "outline"}>{item.status === 0 ? t("supportCommunityCategory.enabled") : t("supportCommunityCategory.disabled")}</Badge> },
|
||||
]}
|
||||
fetchList={fetchCommunityCategoriesAdmin}
|
||||
getItemId={(item) => item.id}
|
||||
createItem={(payload) => saveCommunityCategoryAdmin(payload)}
|
||||
updateItem={(item, payload) => saveCommunityCategoryAdmin({ id: item.id, ...payload })}
|
||||
deleteItem={(item) => deleteCommunityCategoryAdmin(item.id)}
|
||||
deleteConfirm={(item) => ({ title: t("supportCommunityCategory.confirmDeleteTitle"), description: t("supportCommunityCategory.confirmDeleteDescription", { name: item.name }), confirmText: t("supportCommunityCategory.delete"), variant: "destructive" })}
|
||||
sort={{
|
||||
enabled: true,
|
||||
onReorder: (items) => updateCommunityCategorySortAdmin(items.map((item) => item.id)),
|
||||
successMessage: t("supportCommunityCategory.sortUpdated"),
|
||||
errorMessage: t("supportCommunityCategory.sortUpdateFailed"),
|
||||
handleLabel: t("supportCommunityCategory.dragSort", { name: "" }),
|
||||
}}
|
||||
form={{
|
||||
fields: [
|
||||
{ name: "name", label: t("supportCommunityCategory.name"), placeholder: t("supportCommunityCategory.namePlaceholder"), required: true, trim: true },
|
||||
{ name: "slug", label: "Slug", placeholder: t("supportCommunityCategory.slugPlaceholder"), required: true, trim: true, normalizeInput: normalizeSupportSlug, pattern: supportSlugPattern, patternMessage: t("supportCommunityCategory.slugPatternMessage") },
|
||||
{ name: "description", label: t("supportCommunityCategory.description"), placeholder: t("supportCommunityCategory.descriptionPlaceholder"), type: "textarea", rows: 3, trim: true },
|
||||
{ name: "status", label: t("supportCommunityCategory.status"), type: "select", defaultValue: "0", valueType: "number", required: true, options: categoryStatusOptions.filter((item) => item.value !== "all"), valueFromItem: (item) => String(item.status) },
|
||||
{ name: "remark", label: t("supportCommunityCategory.remark"), placeholder: t("supportCommunityCategory.remarkPlaceholder"), type: "textarea", rows: 3, trim: true },
|
||||
],
|
||||
transformSubmitValues: (values) => ({ name: String(values.name), slug: normalizeSupportSlug(String(values.slug)), description: String(values.description ?? ""), status: Number(values.status), remark: String(values.remark ?? "") }),
|
||||
labels: { ...crudFormLabels, createTitle: t("supportCommunityCategory.createTitle"), editTitle: t("supportCommunityCategory.editTitle") },
|
||||
}}
|
||||
labels={{
|
||||
refresh: t("supportCommunityCategory.refresh"), create: t("supportCommunityCategory.new"), query: t("supportCommunityCategory.query"), loading: t("supportCommunityCategory.loading"), empty: t("supportCommunityCategory.empty"), actions: t("supportCommunityCategory.actions"), edit: t("supportCommunityCategory.edit"), delete: t("supportCommunityCategory.delete"), processing: t("supportCommunityCategory.processing"), moreActions: (item) => t("supportCommunityCategory.moreActions", { name: item.name }), loadFailed: t("supportCommunityCategory.loadFailed"), saveFailed: t("supportCommunityCategory.saveFailed"), deleteFailed: t("supportCommunityCategory.deleteFailed"), created: (payload) => t("supportCommunityCategory.created", { name: payload.name }), updated: (item) => t("supportCommunityCategory.updated", { name: item.name }), deleted: (item) => t("supportCommunityCategory.deleted", { name: item.name }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardSupportCommunityAdmin() {
|
||||
const t = useI18n()
|
||||
const [categories, setCategories] = useState<AdminCategory[]>([])
|
||||
const [posts, setPosts] = useState<AdminPost[]>([])
|
||||
const [categoryId, setCategoryId] = useState<number | "all">("all")
|
||||
const [status, setStatus] = useState("all")
|
||||
const [detail, setDetail] = useState<AdminPostDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [comment, setComment] = useState("")
|
||||
|
||||
const reloadPosts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const page = await fetchCommunityPostsAdmin({ categoryId: categoryId === "all" ? undefined : categoryId, status: status === "all" ? undefined : status, limit: 50 })
|
||||
setPosts(page.results)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [categoryId, status])
|
||||
|
||||
useEffect(() => { void fetchCommunityCategoriesAllAdmin().then(setCategories) }, [])
|
||||
useEffect(() => { void reloadPosts() }, [reloadPosts])
|
||||
|
||||
const openPost = async (id: number) => setDetail(await fetchCommunityPostAdmin(id))
|
||||
const refreshDetail = async () => { if (detail) setDetail(await fetchCommunityPostAdmin(detail.post.id)) }
|
||||
|
||||
const moderate = async (nextStatus: string) => {
|
||||
if (!detail) return
|
||||
await moderateCommunityPostAdmin(detail.post.id, nextStatus)
|
||||
toast.success(t("supportCommunityAdmin.postStatusUpdated"))
|
||||
await Promise.all([refreshDetail(), reloadPosts()])
|
||||
}
|
||||
|
||||
const postStatusOptions = getPostStatusOptions(t)
|
||||
|
||||
const reply = async () => {
|
||||
if (!detail || !comment.trim()) return
|
||||
await createCommunityCommentAdmin(detail.post.id, comment.trim())
|
||||
setComment("")
|
||||
toast.success(t("supportCommunityAdmin.commentSuccess"))
|
||||
await Promise.all([refreshDetail(), reloadPosts()])
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
|
||||
<div className="w-full sm:w-44">
|
||||
<OptionCombobox
|
||||
value={String(categoryId)}
|
||||
onChange={(value) => setCategoryId(value === "all" ? "all" : Number(value))}
|
||||
placeholder={t("supportCommunityAdmin.allCategories")}
|
||||
options={[{ value: "all", label: t("supportCommunityAdmin.allCategories") }, ...categories.map((item) => ({ value: String(item.id), label: item.name }))]}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-36">
|
||||
<OptionCombobox
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
placeholder={t("supportCommunityAdmin.allStatuses")}
|
||||
options={postStatusOptions}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void reloadPosts()} disabled={loading}>{t("supportCommunityAdmin.refresh")}</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{posts.map((post) => (
|
||||
<button key={post.id} type="button" className="flex w-full items-center gap-4 border-b px-4 py-3 text-left last:border-b-0 hover:bg-muted/50" onClick={() => void openPost(post.id)}>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted"><MessageSquareTextIcon className="size-4 text-muted-foreground" /></span>
|
||||
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{post.title}</span><span className="mt-1 block text-sm text-muted-foreground">{post.userName || t("supportCommunityAdmin.defaultUser")} · {post.categoryName || t("supportCommunityAdmin.uncategorized")} · {formatDateTime(post.createdAt)}</span></span>
|
||||
<span className="hidden text-sm text-muted-foreground sm:block">{t("supportCommunityAdmin.commentCount", { count: post.commentCount })}</span>
|
||||
<Badge variant={post.status === "resolved" ? "default" : "outline"}>{postStatusLabel(post.status, t)}</Badge>
|
||||
</button>
|
||||
))}
|
||||
{!loading && posts.length === 0 ? <div className="py-16 text-center text-sm text-muted-foreground">{t("supportCommunityAdmin.noMatchingPosts")}</div> : null}
|
||||
{loading ? <div className="py-16 text-center text-sm text-muted-foreground">{t("supportCommunityAdmin.loadingPosts")}</div> : null}
|
||||
</div>
|
||||
|
||||
<ProjectDialog open={Boolean(detail)} onOpenChange={(open) => { if (!open) setDetail(null) }} title={detail?.post.title ?? t("supportCommunityAdmin.moderateDialogTitle")} description={detail ? `${detail.post.userName || t("supportCommunityAdmin.defaultUser")} · ${detail.post.categoryName || t("supportCommunityAdmin.uncategorized")} · ${formatDateTime(detail.post.createdAt)}` : undefined} size="xl" allowFullscreen>
|
||||
{detail ? (
|
||||
<div className="grid gap-5">
|
||||
<div className="flex flex-wrap items-center gap-2"><Badge variant={detail.post.status === "resolved" ? "default" : "outline"}>{postStatusLabel(detail.post.status, t)}</Badge><span className="text-sm text-muted-foreground">{t("supportCommunityAdmin.likesAndViews", { likes: detail.post.reactionCount, views: detail.post.viewCount })}</span></div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7">{detail.post.content}</p>
|
||||
<div className="flex flex-wrap gap-2 border-y py-3">
|
||||
<Button variant="outline" onClick={() => void moderate("normal")}>{t("supportCommunityAdmin.restoreNormalAction")}</Button>
|
||||
<Button variant="outline" onClick={() => void moderate("closed")}>{t("supportCommunityAdmin.closePostAction")}</Button>
|
||||
<Button variant="destructive" onClick={() => void moderate("hidden")}>{t("supportCommunityAdmin.hidePostAction")}</Button>
|
||||
</div>
|
||||
<Card><CardHeader><CardTitle className="text-base">{t("supportCommunityAdmin.commentsTitle", { count: detail.comments.length })}</CardTitle></CardHeader><CardContent className="grid gap-3">{detail.comments.map((item) => <div key={item.id} className="rounded-md border p-3"><div className="flex items-center justify-between gap-3"><span className="text-sm font-medium">{item.authorName || item.authorType}</span>{item.isAccepted ? <Badge>{t("supportCommunityAdmin.acceptedBadge")}</Badge> : <Button size="sm" variant="outline" onClick={() => void acceptCommunityCommentAdmin(detail.post.id, item.id).then(refreshDetail)}>{t("supportCommunityAdmin.acceptAnswerAction")}</Button>}</div><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{item.content}</p></div>)}</CardContent></Card>
|
||||
<div className="grid gap-2"><Textarea value={comment} onChange={(event) => setComment(event.target.value)} rows={5} placeholder={t("supportCommunityAdmin.commentPlaceholder")} /><div className="flex justify-end"><Button onClick={() => void reply()} disabled={!comment.trim()}>{t("supportCommunityAdmin.publishCommentAction")}</Button></div></div>
|
||||
</div>
|
||||
) : null}
|
||||
</ProjectDialog>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { ExternalLinkIcon, GripVerticalIcon, PlusIcon, RefreshCwIcon, SaveIcon, Trash2Icon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar } from "@/components/dashboard-page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
fetchSupportConfigAdmin,
|
||||
saveSupportConfigAdmin,
|
||||
type SupportNavigationMenuItem,
|
||||
} from "@/lib/api/admin"
|
||||
import { isApiRequestError } from "@/lib/api/client"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ConfigFieldError = {
|
||||
path: string
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type NavigationMenuRowProps = {
|
||||
item: SupportNavigationMenuItem
|
||||
disabled: boolean
|
||||
canDelete: boolean
|
||||
onChange: (id: string, values: Partial<SupportNavigationMenuItem>) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const newMenuItem = (): SupportNavigationMenuItem => ({
|
||||
id: `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: "",
|
||||
url: "/support",
|
||||
openInNewWindow: false,
|
||||
visible: true,
|
||||
sortNo: 0,
|
||||
})
|
||||
|
||||
function normalizeRows(rows: SupportNavigationMenuItem[]) {
|
||||
return rows.map((item, index) => ({ ...item, sortNo: (index + 1) * 10 }))
|
||||
}
|
||||
|
||||
function serializeRows(rows: SupportNavigationMenuItem[]) {
|
||||
return JSON.stringify(
|
||||
rows.map(({ id, title, url, openInNewWindow, visible }) => ({
|
||||
id,
|
||||
title: title.trim(),
|
||||
url: url.trim(),
|
||||
openInNewWindow,
|
||||
visible,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export function SupportConfigPanel() {
|
||||
const t = useI18n()
|
||||
const [items, setItems] = useState<SupportNavigationMenuItem[]>([])
|
||||
const [savedSnapshot, setSavedSnapshot] = useState("")
|
||||
const [fieldErrors, setFieldErrors] = useState<ConfigFieldError[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const dirty = useMemo(() => serializeRows(items) !== savedSnapshot, [items, savedSnapshot])
|
||||
const canDelete = items.length > 1
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const config = await fetchSupportConfigAdmin()
|
||||
const nextItems = normalizeRows(config.navigationMenu)
|
||||
setItems(nextItems)
|
||||
setSavedSnapshot(serializeRows(nextItems))
|
||||
setFieldErrors([])
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("supportConfig.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
}, [loadConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) {
|
||||
return
|
||||
}
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ""
|
||||
}
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
}, [dirty])
|
||||
|
||||
function handleAdd() {
|
||||
setItems((current) => normalizeRows([...current, newMenuItem()]))
|
||||
}
|
||||
|
||||
function handleChange(id: string, values: Partial<SupportNavigationMenuItem>) {
|
||||
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...values } : item)))
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
const item = items.find((candidate) => candidate.id === id)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
if (!window.confirm(t("supportConfig.confirmDelete", { title: item.title || t("supportConfig.untitled") }))) {
|
||||
return
|
||||
}
|
||||
setItems((current) => normalizeRows(current.filter((candidate) => candidate.id !== id)))
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
setItems((current) => {
|
||||
const oldIndex = current.findIndex((item) => item.id === active.id)
|
||||
const newIndex = current.findIndex((item) => item.id === over.id)
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return current
|
||||
}
|
||||
return normalizeRows(arrayMove(current, oldIndex, newIndex))
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setSaving(true)
|
||||
const config = await saveSupportConfigAdmin({ navigationMenu: items })
|
||||
const saved = normalizeRows(config.navigationMenu)
|
||||
setItems(saved)
|
||||
setSavedSnapshot(serializeRows(saved))
|
||||
setFieldErrors([])
|
||||
toast.success(t("supportConfig.saved"))
|
||||
} catch (error) {
|
||||
if (isApiRequestError(error)) {
|
||||
setFieldErrors(extractConfigFieldErrors(error.data))
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("supportConfig.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => void loadConfig()} disabled={loading || saving}>
|
||||
<RefreshCwIcon className={cn((loading || saving) && "animate-spin")} />
|
||||
{t("supportConfig.refresh")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSave()} disabled={loading || saving || !dirty}>
|
||||
<SaveIcon />
|
||||
{saving ? t("supportConfig.saving") : t("supportConfig.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t("supportConfig.title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("supportConfig.description")}</p>
|
||||
</div>
|
||||
</DashboardToolbar>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{t("supportConfig.navigationTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("supportConfig.navigationDescription")}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={handleAdd} disabled={loading || saving}>
|
||||
<PlusIcon />
|
||||
{t("supportConfig.addNavigation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fieldErrors.length > 0 ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<div className="font-medium">{t("supportConfig.validationFailed")}</div>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5">
|
||||
{fieldErrors.map((error) => (
|
||||
<li key={`${error.path}-${error.code}`}>{error.path ? `${error.path}: ${error.message}` : error.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DashboardTableShell>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">{t("supportConfig.sort")}</TableHead>
|
||||
<TableHead className="min-w-44">{t("supportConfig.menuTitle")}</TableHead>
|
||||
<TableHead className="min-w-64">{t("supportConfig.menuURL")}</TableHead>
|
||||
<TableHead className="w-40">{t("supportConfig.target")}</TableHead>
|
||||
<TableHead className="w-28">{t("supportConfig.visible")}</TableHead>
|
||||
<TableHead className="w-20 text-right">{t("supportConfig.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<DashboardTableStateRow colSpan={6} loading loadingText={t("supportConfig.loading")} />
|
||||
) : items.length === 0 ? (
|
||||
<DashboardTableStateRow colSpan={6} emptyText={t("supportConfig.emptyNavigation")} />
|
||||
) : (
|
||||
<SortableContext items={items.map((item) => item.id)} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item) => (
|
||||
<NavigationMenuRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
disabled={saving}
|
||||
canDelete={canDelete}
|
||||
onChange={handleChange}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</DashboardTableShell>
|
||||
</section>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
function extractConfigFieldErrors(data: unknown): ConfigFieldError[] {
|
||||
if (!data || typeof data !== "object" || !("errors" in data)) {
|
||||
return []
|
||||
}
|
||||
const errors = (data as { errors?: unknown }).errors
|
||||
if (!Array.isArray(errors)) {
|
||||
return []
|
||||
}
|
||||
return errors.filter((item): item is ConfigFieldError => {
|
||||
return Boolean(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
"code" in item &&
|
||||
"message" in item &&
|
||||
typeof (item as ConfigFieldError).path === "string" &&
|
||||
typeof (item as ConfigFieldError).code === "string" &&
|
||||
typeof (item as ConfigFieldError).message === "string"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function NavigationMenuRow({ item, disabled, canDelete, onChange, onDelete }: NavigationMenuRowProps) {
|
||||
const t = useI18n()
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.id,
|
||||
disabled,
|
||||
})
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={cn(isDragging && "relative z-10 bg-muted/60 shadow-sm")}>
|
||||
<TableCell className="w-12">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 cursor-grab rounded-md active:cursor-grabbing"
|
||||
disabled={disabled}
|
||||
aria-label={t("supportConfig.dragNavigation", { title: item.title || t("supportConfig.untitled") })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-44 whitespace-normal">
|
||||
<Label className="sr-only" htmlFor={`support-nav-title-${item.id}`}>{t("supportConfig.menuTitle")}</Label>
|
||||
<Input
|
||||
id={`support-nav-title-${item.id}`}
|
||||
value={item.title}
|
||||
onChange={(event) => onChange(item.id, { title: event.target.value })}
|
||||
placeholder={t("supportConfig.titlePlaceholder")}
|
||||
disabled={disabled}
|
||||
maxLength={64}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-64 whitespace-normal">
|
||||
<Label className="sr-only" htmlFor={`support-nav-url-${item.id}`}>{t("supportConfig.menuURL")}</Label>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
id={`support-nav-url-${item.id}`}
|
||||
value={item.url}
|
||||
onChange={(event) => onChange(item.id, { url: event.target.value })}
|
||||
placeholder="/support/docs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{item.url ? (
|
||||
<a
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("supportConfig.openURL")}
|
||||
title={t("supportConfig.openURL")}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-40">
|
||||
<Select value={item.openInNewWindow ? "_blank" : "_self"} onValueChange={(value) => onChange(item.id, { openInNewWindow: value === "_blank" })} disabled={disabled}>
|
||||
<SelectTrigger className="w-36 rounded-md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_self">{t("supportConfig.targetSelf")}</SelectItem>
|
||||
<SelectItem value="_blank">{t("supportConfig.targetBlank")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="w-28">
|
||||
<Switch
|
||||
checked={item.visible}
|
||||
onCheckedChange={(checked) => onChange(item.id, { visible: checked })}
|
||||
disabled={disabled}
|
||||
aria-label={t("supportConfig.toggleNavigation", { title: item.title || t("supportConfig.untitled") })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="w-20 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 rounded-md text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={disabled || !canDelete}
|
||||
onClick={() => onDelete(item.id)}
|
||||
aria-label={t("supportConfig.deleteNavigation", { title: item.title || t("supportConfig.untitled") })}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardSupportCommunityCategoryAdmin } from "../_components/support-admin"
|
||||
|
||||
export default function DashboardSupportCommunityCategoriesPage() {
|
||||
return <DashboardSupportCommunityCategoryAdmin />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/community/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/community/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardSupportCommunityAdmin } from "../_components/support-admin"
|
||||
|
||||
export default function DashboardSupportCommunityPage() {
|
||||
return <DashboardSupportCommunityAdmin />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/config/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/config/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportConfigPanel } from "../_components/support-config-panel"
|
||||
|
||||
export default function DashboardSupportConfigPage() {
|
||||
return <SupportConfigPanel />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/docs/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/docs/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SupportDocsWorkbench } from "../_components/help-workbench"
|
||||
|
||||
export default function DashboardDocPage() {
|
||||
return <SupportDocsWorkbench />
|
||||
}
|
||||
5
web/app/(dashboard)/dashboard/support/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function DashboardSupportPage() {
|
||||
redirect("/dashboard/support/docs")
|
||||
}
|
||||
259
web/app/(dashboard)/dashboard/tags/_components/edit.tsx
Normal file
259
web/app/(dashboard)/dashboard/tags/_components/edit.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Controller, Resolver, useForm } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ProjectDialog } from "@/components/project-dialog";
|
||||
import { TagSelector } from "@/components/tag-selector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
type CreateTagPayload,
|
||||
fetchTag,
|
||||
fetchTagsAll,
|
||||
type Tag,
|
||||
type TagTree,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
|
||||
type TagFormDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateTagPayload) => Promise<void>;
|
||||
};
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
parentId: "0",
|
||||
name: "",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
type EditForm = {
|
||||
parentId: string;
|
||||
name: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function buildForm(item: Tag | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm;
|
||||
}
|
||||
|
||||
return {
|
||||
parentId: String(item.parentId),
|
||||
name: item.name,
|
||||
remark: item.remark,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateTagPayload {
|
||||
return {
|
||||
parentId: Number(form.parentId),
|
||||
name: form.name.trim(),
|
||||
remark: form.remark.trim(),
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TagFormDialogProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TagFormDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
itemId={itemId}
|
||||
saving={saving}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TagFormDialogBodyProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
itemId: number | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: CreateTagPayload) => Promise<void>;
|
||||
};
|
||||
|
||||
function TagFormDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TagFormDialogBodyProps) {
|
||||
const t = useI18n();
|
||||
const formId = "tag-edit-form";
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [parentTags, setParentTags] = useState<TagTree[]>([]);
|
||||
|
||||
const tagFormSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
parentId: z.string(),
|
||||
name: z.string().trim().min(1, t("tag.nameRequired")),
|
||||
remark: z.string(),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(tagFormSchema as never) as Resolver<EditForm>,
|
||||
[tagFormSchema],
|
||||
);
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
});
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadParentTags() {
|
||||
try {
|
||||
const data = await fetchTagsAll();
|
||||
setParentTags(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load parent tags:", error);
|
||||
}
|
||||
}
|
||||
void loadParentTags();
|
||||
}, [itemId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(emptyForm);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTag(itemId);
|
||||
reset(buildForm(data));
|
||||
} catch (error) {
|
||||
console.error("Failed to load tag:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadDetail();
|
||||
}, [itemId, reset]);
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
const payload = buildPayload(values);
|
||||
await onSubmit(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={itemId ? t("tag.editTitle") : t("tag.createTitle")}
|
||||
size="md"
|
||||
allowFullscreen
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("tag.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("tag.saving") : itemId ? t("tag.save") : t("tag.create")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("tag.loadingDetail")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field data-invalid={!!errors.parentId}>
|
||||
<FieldLabel htmlFor="tag-parent-id">{t("tag.parent")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parentId"
|
||||
render={({ field }) => (
|
||||
<TagSelector
|
||||
mode="single"
|
||||
value={Number(field.value)}
|
||||
onChange={(value) => field.onChange(String(value))}
|
||||
tags={parentTags}
|
||||
placeholder={t("tag.rootParent")}
|
||||
searchPlaceholder={t("tag.searchParent")}
|
||||
emptyText={t("tag.emptyParent")}
|
||||
disabled={saving}
|
||||
rootOption={{ value: 0, label: t("tag.rootParent") }}
|
||||
excludeIds={itemId ? [itemId] : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.parentId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="tag-name">{t("tag.name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="tag-name"
|
||||
placeholder={t("tag.namePlaceholder")}
|
||||
aria-invalid={!!errors.name}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.remark}>
|
||||
<FieldLabel htmlFor="tag-remark">{t("tag.remark")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="tag-remark"
|
||||
placeholder={t("tag.remarkPlaceholder")}
|
||||
rows={3}
|
||||
aria-invalid={!!errors.remark}
|
||||
{...register("remark")}
|
||||
/>
|
||||
<FieldError errors={[errors.remark]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
);
|
||||
}
|
||||
656
web/app/(dashboard)/dashboard/tags/page.tsx
Normal file
656
web/app/(dashboard)/dashboard/tags/page.tsx
Normal file
@@ -0,0 +1,656 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { CSSProperties } from "react"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
GripVerticalIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
TagIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
createTag,
|
||||
deleteTag,
|
||||
fetchTags,
|
||||
fetchTagsAll,
|
||||
updateTag,
|
||||
updateTagSort,
|
||||
updateTagStatus,
|
||||
type CreateTagPayload,
|
||||
type Tag,
|
||||
type TagTree,
|
||||
} from "@/lib/api/admin"
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardTableStateRow,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { EditDialog } from "./_components/edit"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ButtonGroup } from "@/components/ui/button-group"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { updateTagTreeStatus } from "@/lib/tag-tree"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type TagNode = TagTree & {
|
||||
children: TagNode[]
|
||||
depth: number
|
||||
}
|
||||
|
||||
function withDepth(nodes: TagTree[] | null | undefined, depth = 0): TagNode[] {
|
||||
const safeNodes = Array.isArray(nodes) ? nodes : []
|
||||
|
||||
return safeNodes.map((node) => ({
|
||||
...node,
|
||||
depth,
|
||||
children: withDepth(node.children, depth + 1),
|
||||
}))
|
||||
}
|
||||
|
||||
function collectParentIds(nodes: TagNode[]): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
const walk = (items: TagNode[]) => {
|
||||
items.forEach((item) => {
|
||||
if (item.children.length > 0) {
|
||||
ids.add(item.id)
|
||||
walk(item.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(nodes)
|
||||
return ids
|
||||
}
|
||||
|
||||
function filterTree(nodes: TagNode[], keyword: string, status?: number): TagNode[] {
|
||||
if (!keyword && status === undefined) {
|
||||
return nodes
|
||||
}
|
||||
|
||||
const result: TagNode[] = []
|
||||
|
||||
function matchesFilter(node: TagNode): boolean {
|
||||
const nameMatch = !keyword || node.name.toLowerCase().includes(keyword.toLowerCase())
|
||||
const statusMatch = status === undefined || node.status === status
|
||||
return nameMatch && statusMatch
|
||||
}
|
||||
|
||||
function hasMatchingDescendant(node: TagNode): boolean {
|
||||
if (matchesFilter(node)) {
|
||||
return true
|
||||
}
|
||||
return node.children.some(hasMatchingDescendant)
|
||||
}
|
||||
|
||||
function filterNode(node: TagNode): TagNode | null {
|
||||
if (!hasMatchingDescendant(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filteredChildren = node.children
|
||||
.map(filterNode)
|
||||
.filter((child): child is TagNode => child !== null)
|
||||
|
||||
if (matchesFilter(node)) {
|
||||
return { ...node, children: filteredChildren }
|
||||
}
|
||||
|
||||
if (filteredChildren.length > 0) {
|
||||
return { ...node, children: filteredChildren }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const filtered = filterNode(node)
|
||||
if (filtered) {
|
||||
result.push(filtered)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type SortableRowProps = {
|
||||
item: TagNode & { hasChildren: boolean }
|
||||
disabled: boolean
|
||||
expanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onEdit: (item: TagNode) => void
|
||||
onToggleStatus: (item: TagNode) => void
|
||||
onDelete: (item: TagNode) => void
|
||||
actionLoadingId: number | null
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
item,
|
||||
disabled,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
actionLoadingId,
|
||||
}: SortableRowProps) {
|
||||
const t = useI18n()
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: item.id,
|
||||
disabled,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
isDragging && "relative z-10 bg-muted/60 shadow-sm",
|
||||
!disabled && "cursor-move"
|
||||
)}
|
||||
>
|
||||
<TableCell className="w-14">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 cursor-grab active:cursor-grabbing"
|
||||
disabled={disabled}
|
||||
aria-label={t("tag.dragSort", { name: item.name })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{ paddingLeft: item.depth * 24 }}
|
||||
>
|
||||
{item.hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="flex size-6 items-center justify-center rounded hover:bg-muted"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`size-4 transition-transform ${
|
||||
expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-6" />
|
||||
)}
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<TagIcon className="size-4" />
|
||||
</div>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={item.status === 0}
|
||||
disabled={actionLoadingId === item.id}
|
||||
onCheckedChange={() => void onToggleStatus(item)}
|
||||
aria-label={t("tag.toggleStatus", { name: item.name })}
|
||||
/>
|
||||
<Badge variant={item.status === 0 ? "default" : "outline"}>
|
||||
{item.status === 0 ? t("status.ok") : t("status.disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{item.remark || "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.createdAt}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ButtonGroup className="ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(item)}
|
||||
>
|
||||
{t("tag.edit")}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon-sm" />}
|
||||
aria-label={t("tag.moreActions", { name: item.name })}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => void onDelete(item)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon />
|
||||
{actionLoadingId === item.id ? t("tag.deleting") : t("tag.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardTagsPage() {
|
||||
const t = useI18n()
|
||||
const [keywordInput, setKeywordInput] = useState("")
|
||||
const [statusFilterInput, setStatusFilterInput] = useState("all")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sorting, setSorting] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<{ id: number; name: string } | null>(null)
|
||||
const [allTags, setAllTags] = useState<Tag[]>([])
|
||||
const [tree, setTree] = useState<TagNode[]>([])
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const listStatusOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: t("status.all") },
|
||||
{ value: "0", label: t("status.ok") },
|
||||
{ value: "1", label: t("status.disabled") },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [treeData, listData] = await Promise.all([
|
||||
fetchTagsAll(),
|
||||
fetchTags({ page: 1, limit: 10000 }),
|
||||
])
|
||||
const nextTree = withDepth(treeData)
|
||||
setTree(nextTree)
|
||||
setAllTags(Array.isArray(listData.results) ? listData.results : [])
|
||||
setExpandedIds(collectParentIds(nextTree))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("tag.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
function handleStatusFilterChange(value: string | null) {
|
||||
setStatusFilterInput(value ?? "all")
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
setKeyword(keywordInput)
|
||||
setStatusFilter(statusFilterInput)
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
setExpandedIds(collectParentIds(tree))
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
setExpandedIds(new Set())
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingItem(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(item: TagNode) {
|
||||
setEditingItem(item)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(open: boolean) {
|
||||
if (saving) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setEditingItem(null)
|
||||
}
|
||||
setDialogOpen(open)
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: CreateTagPayload) {
|
||||
if (saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateTag({
|
||||
id: editingItem.id,
|
||||
...payload,
|
||||
})
|
||||
toast.success(t("tag.updated", { name: editingItem.name }))
|
||||
} else {
|
||||
await createTag(payload)
|
||||
toast.success(t("tag.created", { name: payload.name }))
|
||||
}
|
||||
setDialogOpen(false)
|
||||
setEditingItem(null)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("tag.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus(item: TagNode) {
|
||||
setActionLoadingId(item.id)
|
||||
try {
|
||||
const nextStatus = item.status === 0 ? 1 : 0
|
||||
await updateTagStatus(item.id, nextStatus)
|
||||
toast.success(t(nextStatus === 0 ? "tag.enabled" : "tag.disabled", { name: item.name }))
|
||||
setTree((prev) => updateTagTreeStatus(prev, item.id, nextStatus))
|
||||
setAllTags((prev) =>
|
||||
prev.map((tag) => (tag.id === item.id ? { ...tag, status: nextStatus } : tag))
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("tag.statusUpdateFailed"))
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: TagNode) {
|
||||
setActionLoadingId(item.id)
|
||||
try {
|
||||
await deleteTag(item.id)
|
||||
toast.success(t("tag.deleted", { name: item.name }))
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("tag.deleteFailed"))
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredTree = useMemo(
|
||||
() =>
|
||||
filterTree(
|
||||
tree,
|
||||
keyword.trim(),
|
||||
statusFilter === "all" ? undefined : Number(statusFilter)
|
||||
),
|
||||
[keyword, statusFilter, tree]
|
||||
)
|
||||
|
||||
type FlatItem = TagNode & { hasChildren: boolean }
|
||||
const [flatList, setFlatList] = useState<FlatItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const items: FlatItem[] = []
|
||||
function collectVisible(nodes: TagNode[]) {
|
||||
nodes.forEach((node) => {
|
||||
const hasChildren = node.children.length > 0
|
||||
items.push({ ...node, hasChildren })
|
||||
if (expandedIds.has(node.id)) {
|
||||
collectVisible(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
collectVisible(filteredTree)
|
||||
setFlatList(items)
|
||||
}, [filteredTree, expandedIds])
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || sorting) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeItem = flatList.find((item) => item.id === active.id)
|
||||
const overItem = flatList.find((item) => item.id === over.id)
|
||||
if (!activeItem || !overItem) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeItem.parentId !== overItem.parentId) {
|
||||
toast.error(t("tag.sameParentOnly"))
|
||||
return
|
||||
}
|
||||
|
||||
const oldIndex = flatList.findIndex((item) => item.id === active.id)
|
||||
const newIndex = flatList.findIndex((item) => item.id === over.id)
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousList = [...flatList]
|
||||
const nextList = arrayMove(flatList, oldIndex, newIndex)
|
||||
setFlatList(nextList)
|
||||
setSorting(true)
|
||||
|
||||
try {
|
||||
const siblings = allTags.filter((t) => t.parentId === activeItem.parentId)
|
||||
const siblingIds = siblings.map((t) => t.id)
|
||||
const movedId = active.id as number
|
||||
const targetId = over.id as number
|
||||
const movedIndex = siblingIds.indexOf(movedId)
|
||||
const targetIndex = siblingIds.indexOf(targetId)
|
||||
if (movedIndex < 0 || targetIndex < 0) {
|
||||
throw new Error(t("tag.notFound"))
|
||||
}
|
||||
const newSiblingIds = arrayMove(siblingIds, movedIndex, targetIndex)
|
||||
await updateTagSort(newSiblingIds)
|
||||
toast.success(t("tag.sortUpdated"))
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
setFlatList(previousList)
|
||||
toast.error(error instanceof Error ? error.message : t("tag.sortUpdateFailed"))
|
||||
} finally {
|
||||
setSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPage>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
|
||||
{t("tag.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={expandAll} disabled={loading}>
|
||||
{t("tag.expandAll")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={collapseAll} disabled={loading}>
|
||||
{t("tag.collapseAll")}
|
||||
</Button>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<PlusIcon />
|
||||
{t("tag.new")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="relative w-full sm:w-72">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={t("tag.filterName")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-40">
|
||||
<OptionCombobox
|
||||
value={statusFilterInput}
|
||||
options={listStatusOptions}
|
||||
placeholder={t("status.all")}
|
||||
searchPlaceholder={t("tag.searchStatus")}
|
||||
emptyText={t("tag.emptyStatus")}
|
||||
disabled={loading}
|
||||
onChange={handleStatusFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||
<SearchIcon />
|
||||
{t("tag.query")}
|
||||
</Button>
|
||||
</DashboardToolbar>
|
||||
<DashboardTableShell>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead className="w-14" />
|
||||
<TableHead className="min-w-[260px]">{t("tag.columnName")}</TableHead>
|
||||
<TableHead>{t("tag.columnStatus")}</TableHead>
|
||||
<TableHead>{t("tag.columnRemark")}</TableHead>
|
||||
<TableHead>{t("tag.columnCreatedAt")}</TableHead>
|
||||
<TableHead className="w-[92px] text-right">{t("tag.columnActions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={flatList.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{flatList.map((item) => (
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
disabled={loading || sorting}
|
||||
expanded={expandedIds.has(item.id)}
|
||||
onToggleExpand={() => toggleExpanded(item.id)}
|
||||
onEdit={openEditDialog}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDelete}
|
||||
actionLoadingId={actionLoadingId}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{loading || flatList.length === 0 ? (
|
||||
<DashboardTableStateRow
|
||||
colSpan={6}
|
||||
loading={loading}
|
||||
loadingText={t("tag.loading")}
|
||||
emptyText={t("tag.empty")}
|
||||
/>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</DashboardTableShell>
|
||||
</DashboardPage>
|
||||
<EditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving}
|
||||
itemId={editingItem?.id ?? null}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { createTicketFromConversation } from "@/lib/api/ticket"
|
||||
import { EditDialog } from "./edit"
|
||||
|
||||
type ConversationSeed = {
|
||||
id: number
|
||||
customerName: string
|
||||
customerId?: number
|
||||
lastMessageSummary?: string
|
||||
currentAssigneeId?: number
|
||||
}
|
||||
|
||||
type CreateTicketFromConversationDialogProps = {
|
||||
open: boolean
|
||||
conversation: ConversationSeed | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function CreateTicketFromConversationDialog({
|
||||
open,
|
||||
conversation,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: CreateTicketFromConversationDialogProps) {
|
||||
const t = useI18n()
|
||||
const initialValues = conversation
|
||||
? {
|
||||
title: conversation.customerName || "",
|
||||
description: conversation.lastMessageSummary || "",
|
||||
currentAssigneeId: conversation.currentAssigneeId || undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<EditDialog
|
||||
open={open}
|
||||
saving={false}
|
||||
itemId={null}
|
||||
onOpenChange={onOpenChange}
|
||||
fixedConversationId={conversation?.id}
|
||||
fixedCustomerId={conversation?.customerId}
|
||||
initialValues={initialValues}
|
||||
titleOverride={t("ticket.conversationToTicket")}
|
||||
descriptionOverride={t("ticket.conversationToTicketDescription")}
|
||||
onSubmit={async (payload) => {
|
||||
if (!conversation?.id) {
|
||||
throw new Error(t("ticket.conversationMissing"))
|
||||
}
|
||||
await createTicketFromConversation({
|
||||
conversationId: conversation.id,
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
currentAssigneeId: payload.currentAssigneeId,
|
||||
tagIds: payload.tagIds,
|
||||
})
|
||||
toast.success(t("ticket.createSuccess"))
|
||||
onSuccess?.()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
342
web/app/(dashboard)/dashboard/tickets/_components/edit.tsx
Normal file
342
web/app/(dashboard)/dashboard/tickets/_components/edit.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import type { Resolver } from "react-hook-form"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { isRichTextEmpty } from "@/components/safe-rich-html"
|
||||
import { TagSelector } from "@/components/tag-selector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
fetchAgentProfilesAll,
|
||||
fetchTagsAll,
|
||||
type AdminAgentProfile,
|
||||
type TagTree,
|
||||
} from "@/lib/api/admin"
|
||||
import {
|
||||
fetchTicketDetail,
|
||||
type CreateTicketPayload,
|
||||
type TicketItem,
|
||||
type UpdateTicketPayload,
|
||||
} from "@/lib/api/ticket"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
type EditDialogProps = {
|
||||
open: boolean
|
||||
saving: boolean
|
||||
itemId: number | null
|
||||
initialValues?: Partial<CreateTicketPayload>
|
||||
fixedConversationId?: number
|
||||
fixedCustomerId?: number
|
||||
titleOverride?: string
|
||||
descriptionOverride?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (payload: CreateTicketPayload | UpdateTicketPayload) => Promise<void>
|
||||
}
|
||||
|
||||
function createSchema(t: TFunction) {
|
||||
return z.object({
|
||||
title: z.string().trim().min(1, t("ticket.titleRequired")),
|
||||
description: z.string().refine((value) => !isRichTextEmpty(value), t("ticket.descriptionRequired")),
|
||||
currentAssigneeId: z.coerce.number().int().min(0).optional(),
|
||||
tagIds: z.array(z.number().int().positive()).default([]),
|
||||
})
|
||||
}
|
||||
|
||||
type EditForm = {
|
||||
title: string
|
||||
description: string
|
||||
currentAssigneeId?: number
|
||||
tagIds: number[]
|
||||
}
|
||||
|
||||
const emptyForm: EditForm = {
|
||||
title: "",
|
||||
description: "",
|
||||
currentAssigneeId: 0,
|
||||
tagIds: [],
|
||||
}
|
||||
|
||||
function buildForm(item: TicketItem | null): EditForm {
|
||||
if (!item) {
|
||||
return emptyForm
|
||||
}
|
||||
return {
|
||||
title: item.title ?? "",
|
||||
description: item.description ?? "",
|
||||
currentAssigneeId: item.currentAssigneeId ?? 0,
|
||||
tagIds: (item.tags ?? []).map((tag) => tag.id),
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialForm(initialValues?: Partial<CreateTicketPayload>): EditForm {
|
||||
return {
|
||||
title: initialValues?.title?.trim() ?? "",
|
||||
description: initialValues?.description ?? "",
|
||||
currentAssigneeId: initialValues?.currentAssigneeId ?? 0,
|
||||
tagIds: initialValues?.tagIds ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(form: EditForm): CreateTicketPayload {
|
||||
const currentAssigneeId = form.currentAssigneeId ?? 0
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
currentAssigneeId,
|
||||
tagIds: form.tagIds,
|
||||
}
|
||||
}
|
||||
|
||||
export function EditDialog({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
initialValues,
|
||||
fixedConversationId,
|
||||
fixedCustomerId,
|
||||
titleOverride,
|
||||
descriptionOverride,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: EditDialogProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<TicketEditDialogBody
|
||||
key={itemId ? `edit-${itemId}` : "create"}
|
||||
open={open}
|
||||
saving={saving}
|
||||
itemId={itemId}
|
||||
initialValues={initialValues}
|
||||
fixedConversationId={fixedConversationId}
|
||||
fixedCustomerId={fixedCustomerId}
|
||||
titleOverride={titleOverride}
|
||||
descriptionOverride={descriptionOverride}
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TicketEditDialogBodyProps = EditDialogProps
|
||||
|
||||
function TicketEditDialogBody({
|
||||
open,
|
||||
saving,
|
||||
itemId,
|
||||
initialValues,
|
||||
fixedConversationId,
|
||||
fixedCustomerId,
|
||||
titleOverride,
|
||||
descriptionOverride,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: TicketEditDialogBodyProps) {
|
||||
const t = useI18n()
|
||||
const formId = "ticket-edit-form"
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tags, setTags] = useState<TagTree[]>([])
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const schema = useMemo(() => createSchema(t), [t])
|
||||
const editFormResolver = useMemo(
|
||||
() => zodResolver(schema) as Resolver<EditForm>,
|
||||
[schema],
|
||||
)
|
||||
const form = useForm<EditForm>({
|
||||
resolver: editFormResolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDetail() {
|
||||
if (!itemId) {
|
||||
reset(buildInitialForm(initialValues))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchTicketDetail(itemId)
|
||||
reset(buildForm(data.ticket))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void loadDetail()
|
||||
}, [initialValues, itemId, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const [tagData, agentData] = await Promise.all([
|
||||
fetchTagsAll(),
|
||||
fetchAgentProfilesAll(),
|
||||
])
|
||||
setTags(Array.isArray(tagData) ? tagData : [])
|
||||
setAgents(Array.isArray(agentData) ? agentData : [])
|
||||
})()
|
||||
}, [open])
|
||||
|
||||
const agentOptions = [{ value: "0", label: t("ticket.noAssignee") }].concat(
|
||||
agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label:
|
||||
agent.displayName ||
|
||||
agent.nickname ||
|
||||
agent.username ||
|
||||
t("ticket.agentFallback", { id: agent.userId }),
|
||||
})),
|
||||
)
|
||||
|
||||
async function onFormSubmit(values: EditForm) {
|
||||
const payload = buildPayload(values)
|
||||
if (itemId) {
|
||||
await onSubmit({
|
||||
ticketId: itemId,
|
||||
...payload,
|
||||
})
|
||||
return
|
||||
}
|
||||
await onSubmit({
|
||||
...payload,
|
||||
source: fixedConversationId ? "conversation" : "manual",
|
||||
conversationId: fixedConversationId,
|
||||
customerId: fixedCustomerId,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={titleOverride || (itemId ? t("ticket.editTitle") : t("ticket.createTitle"))}
|
||||
description={descriptionOverride || t("ticket.dialogDescription")}
|
||||
size="lg"
|
||||
allowFullscreen
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("ticket.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||
{saving ? t("ticket.saving") : itemId ? t("ticket.save") : t("ticket.create")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">{t("ticket.loading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.title}>
|
||||
<FieldLabel htmlFor="ticket-title">{t("ticket.title")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="ticket-title"
|
||||
placeholder={t("ticket.titlePlaceholder")}
|
||||
aria-invalid={!!errors.title}
|
||||
{...register("title")}
|
||||
/>
|
||||
<FieldError errors={[errors.title]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.description}>
|
||||
<FieldLabel>{t("ticket.description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<ContentEditor
|
||||
value={{ mode: "html", raw: field.value ?? "" }}
|
||||
onChange={(next) => field.onChange(next.raw)}
|
||||
placeholder={t("ticket.descriptionRequired")}
|
||||
disabled={saving || loading}
|
||||
allowedModes={["html"]}
|
||||
height={260}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.description]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("ticket.assignee")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currentAssigneeId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={String(field.value ?? 0)}
|
||||
onChange={(value) => field.onChange(Number(value))}
|
||||
placeholder={t("ticket.selectAssignee")}
|
||||
options={agentOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("ticket.ticketTags")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tagIds"
|
||||
render={({ field }) => (
|
||||
<TagSelector
|
||||
mode="multiple"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
tags={tags}
|
||||
placeholder={t("ticket.selectTags")}
|
||||
selectedCountText={(count) => t("ticket.selectedTags", { count })}
|
||||
searchPlaceholder={t("ticket.searchTags")}
|
||||
emptyText={t("ticket.emptyTags")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, type Resolver, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
fetchAgentProfilesAll,
|
||||
type AdminAgentProfile,
|
||||
} from "@/lib/api/admin"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { assignTicket } from "@/lib/api/ticket"
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function createSchema(t: TFunction) {
|
||||
return z.object({
|
||||
toUserId: z.string().trim().min(1, t("ticket.assigneeRequired")),
|
||||
reason: z.string().trim(),
|
||||
})
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
toUserId: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
const emptyForm: FormValues = {
|
||||
toUserId: "",
|
||||
reason: "",
|
||||
}
|
||||
|
||||
type TicketAssignDialogProps = {
|
||||
open: boolean
|
||||
ticketId: number | null
|
||||
currentAssigneeId?: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function TicketAssignDialog({
|
||||
open,
|
||||
ticketId,
|
||||
currentAssigneeId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: TicketAssignDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{open ? (
|
||||
<TicketAssignDialogBody
|
||||
key={ticketId ?? "ticket-assign"}
|
||||
ticketId={ticketId}
|
||||
currentAssigneeId={currentAssigneeId}
|
||||
onOpenChange={onOpenChange}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketAssignDialogBody({
|
||||
ticketId,
|
||||
currentAssigneeId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: Omit<TicketAssignDialogProps, "open">) {
|
||||
const t = useI18n()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const schema = useMemo(() => createSchema(t), [t])
|
||||
const resolver = useMemo(
|
||||
() => zodResolver(schema) as Resolver<FormValues>,
|
||||
[schema],
|
||||
)
|
||||
const form = useForm<FormValues>({
|
||||
resolver,
|
||||
defaultValues: emptyForm,
|
||||
})
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true
|
||||
return () => {
|
||||
activeRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
toUserId: currentAssigneeId ? String(currentAssigneeId) : "",
|
||||
reason: "",
|
||||
})
|
||||
}, [currentAssigneeId, reset, ticketId])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchAgentProfilesAll()
|
||||
.then((agentData) => {
|
||||
if (!activeRef.current) {
|
||||
return
|
||||
}
|
||||
setAgents(Array.isArray(agentData) ? agentData : [])
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!activeRef.current) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("ticket.loadAssigneesFailed"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}, [t])
|
||||
|
||||
async function onFormSubmit(values: FormValues) {
|
||||
if (!ticketId) {
|
||||
toast.error(t("ticket.selectTicket"))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await assignTicket({
|
||||
ticketId,
|
||||
toUserId: Number(values.toUserId),
|
||||
reason: values.reason.trim() || undefined,
|
||||
})
|
||||
if (!activeRef.current) {
|
||||
return
|
||||
}
|
||||
toast.success(t("ticket.assigneeUpdated"))
|
||||
if (!activeRef.current) {
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
await onSuccess?.()
|
||||
} catch (error) {
|
||||
if (!activeRef.current) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("ticket.assignFailed"))
|
||||
} finally {
|
||||
if (activeRef.current) {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{t("ticket.assignTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<div className="space-y-4 p-6">
|
||||
<Field data-invalid={!!errors.toUserId}>
|
||||
<FieldLabel>{t("ticket.assignee")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="toUserId"
|
||||
render={({ field }) => (
|
||||
<OptionCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={loading ? t("ticket.loading") : t("ticket.selectHandler")}
|
||||
options={agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label:
|
||||
agent.displayName ||
|
||||
agent.nickname ||
|
||||
agent.username ||
|
||||
t("ticket.agentFallback", { id: agent.userId }),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.toUserId]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.reason}>
|
||||
<FieldLabel>{t("ticket.assignReason")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea rows={4} placeholder={t("ticket.assignReasonPlaceholder")} {...register("reason")} />
|
||||
<FieldError errors={[errors.reason]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("ticket.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? t("ticket.submitting") : t("ticket.confirmAssign")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
MessageSquareTextIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { type CustomerFormSavePayload } from "@/components/customer-form"
|
||||
import { CustomerFormDialog } from "@/components/customer-form-dialog"
|
||||
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { isRichTextEmpty, SafeRichHTML } from "@/components/safe-rich-html"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { saveCustomerProfile } from "@/lib/api/customer"
|
||||
import {
|
||||
changeTicketStatus,
|
||||
createTicketProgress,
|
||||
fetchTicketDetail,
|
||||
type CreateTicketPayload,
|
||||
type TicketDetail,
|
||||
type TicketStatus,
|
||||
type UpdateTicketPayload,
|
||||
updateTicket,
|
||||
} from "@/lib/api/ticket"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
import { EditDialog } from "./edit"
|
||||
import { TicketAssignDialog } from "./ticket-assign-dialog"
|
||||
import { getTicketStatusMeta, TicketStatusBadge } from "./ticket-status-badge"
|
||||
|
||||
type TicketDetailDialogProps = {
|
||||
ticketId: number | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function getStatusOptions(t: TFunction): Array<{ value: TicketStatus; label: string }> {
|
||||
return [
|
||||
{ value: "pending", label: t("ticket.statusPending") },
|
||||
{ value: "in_progress", label: t("ticket.statusInProgress") },
|
||||
{ value: "done", label: t("ticket.statusDone") },
|
||||
]
|
||||
}
|
||||
|
||||
function getStatusLabel(status: TicketStatus, t: TFunction) {
|
||||
return getStatusOptions(t).find((option) => option.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
function sourceLabel(source: string, t: TFunction) {
|
||||
switch (source) {
|
||||
case "manual":
|
||||
return t("ticket.manualCreated")
|
||||
case "conversation":
|
||||
return t("ticket.conversationGenerated")
|
||||
default:
|
||||
return source || "-"
|
||||
}
|
||||
}
|
||||
|
||||
function metadataValue(value?: string | number | null) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "-"
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getTicketCustomerId(ticket?: TicketDetail["ticket"] | null) {
|
||||
return Number(ticket?.customer?.id || ticket?.customerId || 0)
|
||||
}
|
||||
|
||||
export function TicketDetailDialog({
|
||||
ticketId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onChanged,
|
||||
}: TicketDetailDialogProps) {
|
||||
const t = useI18n()
|
||||
const confirm = useConfirm()
|
||||
const [detail, setDetail] = useState<TicketDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [statusSaving, setStatusSaving] = useState<TicketStatus | null>(null)
|
||||
const [progressSaving, setProgressSaving] = useState(false)
|
||||
const [progressOpen, setProgressOpen] = useState(false)
|
||||
const [progressContent, setProgressContent] = useState("")
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [customerEditOpen, setCustomerEditOpen] = useState(false)
|
||||
const [customerEditSaving, setCustomerEditSaving] = useState(false)
|
||||
const [customerLinkOpen, setCustomerLinkOpen] = useState(false)
|
||||
const loadSeqRef = useRef(0)
|
||||
const dialogSeqRef = useRef(0)
|
||||
const currentTicketIdRef = useRef<number | null>(null)
|
||||
currentTicketIdRef.current = open ? ticketId : null
|
||||
|
||||
function isCurrentOperation(targetTicketId: number, dialogSeq: number) {
|
||||
return currentTicketIdRef.current === targetTicketId && dialogSeqRef.current === dialogSeq
|
||||
}
|
||||
|
||||
const loadDetail = useCallback(async (targetTicketId = ticketId, dialogSeq = dialogSeqRef.current) => {
|
||||
if (!targetTicketId || !isCurrentOperation(targetTicketId, dialogSeq)) {
|
||||
return
|
||||
}
|
||||
const seq = loadSeqRef.current + 1
|
||||
loadSeqRef.current = seq
|
||||
if (!open || !targetTicketId) {
|
||||
setDetail(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchTicketDetail(targetTicketId)
|
||||
if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) {
|
||||
return
|
||||
}
|
||||
setDetail(data)
|
||||
} catch (error) {
|
||||
if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("ticket.loadDetailFailed"))
|
||||
} finally {
|
||||
if (loadSeqRef.current === seq) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [open, t, ticketId])
|
||||
|
||||
useEffect(() => {
|
||||
dialogSeqRef.current += 1
|
||||
setDetail(null)
|
||||
setLoading(false)
|
||||
setStatusSaving(null)
|
||||
setProgressSaving(false)
|
||||
setProgressOpen(false)
|
||||
setEditSaving(false)
|
||||
setCustomerEditSaving(false)
|
||||
setAssignOpen(false)
|
||||
setEditOpen(false)
|
||||
setCustomerEditOpen(false)
|
||||
setCustomerLinkOpen(false)
|
||||
setProgressContent("")
|
||||
}, [open, ticketId])
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetail(ticketId, dialogSeqRef.current)
|
||||
}, [loadDetail, ticketId])
|
||||
|
||||
async function handleStatusChange(status: TicketStatus) {
|
||||
if (!detail || detail.ticket.status === status) {
|
||||
return
|
||||
}
|
||||
const confirmed = await confirm({
|
||||
title: t("ticket.statusChangeConfirmTitle"),
|
||||
description: t("ticket.statusChangeConfirmDescription", {
|
||||
from: getStatusLabel(detail.ticket.status, t),
|
||||
to: getStatusLabel(status, t),
|
||||
}),
|
||||
confirmText: t("ticket.statusChangeConfirm"),
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
const activeTicketId = detail.ticket.id
|
||||
const activeDialogSeq = dialogSeqRef.current
|
||||
setStatusSaving(status)
|
||||
try {
|
||||
await changeTicketStatus({ ticketId: activeTicketId, status })
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.success(t("ticket.statusUpdated"))
|
||||
await loadDetail(activeTicketId, activeDialogSeq)
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("ticket.statusUpdateFailed"))
|
||||
} finally {
|
||||
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
setStatusSaving(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateProgress() {
|
||||
if (!detail) {
|
||||
return
|
||||
}
|
||||
const activeTicketId = detail.ticket.id
|
||||
const activeDialogSeq = dialogSeqRef.current
|
||||
const content = progressContent.trim()
|
||||
if (isRichTextEmpty(content)) {
|
||||
toast.error(t("ticket.progressRequired"))
|
||||
return
|
||||
}
|
||||
setProgressSaving(true)
|
||||
try {
|
||||
await createTicketProgress({
|
||||
ticketId: activeTicketId,
|
||||
content,
|
||||
})
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.success(t("ticket.progressRecorded"))
|
||||
setProgressContent("")
|
||||
setProgressOpen(false)
|
||||
await loadDetail(activeTicketId, activeDialogSeq)
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("ticket.progressCreateFailed"))
|
||||
} finally {
|
||||
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
setProgressSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssigned() {
|
||||
const activeTicketId = ticket?.id
|
||||
const activeDialogSeq = dialogSeqRef.current
|
||||
if (!activeTicketId || !isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
await loadDetail(activeTicketId, activeDialogSeq)
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
}
|
||||
|
||||
async function handleUpdateTicket(payload: CreateTicketPayload | UpdateTicketPayload) {
|
||||
if (!("ticketId" in payload) || payload.ticketId <= 0) {
|
||||
toast.error(t("ticket.selectTicket"))
|
||||
return
|
||||
}
|
||||
const activeDialogSeq = dialogSeqRef.current
|
||||
setEditSaving(true)
|
||||
try {
|
||||
await updateTicket(payload)
|
||||
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.success(t("ticket.updated"))
|
||||
setEditOpen(false)
|
||||
await loadDetail(payload.ticketId, activeDialogSeq)
|
||||
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("ticket.updateFailed"))
|
||||
} finally {
|
||||
if (isCurrentOperation(payload.ticketId, activeDialogSeq)) {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateCustomer(payload: CustomerFormSavePayload) {
|
||||
const activeCustomerId = getTicketCustomerId(ticket)
|
||||
if (!ticket?.id || activeCustomerId <= 0) {
|
||||
toast.error(t("ticket.noLinkedCustomer"))
|
||||
return
|
||||
}
|
||||
if (customerEditSaving) {
|
||||
return
|
||||
}
|
||||
const activeTicketId = ticket.id
|
||||
const activeDialogSeq = dialogSeqRef.current
|
||||
setCustomerEditSaving(true)
|
||||
try {
|
||||
await saveCustomerProfile({ ...payload, id: activeCustomerId })
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.success(t("ticket.saved"))
|
||||
setCustomerEditOpen(false)
|
||||
await loadDetail(activeTicketId, activeDialogSeq)
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : t("ticket.saveFailed"))
|
||||
} finally {
|
||||
if (isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
setCustomerEditSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomerLinked() {
|
||||
const activeTicketId = ticket?.id
|
||||
const activeDialogSeq = dialogSeqRef.current
|
||||
if (!activeTicketId || !isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
await loadDetail(activeTicketId, activeDialogSeq)
|
||||
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
}
|
||||
|
||||
const ticket = detail?.ticket
|
||||
const customerId = getTicketCustomerId(ticket)
|
||||
const statusOptions = getStatusOptions(t)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<div className="flex min-w-0 items-center gap-2 pr-16 text-base">
|
||||
<span className="truncate">{ticket?.title ?? t("ticket.detailTitle")}</span>
|
||||
{ticket ? <TicketStatusBadge status={ticket.status} /> : null}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
ticket ? (
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-mono">{ticket.ticketNo}</span>
|
||||
<span>{sourceLabel(ticket.source, t)}</span>
|
||||
<span>{t("ticket.creator", { name: metadataValue(ticket.createdByName || ticket.createdBy) })}</span>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
size="xxl"
|
||||
defaultFullscreen
|
||||
bodyScrollable={false}
|
||||
bodyClassName="flex w-full"
|
||||
>
|
||||
{loading && !ticket ? (
|
||||
<div className="flex h-130 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCcwIcon className="size-4 animate-spin" />
|
||||
{t("ticket.loading")}
|
||||
</div>
|
||||
) : ticket ? (
|
||||
<div className="grid w-full h-full grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_380px] border-t">
|
||||
<div className="min-h-0 space-y-5 overflow-y-auto border-b p-6 lg:border-r lg:border-b-0">
|
||||
<section className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground">{t("ticket.description")}</div>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<SafeRichHTML html={ticket.description} fallback={t("ticket.noDescription")} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground">{t("ticket.currentStatus")}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!!statusSaving}
|
||||
className={cn(
|
||||
"h-9 min-w-36 justify-between gap-2 border px-3 font-medium",
|
||||
getTicketStatusMeta(ticket.status)?.className,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{statusSaving ? t("ticket.updating") : getStatusLabel(ticket.status, t)}</span>
|
||||
<ChevronDownIcon className="size-4 opacity-70" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44 min-w-44">
|
||||
{statusOptions.map((option) => {
|
||||
const selected = ticket.status === option.value
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
disabled={!!statusSaving || selected}
|
||||
onClick={() => void handleStatusChange(option.value)}
|
||||
className="justify-between"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{selected ? <CheckIcon className="size-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground">{t("ticket.columnAssignee")}</div>
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<UserRoundIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{ticket.currentAssigneeName || t("ticket.unassigned")}</span>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setAssignOpen(true)}>
|
||||
{t("ticket.assign")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-muted-foreground">{t("ticket.tags")}</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
|
||||
{t("ticket.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
{ticket.tags && ticket.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ticket.tags.map((tag) => (
|
||||
<Badge key={tag.id} variant="outline">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{t("ticket.emptyTags")}</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-md border p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-medium text-muted-foreground">{t("ticket.customerInfo")}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
if (customerId > 0) {
|
||||
setCustomerEditOpen(true)
|
||||
return
|
||||
}
|
||||
setCustomerLinkOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
{customerId > 0 ? t("ticket.edit") : t("ticket.linkOrCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MetadataItem label={t("ticket.customer")} value={ticket.customer?.name || ticket.customerId} />
|
||||
<MetadataItem label={t("ticket.contact")} value={ticket.customer?.primaryMobile || ticket.customer?.primaryEmail} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-md border p-3 text-sm">
|
||||
<div className="font-medium text-muted-foreground">{t("ticket.ticketInfo")}</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MetadataItem label={t("ticket.source")} value={sourceLabel(ticket.source, t)} />
|
||||
<MetadataItem label={t("ticket.channel")} value={ticket.channel} />
|
||||
<MetadataItem label={t("ticket.conversationId")} value={ticket.conversationId || undefined} />
|
||||
<MetadataItem label={t("ticket.columnUpdated")} value={ticket.updatedAt ? formatDateTime(ticket.updatedAt) : undefined} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<MessageSquareTextIcon className="size-4 text-muted-foreground" />
|
||||
{t("ticket.progress")}
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setProgressOpen(true)}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("ticket.addProgress")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{detail.progresses && detail.progresses.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{detail.progresses.map((progress, index) => (
|
||||
<div key={progress.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="mt-1 size-2 rounded-full bg-primary" />
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 w-px flex-1 bg-border",
|
||||
index === detail.progresses!.length - 1 ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{progress.authorName || t("ticket.userFallback", { id: progress.authorId })}</span>
|
||||
<span>{progress.createdAt ? formatDateTime(progress.createdAt) : "-"}</span>
|
||||
</div>
|
||||
<SafeRichHTML html={progress.content} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("ticket.noProgress")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[360px] items-center justify-center text-sm text-muted-foreground">{t("ticket.chooseTicket")}</div>
|
||||
)}
|
||||
</ProjectDialog>
|
||||
|
||||
<TicketAssignDialog
|
||||
open={assignOpen}
|
||||
ticketId={ticket?.id ?? null}
|
||||
currentAssigneeId={ticket?.currentAssigneeId}
|
||||
onOpenChange={setAssignOpen}
|
||||
onSuccess={handleAssigned}
|
||||
/>
|
||||
<EditDialog
|
||||
open={editOpen}
|
||||
saving={editSaving}
|
||||
itemId={ticket?.id ?? null}
|
||||
onOpenChange={setEditOpen}
|
||||
onSubmit={handleUpdateTicket}
|
||||
/>
|
||||
<CustomerFormDialog
|
||||
open={customerEditOpen}
|
||||
onOpenChange={setCustomerEditOpen}
|
||||
saving={customerEditSaving}
|
||||
itemId={customerId > 0 ? customerId : null}
|
||||
onSave={handleUpdateCustomer}
|
||||
/>
|
||||
<CustomerLinkOrCreateDialog
|
||||
open={customerLinkOpen}
|
||||
onOpenChange={setCustomerLinkOpen}
|
||||
ticketId={ticket?.id ?? null}
|
||||
onSuccess={handleCustomerLinked}
|
||||
/>
|
||||
<Dialog
|
||||
open={progressOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (progressSaving) {
|
||||
return
|
||||
}
|
||||
setProgressOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setProgressContent("")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl gap-0 p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{t("ticket.addProgress")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-6 py-4">
|
||||
<ContentEditor
|
||||
value={{ mode: "html", raw: progressContent }}
|
||||
onChange={(next) => setProgressContent(next.raw)}
|
||||
placeholder={t("ticket.progressPlaceholder")}
|
||||
disabled={progressSaving}
|
||||
allowedModes={["html"]}
|
||||
height={260}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={progressSaving}
|
||||
onClick={() => {
|
||||
setProgressOpen(false)
|
||||
setProgressContent("")
|
||||
}}
|
||||
>
|
||||
{t("ticket.cancel")}
|
||||
</Button>
|
||||
<Button type="button" disabled={progressSaving} onClick={() => void handleCreateProgress()}>
|
||||
<SendIcon className="size-3.5" />
|
||||
{progressSaving ? t("ticket.submitting") : t("ticket.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataItem({ label, value }: { label: string; value?: string | number | null }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 truncate">{metadataValue(value)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user