chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

BIN
screenshots/en/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -0,0 +1,17 @@
{
"name": "agent-desk-email-inbound-worker",
"version": "1.0.0",
"description": "Cloudflare Email Routing Worker forwarding inbound emails to the Agent Desk email webhook",
"main": "src/index.ts",
"scripts": {
"deploy": "wrangler deploy"
},
"dependencies": {
"postal-mime": "^2.1.8"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241022.0",
"typescript": "^6.0.3",
"wrangler": "^3.84.1"
}
}

View File

@@ -0,0 +1,984 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
postal-mime:
specifier: ^2.1.8
version: 2.7.6
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20241022.0
version: 4.20260702.1
typescript:
specifier: ^6.0.3
version: 6.0.3
wrangler:
specifier: ^3.84.1
version: 3.114.17(@cloudflare/workers-types@4.20260702.1)
packages:
'@cloudflare/kv-asset-handler@0.3.4':
resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==}
engines: {node: '>=16.13'}
'@cloudflare/unenv-preset@2.0.2':
resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==}
peerDependencies:
unenv: 2.0.0-rc.14
workerd: ^1.20250124.0
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20250718.0':
resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20250718.0':
resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20250718.0':
resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20250718.0':
resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20250718.0':
resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260702.1':
resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@esbuild-plugins/node-globals-polyfill@0.2.3':
resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==}
peerDependencies:
esbuild: '*'
'@esbuild-plugins/node-modules-polyfill@0.2.2':
resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==}
peerDependencies:
esbuild: '*'
'@esbuild/android-arm64@0.17.19':
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.17.19':
resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.17.19':
resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.17.19':
resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.17.19':
resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.17.19':
resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.17.19':
resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.17.19':
resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.17.19':
resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.17.19':
resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.17.19':
resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.17.19':
resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.17.19':
resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.17.19':
resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.17.19':
resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.17.19':
resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.17.19':
resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.17.19':
resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.17.19':
resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.17.19':
resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.17.19':
resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.17.19':
resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-ia32@0.33.5':
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
acorn-walk@8.3.2:
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
engines: {node: '>=0.4.0'}
acorn@8.14.0:
resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
engines: {node: '>=0.4.0'}
hasBin: true
as-table@1.0.55:
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
data-uri-to-buffer@2.0.2:
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
esbuild@0.17.19:
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
engines: {node: '>=12'}
hasBin: true
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
estree-walker@0.6.1:
resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==}
exit-hook@2.2.1:
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
engines: {node: '>=6'}
exsolve@1.1.1:
resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-source@2.0.12:
resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
miniflare@3.20250718.3:
resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==}
engines: {node: '>=16.13'}
hasBin: true
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
ohash@2.0.12:
resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
postal-mime@2.7.6:
resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==}
printable-characters@1.0.42:
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
rollup-plugin-inject@3.0.2:
resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==}
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
rollup-plugin-node-polyfills@0.2.1:
resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==}
rollup-pluginutils@2.8.2:
resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
deprecated: Please use @jridgewell/sourcemap-codec instead
stacktracey@2.2.0:
resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==}
stoppable@1.1.0:
resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==}
engines: {node: '>=4', npm: '>=6'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
undici@5.29.0:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
unenv@2.0.0-rc.14:
resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==}
workerd@1.20250718.0:
resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==}
engines: {node: '>=16'}
hasBin: true
wrangler@3.114.17:
resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==}
engines: {node: '>=16.17.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20250408.0
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch@3.3.4:
resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==}
zod@3.22.3:
resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==}
snapshots:
'@cloudflare/kv-asset-handler@0.3.4':
dependencies:
mime: 3.0.0
'@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)':
dependencies:
unenv: 2.0.0-rc.14
optionalDependencies:
workerd: 1.20250718.0
'@cloudflare/workerd-darwin-64@1.20250718.0':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20250718.0':
optional: true
'@cloudflare/workerd-linux-64@1.20250718.0':
optional: true
'@cloudflare/workerd-linux-arm64@1.20250718.0':
optional: true
'@cloudflare/workerd-windows-64@1.20250718.0':
optional: true
'@cloudflare/workers-types@4.20260702.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)':
dependencies:
esbuild: 0.17.19
'@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)':
dependencies:
esbuild: 0.17.19
escape-string-regexp: 4.0.0
rollup-plugin-node-polyfills: 0.2.1
'@esbuild/android-arm64@0.17.19':
optional: true
'@esbuild/android-arm@0.17.19':
optional: true
'@esbuild/android-x64@0.17.19':
optional: true
'@esbuild/darwin-arm64@0.17.19':
optional: true
'@esbuild/darwin-x64@0.17.19':
optional: true
'@esbuild/freebsd-arm64@0.17.19':
optional: true
'@esbuild/freebsd-x64@0.17.19':
optional: true
'@esbuild/linux-arm64@0.17.19':
optional: true
'@esbuild/linux-arm@0.17.19':
optional: true
'@esbuild/linux-ia32@0.17.19':
optional: true
'@esbuild/linux-loong64@0.17.19':
optional: true
'@esbuild/linux-mips64el@0.17.19':
optional: true
'@esbuild/linux-ppc64@0.17.19':
optional: true
'@esbuild/linux-riscv64@0.17.19':
optional: true
'@esbuild/linux-s390x@0.17.19':
optional: true
'@esbuild/linux-x64@0.17.19':
optional: true
'@esbuild/netbsd-x64@0.17.19':
optional: true
'@esbuild/openbsd-x64@0.17.19':
optional: true
'@esbuild/sunos-x64@0.17.19':
optional: true
'@esbuild/win32-arm64@0.17.19':
optional: true
'@esbuild/win32-ia32@0.17.19':
optional: true
'@esbuild/win32-x64@0.17.19':
optional: true
'@fastify/busboy@2.1.1': {}
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
optional: true
'@img/sharp-darwin-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5':
optional: true
'@img/sharp-libvips-linux-s390x@1.0.4':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
optional: true
'@img/sharp-linux-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4
optional: true
'@img/sharp-linux-arm@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
optional: true
'@img/sharp-linux-s390x@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.0.4
optional: true
'@img/sharp-linux-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
optional: true
'@img/sharp-linuxmusl-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
optional: true
'@img/sharp-linuxmusl-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
optional: true
'@img/sharp-wasm32@0.33.5':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-win32-ia32@0.33.5':
optional: true
'@img/sharp-win32-x64@0.33.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
acorn-walk@8.3.2: {}
acorn@8.14.0: {}
as-table@1.0.55:
dependencies:
printable-characters: 1.0.42
blake3-wasm@2.1.5: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
optional: true
color-name@1.1.4:
optional: true
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
optional: true
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
optional: true
cookie@0.7.2: {}
data-uri-to-buffer@2.0.2: {}
defu@6.1.7: {}
detect-libc@2.1.2:
optional: true
esbuild@0.17.19:
optionalDependencies:
'@esbuild/android-arm': 0.17.19
'@esbuild/android-arm64': 0.17.19
'@esbuild/android-x64': 0.17.19
'@esbuild/darwin-arm64': 0.17.19
'@esbuild/darwin-x64': 0.17.19
'@esbuild/freebsd-arm64': 0.17.19
'@esbuild/freebsd-x64': 0.17.19
'@esbuild/linux-arm': 0.17.19
'@esbuild/linux-arm64': 0.17.19
'@esbuild/linux-ia32': 0.17.19
'@esbuild/linux-loong64': 0.17.19
'@esbuild/linux-mips64el': 0.17.19
'@esbuild/linux-ppc64': 0.17.19
'@esbuild/linux-riscv64': 0.17.19
'@esbuild/linux-s390x': 0.17.19
'@esbuild/linux-x64': 0.17.19
'@esbuild/netbsd-x64': 0.17.19
'@esbuild/openbsd-x64': 0.17.19
'@esbuild/sunos-x64': 0.17.19
'@esbuild/win32-arm64': 0.17.19
'@esbuild/win32-ia32': 0.17.19
'@esbuild/win32-x64': 0.17.19
escape-string-regexp@4.0.0: {}
estree-walker@0.6.1: {}
exit-hook@2.2.1: {}
exsolve@1.1.1: {}
fsevents@2.3.3:
optional: true
get-source@2.0.12:
dependencies:
data-uri-to-buffer: 2.0.2
source-map: 0.6.1
glob-to-regexp@0.4.1: {}
is-arrayish@0.3.4:
optional: true
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8
mime@3.0.0: {}
miniflare@3.20250718.3:
dependencies:
'@cspotcode/source-map-support': 0.8.1
acorn: 8.14.0
acorn-walk: 8.3.2
exit-hook: 2.2.1
glob-to-regexp: 0.4.1
stoppable: 1.1.0
undici: 5.29.0
workerd: 1.20250718.0
ws: 8.18.0
youch: 3.3.4
zod: 3.22.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
mustache@4.2.0: {}
ohash@2.0.12: {}
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
postal-mime@2.7.6: {}
printable-characters@1.0.42: {}
rollup-plugin-inject@3.0.2:
dependencies:
estree-walker: 0.6.1
magic-string: 0.25.9
rollup-pluginutils: 2.8.2
rollup-plugin-node-polyfills@0.2.1:
dependencies:
rollup-plugin-inject: 3.0.2
rollup-pluginutils@2.8.2:
dependencies:
estree-walker: 0.6.1
semver@7.8.5:
optional: true
sharp@0.33.5:
dependencies:
color: 4.2.3
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
'@img/sharp-libvips-darwin-arm64': 1.0.4
'@img/sharp-libvips-darwin-x64': 1.0.4
'@img/sharp-libvips-linux-arm': 1.0.5
'@img/sharp-libvips-linux-arm64': 1.0.4
'@img/sharp-libvips-linux-s390x': 1.0.4
'@img/sharp-libvips-linux-x64': 1.0.4
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
'@img/sharp-linux-arm': 0.33.5
'@img/sharp-linux-arm64': 0.33.5
'@img/sharp-linux-s390x': 0.33.5
'@img/sharp-linux-x64': 0.33.5
'@img/sharp-linuxmusl-arm64': 0.33.5
'@img/sharp-linuxmusl-x64': 0.33.5
'@img/sharp-wasm32': 0.33.5
'@img/sharp-win32-ia32': 0.33.5
'@img/sharp-win32-x64': 0.33.5
optional: true
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
optional: true
source-map@0.6.1: {}
sourcemap-codec@1.4.8: {}
stacktracey@2.2.0:
dependencies:
as-table: 1.0.55
get-source: 2.0.12
stoppable@1.1.0: {}
tslib@2.8.1:
optional: true
typescript@6.0.3: {}
ufo@1.6.4: {}
undici@5.29.0:
dependencies:
'@fastify/busboy': 2.1.1
unenv@2.0.0-rc.14:
dependencies:
defu: 6.1.7
exsolve: 1.1.1
ohash: 2.0.12
pathe: 2.0.3
ufo: 1.6.4
workerd@1.20250718.0:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20250718.0
'@cloudflare/workerd-darwin-arm64': 1.20250718.0
'@cloudflare/workerd-linux-64': 1.20250718.0
'@cloudflare/workerd-linux-arm64': 1.20250718.0
'@cloudflare/workerd-windows-64': 1.20250718.0
wrangler@3.114.17(@cloudflare/workers-types@4.20260702.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.3.4
'@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)
'@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19)
'@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19)
blake3-wasm: 2.1.5
esbuild: 0.17.19
miniflare: 3.20250718.3
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.14
workerd: 1.20250718.0
optionalDependencies:
'@cloudflare/workers-types': 4.20260702.1
fsevents: 2.3.3
sharp: 0.33.5
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch@3.3.4:
dependencies:
cookie: 0.7.2
mustache: 4.2.0
stacktracey: 2.2.0
zod@3.22.3: {}

View File

@@ -0,0 +1,58 @@
import PostalMime from 'postal-mime';
export interface Env {
DESK_WEBHOOK_URL: string;
DESK_WEBHOOK_SECRET: string;
}
export default {
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise<void> {
try {
const rawEmail = await new Response(message.raw).arrayBuffer();
const parser = new PostalMime();
const parsed = await parser.parse(rawEmail);
const fromEmail = message.from || parsed.from?.address || '';
const fromName = parsed.from?.name || '';
const toEmail = message.to || (parsed.to && parsed.to[0]?.address) || '';
const toName = (parsed.to && parsed.to[0]?.name) || '';
const subject = message.headers.get('subject') || parsed.subject || '';
const messageId = message.headers.get('message-id') || parsed.messageId || '';
const inReplyTo = message.headers.get('in-reply-to') || parsed.inReplyTo || '';
const references = message.headers.get('references') || (Array.isArray(parsed.references) ? parsed.references.join(' ') : parsed.references) || '';
const payload = {
from: fromEmail,
from_name: fromName,
to: toEmail,
to_name: toName,
subject: subject,
text: parsed.text || '',
html: parsed.html || '',
message_id: messageId,
in_reply_to: inReplyTo,
references: references,
};
const webhookUrl = env.DESK_WEBHOOK_URL || '';
const webhookSecret = env.DESK_WEBHOOK_SECRET || '';
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': webhookSecret,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(`Failed to forward email to webhook: ${response.status} ${response.statusText}`);
} else {
console.log(`Successfully forwarded email from ${fromEmail} to Agent Desk`);
}
} catch (error) {
console.error('Error processing inbound email in worker:', error);
}
},
};

View File

@@ -0,0 +1,8 @@
name = "agent-desk-email-inbound-worker"
main = "src/index.ts"
compatibility_date = "2024-11-05"
account_id = "5f2a58925e790423dfafa0e6bee46b28"
[vars]
DESK_WEBHOOK_URL = "https://your-desk.example.com/api/third/email/webhook"
DESK_WEBHOOK_SECRET = "replace-with-your-email-inbound-secret"

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Report i18n values that still hold the source language instead of a translation.
Key parity is already enforced by the test suite
(web/i18n/message-integrity.test.mjs and
internal/pkg/i18nx/locale_parity_test.go), so this exists for the gap those
tests cannot see: a key can be present in every locale and still carry the
Chinese string, which is how Chinese kept showing up in an otherwise translated
UI.
Usage:
python scripts/i18n_untranslated.py <base> <child>
<base> is the source language, <child> the one being audited. Values in <child>
containing CJK are listed. Those byte-identical to <base> are reported as
untranslated; the rest are left to judgement, since product names and sample
data legitimately keep CJK.
Exit code: 0 when the child has no untranslated values, 1 when it does,
2 on usage errors.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
# Hangul and Kana are deliberately excluded: this looks for Chinese script,
# which is what leaked into the UI, not for non-ASCII text in general.
CJK = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
USAGE = "Usage: python scripts/i18n_untranslated.py <base> <child>\n"
def flatten_json(value: Any, prefix: str = "", out: dict[str, Any] | None = None) -> dict[str, Any]:
if out is None:
out = {}
if isinstance(value, dict):
for key, child in value.items():
flatten_json(child, f"{prefix}.{key}" if prefix else str(key), out)
return out
out[prefix] = value
return out
def _indent_of(line: str) -> int:
return len(line) - len(line.lstrip())
def parse_flat_yaml(text: str) -> dict[str, Any]:
"""Parse the locale YAML dialect: flat top-level keys, one nested level, block scalars."""
lines = text.splitlines()
out: dict[str, Any] = {}
index = 0
while index < len(lines):
raw = lines[index]
index += 1
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = _indent_of(raw)
if indent != 0:
continue
head = raw.strip()
if ":" not in head:
continue
key, _, rest = head.partition(":")
key = key.strip().strip("\"'")
value = rest.strip()
if value in ("|", ">", "|-", ">-", "|+", ">+"):
block: list[str] = []
block_indent: int | None = None
while index < len(lines):
line = lines[index]
if line.strip() and _indent_of(line) <= indent:
break
line_indent = _indent_of(line)
block_indent = line_indent if block_indent is None else block_indent
block.append(line[min(block_indent, line_indent):])
index += 1
out[key] = "\n".join(block).strip()
elif not value:
# A key with no inline value opens one nested level.
while index < len(lines):
line = lines[index]
if line.strip() and _indent_of(line) <= indent:
break
stripped = line.strip()
index += 1
if ":" in stripped:
nested_key, _, nested_value = stripped.partition(":")
out[f"{key}.{nested_key.strip()}"] = nested_value.strip().strip("\"'")
continue
else:
out[key] = value.strip().strip("\"'")
return out
def load_values(file: str) -> dict[str, Any]:
suffix = Path(file).suffix.lower()
# utf-8-sig tolerates a BOM and a plain utf-8 file equally; a BOM is common
# in locale files authored on Windows and would otherwise abort the run.
text = Path(file).read_text(encoding="utf-8-sig")
if suffix == ".json":
return flatten_json(json.loads(text))
if suffix in (".yml", ".yaml"):
return parse_flat_yaml(text)
raise ValueError(f"Unsupported file format: {file}")
def main(argv: list[str]) -> int:
args = [arg for arg in argv if not arg.startswith("-")]
if len(args) != 2:
sys.stderr.write(USAGE)
return 2
base_file, child_file = args
try:
base = load_values(base_file)
child = load_values(child_file)
except (OSError, ValueError, json.JSONDecodeError) as error:
sys.stderr.write(f"Failed to read bundle: {error}\n")
return 2
untranslated: list[tuple[str, str]] = []
review: list[tuple[str, str]] = []
for key, value in child.items():
if not isinstance(value, str) or not CJK.search(value):
continue
base_value = base.get(key)
if isinstance(base_value, str) and base_value.strip() == value.strip():
untranslated.append((key, value))
else:
review.append((key, value))
print(
f"child {child_file}: {len(child)} values, "
f"{len(untranslated) + len(review)} with CJK "
f"({len(untranslated)} untranslated, {len(review)} to review)"
)
if untranslated:
print(f"\nuntranslated, identical to base ({len(untranslated)}):")
for key, value in untranslated:
print(f" - {key} = {value}")
if review:
print(f"\nCJK but differs from base ({len(review)}):")
for key, value in review:
print(f" ? {key} = {value}")
if not untranslated and not review:
print("OK: no CJK left in child values")
return 0
return 1
if __name__ == "__main__":
# Do not litter the repo with __pycache__ when this is run from tooling.
sys.dont_write_bytecode = True
raise SystemExit(main(sys.argv[1:]))

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8083

42
web/.gitignore vendored Normal file
View 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
View 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.

View 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;
}
}

View 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>
)
}

View 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>
)
}

View File

@@ -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 }

View 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>
)
}

View 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>
)
}

View 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
}
}

View 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 } }

View File

@@ -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>
)
}

View File

@@ -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)
})

View File

@@ -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))}`
}

View File

@@ -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",
})
})

View File

@@ -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,
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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}
/>
</>
)
}

View 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}
/>
</>
);
}

View 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>
);
}

View File

@@ -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}
/>
</>
);
}

View 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>
);
}

View File

@@ -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

View 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>
</>
);
}

View 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>
)
}

View 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 }),
}}
/>
);
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -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"
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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)
}}
/>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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>
)
}

View 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 }),
}}
/>
)
}

View 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 }),
}}
/>
)
}

View File

@@ -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>
);
}

View 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)
}
}}
/>
</>
)
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
);
}

View 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 />
</>
);
}

View File

@@ -0,0 +1,4 @@
"use client"
export { CustomerFormDialog, type CustomerFormDialogProps } from "@/components/customer-form-dialog"
export { CustomerFormDialog as EditDialog } from "@/components/customer-form-dialog"

View 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 }),
}}
/>
);
}

View 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"),
]}
/>
)
}

View File

@@ -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}`
}

View File

@@ -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>
)
}

View File

@@ -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)}
/>
</>
);
}

View 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>
);
}

View File

@@ -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>
)
}

View 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)}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)
})
})

View File

@@ -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),
}
}

View File

@@ -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>
)
}

View File

@@ -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)
}
}}
/>
</>
)
}

View File

@@ -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>
);
}

View 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>
)
}

View 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 }),
}}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
);
}

View 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"),
}}
/>
)
}

View File

@@ -0,0 +1,5 @@
import { DashboardHome } from "./_components/dashboard-home"
export default function DashboardHomePage() {
return <DashboardHome />
}

View 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)
}

View 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 }),
}}
/>
)
}

View File

@@ -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>
);
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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 }

View File

@@ -0,0 +1,5 @@
import { SetupWizardView } from "./_components/setup-wizard-view"
export default function SetupWizardPage() {
return <SetupWizardView />
}

View File

@@ -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>
)
}

View File

@@ -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>
);
}

View 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);
}}
/>
</>
);
}

View File

@@ -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>
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,5 @@
import { DashboardSupportCommunityCategoryAdmin } from "../_components/support-admin"
export default function DashboardSupportCommunityCategoriesPage() {
return <DashboardSupportCommunityCategoryAdmin />
}

View File

@@ -0,0 +1,5 @@
import { DashboardSupportCommunityAdmin } from "../_components/support-admin"
export default function DashboardSupportCommunityPage() {
return <DashboardSupportCommunityAdmin />
}

View File

@@ -0,0 +1,5 @@
import { SupportConfigPanel } from "../_components/support-config-panel"
export default function DashboardSupportConfigPage() {
return <SupportConfigPanel />
}

View File

@@ -0,0 +1,5 @@
import { SupportDocsWorkbench } from "../_components/help-workbench"
export default function DashboardDocPage() {
return <SupportDocsWorkbench />
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function DashboardSupportPage() {
redirect("/dashboard/support/docs")
}

Some files were not shown because too many files have changed in this diff Show More