chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
BIN
screenshots/en/5.png
Normal file
BIN
screenshots/en/5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
17
scripts/cloudflare-email-worker/package.json
Normal file
17
scripts/cloudflare-email-worker/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
984
scripts/cloudflare-email-worker/pnpm-lock.yaml
generated
Normal file
984
scripts/cloudflare-email-worker/pnpm-lock.yaml
generated
Normal 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: {}
|
||||||
58
scripts/cloudflare-email-worker/src/index.ts
Normal file
58
scripts/cloudflare-email-worker/src/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
8
scripts/cloudflare-email-worker/wrangler.toml
Normal file
8
scripts/cloudflare-email-worker/wrangler.toml
Normal 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"
|
||||||
169
scripts/i18n_untranslated.py
Normal file
169
scripts/i18n_untranslated.py
Normal 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
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8083
|
||||||
42
web/.gitignore
vendored
Normal file
42
web/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
web/README.md
Normal file
36
web/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to self-host [Inter](https://rsms.me/inter/) for UI text and [IBM Plex Mono](https://www.ibm.com/plex/mono/) for monospace text, both declared once in `lib/fonts.ts` and wired into the design tokens through `--font-app-sans` / `--font-app-mono`. Latin and Cyrillic subsets are included so the `zh-CN`, `en-US`, and `ru-RU` locales all render from the same font files.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
368
web/app/(dashboard)/dashboard.css
Normal file
368
web/app/(dashboard)/dashboard.css
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-app-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
|
--font-mono: var(--font-app-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root,
|
||||||
|
:root[data-palette="plain"] {
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--background: #f9f9f9;
|
||||||
|
--foreground: #151923;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #151923;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #151923;
|
||||||
|
--primary: #2475fc;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #e6efff;
|
||||||
|
--secondary-foreground: #155be8;
|
||||||
|
--muted: #f4f7fc;
|
||||||
|
--muted-foreground: #667085;
|
||||||
|
--accent: #e6efff;
|
||||||
|
--accent-foreground: #155be8;
|
||||||
|
--destructive: oklch(0.59 0.225 25);
|
||||||
|
--border: #dfe7f3;
|
||||||
|
--input: #dfe7f3;
|
||||||
|
--ring: #2475fc;
|
||||||
|
--chart-1: #2475fc;
|
||||||
|
--chart-2: #14b8a6;
|
||||||
|
--chart-3: #f59e0b;
|
||||||
|
--chart-4: #ec4899;
|
||||||
|
--chart-5: #8b5cf6;
|
||||||
|
--sidebar: #ffffff;
|
||||||
|
--sidebar-foreground: #273142;
|
||||||
|
--sidebar-primary: #2475fc;
|
||||||
|
--sidebar-primary-foreground: #ffffff;
|
||||||
|
--sidebar-accent: #e6efff;
|
||||||
|
--sidebar-accent-foreground: #155be8;
|
||||||
|
--sidebar-border: #dfe7f3;
|
||||||
|
--sidebar-ring: #2475fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-palette="plain"] {
|
||||||
|
--background: #111827;
|
||||||
|
--foreground: #f9fafb;
|
||||||
|
--card: #182235;
|
||||||
|
--card-foreground: #f9fafb;
|
||||||
|
--popover: #182235;
|
||||||
|
--popover-foreground: #f9fafb;
|
||||||
|
--primary: #6da2ff;
|
||||||
|
--primary-foreground: #0f172a;
|
||||||
|
--secondary: #1f3354;
|
||||||
|
--secondary-foreground: #dbe8ff;
|
||||||
|
--muted: #1f2937;
|
||||||
|
--muted-foreground: #9ca3af;
|
||||||
|
--accent: #1f3354;
|
||||||
|
--accent-foreground: #dbe8ff;
|
||||||
|
--destructive: oklch(0.68 0.19 25);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 14%);
|
||||||
|
--ring: #6da2ff;
|
||||||
|
--chart-1: #6da2ff;
|
||||||
|
--chart-2: #2dd4bf;
|
||||||
|
--chart-3: #fbbf24;
|
||||||
|
--chart-4: #f472b6;
|
||||||
|
--chart-5: #a78bfa;
|
||||||
|
--sidebar: #111827;
|
||||||
|
--sidebar-foreground: #f3f4f6;
|
||||||
|
--sidebar-primary: #6da2ff;
|
||||||
|
--sidebar-primary-foreground: #0f172a;
|
||||||
|
--sidebar-accent: #1f3354;
|
||||||
|
--sidebar-accent-foreground: #dbe8ff;
|
||||||
|
--sidebar-border: oklch(1 0 0 / 9%);
|
||||||
|
--sidebar-ring: #6da2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-palette="green"] {
|
||||||
|
--background: oklch(0.973 0.011 112);
|
||||||
|
--foreground: oklch(0.22 0.021 210);
|
||||||
|
--card: oklch(0.996 0.004 105);
|
||||||
|
--card-foreground: oklch(0.22 0.021 210);
|
||||||
|
--popover: oklch(0.996 0.004 105);
|
||||||
|
--popover-foreground: oklch(0.22 0.021 210);
|
||||||
|
--primary: oklch(0.475 0.118 178);
|
||||||
|
--primary-foreground: oklch(0.985 0.012 170);
|
||||||
|
--secondary: oklch(0.94 0.018 124);
|
||||||
|
--secondary-foreground: oklch(0.255 0.035 178);
|
||||||
|
--muted: oklch(0.942 0.012 115);
|
||||||
|
--muted-foreground: oklch(0.49 0.026 205);
|
||||||
|
--accent: oklch(0.91 0.028 169);
|
||||||
|
--accent-foreground: oklch(0.245 0.045 178);
|
||||||
|
--destructive: oklch(0.59 0.225 25);
|
||||||
|
--border: oklch(0.872 0.018 116);
|
||||||
|
--input: oklch(0.872 0.018 116);
|
||||||
|
--ring: oklch(0.56 0.115 178);
|
||||||
|
--chart-1: oklch(0.52 0.13 178);
|
||||||
|
--chart-2: oklch(0.61 0.14 220);
|
||||||
|
--chart-3: oklch(0.72 0.15 78);
|
||||||
|
--chart-4: oklch(0.62 0.15 330);
|
||||||
|
--chart-5: oklch(0.58 0.12 262);
|
||||||
|
--sidebar: oklch(0.925 0.018 118);
|
||||||
|
--sidebar-foreground: oklch(0.245 0.03 190);
|
||||||
|
--sidebar-primary: oklch(0.475 0.118 178);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.012 170);
|
||||||
|
--sidebar-accent: oklch(0.89 0.032 156);
|
||||||
|
--sidebar-accent-foreground: oklch(0.235 0.048 178);
|
||||||
|
--sidebar-border: oklch(0.835 0.022 118);
|
||||||
|
--sidebar-ring: oklch(0.56 0.115 178);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-palette="blue"] {
|
||||||
|
--background: oklch(0.982 0.007 250);
|
||||||
|
--foreground: oklch(0.205 0.018 255);
|
||||||
|
--card: oklch(0.998 0.002 250);
|
||||||
|
--card-foreground: oklch(0.205 0.018 255);
|
||||||
|
--popover: oklch(0.998 0.002 250);
|
||||||
|
--popover-foreground: oklch(0.205 0.018 255);
|
||||||
|
--primary: oklch(0.548 0.19 257);
|
||||||
|
--primary-foreground: oklch(0.985 0.006 248);
|
||||||
|
--secondary: oklch(0.948 0.013 250);
|
||||||
|
--secondary-foreground: oklch(0.245 0.02 252);
|
||||||
|
--muted: oklch(0.95 0.01 250);
|
||||||
|
--muted-foreground: oklch(0.49 0.024 245);
|
||||||
|
--accent: oklch(0.922 0.026 255);
|
||||||
|
--accent-foreground: oklch(0.255 0.044 254);
|
||||||
|
--destructive: oklch(0.59 0.225 25);
|
||||||
|
--border: oklch(0.888 0.014 250);
|
||||||
|
--input: oklch(0.888 0.014 250);
|
||||||
|
--ring: oklch(0.61 0.15 252);
|
||||||
|
--chart-1: oklch(0.59 0.18 255);
|
||||||
|
--chart-2: oklch(0.62 0.13 178);
|
||||||
|
--chart-3: oklch(0.72 0.16 82);
|
||||||
|
--chart-4: oklch(0.61 0.17 333);
|
||||||
|
--chart-5: oklch(0.56 0.13 285);
|
||||||
|
--sidebar: oklch(0.94 0.016 250);
|
||||||
|
--sidebar-foreground: oklch(0.245 0.024 252);
|
||||||
|
--sidebar-primary: oklch(0.548 0.19 257);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.006 248);
|
||||||
|
--sidebar-accent: oklch(0.908 0.025 252);
|
||||||
|
--sidebar-accent-foreground: oklch(0.245 0.038 254);
|
||||||
|
--sidebar-border: oklch(0.858 0.018 250);
|
||||||
|
--sidebar-ring: oklch(0.61 0.15 252);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-palette="gray"] {
|
||||||
|
--background: oklch(0.974 0.006 170);
|
||||||
|
--foreground: oklch(0.205 0.018 255);
|
||||||
|
--card: oklch(0.998 0.001 106);
|
||||||
|
--card-foreground: oklch(0.205 0.018 255);
|
||||||
|
--popover: oklch(0.998 0.001 106);
|
||||||
|
--popover-foreground: oklch(0.205 0.018 255);
|
||||||
|
--primary: oklch(0.538 0.183 257);
|
||||||
|
--primary-foreground: oklch(0.985 0.006 248);
|
||||||
|
--secondary: oklch(0.944 0.011 175);
|
||||||
|
--secondary-foreground: oklch(0.245 0.018 250);
|
||||||
|
--muted: oklch(0.948 0.008 180);
|
||||||
|
--muted-foreground: oklch(0.49 0.024 245);
|
||||||
|
--accent: oklch(0.925 0.018 218);
|
||||||
|
--accent-foreground: oklch(0.255 0.033 250);
|
||||||
|
--destructive: oklch(0.59 0.225 25);
|
||||||
|
--border: oklch(0.886 0.012 178);
|
||||||
|
--input: oklch(0.886 0.012 178);
|
||||||
|
--ring: oklch(0.61 0.15 252);
|
||||||
|
--chart-1: oklch(0.59 0.18 255);
|
||||||
|
--chart-2: oklch(0.62 0.13 178);
|
||||||
|
--chart-3: oklch(0.72 0.16 82);
|
||||||
|
--chart-4: oklch(0.61 0.17 333);
|
||||||
|
--chart-5: oklch(0.56 0.13 285);
|
||||||
|
--sidebar: oklch(0.94 0.011 170);
|
||||||
|
--sidebar-foreground: oklch(0.255 0.022 250);
|
||||||
|
--sidebar-primary: oklch(0.538 0.183 257);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.006 248);
|
||||||
|
--sidebar-accent: oklch(0.904 0.017 190);
|
||||||
|
--sidebar-accent-foreground: oklch(0.245 0.028 250);
|
||||||
|
--sidebar-border: oklch(0.86 0.014 176);
|
||||||
|
--sidebar-ring: oklch(0.61 0.15 252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.18 0 0);
|
||||||
|
--foreground: oklch(0.94 0 0);
|
||||||
|
--card: oklch(0.225 0 0);
|
||||||
|
--card-foreground: oklch(0.94 0 0);
|
||||||
|
--popover: oklch(0.225 0 0);
|
||||||
|
--popover-foreground: oklch(0.94 0 0);
|
||||||
|
--primary: oklch(0.82 0 0);
|
||||||
|
--primary-foreground: oklch(0.18 0 0);
|
||||||
|
--secondary: oklch(0.285 0 0);
|
||||||
|
--secondary-foreground: oklch(0.94 0 0);
|
||||||
|
--muted: oklch(0.285 0 0);
|
||||||
|
--muted-foreground: oklch(0.7 0 0);
|
||||||
|
--accent: oklch(0.32 0 0);
|
||||||
|
--accent-foreground: oklch(0.94 0 0);
|
||||||
|
--destructive: oklch(0.68 0.19 25);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 14%);
|
||||||
|
--ring: oklch(0.62 0 0);
|
||||||
|
--chart-1: oklch(0.82 0 0);
|
||||||
|
--chart-2: oklch(0.72 0 0);
|
||||||
|
--chart-3: oklch(0.62 0 0);
|
||||||
|
--chart-4: oklch(0.52 0 0);
|
||||||
|
--chart-5: oklch(0.42 0 0);
|
||||||
|
--sidebar: oklch(0.16 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.93 0 0);
|
||||||
|
--sidebar-primary: oklch(0.82 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.18 0 0);
|
||||||
|
--sidebar-accent: oklch(0.27 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.94 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 9%);
|
||||||
|
--sidebar-ring: oklch(0.62 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-palette="green"] {
|
||||||
|
--background: oklch(0.18 0.016 190);
|
||||||
|
--foreground: oklch(0.94 0.009 155);
|
||||||
|
--card: oklch(0.225 0.018 190);
|
||||||
|
--card-foreground: oklch(0.94 0.009 155);
|
||||||
|
--popover: oklch(0.225 0.018 190);
|
||||||
|
--popover-foreground: oklch(0.94 0.009 155);
|
||||||
|
--primary: oklch(0.67 0.13 174);
|
||||||
|
--primary-foreground: oklch(0.16 0.024 185);
|
||||||
|
--secondary: oklch(0.285 0.019 185);
|
||||||
|
--secondary-foreground: oklch(0.94 0.009 155);
|
||||||
|
--muted: oklch(0.285 0.019 185);
|
||||||
|
--muted-foreground: oklch(0.72 0.02 165);
|
||||||
|
--accent: oklch(0.33 0.032 174);
|
||||||
|
--accent-foreground: oklch(0.955 0.011 155);
|
||||||
|
--destructive: oklch(0.68 0.19 25);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 14%);
|
||||||
|
--ring: oklch(0.67 0.13 174);
|
||||||
|
--chart-1: oklch(0.68 0.13 174);
|
||||||
|
--chart-2: oklch(0.68 0.13 220);
|
||||||
|
--chart-3: oklch(0.75 0.15 78);
|
||||||
|
--chart-4: oklch(0.68 0.16 333);
|
||||||
|
--chart-5: oklch(0.67 0.13 262);
|
||||||
|
--sidebar: oklch(0.16 0.019 185);
|
||||||
|
--sidebar-foreground: oklch(0.93 0.01 155);
|
||||||
|
--sidebar-primary: oklch(0.67 0.13 174);
|
||||||
|
--sidebar-primary-foreground: oklch(0.16 0.024 185);
|
||||||
|
--sidebar-accent: oklch(0.27 0.028 178);
|
||||||
|
--sidebar-accent-foreground: oklch(0.955 0.011 155);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 9%);
|
||||||
|
--sidebar-ring: oklch(0.67 0.13 174);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-palette="blue"] {
|
||||||
|
--background: oklch(0.178 0.012 245);
|
||||||
|
--foreground: oklch(0.948 0.006 230);
|
||||||
|
--card: oklch(0.226 0.014 245);
|
||||||
|
--card-foreground: oklch(0.948 0.006 230);
|
||||||
|
--popover: oklch(0.226 0.014 245);
|
||||||
|
--popover-foreground: oklch(0.948 0.006 230);
|
||||||
|
--primary: oklch(0.66 0.16 252);
|
||||||
|
--primary-foreground: oklch(0.168 0.015 250);
|
||||||
|
--secondary: oklch(0.285 0.014 245);
|
||||||
|
--secondary-foreground: oklch(0.94 0.006 230);
|
||||||
|
--muted: oklch(0.285 0.014 245);
|
||||||
|
--muted-foreground: oklch(0.72 0.015 238);
|
||||||
|
--accent: oklch(0.32 0.022 225);
|
||||||
|
--accent-foreground: oklch(0.955 0.006 230);
|
||||||
|
--destructive: oklch(0.68 0.19 25);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 14%);
|
||||||
|
--ring: oklch(0.66 0.16 252);
|
||||||
|
--chart-1: oklch(0.69 0.15 252);
|
||||||
|
--chart-2: oklch(0.68 0.12 178);
|
||||||
|
--chart-3: oklch(0.75 0.15 82);
|
||||||
|
--chart-4: oklch(0.68 0.16 333);
|
||||||
|
--chart-5: oklch(0.67 0.13 285);
|
||||||
|
--sidebar: oklch(0.158 0.012 248);
|
||||||
|
--sidebar-foreground: oklch(0.93 0.006 230);
|
||||||
|
--sidebar-primary: oklch(0.66 0.16 252);
|
||||||
|
--sidebar-primary-foreground: oklch(0.168 0.015 250);
|
||||||
|
--sidebar-accent: oklch(0.255 0.017 245);
|
||||||
|
--sidebar-accent-foreground: oklch(0.955 0.006 230);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 9%);
|
||||||
|
--sidebar-ring: oklch(0.66 0.16 252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-palette="gray"] {
|
||||||
|
--background: oklch(0.178 0.012 245);
|
||||||
|
--foreground: oklch(0.948 0.006 230);
|
||||||
|
--card: oklch(0.226 0.014 245);
|
||||||
|
--card-foreground: oklch(0.948 0.006 230);
|
||||||
|
--popover: oklch(0.226 0.014 245);
|
||||||
|
--popover-foreground: oklch(0.948 0.006 230);
|
||||||
|
--primary: oklch(0.66 0.16 252);
|
||||||
|
--primary-foreground: oklch(0.168 0.015 250);
|
||||||
|
--secondary: oklch(0.285 0.014 245);
|
||||||
|
--secondary-foreground: oklch(0.94 0.006 230);
|
||||||
|
--muted: oklch(0.285 0.014 245);
|
||||||
|
--muted-foreground: oklch(0.72 0.015 238);
|
||||||
|
--accent: oklch(0.32 0.022 225);
|
||||||
|
--accent-foreground: oklch(0.955 0.006 230);
|
||||||
|
--destructive: oklch(0.68 0.19 25);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 14%);
|
||||||
|
--ring: oklch(0.66 0.16 252);
|
||||||
|
--chart-1: oklch(0.69 0.15 252);
|
||||||
|
--chart-2: oklch(0.68 0.12 178);
|
||||||
|
--chart-3: oklch(0.75 0.15 82);
|
||||||
|
--chart-4: oklch(0.68 0.16 333);
|
||||||
|
--chart-5: oklch(0.67 0.13 285);
|
||||||
|
--sidebar: oklch(0.158 0.012 248);
|
||||||
|
--sidebar-foreground: oklch(0.93 0.006 230);
|
||||||
|
--sidebar-primary: oklch(0.66 0.16 252);
|
||||||
|
--sidebar-primary-foreground: oklch(0.168 0.015 250);
|
||||||
|
--sidebar-accent: oklch(0.255 0.017 245);
|
||||||
|
--sidebar-accent-foreground: oklch(0.955 0.006 230);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 9%);
|
||||||
|
--sidebar-ring: oklch(0.66 0.16 252);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
web/app/(dashboard)/dashboard/_components/alert-list.tsx
Normal file
74
web/app/(dashboard)/dashboard/_components/alert-list.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowRightIcon, ShieldAlertIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import type { DashboardAlert } from "@/lib/api/dashboard"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
|
||||||
|
type AlertListProps = {
|
||||||
|
alerts: DashboardAlert[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertBadgeVariant(level: DashboardAlert["level"]) {
|
||||||
|
if (level === "error") {
|
||||||
|
return "destructive" as const
|
||||||
|
}
|
||||||
|
if (level === "warning") {
|
||||||
|
return "secondary" as const
|
||||||
|
}
|
||||||
|
return "outline" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertList({ alerts }: AlertListProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-md shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("dashboardHome.riskAlerts")}</CardTitle>
|
||||||
|
<CardDescription>{t("dashboardHome.riskAlertsDescription")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed px-4 py-10 text-center">
|
||||||
|
<ShieldAlertIcon className="mx-auto mb-3 size-8 text-muted-foreground" />
|
||||||
|
<div className="text-sm font-medium">{t("dashboardHome.noRiskTitle")}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("dashboardHome.noRiskDescription")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
alerts.map((item) => (
|
||||||
|
<Link key={item.id} href={item.link} className="block">
|
||||||
|
<div className="rounded-md border p-4 transition-colors hover:border-primary/40">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium">{item.title}</div>
|
||||||
|
<Badge variant={getAlertBadgeVariant(item.level)}>
|
||||||
|
{item.count}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="mt-0.5 size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
web/app/(dashboard)/dashboard/_components/dashboard-home.tsx
Normal file
169
web/app/(dashboard)/dashboard/_components/dashboard-home.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { RefreshCwIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
fetchDashboardOverview,
|
||||||
|
type DashboardOverview,
|
||||||
|
type DashboardRange,
|
||||||
|
} from "@/lib/api/dashboard"
|
||||||
|
import { SummaryCards } from "./summary-cards"
|
||||||
|
import { TrendPanel } from "./trend-panel"
|
||||||
|
import { TeamLoadPanel } from "./team-load-panel"
|
||||||
|
import { AlertList } from "./alert-list"
|
||||||
|
import { SetupReadinessBanner } from "./setup-readiness-banner"
|
||||||
|
|
||||||
|
function LoadingCards() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<Card key={index} className="rounded-md shadow-none">
|
||||||
|
<CardContent className="space-y-3 p-4">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHome() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [range, setRange] = useState<DashboardRange>("7d")
|
||||||
|
const [data, setData] = useState<DashboardOverview | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (nextRange: DashboardRange, showRefreshing = false) => {
|
||||||
|
if (showRefreshing) {
|
||||||
|
setRefreshing(true)
|
||||||
|
} else {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fetchDashboardOverview(nextRange)
|
||||||
|
setData(result)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("dashboardHome.loadFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData(range)
|
||||||
|
}, [loadData, range])
|
||||||
|
|
||||||
|
const rangeOptions: Array<{ value: DashboardRange; label: string }> = [
|
||||||
|
{ value: "today", label: t("dashboardHome.rangeToday") },
|
||||||
|
{ value: "7d", label: t("dashboardHome.range7d") },
|
||||||
|
{ value: "30d", label: t("dashboardHome.range30d") },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 lg:p-5">
|
||||||
|
<SetupReadinessBanner onUpdated={() => void loadData(range, true)} />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="rounded-md border bg-background p-1">
|
||||||
|
{rangeOptions.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
variant={range === item.value ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRange(item.value)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadData(range, true)}
|
||||||
|
disabled={loading || refreshing}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={refreshing ? "mr-2 size-4 animate-spin" : "mr-2 size-4"} />
|
||||||
|
{t("dashboardHome.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !data ? (
|
||||||
|
<LoadingCards />
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
<SummaryCards summary={data.summary} />
|
||||||
|
|
||||||
|
<TrendPanel
|
||||||
|
title={t("dashboardHome.conversationTrend")}
|
||||||
|
description={t("dashboardHome.conversationTrendDescription")}
|
||||||
|
trend={data.conversationStats.trend}
|
||||||
|
distribution={data.conversationStats.statusDistribution}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<TeamLoadPanel agentStats={data.agentStats} />
|
||||||
|
|
||||||
|
<Card className="rounded-md shadow-none">
|
||||||
|
<CardContent className="grid gap-3 p-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||||
|
<div className="text-sm text-muted-foreground">{t("dashboardHome.enabledAiAgents")}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold">{data.aiStats.enabledAiAgents}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||||
|
<div className="text-sm text-muted-foreground">{t("dashboardHome.enabledChannels")}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold">{data.aiStats.enabledChannels}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||||
|
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieves")}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold">
|
||||||
|
{data.aiStats.todayKnowledgeRetrieves}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||||
|
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayKnowledgeRetrieveFailRate")}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold">
|
||||||
|
{data.aiStats.todayKnowledgeRetrieveFailRate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||||
|
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayAgentRunFailCount")}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold">
|
||||||
|
{data.aiStats.todayAgentRunFailCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-background px-3 py-2.5">
|
||||||
|
<div className="text-sm text-muted-foreground">{t("dashboardHome.todayAiHandoffCount")}</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold">
|
||||||
|
{data.aiStats.todayAiHandoffCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertList alerts={data.alerts} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card className="rounded-md shadow-none">
|
||||||
|
<CardContent className="flex min-h-60 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||||
|
{t("dashboardHome.empty")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
Loader2Icon,
|
||||||
|
SparklesIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
notifySetupReadinessUpdated,
|
||||||
|
SETUP_READINESS_UPDATED_EVENT,
|
||||||
|
} from "@/components/operator-readiness-indicator"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
applySetupQuickFix,
|
||||||
|
fetchSetupReadiness,
|
||||||
|
type SetupReadinessResponse,
|
||||||
|
} from "@/lib/api/setup-readiness"
|
||||||
|
|
||||||
|
export function SetupReadinessBanner({
|
||||||
|
onUpdated,
|
||||||
|
}: {
|
||||||
|
onUpdated?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||||
|
const [fixing, setFixing] = useState(false)
|
||||||
|
|
||||||
|
const loadReadiness = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchSetupReadiness()
|
||||||
|
setReadiness(data)
|
||||||
|
} catch {
|
||||||
|
// Ignore in banner if failed
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadReadiness()
|
||||||
|
const handleUpdated = () => {
|
||||||
|
void loadReadiness()
|
||||||
|
}
|
||||||
|
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||||
|
}
|
||||||
|
}, [loadReadiness])
|
||||||
|
|
||||||
|
async function handleQuickSetupAll() {
|
||||||
|
setFixing(true)
|
||||||
|
try {
|
||||||
|
const res = await applySetupQuickFix({ action: "quick_setup_all" })
|
||||||
|
setReadiness(res.readiness)
|
||||||
|
notifySetupReadinessUpdated()
|
||||||
|
onUpdated?.()
|
||||||
|
toast.success(t("setupWizard.quickFixSuccess"))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setFixing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readiness) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const allReady =
|
||||||
|
readiness.can_receive_requests_now &&
|
||||||
|
readiness.current_operator.can_receive_now &&
|
||||||
|
readiness.overall_status === "ok"
|
||||||
|
|
||||||
|
if (allReady) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const canQuickSetupAll = readiness.steps.some((s) =>
|
||||||
|
s.quick_fix_actions.includes("quick_setup_all"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const incompleteSteps = readiness.steps.filter((s) => s.status !== "ok")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 p-4">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<AlertTriangleIcon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{t("setupWizard.banner.title")}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md border-amber-500/40 bg-amber-500/10 text-xs text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{t("setupWizard.stepsProgress", {
|
||||||
|
ready: readiness.ready_steps,
|
||||||
|
total: readiness.total_steps,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("setupWizard.banner.description")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
{incompleteSteps.map((step) => (
|
||||||
|
<Badge
|
||||||
|
key={step.key}
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-md text-xs font-normal"
|
||||||
|
>
|
||||||
|
{step.step_number}. {t(`setupWizard.steps.${step.key}.title`)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{!readiness.current_operator.can_receive_now
|
||||||
|
? readiness.current_operator.blocker_codes.map((code) => (
|
||||||
|
<Badge
|
||||||
|
key={code}
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md border-amber-500/30 text-xs font-normal text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{t(`setupWizard.operatorBlockers.${code}`)}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{canQuickSetupAll ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 rounded-md"
|
||||||
|
disabled={fixing}
|
||||||
|
onClick={() => void handleQuickSetupAll()}
|
||||||
|
>
|
||||||
|
{fixing ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{fixing
|
||||||
|
? t("setupWizard.quickSetupRunning")
|
||||||
|
: t("setupWizard.quickSetupAll")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 rounded-md"
|
||||||
|
render={<Link href="/dashboard/setup" />}
|
||||||
|
>
|
||||||
|
<span>{t("setupWizard.openSetupWizard")}</span>
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CheckCircle2Icon }
|
||||||
108
web/app/(dashboard)/dashboard/_components/summary-cards.tsx
Normal file
108
web/app/(dashboard)/dashboard/_components/summary-cards.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
BotMessageSquareIcon,
|
||||||
|
CircleDashedIcon,
|
||||||
|
HeadsetIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
WavesIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import type { DashboardOverview } from "@/lib/api/dashboard"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type SummaryCardsProps = {
|
||||||
|
summary: DashboardOverview["summary"]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryCardItem = {
|
||||||
|
key: keyof DashboardOverview["summary"]
|
||||||
|
titleKey: string
|
||||||
|
descriptionKey: string
|
||||||
|
link: string
|
||||||
|
icon: typeof BotMessageSquareIcon
|
||||||
|
format?: (value: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards: SummaryCardItem[] = [
|
||||||
|
{
|
||||||
|
key: "todayNewConversations",
|
||||||
|
titleKey: "dashboardHome.summaryTodayNewConversations",
|
||||||
|
descriptionKey: "dashboardHome.summaryTodayNewConversationsDescription",
|
||||||
|
link: "/dashboard/conversations",
|
||||||
|
icon: BotMessageSquareIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "processingConversations",
|
||||||
|
titleKey: "dashboardHome.summaryProcessingConversations",
|
||||||
|
descriptionKey: "dashboardHome.summaryProcessingConversationsDescription",
|
||||||
|
link: "/dashboard/conversations",
|
||||||
|
icon: WavesIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pendingDispatchConversations",
|
||||||
|
titleKey: "dashboardHome.summaryPendingDispatchConversations",
|
||||||
|
descriptionKey: "dashboardHome.summaryPendingDispatchConversationsDescription",
|
||||||
|
link: "/dashboard/conversations",
|
||||||
|
icon: CircleDashedIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "onlineAgents",
|
||||||
|
titleKey: "dashboardHome.summaryOnlineAgents",
|
||||||
|
descriptionKey: "dashboardHome.summaryOnlineAgentsDescription",
|
||||||
|
link: "/dashboard/agents",
|
||||||
|
icon: HeadsetIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aiServiceRate",
|
||||||
|
titleKey: "dashboardHome.summaryAiServiceRate",
|
||||||
|
descriptionKey: "dashboardHome.summaryAiServiceRateDescription",
|
||||||
|
link: "/dashboard/ai-agents",
|
||||||
|
icon: SparklesIcon,
|
||||||
|
format: (value: number) => `${value.toFixed(1)}%`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||||
|
{cards.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const rawValue = summary[item.key]
|
||||||
|
const value =
|
||||||
|
typeof item.format === "function"
|
||||||
|
? item.format(Number(rawValue))
|
||||||
|
: Number(rawValue).toLocaleString()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={item.key} href={item.link}>
|
||||||
|
<Card className="h-full rounded-md shadow-none transition-colors hover:border-primary/40">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 p-4 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-sm font-medium">{t(item.titleKey)}</CardTitle>
|
||||||
|
<CardDescription>{t(item.descriptionKey)}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-muted p-2 text-muted-foreground">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4">
|
||||||
|
<div className="text-2xl font-semibold tracking-tight">{value}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
web/app/(dashboard)/dashboard/_components/team-load-panel.tsx
Normal file
112
web/app/(dashboard)/dashboard/_components/team-load-panel.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import type { DashboardOverview } from "@/lib/api/dashboard"
|
||||||
|
|
||||||
|
type TeamLoadPanelProps = {
|
||||||
|
agentStats: DashboardOverview["agentStats"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoadTone(loadRate: number) {
|
||||||
|
if (loadRate >= 85) {
|
||||||
|
return "bg-red-500"
|
||||||
|
}
|
||||||
|
if (loadRate >= 60) {
|
||||||
|
return "bg-amber-500"
|
||||||
|
}
|
||||||
|
return "bg-emerald-500"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-md shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("dashboardHome.teamLoad")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("dashboardHome.teamLoadDescription", {
|
||||||
|
online: agentStats.onlineAgents,
|
||||||
|
busy: agentStats.busyAgents,
|
||||||
|
offline: agentStats.offlineAgents,
|
||||||
|
})}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{agentStats.teamLoads.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t("dashboardHome.noTeamData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
agentStats.teamLoads.map((item) => (
|
||||||
|
<div key={item.teamId} className="rounded-md border p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium">{item.teamName}</div>
|
||||||
|
{item.hasScheduleNow ? (
|
||||||
|
<Badge variant="secondary">{t("dashboardHome.scheduled")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{t("dashboardHome.notScheduled")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("dashboardHome.teamStats", {
|
||||||
|
total: item.totalAgents,
|
||||||
|
online: item.onlineAgents,
|
||||||
|
busy: item.busyAgents,
|
||||||
|
offline: item.offlineAgents,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-semibold">{item.loadRate.toFixed(1)}%</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("dashboardHome.load", {
|
||||||
|
processing: item.processingConversations,
|
||||||
|
capacity: item.maxConcurrentCapacity || 0,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 h-2 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${getLoadTone(item.loadRate)}`}
|
||||||
|
style={{ width: `${Math.min(item.loadRate, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">{t("dashboardHome.waiting")}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">{item.waitingConversations}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">{t("dashboardHome.processing")}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">{item.processingConversations}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">{t("dashboardHome.capacity")}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">{item.maxConcurrentCapacity}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-muted/35 px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">{t("dashboardHome.busyAgents")}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">{item.busyAgents}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
web/app/(dashboard)/dashboard/_components/trend-panel.tsx
Normal file
141
web/app/(dashboard)/dashboard/_components/trend-panel.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
|
import type { DashboardStatusDistributionItem, DashboardTrendItem } from "@/lib/api/dashboard"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type TrendPanelProps = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
trend: DashboardTrendItem[]
|
||||||
|
distribution: DashboardStatusDistributionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendPanel({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
trend,
|
||||||
|
distribution,
|
||||||
|
}: TrendPanelProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const trendConfig = {
|
||||||
|
newCount: {
|
||||||
|
label: t("dashboardHome.chartNew"),
|
||||||
|
color: "hsl(24 95% 53%)",
|
||||||
|
},
|
||||||
|
closedCount: {
|
||||||
|
label: t("dashboardHome.chartClosed"),
|
||||||
|
color: "hsl(190 95% 39%)",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
const distributionConfig = {
|
||||||
|
count: {
|
||||||
|
label: t("dashboardHome.chartCount"),
|
||||||
|
theme: {
|
||||||
|
light: "hsl(222 47% 11%)",
|
||||||
|
dark: "hsl(210 40% 98%)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
const localizedDistribution = distribution.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: getStatusLabel(item.status, item.label, t),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.5fr_0.9fr]">
|
||||||
|
<Card className="rounded-md shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={trendConfig} className="h-72 w-full">
|
||||||
|
<AreaChart data={trend}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="newCount"
|
||||||
|
stroke="var(--color-newCount)"
|
||||||
|
fill="var(--color-newCount)"
|
||||||
|
fillOpacity={0.18}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="closedCount"
|
||||||
|
stroke="var(--color-closedCount)"
|
||||||
|
fill="var(--color-closedCount)"
|
||||||
|
fillOpacity={0.08}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-md shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("dashboardHome.statusDistribution")}</CardTitle>
|
||||||
|
<CardDescription>{t("dashboardHome.statusDistributionDescription")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ChartContainer config={distributionConfig} className="h-72 w-full">
|
||||||
|
<BarChart data={localizedDistribution} layout="vertical" margin={{ left: 20 }}>
|
||||||
|
<CartesianGrid horizontal={false} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="label"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={64}
|
||||||
|
/>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="var(--color-count)"
|
||||||
|
radius={[0, 8, 8, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(
|
||||||
|
status: number,
|
||||||
|
fallback: string,
|
||||||
|
t: (key: string) => string
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return t("dashboardHome.statusAiServing")
|
||||||
|
case 2:
|
||||||
|
return t("dashboardHome.statusPending")
|
||||||
|
case 3:
|
||||||
|
return t("dashboardHome.statusActive")
|
||||||
|
case 4:
|
||||||
|
return t("dashboardHome.statusClosed")
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
163
web/app/(dashboard)/dashboard/agent-runs/page.tsx
Normal file
163
web/app/(dashboard)/dashboard/agent-runs/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { AlertTriangleIcon, BotMessageSquareIcon, Clock3Icon, WorkflowIcon, WrenchIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { DashboardListPage } from "@/components/dashboard/list"
|
||||||
|
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { fetchAgentRun, fetchAgentRunMetrics, fetchAgentRuns, fetchAIWorkflowRun, saveAgentRunQualityFeedback, type AgentRun, type AgentRunMetrics, type AgentStep, type AgentToolCall, type AIWorkflowRun } from "@/lib/api/admin"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { WorkflowRunAuditGraph } from "../ai-workflow-runs/_components/workflow-run-audit-graph"
|
||||||
|
|
||||||
|
function statusVariant(status: string) {
|
||||||
|
if (status === "failed") return "destructive" as const
|
||||||
|
if (status === "interrupted") return "outline" as const
|
||||||
|
if (status === "completed") return "default" as const
|
||||||
|
return "secondary" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardAgentRunsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [run, setRun] = useState<AgentRun | null>(null)
|
||||||
|
const [workflowAuditOpen, setWorkflowAuditOpen] = useState(false)
|
||||||
|
const [workflowAuditLoading, setWorkflowAuditLoading] = useState(false)
|
||||||
|
const [workflowRun, setWorkflowRun] = useState<AIWorkflowRun | null>(null)
|
||||||
|
const [metrics, setMetrics] = useState<AgentRunMetrics | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAgentRunMetrics().then(setMetrics).catch(() => setMetrics(null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function openDetail(id: number) {
|
||||||
|
setOpen(true)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setRun(await fetchAgentRun(id))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentRun.loadDetailFailed"))
|
||||||
|
setOpen(false)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkflowAudit(id: number) {
|
||||||
|
if (id <= 0) return
|
||||||
|
setWorkflowAuditOpen(true)
|
||||||
|
setWorkflowAuditLoading(true)
|
||||||
|
try {
|
||||||
|
setWorkflowRun(await fetchAIWorkflowRun(id))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadDetailFailed"))
|
||||||
|
setWorkflowAuditOpen(false)
|
||||||
|
} finally {
|
||||||
|
setWorkflowAuditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{metrics ? <div className="grid grid-cols-2 gap-px border-b bg-border sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-10">
|
||||||
|
<Metric label={t("agentRun.metricCompletionRate")} value={`${Math.round(metrics.completionRate * 100)}%`} detail={`${metrics.completedRuns}/${metrics.totalRuns}`} />
|
||||||
|
<Metric label={t("agentRun.metricResolutionRate")} value={metrics.reviewedRuns ? `${Math.round(metrics.resolutionRate * 100)}%` : "-"} detail={`${metrics.resolvedRuns}/${metrics.reviewedRuns}`} />
|
||||||
|
<Metric label={t("agentRun.metricUnsupportedRate")} value={metrics.reviewedRuns ? `${Math.round(metrics.unsupportedEvidenceRate * 100)}%` : "-"} detail={`${metrics.unsupportedEvidenceRuns}/${metrics.reviewedRuns}`} />
|
||||||
|
<Metric label={t("agentRun.metricToolSuccessRate")} value={metrics.toolCalls ? `${Math.round(metrics.toolSuccessRate * 100)}%` : "-"} detail={`${metrics.toolCalls}`} />
|
||||||
|
<Metric label={t("agentRun.metricAvgSteps")} value={metrics.averageSteps.toFixed(1)} detail={`${metrics.totalRuns}`} />
|
||||||
|
<Metric label={t("agentRun.metricP95Latency")} value={`${metrics.p95DurationMs} ms`} detail={`avg ${metrics.averageDurationMs} ms`} />
|
||||||
|
<Metric label={t("agentRun.metricToken")} value={`${metrics.promptTokens + metrics.completionTokens}`} detail={`${metrics.promptTokens}/${metrics.completionTokens}`} />
|
||||||
|
<Metric label={t("agentRun.metricHandoffRate")} value={`${Math.round(metrics.handoffRate * 100)}%`} detail="Handoff" />
|
||||||
|
<Metric label={t("agentRun.metricFallbackRate")} value={`${Math.round(metrics.knowledgeFallbackRate * 100)}%`} detail="Fallback" />
|
||||||
|
<Metric label={t("agentRun.metricInterruptRecoveryRate")} value={metrics.resumedInterrupts ? `${Math.round(metrics.interruptRecoveryRate * 100)}%` : "-"} detail={`${metrics.resolvedInterrupts}/${metrics.resumedInterrupts}`} />
|
||||||
|
</div> : null}
|
||||||
|
<DashboardListPage<AgentRun>
|
||||||
|
filters={[
|
||||||
|
{ name: "conversationId", label: t("agentRun.conversation"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
|
||||||
|
{ name: "aiAgentId", label: t("agentRun.agent"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
|
||||||
|
{ name: "status", label: t("agentRun.status"), defaultValue: "", className: "w-full sm:w-40" },
|
||||||
|
]}
|
||||||
|
fetchList={fetchAgentRuns}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
getRowClassName={() => "cursor-pointer"}
|
||||||
|
onRowClick={(item) => void openDetail(item.id)}
|
||||||
|
columns={[
|
||||||
|
{ key: "startedAt", label: t("agentRun.startedAt"), className: "w-42 text-xs text-muted-foreground", render: (item) => formatDateTime(item.startedAt || item.createdAt) },
|
||||||
|
{ key: "agent", label: t("agentRun.agent"), className: "w-28", render: (item) => `#${item.aiAgentId || "-"}` },
|
||||||
|
{ key: "conversation", label: t("agentRun.conversation"), className: "w-28", render: (item) => `#${item.conversationId || "-"}` },
|
||||||
|
{ key: "status", label: t("agentRun.status"), className: "w-30", render: (item) => <Badge variant={statusVariant(item.status)}>{item.status || "-"}</Badge> },
|
||||||
|
{ key: "duration", label: t("agentRun.duration"), className: "w-24 text-right", render: (item) => `${item.durationMs || 0} ms` },
|
||||||
|
{ key: "tokens", label: t("agentRun.tokens"), className: "w-28 text-right", render: (item) => `${item.promptTokens || 0}/${item.completionTokens || 0}` },
|
||||||
|
{ key: "error", label: t("agentRun.error"), className: "w-72 max-w-72", render: (item) => item.errorMessage ? <span className="block truncate text-xs text-destructive" title={item.errorMessage}>{item.errorMessage}</span> : "-" },
|
||||||
|
]}
|
||||||
|
labels={{ refresh: t("agentRun.refresh"), query: t("agentRun.query"), loading: t("agentRun.loading"), empty: t("agentRun.empty"), loadFailed: t("agentRun.loadFailed") }}
|
||||||
|
/>
|
||||||
|
<AgentRunDetailDialog open={open} loading={loading} run={run} onOpenWorkflowAudit={openWorkflowAudit} onQualityFeedbackSaved={(id) => void openDetail(id)} onOpenChange={(next) => { setOpen(next); if (!next) setRun(null) }} t={t} />
|
||||||
|
<WorkflowAuditDialog open={workflowAuditOpen} loading={workflowAuditLoading} run={workflowRun} onOpenChange={(next) => { setWorkflowAuditOpen(next); if (!next) setWorkflowRun(null) }} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) { return <div className="bg-background px-4 py-3"><div className="text-xs text-muted-foreground">{label}</div><div className="mt-1 text-lg font-semibold">{value}</div><div className="text-xs text-muted-foreground">{detail}</div></div> }
|
||||||
|
|
||||||
|
function AgentRunDetailDialog({ open, loading, run, onOpenChange, onOpenWorkflowAudit, onQualityFeedbackSaved, t }: { open: boolean; loading: boolean; run: AgentRun | null; onOpenChange: (open: boolean) => void; onOpenWorkflowAudit: (workflowRunId: number) => void; onQualityFeedbackSaved: (agentRunId: number) => void; t: (key: string) => string }) {
|
||||||
|
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><BotMessageSquareIcon className="size-4" />{t("agentRun.detailTitle")}</span>} description={run ? `Run #${run.id}` : t("agentRun.detailDescription")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("agentRun.close")}</Button>}>
|
||||||
|
{loading ? <div className="py-10 text-sm text-muted-foreground">{t("agentRun.loadingDetail")}</div> : run ? <div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("agentRun.status")} value={run.status} /><Meta label={t("agentRun.agent")} value={`#${run.aiAgentId}`} /><Meta label={t("agentRun.revision")} value={`#${run.agentRevisionId || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /><Meta label={t("agentRun.tokens")} value={`${run.promptTokens || 0}/${run.completionTokens || 0}`} /></div>
|
||||||
|
{run.workflowRunId > 0 ? <section className="flex items-center justify-between gap-3 border px-3 py-2"><div><div className="text-sm font-medium">{t("agentRun.workflowAudit")}</div><div className="text-xs text-muted-foreground">Workflow Run #{run.workflowRunId}</div></div><Button type="button" variant="outline" size="sm" onClick={() => onOpenWorkflowAudit(run.workflowRunId)}><WorkflowIcon />{t("agentRun.viewWorkflowAudit")}</Button></section> : null}
|
||||||
|
<QualityFeedbackPanel run={run} onSaved={onQualityFeedbackSaved} t={t} />
|
||||||
|
{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}
|
||||||
|
<Preview title={t("agentRun.trace")} raw={run.traceData} />
|
||||||
|
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.steps")}</h3>{(run.steps ?? []).map((step) => <StepBlock key={step.id} step={step} t={t} />)}{!run.steps?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptySteps")}</p> : null}</section>
|
||||||
|
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.toolCalls")}</h3>{(run.toolCalls ?? []).map((call) => <ToolCallBlock key={call.id} call={call} t={t} />)}{!run.toolCalls?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptyToolCalls")}</p> : null}</section>
|
||||||
|
</div> : <div className="py-10 text-sm text-muted-foreground">{t("agentRun.notFound")}</div>}
|
||||||
|
</ProjectDialog>
|
||||||
|
}
|
||||||
|
|
||||||
|
function QualityFeedbackPanel({ run, onSaved, t }: { run: AgentRun; onSaved: (agentRunId: number) => void; t: (key: string) => string }) {
|
||||||
|
const [resolutionStatus, setResolutionStatus] = useState<"unknown" | "resolved" | "unresolved">("unknown")
|
||||||
|
const [evidenceStatus, setEvidenceStatus] = useState<"unknown" | "supported" | "unsupported">("unknown")
|
||||||
|
const [comment, setComment] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResolutionStatus(run.qualityFeedback?.resolutionStatus ?? "unknown")
|
||||||
|
setEvidenceStatus(run.qualityFeedback?.evidenceStatus ?? "unknown")
|
||||||
|
setComment(run.qualityFeedback?.comment ?? "")
|
||||||
|
}, [run.id, run.qualityFeedback])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await saveAgentRunQualityFeedback({ agentRunId: run.id, resolutionStatus, evidenceStatus, comment })
|
||||||
|
toast.success(t("agentRun.qualitySaved"))
|
||||||
|
onSaved(run.id)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Save failed")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className="space-y-3 border p-3"><div><h3 className="text-sm font-medium">{t("agentRun.qualityTitle")}</h3><p className="text-xs text-muted-foreground">{t("agentRun.qualityDescription")}</p></div><div className="grid gap-3 sm:grid-cols-2"><OptionCombobox value={resolutionStatus} placeholder={t("agentRun.selectResolution")} options={[{ value: "unknown", label: "Resolution: Unknown" }, { value: "resolved", label: "Resolution: Resolved" }, { value: "unresolved", label: "Resolution: Unresolved" }]} onChange={(value) => setResolutionStatus(value === "resolved" || value === "unresolved" ? value : "unknown")} /><OptionCombobox value={evidenceStatus} placeholder={t("agentRun.selectEvidence")} options={[{ value: "unknown", label: "Evidence: Unknown" }, { value: "supported", label: "Evidence: Supported" }, { value: "unsupported", label: "Evidence: Unsupported" }]} onChange={(value) => setEvidenceStatus(value === "supported" || value === "unsupported" ? value : "unknown")} /></div><Textarea rows={3} value={comment} onChange={(event) => setComment(event.target.value)} placeholder={t("agentRun.qualityCommentPlaceholder")} /><div className="flex items-center justify-between gap-3"><span className="text-xs text-muted-foreground">{run.qualityFeedback?.updatedAt ? `Last annotated: ${run.qualityFeedback.updatedAt}` : "-"}</span><Button type="button" size="sm" disabled={saving} onClick={save}>{t("agentRun.saveQuality")}</Button></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowAuditDialog({ open, loading, run, onOpenChange }: { open: boolean; loading: boolean; run: AIWorkflowRun | null; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const t = useI18n()
|
||||||
|
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><WorkflowIcon className="size-4" />{t("agentRun.workflowAudit")}</span>} description={run ? `Workflow Run #${run.id}` : t("agentRun.workflowAudit")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("common.close")}</Button>}>
|
||||||
|
{loading ? <div className="py-10 text-sm text-muted-foreground">{t("common.loading")}</div> : run ? <div className="space-y-3"><div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("common.status")} value={run.statusName} /><Meta label="Workflow" value={run.workflowName || `#${run.workflowId}`} /><Meta label={t("agentRun.revision")} value={`v${run.workflowVersion || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /></div>{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}<WorkflowRunAuditGraph run={run} /></div> : <div className="py-10 text-sm text-muted-foreground">{t("common.emptyData")}</div>}
|
||||||
|
</ProjectDialog>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value }: { label: string; value: string }) { return <span className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-1"><span className="text-muted-foreground">{label}</span><span className="font-medium">{value || "-"}</span></span> }
|
||||||
|
function StepBlock({ step, t }: { step: AgentStep; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><Clock3Icon className="size-4 text-muted-foreground" /><span className="font-medium">{step.stepCode || step.stepType}</span><Badge variant={statusVariant(step.status)}>{step.status}</Badge><span className="text-xs text-muted-foreground">{step.durationMs || 0} ms</span></div>{step.errorMessage ? <p className="mt-2 text-xs text-destructive">{step.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.input")} raw={step.inputPreview} /><Preview title={t("agentRun.output")} raw={step.outputPreview} /></div></div> }
|
||||||
|
function ToolCallBlock({ call, t }: { call: AgentToolCall; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><WrenchIcon className="size-4 text-muted-foreground" /><span className="font-medium">{call.toolCode}</span><Badge variant={statusVariant(call.status)}>{call.status}</Badge><span className="text-xs text-muted-foreground">{call.riskLevel}</span></div>{call.errorMessage ? <p className="mt-2 text-xs text-destructive">{call.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.arguments")} raw={call.argumentsPreview} /><Preview title={t("agentRun.result")} raw={call.resultPreview} /></div></div> }
|
||||||
|
function Preview({ title, raw }: { title: string; raw: string }) { const value = parseJSON(raw); return <div className="min-w-0"><div className="mb-1 text-xs text-muted-foreground">{title}</div>{value !== null ? <JsonTreeViewer value={value} collapsed={2} /> : raw?.trim() ? <pre className="max-h-52 overflow-auto rounded-md border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-all">{raw}</pre> : <div className="rounded-md border bg-muted/20 px-2 py-1.5 text-xs text-muted-foreground">-</div>}</div> }
|
||||||
|
function parseJSON(raw: string): unknown | null { try { return raw?.trim() ? JSON.parse(raw) : null } catch { return null } }
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { ArrowLeftIcon, CheckIcon, Loader2Icon, XIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
fetchAgentTeamsAll,
|
||||||
|
generateAgentTeamScheduleBatch,
|
||||||
|
previewAgentTeamScheduleBatch,
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type AdminAgentTeamScheduleBatchPreview,
|
||||||
|
type BatchAdminAgentTeamSchedulePayload,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type BatchScheduleDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSuccess: (created: number) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
const weekdayKeys = [
|
||||||
|
"weekdayMon",
|
||||||
|
"weekdayTue",
|
||||||
|
"weekdayWed",
|
||||||
|
"weekdayThu",
|
||||||
|
"weekdayFri",
|
||||||
|
"weekdaySat",
|
||||||
|
"weekdaySun",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function getWeekdayOptions(t: TFunction) {
|
||||||
|
return weekdayKeys.map((key, index) => ({
|
||||||
|
value: index + 1,
|
||||||
|
label: t(`agentTeamSchedule.${key}`),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayDateValue() {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(today.getDate()).padStart(2, "0")
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultFormState() {
|
||||||
|
const today = todayDateValue()
|
||||||
|
return {
|
||||||
|
selectedTeamIds: [] as number[],
|
||||||
|
startDate: today,
|
||||||
|
endDate: today,
|
||||||
|
weekdays: [1, 2, 3, 4, 5],
|
||||||
|
startTime: "09:00",
|
||||||
|
endTime: "18:00",
|
||||||
|
remark: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchFormState = ReturnType<typeof defaultFormState>
|
||||||
|
type DialogStep = "form" | "preview"
|
||||||
|
|
||||||
|
function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload {
|
||||||
|
return {
|
||||||
|
teamIds: [...form.selectedTeamIds],
|
||||||
|
startDate: form.startDate,
|
||||||
|
endDate: form.endDate,
|
||||||
|
weekdays: [...form.weekdays],
|
||||||
|
startTime: form.startTime,
|
||||||
|
endTime: form.endTime,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekdayLabel(value: number, t: TFunction) {
|
||||||
|
const weekdayOptions = getWeekdayOptions(t)
|
||||||
|
return weekdayOptions.find((option) => option.value === value)?.label ?? String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(form: BatchFormState, t: TFunction) {
|
||||||
|
const today = todayDateValue()
|
||||||
|
if (form.selectedTeamIds.length === 0) {
|
||||||
|
return t("agentTeamSchedule.selectAtLeastOneTeam")
|
||||||
|
}
|
||||||
|
if (!form.startDate || !form.endDate) {
|
||||||
|
return t("agentTeamSchedule.selectDateRange")
|
||||||
|
}
|
||||||
|
if (form.startDate < today) {
|
||||||
|
return t("agentTeamSchedule.startBeforeToday")
|
||||||
|
}
|
||||||
|
if (form.endDate < form.startDate) {
|
||||||
|
return t("agentTeamSchedule.endBeforeStart")
|
||||||
|
}
|
||||||
|
if (form.weekdays.length === 0) {
|
||||||
|
return t("agentTeamSchedule.selectAtLeastOneWeekday")
|
||||||
|
}
|
||||||
|
if (!form.startTime || !form.endTime) {
|
||||||
|
return t("agentTeamSchedule.selectStartEndTime")
|
||||||
|
}
|
||||||
|
if (form.endTime <= form.startTime) {
|
||||||
|
return t("agentTeamSchedule.endAfterStart")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchScheduleDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: BatchScheduleDialogProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||||
|
const [form, setForm] = useState(defaultFormState)
|
||||||
|
const [step, setStep] = useState<DialogStep>("form")
|
||||||
|
const [preview, setPreview] = useState<AdminAgentTeamScheduleBatchPreview | null>(null)
|
||||||
|
const [previewPayload, setPreviewPayload] = useState<BatchAdminAgentTeamSchedulePayload | null>(null)
|
||||||
|
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const openRef = useRef(open)
|
||||||
|
const previewRequestIdRef = useRef(0)
|
||||||
|
const busy = loadingTeams || previewing || submitting
|
||||||
|
const weekdayOptions = useMemo(() => getWeekdayOptions(t), [t])
|
||||||
|
|
||||||
|
const teamOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
teams
|
||||||
|
.filter((team) => !form.selectedTeamIds.includes(team.id))
|
||||||
|
.map((team) => ({ value: String(team.id), label: team.name })),
|
||||||
|
[form.selectedTeamIds, teams]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedTeams = useMemo(() => {
|
||||||
|
const teamMap = new Map(teams.map((team) => [team.id, team]))
|
||||||
|
return form.selectedTeamIds.map((teamId) => teamMap.get(teamId)).filter(Boolean) as AdminAgentTeam[]
|
||||||
|
}, [form.selectedTeamIds, teams])
|
||||||
|
|
||||||
|
const selectedWeekdays = useMemo(
|
||||||
|
() => new Set(form.weekdays),
|
||||||
|
[form.weekdays]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasConflict = preview?.conflict === true
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = open
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
previewRequestIdRef.current += 1
|
||||||
|
setForm(defaultFormState())
|
||||||
|
setStep("form")
|
||||||
|
setPreview(null)
|
||||||
|
setPreviewPayload(null)
|
||||||
|
setPreviewing(false)
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ignore = false
|
||||||
|
async function loadTeams() {
|
||||||
|
setLoadingTeams(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentTeamsAll()
|
||||||
|
if (!ignore) {
|
||||||
|
setTeams(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!ignore) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!ignore) {
|
||||||
|
setLoadingTeams(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTeams()
|
||||||
|
return () => {
|
||||||
|
ignore = true
|
||||||
|
}
|
||||||
|
}, [open, t])
|
||||||
|
|
||||||
|
function updateForm(values: Partial<BatchFormState>) {
|
||||||
|
previewRequestIdRef.current += 1
|
||||||
|
setForm((current) => ({ ...current, ...values }))
|
||||||
|
setPreview(null)
|
||||||
|
setPreviewPayload(null)
|
||||||
|
setPreviewing(false)
|
||||||
|
setStep("form")
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
if (!nextOpen && busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onOpenChange(nextOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTeamSelect(value: string) {
|
||||||
|
const teamId = Number(value)
|
||||||
|
if (!Number.isFinite(teamId) || form.selectedTeamIds.includes(teamId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateForm({ selectedTeamIds: [...form.selectedTeamIds, teamId] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTeam(teamId: number) {
|
||||||
|
updateForm({
|
||||||
|
selectedTeamIds: form.selectedTeamIds.filter((selectedTeamId) => selectedTeamId !== teamId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWeekday(weekday: number) {
|
||||||
|
const nextWeekdays = selectedWeekdays.has(weekday)
|
||||||
|
? form.weekdays.filter((value) => value !== weekday)
|
||||||
|
: [...form.weekdays, weekday].sort((a, b) => a - b)
|
||||||
|
updateForm({ weekdays: nextWeekdays })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
const validationMessage = validateForm(form, t)
|
||||||
|
if (validationMessage) {
|
||||||
|
toast.error(validationMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildPayload(form)
|
||||||
|
const requestId = previewRequestIdRef.current + 1
|
||||||
|
previewRequestIdRef.current = requestId
|
||||||
|
setPreviewing(true)
|
||||||
|
try {
|
||||||
|
const data = await previewAgentTeamScheduleBatch(payload)
|
||||||
|
if (!openRef.current || previewRequestIdRef.current !== requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreview(data)
|
||||||
|
setPreviewPayload(payload)
|
||||||
|
setStep("preview")
|
||||||
|
} catch (error) {
|
||||||
|
if (openRef.current && previewRequestIdRef.current === requestId) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.previewFailed"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (openRef.current && previewRequestIdRef.current === requestId) {
|
||||||
|
setPreviewing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!preview || !previewPayload) {
|
||||||
|
toast.error(t("agentTeamSchedule.previewFirst"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (preview.conflict) {
|
||||||
|
toast.error(t("agentTeamSchedule.conflictCannotSubmit"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = previewPayload
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const data = await generateAgentTeamScheduleBatch(payload)
|
||||||
|
toast.success(t("agentTeamSchedule.batchCreated", { count: data.created }))
|
||||||
|
setPreview(null)
|
||||||
|
setPreviewPayload(null)
|
||||||
|
onOpenChange(false)
|
||||||
|
try {
|
||||||
|
await onSuccess(data.created)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? t("agentTeamSchedule.batchRefreshFailed", { message: error.message }) : t("agentTeamSchedule.batchRefreshFailedFallback"))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.generateFailed"))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
|
||||||
|
<DialogHeader className="shrink-0 px-6 pt-6">
|
||||||
|
<DialogTitle>{t("agentTeamSchedule.batch")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("agentTeamSchedule.batchDescription")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{step === "form" ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("agentTeamSchedule.team")}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<OptionCombobox
|
||||||
|
value=""
|
||||||
|
options={teamOptions}
|
||||||
|
placeholder={loadingTeams ? t("agentTeamSchedule.loadingTeams") : t("agentTeamSchedule.addTeam")}
|
||||||
|
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
|
||||||
|
emptyText={teams.length === 0 ? t("agentTeamSchedule.noTeams") : t("agentTeamSchedule.allTeamsSelected")}
|
||||||
|
disabled={loadingTeams}
|
||||||
|
onChange={handleTeamSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedTeams.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedTeams.map((team) => (
|
||||||
|
<Badge key={team.id} variant="secondary" className="gap-1 pr-1">
|
||||||
|
<span className="max-w-44 truncate">{team.name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="size-5 rounded-sm"
|
||||||
|
onClick={() => removeTeam(team.id)}
|
||||||
|
aria-label={t("agentTeamSchedule.removeTeam", { name: team.name })}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">{t("agentTeamSchedule.noSelectedTeams")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="batch-schedule-start-date">{t("agentTeamSchedule.startDate")}</Label>
|
||||||
|
<Input
|
||||||
|
id="batch-schedule-start-date"
|
||||||
|
type="date"
|
||||||
|
min={todayDateValue()}
|
||||||
|
value={form.startDate}
|
||||||
|
onChange={(event) => updateForm({ startDate: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="batch-schedule-end-date">{t("agentTeamSchedule.endDate")}</Label>
|
||||||
|
<Input
|
||||||
|
id="batch-schedule-end-date"
|
||||||
|
type="date"
|
||||||
|
min={form.startDate || todayDateValue()}
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={(event) => updateForm({ endDate: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("agentTeamSchedule.weekday")}</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{weekdayOptions.map((option) => {
|
||||||
|
const selected = selectedWeekdays.has(option.value)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={selected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => toggleWeekday(option.value)}
|
||||||
|
>
|
||||||
|
{selected ? <CheckIcon className="size-4" /> : null}
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="batch-schedule-start-time">{t("agentTeamSchedule.startTime")}</Label>
|
||||||
|
<Input
|
||||||
|
id="batch-schedule-start-time"
|
||||||
|
type="time"
|
||||||
|
value={form.startTime}
|
||||||
|
onChange={(event) => updateForm({ startTime: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="batch-schedule-end-time">{t("agentTeamSchedule.endTime")}</Label>
|
||||||
|
<Input
|
||||||
|
id="batch-schedule-end-time"
|
||||||
|
type="time"
|
||||||
|
value={form.endTime}
|
||||||
|
onChange={(event) => updateForm({ endTime: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="batch-schedule-remark">{t("agentTeamSchedule.remark")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="batch-schedule-remark"
|
||||||
|
rows={4}
|
||||||
|
placeholder={t("agentTeamSchedule.remarkPlaceholder")}
|
||||||
|
value={form.remark}
|
||||||
|
onChange={(event) => updateForm({ remark: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{hasConflict
|
||||||
|
? t("agentTeamSchedule.previewSummaryConflict", { total: preview?.total ?? 0 })
|
||||||
|
: t("agentTeamSchedule.previewSummaryReady", { total: preview?.total ?? 0 })}
|
||||||
|
</div>
|
||||||
|
{hasConflict ? (
|
||||||
|
<Badge variant="destructive">{t("agentTeamSchedule.hasConflict")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">{t("agentTeamSchedule.noConflict")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<div className="min-w-[760px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("agentTeamSchedule.team")}</TableHead>
|
||||||
|
<TableHead>{t("agentTeamSchedule.date")}</TableHead>
|
||||||
|
<TableHead>{t("agentTeamSchedule.weekday")}</TableHead>
|
||||||
|
<TableHead>{t("agentTeamSchedule.time")}</TableHead>
|
||||||
|
<TableHead>{t("agentTeamSchedule.remark")}</TableHead>
|
||||||
|
<TableHead>{t("agentTeamSchedule.status")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{preview?.items.map((item, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={`${item.teamId}-${item.startAt}-${index}`}
|
||||||
|
className={cn(
|
||||||
|
item.conflict && "bg-destructive/10 text-destructive hover:bg-destructive/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.teamId", { id: item.teamId })}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.date}</TableCell>
|
||||||
|
<TableCell>{getWeekdayLabel(item.weekday, t)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.startAt.slice(11, 16)} - {item.endAt.slice(11, 16)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-56 truncate">{item.remark || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.conflict ? (
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.conflictReason || t("agentTeamSchedule.conflictFallback")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t("agentTeamSchedule.canGenerate")}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{preview && preview.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
|
||||||
|
{t("agentTeamSchedule.emptyPreview")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mx-0 mb-0 shrink-0 border-t px-6 py-4">
|
||||||
|
{step === "preview" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep("form")}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
{t("agentTeamSchedule.backToEdit")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t("agentTeamSchedule.cancel")}
|
||||||
|
</Button>
|
||||||
|
{step === "form" ? (
|
||||||
|
<Button type="button" onClick={() => void handlePreview()} disabled={busy}>
|
||||||
|
{previewing ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
|
||||||
|
{t("agentTeamSchedule.preview")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={submitting || hasConflict || !preview || !previewPayload || preview.items.length === 0}
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
|
||||||
|
{t("agentTeamSchedule.generate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { addDays, formatWeekTitle, isSameLocalDay, startOfWeek } from "./calendar-date-range.ts"
|
||||||
|
|
||||||
|
test("builds Monday-based week range and title", () => {
|
||||||
|
const start = startOfWeek(new Date(2026, 3, 29, 14, 0, 0))
|
||||||
|
|
||||||
|
assert.equal(start.getFullYear(), 2026)
|
||||||
|
assert.equal(start.getMonth(), 3)
|
||||||
|
assert.equal(start.getDate(), 27)
|
||||||
|
assert.equal(formatWeekTitle(start), "2026-04-27 - 2026-05-03")
|
||||||
|
assert.equal(addDays(start, 7).getDate(), 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("compares local calendar dates", () => {
|
||||||
|
assert.equal(isSameLocalDay(new Date(2026, 3, 29, 0, 0), new Date(2026, 3, 29, 23, 59)), true)
|
||||||
|
assert.equal(isSameLocalDay(new Date(2026, 3, 29, 23, 59), new Date(2026, 3, 30, 0, 0)), false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export function startOfDay(date: Date) {
|
||||||
|
const ret = new Date(date)
|
||||||
|
ret.setHours(0, 0, 0, 0)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfWeek(date: Date) {
|
||||||
|
const ret = startOfDay(date)
|
||||||
|
const day = ret.getDay()
|
||||||
|
const offset = day === 0 ? -6 : 1 - day
|
||||||
|
ret.setDate(ret.getDate() + offset)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameLocalDay(a: Date, b: Date) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfMonth(date: Date) {
|
||||||
|
const ret = startOfDay(date)
|
||||||
|
ret.setDate(1)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfMonthCalendar(date: Date) {
|
||||||
|
return startOfWeek(startOfMonth(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endOfMonthCalendar(date: Date) {
|
||||||
|
const monthEnd = startOfMonth(date)
|
||||||
|
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
||||||
|
const ret = startOfWeek(monthEnd)
|
||||||
|
if (ret.getTime() < monthEnd.getTime()) {
|
||||||
|
ret.setDate(ret.getDate() + 7)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: Date, days: number) {
|
||||||
|
const ret = new Date(date)
|
||||||
|
ret.setDate(ret.getDate() + days)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMonths(date: Date, months: number) {
|
||||||
|
const ret = startOfMonth(date)
|
||||||
|
ret.setMonth(ret.getMonth() + months)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeValue(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
const hour = String(date.getHours()).padStart(2, "0")
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, "0")
|
||||||
|
const second = String(date.getSeconds()).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonthTitle(monthStart: Date) {
|
||||||
|
return `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWeekTitle(weekStart: Date) {
|
||||||
|
return `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { buildDayTimeLayout } from "./calendar-time-layout.ts"
|
||||||
|
|
||||||
|
test("scales schedule bars by the visible span of that day", () => {
|
||||||
|
const layout = buildDayTimeLayout(
|
||||||
|
[
|
||||||
|
{ id: 1, startAt: "2026-04-29 07:00:00", endAt: "2026-04-29 17:00:00" },
|
||||||
|
{ id: 2, startAt: "2026-04-29 09:00:00", endAt: "2026-04-29 12:00:00" },
|
||||||
|
{ id: 3, startAt: "2026-04-29 13:00:00", endAt: "2026-04-29 17:00:00" },
|
||||||
|
],
|
||||||
|
new Date(2026, 3, 29)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(layout.rangeLabel, "07:00 - 17:00")
|
||||||
|
assert.deepEqual(layout.items.get(1), {
|
||||||
|
leftPercent: 0,
|
||||||
|
widthPercent: 100,
|
||||||
|
startLabel: "07:00",
|
||||||
|
endLabel: "17:00",
|
||||||
|
})
|
||||||
|
assert.deepEqual(layout.items.get(2), {
|
||||||
|
leftPercent: 20,
|
||||||
|
widthPercent: 30,
|
||||||
|
startLabel: "09:00",
|
||||||
|
endLabel: "12:00",
|
||||||
|
})
|
||||||
|
assert.deepEqual(layout.items.get(3), {
|
||||||
|
leftPercent: 60,
|
||||||
|
widthPercent: 40,
|
||||||
|
startLabel: "13:00",
|
||||||
|
endLabel: "17:00",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clips cross-day schedules to the current day before scaling", () => {
|
||||||
|
const layout = buildDayTimeLayout(
|
||||||
|
[
|
||||||
|
{ id: 1, startAt: "2026-04-28 22:00:00", endAt: "2026-04-29 08:00:00" },
|
||||||
|
{ id: 2, startAt: "2026-04-29 07:00:00", endAt: "2026-04-29 17:00:00" },
|
||||||
|
],
|
||||||
|
new Date(2026, 3, 29)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(layout.rangeLabel, "00:00 - 17:00")
|
||||||
|
assert.deepEqual(layout.items.get(1), {
|
||||||
|
leftPercent: 0,
|
||||||
|
widthPercent: 47.06,
|
||||||
|
startLabel: "00:00",
|
||||||
|
endLabel: "08:00",
|
||||||
|
})
|
||||||
|
assert.deepEqual(layout.items.get(2), {
|
||||||
|
leftPercent: 41.18,
|
||||||
|
widthPercent: 58.82,
|
||||||
|
startLabel: "07:00",
|
||||||
|
endLabel: "17:00",
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const dayMs = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export type TimeLayoutSchedule = {
|
||||||
|
id: number
|
||||||
|
startAt: string
|
||||||
|
endAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeLayoutItem = {
|
||||||
|
leftPercent: number
|
||||||
|
widthPercent: number
|
||||||
|
startLabel: string
|
||||||
|
endLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DayTimeLayout = {
|
||||||
|
rangeLabel: string
|
||||||
|
items: Map<number, TimeLayoutItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDateTime(value: string) {
|
||||||
|
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/)
|
||||||
|
if (!match) {
|
||||||
|
return new Date(value)
|
||||||
|
}
|
||||||
|
return new Date(
|
||||||
|
Number(match[1]),
|
||||||
|
Number(match[2]) - 1,
|
||||||
|
Number(match[3]),
|
||||||
|
Number(match[4]),
|
||||||
|
Number(match[5]),
|
||||||
|
Number(match[6] ?? 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
const ret = new Date(date)
|
||||||
|
ret.setHours(0, 0, 0, 0)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date) {
|
||||||
|
const hour = String(date.getHours()).padStart(2, "0")
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, "0")
|
||||||
|
return `${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundPercent(value: number) {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDayTimeLayout(schedules: TimeLayoutSchedule[], day: Date): DayTimeLayout {
|
||||||
|
const dayStart = startOfDay(day)
|
||||||
|
const dayEnd = new Date(dayStart.getTime() + dayMs)
|
||||||
|
const visibleItems = schedules
|
||||||
|
.map((item) => {
|
||||||
|
const scheduleStart = parseLocalDateTime(item.startAt)
|
||||||
|
const scheduleEnd = parseLocalDateTime(item.endAt)
|
||||||
|
const visibleStart = new Date(Math.max(scheduleStart.getTime(), dayStart.getTime()))
|
||||||
|
const visibleEnd = new Date(Math.min(scheduleEnd.getTime(), dayEnd.getTime()))
|
||||||
|
return { item, visibleStart, visibleEnd }
|
||||||
|
})
|
||||||
|
.filter(({ visibleStart, visibleEnd }) => visibleEnd > visibleStart)
|
||||||
|
|
||||||
|
if (visibleItems.length === 0) {
|
||||||
|
return { rangeLabel: "", items: new Map() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeStart = new Date(Math.min(...visibleItems.map(({ visibleStart }) => visibleStart.getTime())))
|
||||||
|
const rangeEnd = new Date(Math.max(...visibleItems.map(({ visibleEnd }) => visibleEnd.getTime())))
|
||||||
|
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), 1)
|
||||||
|
const items = new Map<number, TimeLayoutItem>()
|
||||||
|
|
||||||
|
visibleItems.forEach(({ item, visibleStart, visibleEnd }) => {
|
||||||
|
items.set(item.id, {
|
||||||
|
leftPercent: roundPercent(((visibleStart.getTime() - rangeStart.getTime()) / rangeMs) * 100),
|
||||||
|
widthPercent: roundPercent(((visibleEnd.getTime() - visibleStart.getTime()) / rangeMs) * 100),
|
||||||
|
startLabel: formatTime(visibleStart),
|
||||||
|
endLabel: formatTime(visibleEnd),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
rangeLabel: `${formatTime(rangeStart)} - ${formatTime(rangeEnd)}`,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CalendarPlusIcon, GripVerticalIcon } from "lucide-react"
|
||||||
|
import { useState, type PointerEvent as ReactPointerEvent } from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminAgentTeam,
|
||||||
|
AdminAgentTeamSchedule,
|
||||||
|
CreateAdminAgentTeamSchedulePayload,
|
||||||
|
UpdateAdminAgentTeamSchedulePayload,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { cn, formatDateTime } from "@/lib/utils"
|
||||||
|
import { isSameLocalDay } from "./calendar-date-range"
|
||||||
|
import { buildDayTimeLayout } from "./calendar-time-layout"
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
const weekDayKeys = [
|
||||||
|
"weekdayShortMon",
|
||||||
|
"weekdayShortTue",
|
||||||
|
"weekdayShortWed",
|
||||||
|
"weekdayShortThu",
|
||||||
|
"weekdayShortFri",
|
||||||
|
"weekdayShortSat",
|
||||||
|
"weekdayShortSun",
|
||||||
|
] as const
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000
|
||||||
|
const minuteMs = 60 * 1000
|
||||||
|
const minDurationMs = 15 * minuteMs
|
||||||
|
|
||||||
|
type ScheduleCalendarProps = {
|
||||||
|
variant?: "month" | "week"
|
||||||
|
monthStart: Date
|
||||||
|
calendarStart: Date
|
||||||
|
calendarEnd: Date
|
||||||
|
teams: AdminAgentTeam[]
|
||||||
|
schedules: AdminAgentTeamSchedule[]
|
||||||
|
loading: boolean
|
||||||
|
savingId: number | null
|
||||||
|
onCreate: (defaults: Partial<CreateAdminAgentTeamSchedulePayload>) => void
|
||||||
|
onEdit: (item: AdminAgentTeamSchedule) => void
|
||||||
|
onMove: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
|
||||||
|
onResize: (payload: UpdateAdminAgentTeamSchedulePayload) => Promise<void>
|
||||||
|
t: TFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragState =
|
||||||
|
| {
|
||||||
|
type: "move"
|
||||||
|
item: AdminAgentTeamSchedule
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
moved: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "resize"
|
||||||
|
edge: "start" | "end"
|
||||||
|
item: AdminAgentTeamSchedule
|
||||||
|
moved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type InteractionPreview = {
|
||||||
|
itemId: number
|
||||||
|
date: string | null
|
||||||
|
label: string
|
||||||
|
invalid: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number) {
|
||||||
|
const ret = new Date(date)
|
||||||
|
ret.setDate(ret.getDate() + days)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
const ret = new Date(date)
|
||||||
|
ret.setHours(0, 0, 0, 0)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDateTime(value: string) {
|
||||||
|
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/)
|
||||||
|
if (!match) {
|
||||||
|
return new Date(value)
|
||||||
|
}
|
||||||
|
return new Date(
|
||||||
|
Number(match[1]),
|
||||||
|
Number(match[2]) - 1,
|
||||||
|
Number(match[3]),
|
||||||
|
Number(match[4]),
|
||||||
|
Number(match[5]),
|
||||||
|
Number(match[6] ?? 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeValue(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
const hour = String(date.getHours()).padStart(2, "0")
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, "0")
|
||||||
|
const second = String(date.getSeconds()).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateTime: string) {
|
||||||
|
return formatDateTime(dateTime).slice(11, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToQuarterHour(date: Date) {
|
||||||
|
const ret = new Date(date)
|
||||||
|
ret.setSeconds(0, 0)
|
||||||
|
const minutes = ret.getHours() * 60 + ret.getMinutes()
|
||||||
|
const rounded = Math.round(minutes / 15) * 15
|
||||||
|
ret.setHours(Math.floor(rounded / 60), rounded % 60, 0, 0)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointerDateInCell(event: PointerEvent, cell: Element) {
|
||||||
|
const rect = cell.getBoundingClientRect()
|
||||||
|
const day = startOfDay(parseLocalDateTime(`${cell.getAttribute("data-date")} 00:00:00`))
|
||||||
|
const ratio = clamp((event.clientX - rect.left) / rect.width, 0, 1)
|
||||||
|
return roundToQuarterHour(new Date(day.getTime() + ratio * dayMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDropCell(event: PointerEvent) {
|
||||||
|
const element = document.elementFromPoint(event.clientX, event.clientY)
|
||||||
|
return element?.closest("[data-schedule-cell]")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHistoricalDay(day: Date) {
|
||||||
|
return startOfDay(day).getTime() < startOfDay(new Date()).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMovePayload(item: AdminAgentTeamSchedule, date: string): UpdateAdminAgentTeamSchedulePayload {
|
||||||
|
const originalStart = parseLocalDateTime(item.startAt)
|
||||||
|
const originalEnd = parseLocalDateTime(item.endAt)
|
||||||
|
const duration = originalEnd.getTime() - originalStart.getTime()
|
||||||
|
const nextDay = startOfDay(parseLocalDateTime(`${date} 00:00:00`))
|
||||||
|
const nextStart = new Date(nextDay)
|
||||||
|
nextStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0)
|
||||||
|
const nextEnd = new Date(nextStart.getTime() + duration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
teamId: item.teamId,
|
||||||
|
startAt: formatDateTimeValue(nextStart),
|
||||||
|
endAt: formatDateTimeValue(nextEnd),
|
||||||
|
remark: item.remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResizePayload(
|
||||||
|
item: AdminAgentTeamSchedule,
|
||||||
|
edge: "start" | "end",
|
||||||
|
nextTime: Date
|
||||||
|
): UpdateAdminAgentTeamSchedulePayload | null {
|
||||||
|
const startAt = parseLocalDateTime(item.startAt)
|
||||||
|
const endAt = parseLocalDateTime(item.endAt)
|
||||||
|
if (!isSameLocalDay(startAt, nextTime)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (edge === "start") {
|
||||||
|
if (endAt.getTime() - nextTime.getTime() < minDurationMs) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
startAt.setTime(nextTime.getTime())
|
||||||
|
} else {
|
||||||
|
if (nextTime.getTime() - startAt.getTime() < minDurationMs) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
endAt.setTime(nextTime.getTime())
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
teamId: item.teamId,
|
||||||
|
startAt: formatDateTimeValue(startAt),
|
||||||
|
endAt: formatDateTimeValue(endAt),
|
||||||
|
remark: item.remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewFromPayload(
|
||||||
|
itemId: number,
|
||||||
|
date: string | null,
|
||||||
|
payload: UpdateAdminAgentTeamSchedulePayload | null,
|
||||||
|
point: { x: number; y: number },
|
||||||
|
fallbackLabel: string
|
||||||
|
): InteractionPreview {
|
||||||
|
return {
|
||||||
|
itemId,
|
||||||
|
date,
|
||||||
|
label: payload ? `${formatTime(payload.startAt)} - ${formatTime(payload.endAt)}` : fallbackLabel,
|
||||||
|
invalid: !payload,
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectsDay(item: AdminAgentTeamSchedule, day: Date) {
|
||||||
|
const dayStart = startOfDay(day)
|
||||||
|
const dayEnd = addDays(dayStart, 1)
|
||||||
|
const scheduleStart = parseLocalDateTime(item.startAt)
|
||||||
|
const scheduleEnd = parseLocalDateTime(item.endAt)
|
||||||
|
return scheduleStart < dayEnd && scheduleEnd > dayStart
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCalendarDays(calendarStart: Date, calendarEnd: Date) {
|
||||||
|
const days: Date[] = []
|
||||||
|
for (let current = startOfDay(calendarStart); current < calendarEnd; current = addDays(current, 1)) {
|
||||||
|
days.push(current)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleCalendar({
|
||||||
|
variant = "month",
|
||||||
|
monthStart,
|
||||||
|
calendarStart,
|
||||||
|
calendarEnd,
|
||||||
|
teams,
|
||||||
|
schedules,
|
||||||
|
loading,
|
||||||
|
savingId,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onMove,
|
||||||
|
onResize,
|
||||||
|
t,
|
||||||
|
}: ScheduleCalendarProps) {
|
||||||
|
const days = buildCalendarDays(calendarStart, calendarEnd)
|
||||||
|
const defaultTeamID = teams[0]?.id ?? 0
|
||||||
|
const [interactionPreview, setInteractionPreview] = useState<InteractionPreview | null>(null)
|
||||||
|
|
||||||
|
function handleBlankCellClick(day: Date) {
|
||||||
|
const startAt = new Date(day)
|
||||||
|
startAt.setHours(9, 0, 0, 0)
|
||||||
|
const endAt = new Date(day)
|
||||||
|
endAt.setHours(18, 0, 0, 0)
|
||||||
|
onCreate({
|
||||||
|
teamId: defaultTeamID || undefined,
|
||||||
|
startAt: formatDateTimeValue(startAt),
|
||||||
|
endAt: formatDateTimeValue(endAt),
|
||||||
|
remark: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInteractionPreview(state: DragState, pointerEvent: PointerEvent): InteractionPreview | null {
|
||||||
|
const cell = getDropCell(pointerEvent)
|
||||||
|
if (!cell) {
|
||||||
|
return {
|
||||||
|
itemId: state.item.id,
|
||||||
|
date: null,
|
||||||
|
label: t("agentTeamSchedule.dropInsideCalendar"),
|
||||||
|
invalid: true,
|
||||||
|
x: pointerEvent.clientX,
|
||||||
|
y: pointerEvent.clientY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = cell.getAttribute("data-date")
|
||||||
|
if (!date) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (isHistoricalDay(parseLocalDateTime(`${date} 00:00:00`))) {
|
||||||
|
return {
|
||||||
|
itemId: state.item.id,
|
||||||
|
date,
|
||||||
|
label: t("agentTeamSchedule.historyReadonly"),
|
||||||
|
invalid: true,
|
||||||
|
x: pointerEvent.clientX,
|
||||||
|
y: pointerEvent.clientY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = { x: pointerEvent.clientX, y: pointerEvent.clientY }
|
||||||
|
if (state.type === "move") {
|
||||||
|
return buildPreviewFromPayload(state.item.id, date, buildMovePayload(state.item, date), point, t("agentTeamSchedule.cannotMoveHere"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell))
|
||||||
|
return buildPreviewFromPayload(state.item.id, date, payload, point, t("agentTeamSchedule.resizeInvalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPointerInteraction(
|
||||||
|
target: HTMLElement,
|
||||||
|
pointerId: number,
|
||||||
|
handlePointerMove: (moveEvent: PointerEvent) => void,
|
||||||
|
handlePointerUp: (upEvent: PointerEvent) => void,
|
||||||
|
handlePointerCancel: () => void
|
||||||
|
) {
|
||||||
|
if (target.hasPointerCapture(pointerId)) {
|
||||||
|
target.releasePointerCapture(pointerId)
|
||||||
|
}
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove)
|
||||||
|
window.removeEventListener("pointerup", handlePointerUp)
|
||||||
|
window.removeEventListener("pointercancel", handlePointerCancel)
|
||||||
|
setInteractionPreview(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: ReactPointerEvent, item: AdminAgentTeamSchedule, type: DragState["type"], edge?: "start" | "end") {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
if (target.isConnected) {
|
||||||
|
try {
|
||||||
|
target.setPointerCapture(event.pointerId)
|
||||||
|
} catch {
|
||||||
|
// Some synthetic/browser edge events do not expose a capturable pointer id.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const state: DragState =
|
||||||
|
type === "resize"
|
||||||
|
? { type: "resize", edge: edge ?? "end", item, moved: false }
|
||||||
|
: { type: "move", item, startX: event.clientX, startY: event.clientY, moved: false }
|
||||||
|
|
||||||
|
function handlePointerMove(moveEvent: PointerEvent) {
|
||||||
|
if (state.type === "move") {
|
||||||
|
if (Math.abs(moveEvent.clientX - state.startX) > 4 || Math.abs(moveEvent.clientY - state.startY) > 4) {
|
||||||
|
state.moved = true
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.moved = true
|
||||||
|
}
|
||||||
|
setInteractionPreview(buildInteractionPreview(state, moveEvent))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePointerUp(upEvent: PointerEvent) {
|
||||||
|
cleanupPointerInteraction(target, event.pointerId, handlePointerMove, handlePointerUp, handlePointerCancel)
|
||||||
|
if (!state.moved) {
|
||||||
|
onEdit(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cell = getDropCell(upEvent)
|
||||||
|
if (!cell) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.type === "move") {
|
||||||
|
const date = cell.getAttribute("data-date")
|
||||||
|
if (!date) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isHistoricalDay(parseLocalDateTime(`${date} 00:00:00`))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await onMove(buildMovePayload(item, date))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = buildResizePayload(item, state.edge, getPointerDateInCell(upEvent, cell))
|
||||||
|
if (payload) {
|
||||||
|
await onResize(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerCancel() {
|
||||||
|
cleanupPointerInteraction(target, event.pointerId, handlePointerMove, handlePointerUp, handlePointerCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handlePointerMove)
|
||||||
|
window.addEventListener("pointerup", handlePointerUp)
|
||||||
|
window.addEventListener("pointercancel", handlePointerCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teams.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-64 items-center justify-center rounded-lg border bg-background text-sm text-muted-foreground">
|
||||||
|
{t("agentTeamSchedule.noTeamsCalendar")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDayCell(day: Date, dayIndex: number, options?: { inMonth?: boolean; className?: string; showFullDate?: boolean }) {
|
||||||
|
const date = formatDate(day)
|
||||||
|
const inMonth = options?.inMonth ?? day.getMonth() === monthStart.getMonth()
|
||||||
|
const historical = isHistoricalDay(day)
|
||||||
|
const today = isSameLocalDay(day, new Date())
|
||||||
|
const daySchedules = schedules
|
||||||
|
.filter((item) => intersectsDay(item, day))
|
||||||
|
.sort((a, b) => parseLocalDateTime(a.startAt).getTime() - parseLocalDateTime(b.startAt).getTime())
|
||||||
|
const dayTimeLayout = buildDayTimeLayout(daySchedules, day)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
data-schedule-cell
|
||||||
|
data-date={date}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
"border-l border-t bg-background p-2 text-left outline-none transition-colors first:border-l-0 hover:bg-muted/20 focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
dayIndex % 7 === 0 && "border-l-0",
|
||||||
|
!inMonth && "bg-muted/20 text-muted-foreground",
|
||||||
|
historical && "cursor-not-allowed bg-muted/30 hover:bg-muted/30",
|
||||||
|
interactionPreview?.date === date &&
|
||||||
|
(interactionPreview.invalid ? "bg-destructive/5 ring-2 ring-destructive/30" : "bg-primary/5 ring-2 ring-primary/35"),
|
||||||
|
options?.className
|
||||||
|
)}
|
||||||
|
onClick={(event) => {
|
||||||
|
if ((event.target as HTMLElement).closest("[data-schedule-block]")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (historical) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleBlankCellClick(day)
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (historical) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault()
|
||||||
|
handleBlankCellClick(day)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className={cn("text-sm font-medium", !inMonth && "text-muted-foreground")}>
|
||||||
|
{options?.showFullDate ? date : day.getDate()}
|
||||||
|
</div>
|
||||||
|
{dayTimeLayout.rangeLabel ? (
|
||||||
|
<div className="mt-0.5 text-[10px] leading-none text-muted-foreground">{dayTimeLayout.rangeLabel}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{today ? (
|
||||||
|
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground">
|
||||||
|
{t("agentTeamSchedule.today")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{historical ? null : <CalendarPlusIcon className="size-3.5 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{daySchedules.slice(0, 5).map((item) => {
|
||||||
|
const teamName = item.teamName || teams.find((team) => team.id === item.teamId)?.name || t("agentTeamSchedule.teamFallback", { id: item.teamId })
|
||||||
|
const busy = savingId === item.id
|
||||||
|
const active = interactionPreview?.itemId === item.id
|
||||||
|
const timeLayout = dayTimeLayout.items.get(item.id)
|
||||||
|
const readonly = historical || isHistoricalDay(parseLocalDateTime(item.startAt))
|
||||||
|
return (
|
||||||
|
<div key={`${item.id}-${date}`} className="relative h-10 rounded-sm bg-muted/25">
|
||||||
|
<div
|
||||||
|
data-schedule-block
|
||||||
|
data-time-left={timeLayout?.leftPercent ?? 0}
|
||||||
|
data-time-width={timeLayout?.widthPercent ?? 100}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 cursor-grab overflow-hidden rounded-md border border-primary/20 bg-primary/10 px-2 py-1.5 pl-4 pr-4 text-primary shadow-sm outline-none transition active:cursor-grabbing",
|
||||||
|
active && "scale-[0.98] border-primary/50 bg-primary/15 opacity-80 ring-2 ring-primary/30",
|
||||||
|
readonly && "cursor-not-allowed opacity-60",
|
||||||
|
busy && "pointer-events-none opacity-60"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${timeLayout?.leftPercent ?? 0}%`,
|
||||||
|
width: `${timeLayout?.widthPercent ?? 100}%`,
|
||||||
|
minWidth: 34,
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (readonly) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlePointerDown(event, item, "move")
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (readonly) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault()
|
||||||
|
onEdit(item)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 flex h-full w-3 cursor-ew-resize items-center justify-center bg-primary/15"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (readonly) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlePointerDown(event, item, "resize", "start")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="size-3" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 flex h-full w-3 cursor-ew-resize items-center justify-center bg-primary/15"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (readonly) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlePointerDown(event, item, "resize", "end")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="size-3" />
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs font-medium">{teamName}</div>
|
||||||
|
<div className="truncate text-xs">
|
||||||
|
{timeLayout ? `${timeLayout.startLabel} - ${timeLayout.endLabel}` : `${formatTime(item.startAt)} - ${formatTime(item.endAt)}`}
|
||||||
|
</div>
|
||||||
|
{item.remark ? <div className="truncate text-[11px] text-primary/80">{item.remark}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{daySchedules.length > 5 ? (
|
||||||
|
<div className="text-xs text-muted-foreground">{t("agentTeamSchedule.moreItems", { count: daySchedules.length - 5 })}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "week") {
|
||||||
|
return (
|
||||||
|
<div className="min-w-[760px] overflow-hidden rounded-lg border bg-background">
|
||||||
|
<div className={cn("divide-y", loading && "opacity-60")}>
|
||||||
|
{days.map((day, dayIndex) => {
|
||||||
|
const date = formatDate(day)
|
||||||
|
return (
|
||||||
|
<div key={date} className="grid grid-cols-[112px_minmax(0,1fr)]">
|
||||||
|
<div className="border-r bg-muted/40 px-3 py-3 text-sm font-medium">
|
||||||
|
<div>{t(`agentTeamSchedule.${weekDayKeys[dayIndex] ?? "weekdayShortMon"}`)}</div>
|
||||||
|
<div className="mt-1 text-xs font-normal text-muted-foreground">{date.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
{renderDayCell(day, dayIndex, {
|
||||||
|
inMonth: true,
|
||||||
|
className: "min-h-24 border-l-0 border-t-0",
|
||||||
|
showFullDate: false,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{interactionPreview ? (
|
||||||
|
<div
|
||||||
|
data-schedule-preview
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none fixed z-50 rounded-md border bg-popover px-3 py-2 text-xs font-medium text-popover-foreground shadow-md",
|
||||||
|
interactionPreview.invalid && "border-destructive/40 bg-destructive text-destructive-foreground"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: interactionPreview.x + 12,
|
||||||
|
top: interactionPreview.y + 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{interactionPreview.label}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-[960px] overflow-hidden rounded-lg border bg-background">
|
||||||
|
<div className="grid grid-cols-7 border-b bg-muted/40">
|
||||||
|
{weekDayKeys.map((key) => (
|
||||||
|
<div key={key} className="flex h-10 items-center justify-center border-l first:border-l-0 text-sm font-medium">
|
||||||
|
{t(`agentTeamSchedule.${key}`)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={cn("grid grid-cols-7", loading && "opacity-60")}>
|
||||||
|
{days.map((day, dayIndex) => renderDayCell(day, dayIndex, { className: "min-h-36" }))}
|
||||||
|
</div>
|
||||||
|
{interactionPreview ? (
|
||||||
|
<div
|
||||||
|
data-schedule-preview
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none fixed z-50 rounded-md border bg-popover px-3 py-2 text-xs font-medium text-popover-foreground shadow-md",
|
||||||
|
interactionPreview.invalid && "border-destructive/40 bg-destructive text-destructive-foreground"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: interactionPreview.x + 12,
|
||||||
|
top: interactionPreview.y + 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{interactionPreview.label}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"use client"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { Controller, type Resolver, useForm } from "react-hook-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type AdminAgentTeamSchedule,
|
||||||
|
type CreateAdminAgentTeamSchedulePayload,
|
||||||
|
fetchAgentTeamSchedule,
|
||||||
|
fetchAgentTeamsAll,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type ScheduleEditDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
saving: boolean
|
||||||
|
itemId: number | null
|
||||||
|
defaultValues?: Partial<CreateAdminAgentTeamSchedulePayload> | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (payload: CreateAdminAgentTeamSchedulePayload) => Promise<void>
|
||||||
|
onDelete?: (id: number) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
teamId: "",
|
||||||
|
startAt: "",
|
||||||
|
endAt: "",
|
||||||
|
remark: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
teamId: string
|
||||||
|
startAt: string
|
||||||
|
endAt: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEditFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
teamId: z.string().trim().regex(/^\d+$/, t("agentTeamSchedule.teamRequired")),
|
||||||
|
startAt: z.string().trim().min(1, t("agentTeamSchedule.startRequired")),
|
||||||
|
endAt: z.string().trim().min(1, t("agentTeamSchedule.endRequired")),
|
||||||
|
remark: z.string().trim(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
const startAt = parseDateTimeLocal(value.startAt)
|
||||||
|
const endAt = parseDateTimeLocal(value.endAt)
|
||||||
|
if (!startAt || !endAt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!endAt || endAt <= startAt) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["endAt"],
|
||||||
|
message: t("agentTeamSchedule.endAfterStart"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isSameLocalDay(startAt, endAt)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["endAt"],
|
||||||
|
message: t("agentTeamSchedule.singleDayOnly"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (startAt < startOfLocalDay(new Date())) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["startAt"],
|
||||||
|
message: t("agentTeamSchedule.historyReadonly"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeLocal(value?: string) {
|
||||||
|
if (!value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.replace(" ", "T").slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTimeLocal(value: string) {
|
||||||
|
const ret = new Date(value)
|
||||||
|
return Number.isNaN(ret.getTime()) ? null : ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfLocalDay(value: Date) {
|
||||||
|
const ret = new Date(value)
|
||||||
|
ret.setHours(0, 0, 0, 0)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameLocalDay(a: Date, b: Date) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayDateTimeLocalMin() {
|
||||||
|
const today = startOfLocalDay(new Date())
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(today.getDate()).padStart(2, "0")
|
||||||
|
return `${today.getFullYear()}-${month}-${day}T00:00`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm(item: AdminAgentTeamSchedule | null, defaultValues?: Partial<CreateAdminAgentTeamSchedulePayload> | null): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return {
|
||||||
|
teamId: defaultValues?.teamId ? String(defaultValues.teamId) : emptyForm.teamId,
|
||||||
|
startAt: toDateTimeLocal(defaultValues?.startAt),
|
||||||
|
endAt: toDateTimeLocal(defaultValues?.endAt),
|
||||||
|
remark: defaultValues?.remark ?? emptyForm.remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
teamId: String(item.teamId),
|
||||||
|
startAt: toDateTimeLocal(item.startAt),
|
||||||
|
endAt: toDateTimeLocal(item.endAt),
|
||||||
|
remark: item.remark || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: EditForm): CreateAdminAgentTeamSchedulePayload {
|
||||||
|
return {
|
||||||
|
teamId: Number(form.teamId),
|
||||||
|
startAt: form.startAt.trim(),
|
||||||
|
endAt: form.endAt.trim(),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
defaultValues,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
}: ScheduleEditDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{open ? (
|
||||||
|
<ScheduleEditDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
itemId={itemId}
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
saving={saving}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduleEditDialogBodyProps = Omit<ScheduleEditDialogProps, "open">
|
||||||
|
|
||||||
|
function ScheduleEditDialogBody({
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
defaultValues,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
}: ScheduleEditDialogBodyProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const loadOptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const teamsData = await fetchAgentTeamsAll()
|
||||||
|
setTeams(teamsData)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadOptionsFailed"))
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
const editFormSchema = useMemo(() => createEditFormSchema(t), [t])
|
||||||
|
const editFormResolver = useMemo(
|
||||||
|
() => zodResolver(editFormSchema) as Resolver<EditForm>,
|
||||||
|
[editFormSchema],
|
||||||
|
)
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver: editFormResolver,
|
||||||
|
defaultValues: emptyForm,
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = form
|
||||||
|
const minDateTime = todayDateTimeLocalMin()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!itemId) {
|
||||||
|
reset(buildForm(null, defaultValues))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentTeamSchedule(itemId)
|
||||||
|
reset(buildForm(data))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadDetailFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDetail()
|
||||||
|
}, [defaultValues, itemId, reset, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOptions()
|
||||||
|
}, [loadOptions])
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
await onSubmit(buildPayload(values))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
|
||||||
|
<DialogHeader className="px-6 pt-6">
|
||||||
|
<DialogTitle>{itemId ? t("agentTeamSchedule.editTitle") : t("agentTeamSchedule.createTitle")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-muted-foreground">{t("agentTeamSchedule.loading")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Field data-invalid={!!errors.teamId}>
|
||||||
|
<FieldLabel>{t("agentTeamSchedule.team")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="teamId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={field.value}
|
||||||
|
options={teams.map((team) => ({
|
||||||
|
value: String(team.id),
|
||||||
|
label: team.name,
|
||||||
|
}))}
|
||||||
|
placeholder={t("agentTeamSchedule.teamRequired")}
|
||||||
|
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
|
||||||
|
emptyText={t("agentTeamSchedule.emptyTeam")}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.teamId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.startAt}>
|
||||||
|
<FieldLabel htmlFor="agent-team-schedule-start-at">{t("agentTeamSchedule.startTime")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input id="agent-team-schedule-start-at" type="datetime-local" min={minDateTime} {...register("startAt")} />
|
||||||
|
<FieldError errors={[errors.startAt]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.endAt}>
|
||||||
|
<FieldLabel htmlFor="agent-team-schedule-end-at">{t("agentTeamSchedule.endTime")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input id="agent-team-schedule-end-at" type="datetime-local" min={minDateTime} {...register("endAt")} />
|
||||||
|
<FieldError errors={[errors.endAt]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="agent-team-schedule-remark">{t("agentTeamSchedule.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea id="agent-team-schedule-remark" rows={4} placeholder={t("agentTeamSchedule.remarkPlaceholder")} {...register("remark")} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||||
|
{itemId && onDelete ? (
|
||||||
|
<Button type="button" variant="destructive" onClick={() => void onDelete(itemId)} disabled={saving}>
|
||||||
|
{t("agentTeamSchedule.delete")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
|
{t("agentTeamSchedule.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving || loading}>
|
||||||
|
{saving ? t("agentTeamSchedule.saving") : t("agentTeamSchedule.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
494
web/app/(dashboard)/dashboard/agent-team-schedules/page.tsx
Normal file
494
web/app/(dashboard)/dashboard/agent-team-schedules/page.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
CalendarDaysIcon,
|
||||||
|
CalendarRangeIcon,
|
||||||
|
CalendarSearchIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
LayersIcon,
|
||||||
|
ListIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardCrudPage,
|
||||||
|
type DashboardCrudColumn,
|
||||||
|
type DashboardCrudFilter,
|
||||||
|
} from "@/components/dashboard/crud"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ButtonGroup } from "@/components/ui/button-group"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import {
|
||||||
|
createAgentTeamSchedule,
|
||||||
|
deleteAgentTeamSchedule,
|
||||||
|
fetchAgentTeamScheduleCalendar,
|
||||||
|
fetchAgentTeamSchedules,
|
||||||
|
fetchAgentTeamsAll,
|
||||||
|
updateAgentTeamSchedule,
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type AdminAgentTeamSchedule,
|
||||||
|
type CreateAdminAgentTeamSchedulePayload,
|
||||||
|
type UpdateAdminAgentTeamSchedulePayload,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { BatchScheduleDialog } from "./_components/batch-schedule-dialog"
|
||||||
|
import { ScheduleCalendar } from "./_components/calendar"
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
formatDateTimeValue,
|
||||||
|
formatWeekTitle,
|
||||||
|
startOfDay,
|
||||||
|
startOfMonth,
|
||||||
|
startOfMonthCalendar,
|
||||||
|
startOfWeek,
|
||||||
|
endOfMonthCalendar,
|
||||||
|
} from "./_components/calendar-date-range"
|
||||||
|
import { EditDialog } from "./_components/edit"
|
||||||
|
|
||||||
|
type ViewMode = "month" | "week" | "list"
|
||||||
|
|
||||||
|
function parseLocalDateTime(value: string) {
|
||||||
|
const ret = new Date(value.replace(" ", "T"))
|
||||||
|
return Number.isNaN(ret.getTime()) ? null : ret
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHistoricalSchedule(item: AdminAgentTeamSchedule) {
|
||||||
|
const startAt = parseLocalDateTime(item.startAt)
|
||||||
|
return !!startAt && startAt < startOfDay(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardAgentTeamSchedulesPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("month")
|
||||||
|
const [teamFilterInput, setTeamFilterInput] = useState("all")
|
||||||
|
const [teamFilter, setTeamFilter] = useState("all")
|
||||||
|
const [listReloadKey, setListReloadKey] = useState(0)
|
||||||
|
const [monthStart, setMonthStart] = useState(() => startOfMonth(new Date()))
|
||||||
|
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()))
|
||||||
|
const [calendarLoading, setCalendarLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
|
||||||
|
const [editingItem, setEditingItem] = useState<AdminAgentTeamSchedule | null>(null)
|
||||||
|
const [dialogDefaults, setDialogDefaults] = useState<Partial<CreateAdminAgentTeamSchedulePayload> | null>(null)
|
||||||
|
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||||
|
const [calendarItems, setCalendarItems] = useState<AdminAgentTeamSchedule[]>([])
|
||||||
|
|
||||||
|
const visibleTeams = useMemo(() => {
|
||||||
|
if (teamFilter === "all") {
|
||||||
|
return teams
|
||||||
|
}
|
||||||
|
return teams.filter((team) => String(team.id) === teamFilter)
|
||||||
|
}, [teamFilter, teams])
|
||||||
|
|
||||||
|
const refreshing = calendarLoading
|
||||||
|
|
||||||
|
const listFilters = useMemo<DashboardCrudFilter[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "teamId",
|
||||||
|
label: t("agentTeamSchedule.filterTeam"),
|
||||||
|
defaultValue: teamFilter,
|
||||||
|
allValue: "all",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "all", label: t("agentTeamSchedule.allTeams") },
|
||||||
|
...teams.map((team) => ({ value: String(team.id), label: team.name })),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, teamFilter, teams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const listColumns = useMemo<DashboardCrudColumn<AdminAgentTeamSchedule>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "team",
|
||||||
|
label: t("agentTeamSchedule.team"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">
|
||||||
|
{item.teamName || t("agentTeamSchedule.teamFallback", { id: item.teamId })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("agentTeamSchedule.teamId", { id: item.teamId })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "timeRange",
|
||||||
|
label: t("agentTeamSchedule.timeRange"),
|
||||||
|
render: (item) => (
|
||||||
|
<>
|
||||||
|
<div className="text-sm">{formatDateTime(item.startAt)}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{formatDateTime(item.endAt)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadCalendarData = useCallback(async () => {
|
||||||
|
setCalendarLoading(true)
|
||||||
|
const rangeStart = viewMode === "week" ? weekStart : startOfMonthCalendar(monthStart)
|
||||||
|
const rangeEnd = viewMode === "week" ? addDays(weekStart, 7) : endOfMonthCalendar(monthStart)
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentTeamScheduleCalendar({
|
||||||
|
startAt: formatDateTimeValue(rangeStart),
|
||||||
|
endAt: formatDateTimeValue(rangeEnd),
|
||||||
|
teamId: teamFilter === "all" ? undefined : teamFilter,
|
||||||
|
})
|
||||||
|
setCalendarItems(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadCalendarFailed"))
|
||||||
|
} finally {
|
||||||
|
setCalendarLoading(false)
|
||||||
|
}
|
||||||
|
}, [monthStart, t, teamFilter, viewMode, weekStart])
|
||||||
|
|
||||||
|
const loadTeams = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentTeamsAll()
|
||||||
|
setTeams(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed"))
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const refreshActiveView = useCallback(async () => {
|
||||||
|
await loadCalendarData()
|
||||||
|
if (viewMode === "list") {
|
||||||
|
setListReloadKey((value) => value + 1)
|
||||||
|
}
|
||||||
|
}, [loadCalendarData, viewMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCalendarData()
|
||||||
|
}, [loadCalendarData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTeams()
|
||||||
|
}, [loadTeams])
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
setTeamFilter(teamFilterInput)
|
||||||
|
setListReloadKey((value) => value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog(defaults?: Partial<CreateAdminAgentTeamSchedulePayload>) {
|
||||||
|
setEditingItem(null)
|
||||||
|
setDialogDefaults(defaults ?? null)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(item: AdminAgentTeamSchedule) {
|
||||||
|
if (isHistoricalSchedule(item)) {
|
||||||
|
toast.error(t("agentTeamSchedule.historyReadonly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDialogDefaults(null)
|
||||||
|
setEditingItem(item)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogOpenChange(open: boolean) {
|
||||||
|
if (saving) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setEditingItem(null)
|
||||||
|
setDialogDefaults(null)
|
||||||
|
}
|
||||||
|
setDialogOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: CreateAdminAgentTeamSchedulePayload) {
|
||||||
|
if (saving) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateAgentTeamSchedule({ id: editingItem.id, ...payload })
|
||||||
|
toast.success(t("agentTeamSchedule.updated"))
|
||||||
|
} else {
|
||||||
|
await createAgentTeamSchedule(payload)
|
||||||
|
toast.success(t("agentTeamSchedule.created"))
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingItem(null)
|
||||||
|
setDialogDefaults(null)
|
||||||
|
await refreshActiveView()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.saveFailed"))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchSuccess() {
|
||||||
|
await refreshActiveView()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteById(id: number) {
|
||||||
|
setActionLoadingId(id)
|
||||||
|
try {
|
||||||
|
await deleteAgentTeamSchedule(id)
|
||||||
|
toast.success(t("agentTeamSchedule.deleted"))
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingItem(null)
|
||||||
|
setDialogDefaults(null)
|
||||||
|
await refreshActiveView()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.deleteFailed"))
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) {
|
||||||
|
const startAt = parseLocalDateTime(payload.startAt)
|
||||||
|
if (startAt && startAt < startOfDay(new Date())) {
|
||||||
|
toast.error(t("agentTeamSchedule.historyReadonly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActionLoadingId(payload.id)
|
||||||
|
try {
|
||||||
|
await updateAgentTeamSchedule(payload)
|
||||||
|
toast.success(t("agentTeamSchedule.updated"))
|
||||||
|
await loadCalendarData()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.updateFailed"))
|
||||||
|
await loadCalendarData()
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToToday() {
|
||||||
|
const today = new Date()
|
||||||
|
setMonthStart(startOfMonth(today))
|
||||||
|
setWeekStart(startOfWeek(today))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4 lg:p-6">
|
||||||
|
<div className="shrink-0 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "month" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("month")}
|
||||||
|
>
|
||||||
|
<CalendarDaysIcon />
|
||||||
|
{t("agentTeamSchedule.month")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "week" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("week")}
|
||||||
|
>
|
||||||
|
<CalendarRangeIcon />
|
||||||
|
{t("agentTeamSchedule.week")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
>
|
||||||
|
<ListIcon />
|
||||||
|
{t("agentTeamSchedule.list")}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
{viewMode === "month" ? (
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, -1))} aria-label={t("agentTeamSchedule.prevMonth")}>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setMonthStart(startOfMonth(new Date()))}>
|
||||||
|
{t("agentTeamSchedule.thisMonth")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon-sm" onClick={() => setMonthStart(addMonths(monthStart, 1))} aria-label={t("agentTeamSchedule.nextMonth")}>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
) : null}
|
||||||
|
{viewMode === "week" ? (
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, -7))} aria-label={t("agentTeamSchedule.prevWeek")}>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date()))}>
|
||||||
|
{t("agentTeamSchedule.thisWeek")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon-sm" onClick={() => setWeekStart(addDays(weekStart, 7))} aria-label={t("agentTeamSchedule.nextWeek")}>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
) : null}
|
||||||
|
{viewMode === "month" ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("agentTeamSchedule.monthTitle", {
|
||||||
|
year: monthStart.getFullYear(),
|
||||||
|
month: String(monthStart.getMonth() + 1).padStart(2, "0"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{viewMode === "week" ? (
|
||||||
|
<div className="text-sm text-muted-foreground">{formatWeekTitle(weekStart)}</div>
|
||||||
|
) : null}
|
||||||
|
{viewMode !== "list" ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={goToToday}>
|
||||||
|
<CalendarSearchIcon />
|
||||||
|
{t("agentTeamSchedule.today")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:justify-end">
|
||||||
|
<div className="w-full sm:w-48">
|
||||||
|
<OptionCombobox
|
||||||
|
value={teamFilterInput}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: t("agentTeamSchedule.allTeams") },
|
||||||
|
...teams.map((team) => ({ value: String(team.id), label: team.name })),
|
||||||
|
]}
|
||||||
|
placeholder={t("agentTeamSchedule.filterTeam")}
|
||||||
|
searchPlaceholder={t("agentTeamSchedule.searchTeam")}
|
||||||
|
emptyText={t("agentTeamSchedule.emptyTeam")}
|
||||||
|
onChange={(value) => setTeamFilterInput(value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={applyFilters} disabled={refreshing}>
|
||||||
|
<SearchIcon />
|
||||||
|
{t("agentTeamSchedule.query")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void refreshActiveView()}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} />
|
||||||
|
{t("agentTeamSchedule.refresh")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setBatchDialogOpen(true)}>
|
||||||
|
<LayersIcon />
|
||||||
|
{t("agentTeamSchedule.batch")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => openCreateDialog()}>
|
||||||
|
<PlusIcon />
|
||||||
|
{t("agentTeamSchedule.new")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "month" || viewMode === "week" ? (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<ScheduleCalendar
|
||||||
|
variant={viewMode}
|
||||||
|
monthStart={monthStart}
|
||||||
|
calendarStart={viewMode === "week" ? weekStart : startOfMonthCalendar(monthStart)}
|
||||||
|
calendarEnd={viewMode === "week" ? addDays(weekStart, 7) : endOfMonthCalendar(monthStart)}
|
||||||
|
teams={visibleTeams}
|
||||||
|
schedules={calendarItems}
|
||||||
|
loading={calendarLoading}
|
||||||
|
savingId={actionLoadingId}
|
||||||
|
onCreate={openCreateDialog}
|
||||||
|
onEdit={openEditDialog}
|
||||||
|
onMove={handleCalendarUpdate}
|
||||||
|
onResize={handleCalendarUpdate}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 space-y-4 overflow-auto">
|
||||||
|
<DashboardCrudPage<AdminAgentTeamSchedule, CreateAdminAgentTeamSchedulePayload>
|
||||||
|
key={`${teamFilter}-${listReloadKey}`}
|
||||||
|
layout="fragment"
|
||||||
|
showToolbar={false}
|
||||||
|
filters={listFilters}
|
||||||
|
columns={listColumns}
|
||||||
|
fetchList={(query) =>
|
||||||
|
fetchAgentTeamSchedules({
|
||||||
|
teamId:
|
||||||
|
typeof query.teamId === "string" ? query.teamId : undefined,
|
||||||
|
page: Number(query.page),
|
||||||
|
limit: Number(query.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createAgentTeamSchedule}
|
||||||
|
updateItem={async (item, payload) => {
|
||||||
|
await updateAgentTeamSchedule({ id: item.id, ...payload })
|
||||||
|
await loadCalendarData()
|
||||||
|
}}
|
||||||
|
canEdit={(item) => !isHistoricalSchedule(item)}
|
||||||
|
deleteItem={async (item) => {
|
||||||
|
await deleteAgentTeamSchedule(item.id)
|
||||||
|
await loadCalendarData()
|
||||||
|
}}
|
||||||
|
canDelete={(item) => !isHistoricalSchedule(item)}
|
||||||
|
renderEditDialog={({ open, saving: dialogSaving, itemId, onOpenChange, onSubmit }) => (
|
||||||
|
<EditDialog
|
||||||
|
open={open}
|
||||||
|
saving={dialogSaving}
|
||||||
|
itemId={itemId}
|
||||||
|
defaultValues={null}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
labels={{
|
||||||
|
refresh: t("agentTeamSchedule.refresh"),
|
||||||
|
create: t("agentTeamSchedule.new"),
|
||||||
|
query: t("agentTeamSchedule.query"),
|
||||||
|
loading: t("agentTeamSchedule.loading"),
|
||||||
|
empty: t("agentTeamSchedule.emptyRows"),
|
||||||
|
actions: t("agentTeamSchedule.actions"),
|
||||||
|
edit: t("agentTeamSchedule.edit"),
|
||||||
|
delete: t("agentTeamSchedule.delete"),
|
||||||
|
processing: t("agentTeamSchedule.deleting"),
|
||||||
|
moreActions: (item) =>
|
||||||
|
t("agentTeamSchedule.moreActions", { name: item.startAt }),
|
||||||
|
loadFailed: t("agentTeamSchedule.loadFailed"),
|
||||||
|
saveFailed: t("agentTeamSchedule.saveFailed"),
|
||||||
|
deleteFailed: t("agentTeamSchedule.deleteFailed"),
|
||||||
|
created: () => t("agentTeamSchedule.created"),
|
||||||
|
updated: () => t("agentTeamSchedule.updated"),
|
||||||
|
deleted: () => t("agentTeamSchedule.deleted"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<EditDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
saving={saving || actionLoadingId === editingItem?.id}
|
||||||
|
itemId={editingItem?.id ?? null}
|
||||||
|
defaultValues={dialogDefaults}
|
||||||
|
onOpenChange={handleDialogOpenChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onDelete={handleDeleteById}
|
||||||
|
/>
|
||||||
|
<BatchScheduleDialog
|
||||||
|
open={batchDialogOpen}
|
||||||
|
onOpenChange={setBatchDialogOpen}
|
||||||
|
onSuccess={handleBatchSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
659
web/app/(dashboard)/dashboard/agents/_components/edit.tsx
Normal file
659
web/app/(dashboard)/dashboard/agents/_components/edit.tsx
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { ImageInput } from "@/components/image-input";
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { EditDialog as TeamEditDialog } from "./team-edit";
|
||||||
|
import {
|
||||||
|
createAgentTeam,
|
||||||
|
fetchAgentProfile,
|
||||||
|
fetchAgentTeamsAll,
|
||||||
|
fetchUsersAll,
|
||||||
|
type AdminAgentProfile,
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type AdminUser,
|
||||||
|
type CreateAdminAgentProfilePayload,
|
||||||
|
type CreateAdminAgentTeamPayload,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { ServiceStatus } from "@/lib/generated/enums";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
type AgentEditDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
defaultTeamId: number | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
userId: "",
|
||||||
|
teamId: "",
|
||||||
|
agentCode: "",
|
||||||
|
displayName: "",
|
||||||
|
avatar: "",
|
||||||
|
serviceStatus: String(ServiceStatus.Idle) as "0" | "1",
|
||||||
|
maxConcurrentCount: "0",
|
||||||
|
priorityLevel: "0",
|
||||||
|
autoAssignEnabled: true,
|
||||||
|
receiveOfflineMessage: false,
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
userId: string;
|
||||||
|
teamId: string;
|
||||||
|
agentCode: string;
|
||||||
|
displayName: string;
|
||||||
|
avatar: string;
|
||||||
|
serviceStatus: "0" | "1";
|
||||||
|
maxConcurrentCount: string;
|
||||||
|
priorityLevel: string;
|
||||||
|
autoAssignEnabled: boolean;
|
||||||
|
receiveOfflineMessage: boolean;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createEditFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
userId: z.string().trim().min(1, t("agentProfile.userRequired")),
|
||||||
|
teamId: z.string().trim().min(1, t("agentProfile.teamRequired")),
|
||||||
|
agentCode: z.string().trim().min(1, t("agentProfile.agentCodeRequired")),
|
||||||
|
displayName: z.string().trim().min(1, t("agentProfile.displayNameRequired")),
|
||||||
|
avatar: z.string().trim(),
|
||||||
|
serviceStatus: z.enum(["0", "1"], {
|
||||||
|
message: t("agentProfile.statusRequired"),
|
||||||
|
}),
|
||||||
|
maxConcurrentCount: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^\d+$/, t("agentProfile.maxConcurrentInvalid")),
|
||||||
|
priorityLevel: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^-?\d+$/, t("agentProfile.priorityInvalid")),
|
||||||
|
autoAssignEnabled: z.boolean(),
|
||||||
|
receiveOfflineMessage: z.boolean(),
|
||||||
|
remark: z.string().trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServiceStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||||
|
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm(item: AdminAgentProfile | null): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return emptyForm;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: String(item.userId),
|
||||||
|
teamId: String(item.teamId),
|
||||||
|
agentCode: item.agentCode,
|
||||||
|
displayName: item.displayName,
|
||||||
|
avatar: item.avatar || "",
|
||||||
|
serviceStatus: String(item.serviceStatus) as EditForm["serviceStatus"],
|
||||||
|
maxConcurrentCount: String(item.maxConcurrentCount),
|
||||||
|
priorityLevel: String(item.priorityLevel),
|
||||||
|
autoAssignEnabled: item.autoAssignEnabled,
|
||||||
|
receiveOfflineMessage: item.receiveOfflineMessage,
|
||||||
|
remark: item.remark || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormWithDefaultTeam(
|
||||||
|
item: AdminAgentProfile | null,
|
||||||
|
defaultTeamId: number | null,
|
||||||
|
): EditForm {
|
||||||
|
const form = buildForm(item);
|
||||||
|
if (!item && defaultTeamId) {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
teamId: String(defaultTeamId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: EditForm): CreateAdminAgentProfilePayload {
|
||||||
|
return {
|
||||||
|
userId: Number(form.userId),
|
||||||
|
teamId: Number(form.teamId),
|
||||||
|
agentCode: form.agentCode.trim(),
|
||||||
|
displayName: form.displayName.trim(),
|
||||||
|
avatar: form.avatar.trim(),
|
||||||
|
serviceStatus: Number(form.serviceStatus),
|
||||||
|
maxConcurrentCount: Number(form.maxConcurrentCount),
|
||||||
|
priorityLevel: Number(form.priorityLevel),
|
||||||
|
autoAssignEnabled: form.autoAssignEnabled,
|
||||||
|
receiveOfflineMessage: form.receiveOfflineMessage,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
defaultTeamId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AgentEditDialogProps) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentEditDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
open={open}
|
||||||
|
itemId={itemId}
|
||||||
|
defaultTeamId={defaultTeamId}
|
||||||
|
saving={saving}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentEditDialogBodyProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
defaultTeamId: number | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateAdminAgentProfilePayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AgentEditDialogBody({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
defaultTeamId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AgentEditDialogBodyProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
|
||||||
|
const [userSelectOpen, setUserSelectOpen] = useState(false);
|
||||||
|
const [teamSelectOpen, setTeamSelectOpen] = useState(false);
|
||||||
|
const [createTeamOpen, setCreateTeamOpen] = useState(false);
|
||||||
|
const [createTeamSaving, setCreateTeamSaving] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCreateTeamSubmit = async (payload: CreateAdminAgentTeamPayload) => {
|
||||||
|
setCreateTeamSaving(true);
|
||||||
|
try {
|
||||||
|
const newTeam = await createAgentTeam(payload);
|
||||||
|
toast.success(t("agentProfile.teamCreated", { name: payload.name }));
|
||||||
|
setTeams((prev) => [...prev, newTeam]);
|
||||||
|
form.setValue("teamId", String(newTeam.id), { shouldValidate: true });
|
||||||
|
setCreateTeamOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("agentProfile.teamSaveFailed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setCreateTeamSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const userOptions = users.map((user) => ({
|
||||||
|
value: String(user.id),
|
||||||
|
label: `${user.nickname || user.username} (${user.username})`,
|
||||||
|
}));
|
||||||
|
const teamOptions = teams.map((team) => ({
|
||||||
|
value: String(team.id),
|
||||||
|
label: team.name,
|
||||||
|
}));
|
||||||
|
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
|
||||||
|
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
|
||||||
|
const editFormResolver = useMemo(
|
||||||
|
() => zodResolver(editFormSchema) as Resolver<EditForm>,
|
||||||
|
[editFormSchema],
|
||||||
|
);
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver: editFormResolver,
|
||||||
|
defaultValues: buildFormWithDefaultTeam(null, defaultTeamId),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const loadOptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [usersData, teamsData] = await Promise.all([
|
||||||
|
fetchUsersAll(),
|
||||||
|
fetchAgentTeamsAll(),
|
||||||
|
]);
|
||||||
|
setUsers(usersData);
|
||||||
|
setTeams(teamsData);
|
||||||
|
if (!itemId && teamsData.length > 0) {
|
||||||
|
const current = form.getValues("teamId");
|
||||||
|
if (!current) {
|
||||||
|
form.setValue("teamId", String(defaultTeamId || teamsData[0].id), { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentProfile.loadOptionsFailed"));
|
||||||
|
}
|
||||||
|
}, [defaultTeamId, form, itemId, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!itemId) {
|
||||||
|
reset(buildFormWithDefaultTeam(null, defaultTeamId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentProfile(itemId);
|
||||||
|
reset(buildForm(data));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentProfile.loadDetailFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDetail();
|
||||||
|
}, [itemId, defaultTeamId, reset, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
void loadOptions();
|
||||||
|
}
|
||||||
|
}, [loadOptions, open]);
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
await onSubmit(buildPayload(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
const formId = "agent-edit-form";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={itemId ? t("agentProfile.editTitle") : t("agentProfile.createTitle")}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("agentProfile.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||||
|
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-muted-foreground">{t("agentProfile.loading")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.userId}>
|
||||||
|
<FieldLabel>{t("agentProfile.linkedUser")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="userId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Popover
|
||||||
|
open={userSelectOpen}
|
||||||
|
onOpenChange={setUserSelectOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={userSelectOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{userOptions.find(
|
||||||
|
(option) => option.value === field.value,
|
||||||
|
)?.label ?? t("agentProfile.selectUser")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popper-anchor-width)] p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t("agentProfile.searchUser")} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{userOptions.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(option.value);
|
||||||
|
setUserSelectOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={`mr-2 size-4 ${
|
||||||
|
field.value === option.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.userId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.teamId}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FieldLabel>{t("agentProfile.linkedTeam")}</FieldLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto p-0 text-xs text-primary hover:underline"
|
||||||
|
onClick={() => setCreateTeamOpen(true)}
|
||||||
|
>
|
||||||
|
+ {t("agentProfile.createTeamAction")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="teamId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Popover
|
||||||
|
open={teamSelectOpen}
|
||||||
|
onOpenChange={setTeamSelectOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={teamSelectOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{teamOptions.find(
|
||||||
|
(option) => option.value === field.value,
|
||||||
|
)?.label ?? t("agentProfile.selectTeam")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popper-anchor-width)] p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t("agentProfile.searchTeam")} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("agentProfile.emptyTeam")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{teamOptions.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(option.value);
|
||||||
|
setTeamSelectOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={`mr-2 size-4 ${
|
||||||
|
field.value === option.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{teams.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<span>{t("agentProfile.noTeamsAvailable")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-300"
|
||||||
|
onClick={() => setCreateTeamOpen(true)}
|
||||||
|
>
|
||||||
|
{t("agentProfile.createTeamAction")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<FieldError errors={[errors.teamId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.displayName}>
|
||||||
|
<FieldLabel htmlFor="agent-display-name">{t("agentProfile.displayName")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="agent-display-name"
|
||||||
|
placeholder={t("agentProfile.displayNamePlaceholder")}
|
||||||
|
{...register("displayName")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.displayName]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.agentCode}>
|
||||||
|
<FieldLabel htmlFor="agent-code">{t("agentProfile.agentCodeLabel")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="agent-code"
|
||||||
|
placeholder={t("agentProfile.agentCodePlaceholder")}
|
||||||
|
{...register("agentCode")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.agentCode]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.serviceStatus}>
|
||||||
|
<FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="serviceStatus"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
options={serviceStatusOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={t("agentProfile.selectStatus")}
|
||||||
|
searchPlaceholder={t("agentProfile.searchStatus")}
|
||||||
|
emptyText={t("agentProfile.emptyStatus")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.serviceStatus]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field className="min-h-32">
|
||||||
|
<FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="avatar"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ImageInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
disabled={saving}
|
||||||
|
prefix="avatar"
|
||||||
|
placeholder={t("agentProfile.avatarUpload")}
|
||||||
|
className="size-16 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.maxConcurrentCount}>
|
||||||
|
<FieldLabel htmlFor="agent-max-concurrent-count">
|
||||||
|
{t("agentProfile.maxConcurrent")}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="agent-max-concurrent-count"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
{...register("maxConcurrentCount")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.maxConcurrentCount]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.priorityLevel}>
|
||||||
|
<FieldLabel htmlFor="agent-priority-level">{t("agentProfile.priority")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="agent-priority-level"
|
||||||
|
type="number"
|
||||||
|
step={1}
|
||||||
|
{...register("priorityLevel")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.priorityLevel]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("agentProfile.autoAssignEnabled")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="autoAssignEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("agentProfile.receiveOfflineMessage")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="receiveOfflineMessage"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="agent-remark">{t("agentProfile.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="agent-remark"
|
||||||
|
rows={4}
|
||||||
|
placeholder={t("agentProfile.remarkPlaceholder")}
|
||||||
|
{...register("remark")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
<TeamEditDialog
|
||||||
|
open={createTeamOpen}
|
||||||
|
saving={createTeamSaving}
|
||||||
|
itemId={null}
|
||||||
|
onOpenChange={setCreateTeamOpen}
|
||||||
|
onSubmit={handleCreateTeamSubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
web/app/(dashboard)/dashboard/agents/_components/team-edit.tsx
Normal file
361
web/app/(dashboard)/dashboard/agents/_components/team-edit.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
||||||
|
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type CreateAdminAgentTeamPayload,
|
||||||
|
fetchAgentTeam,
|
||||||
|
fetchUsersAll,
|
||||||
|
type AdminUser,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { Status } from "@/lib/generated/enums";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
type TeamEditDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateAdminAgentTeamPayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
name: "",
|
||||||
|
leaderUserId: "0",
|
||||||
|
status: String(Status.Ok),
|
||||||
|
description: "",
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
name: string;
|
||||||
|
leaderUserId: string;
|
||||||
|
status: string;
|
||||||
|
description: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createEditFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
name: z.string().trim().min(1, t("agentProfile.teamNameRequired")),
|
||||||
|
leaderUserId: z.string().trim().regex(/^\d+$/, t("agentProfile.leaderInvalid")),
|
||||||
|
status: z.enum([String(Status.Ok), String(Status.Disabled)], {
|
||||||
|
message: t("agentProfile.teamStatusRequired"),
|
||||||
|
}),
|
||||||
|
description: z.string().trim(),
|
||||||
|
remark: z.string().trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
|
||||||
|
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm(item: AdminAgentTeam | null): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return emptyForm;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
leaderUserId: String(item.leaderUserId),
|
||||||
|
status: String(item.status),
|
||||||
|
description: item.description || "",
|
||||||
|
remark: item.remark || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: EditForm): CreateAdminAgentTeamPayload {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
leaderUserId: Number(form.leaderUserId),
|
||||||
|
status: Number(form.status),
|
||||||
|
description: form.description.trim(),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: TeamEditDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{open ? (
|
||||||
|
<TeamEditDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
itemId={itemId}
|
||||||
|
saving={saving}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamEditDialogBodyProps = Omit<TeamEditDialogProps, "open">;
|
||||||
|
|
||||||
|
function TeamEditDialogBody({
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: TeamEditDialogBodyProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [userSelectOpen, setUserSelectOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const userOptions = users.map((user) => ({
|
||||||
|
value: String(user.id),
|
||||||
|
label: `${user.nickname || user.username} (${user.username})`,
|
||||||
|
}));
|
||||||
|
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchUsersAll();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentProfile.loadUsersFailed"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
const editFormSchema = useMemo(() => createEditFormSchema(t), [t]);
|
||||||
|
const editFormResolver = useMemo(
|
||||||
|
() => zodResolver(editFormSchema) as Resolver<EditForm>,
|
||||||
|
[editFormSchema],
|
||||||
|
);
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver: editFormResolver,
|
||||||
|
defaultValues: emptyForm,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!itemId) {
|
||||||
|
reset(emptyForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentTeam(itemId);
|
||||||
|
reset(buildForm(data));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamDetailFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDetail();
|
||||||
|
}, [itemId, reset, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUsers();
|
||||||
|
}, [loadUsers]);
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
await onSubmit(buildPayload(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent className="max-w-xl gap-0 p-0 sm:max-w-xl">
|
||||||
|
<DialogHeader className="px-6 pt-6">
|
||||||
|
<DialogTitle>{itemId ? t("agentProfile.teamEditTitle") : t("agentProfile.teamCreateTitle")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-muted-foreground">{t("agentProfile.loading")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="agent-team-name">{t("agentProfile.teamName")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="agent-team-name"
|
||||||
|
placeholder={t("agentProfile.teamNamePlaceholder")}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.name]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.leaderUserId}>
|
||||||
|
<FieldLabel>{t("agentProfile.leader")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="leaderUserId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Popover open={userSelectOpen} onOpenChange={setUserSelectOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={userSelectOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{field.value === "0"
|
||||||
|
? t("agentProfile.noLeader")
|
||||||
|
: userOptions.find((option) => option.value === field.value)?.label ?? t("agentProfile.selectLeader")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popper-anchor-width)] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t("agentProfile.searchUser")} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("agentProfile.emptyUser")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value={t("agentProfile.noLeader")}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange("0");
|
||||||
|
setUserSelectOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={`mr-2 size-4 ${field.value === "0" ? "opacity-100" : "opacity-0"}`}
|
||||||
|
/>
|
||||||
|
{t("agentProfile.noLeader")}
|
||||||
|
</CommandItem>
|
||||||
|
{userOptions.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(option.value);
|
||||||
|
setUserSelectOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={`mr-2 size-4 ${
|
||||||
|
field.value === option.value ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.leaderUserId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.status}>
|
||||||
|
<FieldLabel>{t("agentProfile.status")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
options={statusOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={t("agentProfile.selectStatus")}
|
||||||
|
searchPlaceholder={t("agentProfile.searchStatus")}
|
||||||
|
emptyText={t("agentProfile.emptyStatus")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.status]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="agent-team-description">{t("agentProfile.description")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="agent-team-description"
|
||||||
|
placeholder={t("agentProfile.descriptionPlaceholder")}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="agent-team-remark">{t("agentProfile.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="agent-team-remark"
|
||||||
|
rows={4}
|
||||||
|
placeholder={t("agentProfile.remarkPlaceholder")}
|
||||||
|
{...register("remark")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mx-0 mb-0 px-6 py-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("agentProfile.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving || loading}>
|
||||||
|
{saving ? t("agentProfile.saving") : t("agentProfile.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
Pencil,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SearchIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UsersRoundIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { EditDialog } from "./team-edit";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
createAgentTeam,
|
||||||
|
deleteAgentTeam,
|
||||||
|
fetchAgentTeams,
|
||||||
|
updateAgentTeam,
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type CreateAdminAgentTeamPayload,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { Status } from "@/lib/generated/enums";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AgentTeamSidebarProps = {
|
||||||
|
selectedTeamId: number | null;
|
||||||
|
onSelectTeam: (team: AdminAgentTeam | null) => void;
|
||||||
|
onTeamsChange?: (teams: AdminAgentTeam[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusTabs(t: (key: string, values?: Record<string, string | number>) => string) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("agentProfile.all") },
|
||||||
|
{ value: String(Status.Ok), label: t("agentProfile.enabled") },
|
||||||
|
{ value: String(Status.Disabled), label: t("agentProfile.disabled") },
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentTeamSidebar({
|
||||||
|
selectedTeamId,
|
||||||
|
onSelectTeam,
|
||||||
|
onTeamsChange,
|
||||||
|
}: AgentTeamSidebarProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const statusTabs = getStatusTabs(t);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] =
|
||||||
|
useState<(typeof statusTabs)[number]["value"]>("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<AdminAgentTeam | null>(null);
|
||||||
|
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentTeams({ page: 1, limit: 200 });
|
||||||
|
setTeams(data);
|
||||||
|
onTeamsChange?.(data);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentProfile.loadTeamsFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [onTeamsChange, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTeamId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matchedTeam =
|
||||||
|
teams.find((item) => item.id === selectedTeamId) ?? null;
|
||||||
|
if (matchedTeam) {
|
||||||
|
onSelectTeam(matchedTeam);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loading && teams.length > 0) {
|
||||||
|
onSelectTeam(teams[0]);
|
||||||
|
}
|
||||||
|
}, [loading, onSelectTeam, selectedTeamId, teams]);
|
||||||
|
|
||||||
|
const filteredTeams = useMemo(() => {
|
||||||
|
const output = keyword.trim().toLowerCase();
|
||||||
|
return teams.filter((item) => {
|
||||||
|
const matchedKeyword =
|
||||||
|
output.length === 0 ||
|
||||||
|
item.name.toLowerCase().includes(output) ||
|
||||||
|
item.description.toLowerCase().includes(output);
|
||||||
|
const matchedStatus =
|
||||||
|
statusFilter === "all" || String(item.status) === statusFilter;
|
||||||
|
return matchedKeyword && matchedStatus;
|
||||||
|
});
|
||||||
|
}, [keyword, statusFilter, teams]);
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditingItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(item: AdminAgentTeam) {
|
||||||
|
setEditingItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogOpenChange(open: boolean) {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setEditingItem(null);
|
||||||
|
}
|
||||||
|
setDialogOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: CreateAdminAgentTeamPayload) {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateAgentTeam({ id: editingItem.id, ...payload });
|
||||||
|
toast.success(t("agentProfile.teamUpdated", { name: editingItem.name }));
|
||||||
|
} else {
|
||||||
|
await createAgentTeam(payload);
|
||||||
|
toast.success(t("agentProfile.teamCreated", { name: payload.name }));
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentProfile.teamSaveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: AdminAgentTeam) {
|
||||||
|
setActionLoadingId(item.id);
|
||||||
|
try {
|
||||||
|
await deleteAgentTeam(item.id);
|
||||||
|
toast.success(t("agentProfile.teamDeleted", { name: item.name }));
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("agentProfile.teamDeleteFailed"));
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-full flex-col border-r bg-muted/10">
|
||||||
|
<div className="border-b px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{t("agentProfile.teamTitle")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-3">
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder={t("agentProfile.searchTeams")}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{statusTabs.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
variant={statusFilter === item.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter(item.value)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadData()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={cn("size-4", loading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="icon-sm" onClick={openCreateDialog}>
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
{filteredTeams.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"group mt-1 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors hover:bg-accent",
|
||||||
|
selectedTeamId === item.id &&
|
||||||
|
"bg-accent text-accent-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
onClick={() => onSelectTeam(item)}
|
||||||
|
>
|
||||||
|
<UsersRoundIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
item.status === Status.Ok ? "secondary" : "outline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.status === Status.Ok ? t("agentProfile.enabled") : t("agentProfile.disabled")}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="opacity-0 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
aria-label={t("agentProfile.moreActions", { name: item.name })}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||||
|
<Pencil />
|
||||||
|
{t("agentProfile.edit")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
{actionLoadingId === item.id ? t("agentProfile.deleting") : t("agentProfile.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!loading && filteredTeams.length === 0 ? (
|
||||||
|
<div className="px-2 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{t("agentProfile.noTeams")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
<EditDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
saving={saving}
|
||||||
|
itemId={editingItem?.id ?? null}
|
||||||
|
onOpenChange={handleDialogOpenChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
web/app/(dashboard)/dashboard/agents/page.tsx
Normal file
299
web/app/(dashboard)/dashboard/agents/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PanelLeftCloseIcon,
|
||||||
|
PanelLeftOpenIcon,
|
||||||
|
UserCogIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardCrudPage,
|
||||||
|
type DashboardCrudColumn,
|
||||||
|
type DashboardCrudFilter,
|
||||||
|
} from "@/components/dashboard/crud";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
createAgentProfile,
|
||||||
|
deleteAgentProfile,
|
||||||
|
fetchAgentProfiles,
|
||||||
|
updateAgentProfile,
|
||||||
|
type AdminAgentProfile,
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type CreateAdminAgentProfilePayload,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { ServiceStatus } from "@/lib/generated/enums";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import { EditDialog } from "./_components/edit";
|
||||||
|
import { AgentTeamSidebar } from "./_components/team-sidebar";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function getServiceStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("agentProfile.allStatuses") },
|
||||||
|
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||||
|
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(value: number, t: TFunction) {
|
||||||
|
return (
|
||||||
|
getServiceStatusOptions(t).find((item) => item.value === String(value))
|
||||||
|
?.label ?? String(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardAgentsPage() {
|
||||||
|
const t = useI18n();
|
||||||
|
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const handleTeamsChange = useCallback((nextTeams: AdminAgentTeam[]) => {
|
||||||
|
setSelectedTeam((current) => {
|
||||||
|
if (nextTeams.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
return nextTeams[0];
|
||||||
|
}
|
||||||
|
return nextTeams.find((item) => item.id === current.id) ?? nextTeams[0];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filters = useMemo<DashboardCrudFilter[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "displayName",
|
||||||
|
label: t("agentProfile.filterDisplayName"),
|
||||||
|
placeholder: t("agentProfile.filterDisplayName"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "agentCode",
|
||||||
|
label: t("agentProfile.filterAgentCode"),
|
||||||
|
placeholder: t("agentProfile.filterAgentCode"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-44",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "serviceStatus",
|
||||||
|
label: t("agentProfile.allStatuses"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: serviceStatusOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[serviceStatusOptions, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<DashboardCrudColumn<AdminAgentProfile>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "agent",
|
||||||
|
label: t("agentProfile.columnAgent"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-10 items-center justify-center overflow-hidden rounded-md bg-muted">
|
||||||
|
{item.avatar ? (
|
||||||
|
<img
|
||||||
|
src={item.avatar}
|
||||||
|
alt={item.displayName}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserCogIcon className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">{item.displayName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.nickname ||
|
||||||
|
item.username ||
|
||||||
|
t("agentProfile.userFallback", { id: item.userId })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("agentProfile.agentCode", { code: item.agentCode })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rules",
|
||||||
|
label: t("agentProfile.columnRules"),
|
||||||
|
render: (item) => (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{getStatusLabel(item.serviceStatus, t)}
|
||||||
|
</Badge>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{t("agentProfile.capacityPriority", {
|
||||||
|
capacity: item.maxConcurrentCount,
|
||||||
|
priority: item.priorityLevel,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dispatch",
|
||||||
|
label: t("agentProfile.columnDispatch"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge variant={item.autoAssignEnabled ? "secondary" : "outline"}>
|
||||||
|
{item.autoAssignEnabled
|
||||||
|
? t("agentProfile.autoAssign")
|
||||||
|
: t("agentProfile.noAutoAssign")}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={item.receiveOfflineMessage ? "secondary" : "outline"}
|
||||||
|
>
|
||||||
|
{item.receiveOfflineMessage
|
||||||
|
? t("agentProfile.offlineReceive")
|
||||||
|
: t("agentProfile.noOfflineReceive")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recent",
|
||||||
|
label: t("agentProfile.columnRecent"),
|
||||||
|
render: (item) => (
|
||||||
|
<>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("agentProfile.onlineAt", {
|
||||||
|
time: formatDateTime(item.lastOnlineAt),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("agentProfile.statusAt", {
|
||||||
|
time: formatDateTime(item.lastStatusAt),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)]">
|
||||||
|
<div
|
||||||
|
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
|
||||||
|
sidebarCollapsed ? "w-0" : "w-80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AgentTeamSidebar
|
||||||
|
selectedTeamId={selectedTeam?.id ?? null}
|
||||||
|
onSelectTeam={setSelectedTeam}
|
||||||
|
onTeamsChange={handleTeamsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0 bg-background">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-4 left-1/2 z-10 size-7 -translate-x-1/2 rounded-full shadow-sm"
|
||||||
|
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||||
|
aria-label={
|
||||||
|
sidebarCollapsed
|
||||||
|
? t("agentProfile.expandTeams")
|
||||||
|
: t("agentProfile.collapseTeams")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<PanelLeftOpenIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftCloseIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 p-4 lg:p-6">
|
||||||
|
<div className="flex h-full flex-col gap-6">
|
||||||
|
<DashboardCrudPage<AdminAgentProfile, CreateAdminAgentProfilePayload>
|
||||||
|
key={selectedTeam?.id ?? "all"}
|
||||||
|
layout="fragment"
|
||||||
|
tableShellClassName="min-h-0"
|
||||||
|
filters={filters}
|
||||||
|
columns={columns}
|
||||||
|
fetchList={(query) =>
|
||||||
|
fetchAgentProfiles({
|
||||||
|
teamId: selectedTeam?.id,
|
||||||
|
displayName:
|
||||||
|
typeof query.displayName === "string"
|
||||||
|
? query.displayName
|
||||||
|
: undefined,
|
||||||
|
agentCode:
|
||||||
|
typeof query.agentCode === "string"
|
||||||
|
? query.agentCode
|
||||||
|
: undefined,
|
||||||
|
serviceStatus:
|
||||||
|
typeof query.serviceStatus === "string"
|
||||||
|
? query.serviceStatus
|
||||||
|
: undefined,
|
||||||
|
page: Number(query.page),
|
||||||
|
limit: Number(query.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createAgentProfile}
|
||||||
|
updateItem={(item, payload) =>
|
||||||
|
updateAgentProfile({ id: item.id, ...payload })
|
||||||
|
}
|
||||||
|
deleteItem={(item) => deleteAgentProfile(item.id)}
|
||||||
|
renderEditDialog={({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}) => (
|
||||||
|
<EditDialog
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
defaultTeamId={selectedTeam?.id ?? null}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
labels={{
|
||||||
|
refresh: t("agentProfile.refresh"),
|
||||||
|
create: t("agentProfile.new"),
|
||||||
|
query: t("agentProfile.query"),
|
||||||
|
loading: t("agentProfile.loadingRows"),
|
||||||
|
empty: selectedTeam
|
||||||
|
? t("agentProfile.emptyTeamRows")
|
||||||
|
: t("agentProfile.emptyRows"),
|
||||||
|
actions: t("agentProfile.columnActions"),
|
||||||
|
edit: t("agentProfile.edit"),
|
||||||
|
delete: t("agentProfile.delete"),
|
||||||
|
processing: t("agentProfile.deleting"),
|
||||||
|
moreActions: (item) =>
|
||||||
|
t("agentProfile.moreActions", { name: item.displayName }),
|
||||||
|
loadFailed: t("agentProfile.loadFailed"),
|
||||||
|
saveFailed: t("agentProfile.saveFailed"),
|
||||||
|
deleteFailed: t("agentProfile.deleteFailed"),
|
||||||
|
created: (payload) =>
|
||||||
|
t("agentProfile.created", { name: payload.displayName }),
|
||||||
|
updated: (item) =>
|
||||||
|
t("agentProfile.updated", { name: item.displayName }),
|
||||||
|
deleted: (item) =>
|
||||||
|
t("agentProfile.deleted", { name: item.displayName }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
|
||||||
|
const configWorkbenchSource = await readFile(new URL("./config-workbench.tsx", import.meta.url), "utf8")
|
||||||
|
const zhMessagesSource = await readFile(new URL("../../../../../messages/zh-CN.json", import.meta.url), "utf8")
|
||||||
|
const adminApiSource = await readFile(new URL("../../../../../lib/api/admin.ts", import.meta.url), "utf8")
|
||||||
|
const zhMessages = JSON.parse(zhMessagesSource)
|
||||||
|
|
||||||
|
test("AI Agent policy copy separates handoff execution from knowledge fallback", () => {
|
||||||
|
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||||
|
|
||||||
|
assert.match(combinedSource, /转人工执行方式/)
|
||||||
|
assert.match(combinedSource, /知识不足回复策略/)
|
||||||
|
assert.match(combinedSource, /知识不足回复文案/)
|
||||||
|
assert.match(combinedSource, /AI继续接待并提醒人工/)
|
||||||
|
assert.match(combinedSource, /直接说明知识不足/)
|
||||||
|
assert.match(combinedSource, /引导用户补充信息/)
|
||||||
|
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /转人工模式/)
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /兜底策略/)
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /兜底文案/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("AI Agent config no longer exposes legacy graph tool routing knobs", () => {
|
||||||
|
const aiAgentMessages = JSON.stringify(zhMessages.aiAgent ?? {})
|
||||||
|
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /graphTools/)
|
||||||
|
assert.doesNotMatch(adminApiSource, /graphTools/)
|
||||||
|
assert.doesNotMatch(aiAgentMessages, /graphTools/)
|
||||||
|
assert.doesNotMatch(aiAgentMessages, /Graph Tool/)
|
||||||
|
assert.doesNotMatch(aiAgentMessages, /内置流程/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("AI Agent config uses one Agent Loop without a runtime mode selector", () => {
|
||||||
|
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /runtimeMode/)
|
||||||
|
assert.doesNotMatch(adminApiSource, /runtimeMode/)
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /运行方式/)
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /Workflow 是 Agent 的可选能力/)
|
||||||
|
assert.doesNotMatch(configWorkbenchSource, /管理工作流/)
|
||||||
|
assert.match(combinedSource, /写操作(需确认)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("publishing an AI Agent saves the current form before publishing", () => {
|
||||||
|
const publishFunction = configWorkbenchSource.match(
|
||||||
|
/async function publishAgent\(\) \{([\s\S]*?)\n \}/,
|
||||||
|
)?.[1]
|
||||||
|
|
||||||
|
assert.ok(publishFunction)
|
||||||
|
assert.match(publishFunction, /const payload = buildPayload\(\)/)
|
||||||
|
|
||||||
|
const saveIndex = publishFunction.indexOf(
|
||||||
|
"await updateAIAgent({ id: agent.id, ...payload })",
|
||||||
|
)
|
||||||
|
const publishIndex = publishFunction.indexOf("await publishAIAgent(agent.id)")
|
||||||
|
|
||||||
|
assert.ok(saveIndex >= 0)
|
||||||
|
assert.ok(publishIndex > saveIndex)
|
||||||
|
assert.match(publishFunction, /publishedSuccess/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("saving a published AI Agent keeps the active revision online", () => {
|
||||||
|
const saveFunction = configWorkbenchSource.match(
|
||||||
|
/async function saveAgentSettings\(\) \{([\s\S]*?)\n \}/,
|
||||||
|
)?.[1]
|
||||||
|
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||||
|
|
||||||
|
assert.ok(saveFunction)
|
||||||
|
assert.match(combinedSource, /配置已保存,当前已发布版本继续生效/)
|
||||||
|
assert.match(combinedSource, /已发布版本正在生效;再次发布后应用当前配置/)
|
||||||
|
assert.doesNotMatch(saveFunction, /loadData\(\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("trusted MCP tools use backend risk metadata and cannot be edited", () => {
|
||||||
|
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
|
||||||
|
assert.match(adminApiSource, /riskEditable: boolean/)
|
||||||
|
assert.match(configWorkbenchSource, /riskLevel: tool\.riskLevel/)
|
||||||
|
assert.match(configWorkbenchSource, /requireConfirmation: tool\.requireConfirmation/)
|
||||||
|
assert.match(combinedSource, /系统定义/)
|
||||||
|
assert.match(configWorkbenchSource, /!catalogTool\.riskEditable/)
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
337
web/app/(dashboard)/dashboard/ai-agents/page.tsx
Normal file
337
web/app/(dashboard)/dashboard/ai-agents/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BotMessageSquareIcon, GitBranchIcon, PowerIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardCrudPage,
|
||||||
|
type DashboardCrudActionState,
|
||||||
|
createDashboardStatusColumn,
|
||||||
|
createDashboardStatusToggleAction,
|
||||||
|
type DashboardCrudColumn,
|
||||||
|
type DashboardCrudFilter,
|
||||||
|
} from "@/components/dashboard/crud";
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
createAIAgent,
|
||||||
|
deleteAIAgent,
|
||||||
|
fetchAIAgents,
|
||||||
|
updateAIAgent,
|
||||||
|
updateAIAgentSort,
|
||||||
|
updateAIAgentStatus,
|
||||||
|
type AIAgent,
|
||||||
|
type CreateAIAgentPayload,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { IMConversationServiceMode, Status } from "@/lib/generated/enums";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { AIAgentConfigWorkbench } from "./_components/config-workbench";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function getStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("aiAgent.allStatuses") },
|
||||||
|
{ value: String(Status.Ok), label: t("aiAgent.enabled") },
|
||||||
|
{ value: String(Status.Disabled), label: t("aiAgent.disabled") },
|
||||||
|
{ value: String(Status.Deleted), label: t("status.deleted") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(value: string, t: TFunction) {
|
||||||
|
return (
|
||||||
|
getStatusOptions(t).find((item) => item.value === value)?.label ??
|
||||||
|
t("aiAgent.allStatuses")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServiceModeLabel(mode: number, t: TFunction) {
|
||||||
|
switch (mode) {
|
||||||
|
case IMConversationServiceMode.AIOnly:
|
||||||
|
return t("aiAgent.serviceAiOnly");
|
||||||
|
case IMConversationServiceMode.HumanOnly:
|
||||||
|
return t("aiAgent.serviceHumanOnly");
|
||||||
|
case IMConversationServiceMode.AIFirst:
|
||||||
|
return t("aiAgent.serviceAiFirst");
|
||||||
|
default:
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextStatus(item: AIAgent) {
|
||||||
|
return item.status === Status.Ok ? Status.Disabled : Status.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardAIAgentsPage() {
|
||||||
|
const t = useI18n();
|
||||||
|
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||||
|
const [configAgentId, setConfigAgentId] = useState<number | null>(null);
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
const [crudActions, setCrudActions] = useState<DashboardCrudActionState | null>(null);
|
||||||
|
|
||||||
|
const filters = useMemo<DashboardCrudFilter[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("aiAgent.filterName"),
|
||||||
|
placeholder: t("aiAgent.filterName"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("aiAgent.allStatuses"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: statusOptions,
|
||||||
|
className: "w-full sm:w-52",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[statusOptions, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<DashboardCrudColumn<AIAgent>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "agent",
|
||||||
|
label: "Agent",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<BotMessageSquareIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{item.name}</div>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
|
Agent ID: {item.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aiConfig",
|
||||||
|
label: t("aiAgent.columnAiConfig"),
|
||||||
|
render: (item) => item.aiConfigName || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "serviceMode",
|
||||||
|
label: t("aiAgent.columnServiceMode"),
|
||||||
|
render: (item) => getServiceModeLabel(item.serviceMode, t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "publication",
|
||||||
|
label: t("aiAgent.publishStatus"),
|
||||||
|
render: (item) => {
|
||||||
|
const published = item.publishedRevisionId > 0;
|
||||||
|
const workflowCount = item.workflowBindings?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Badge variant={published ? "default" : "outline"}>
|
||||||
|
{published ? t("aiAgent.published") : t("aiAgent.unpublished")}
|
||||||
|
</Badge>
|
||||||
|
{published ? (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
Revision #{item.publishedRevisionId}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{workflowCount > 0
|
||||||
|
? t("aiAgent.configuredWorkflows", { count: String(workflowCount) })
|
||||||
|
: t("aiAgent.directAutonomous")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skills",
|
||||||
|
label: t("aiAgent.columnSkills"),
|
||||||
|
render: (item) => {
|
||||||
|
const skills = item.skills ?? [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("aiAgent.ragOnly")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
skills.map((skill) => (
|
||||||
|
<Badge key={skill.id} variant="outline">
|
||||||
|
{skill.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "capabilities",
|
||||||
|
label: t("aiAgent.columnCapabilities"),
|
||||||
|
render: (item) => {
|
||||||
|
const skills = item.skills ?? [];
|
||||||
|
const mcpTools = item.mcpTools ?? [];
|
||||||
|
const mcpServerCodes = Array.from(
|
||||||
|
new Set(mcpTools.map((tool) => tool.serverCode).filter(Boolean)),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Badge variant="secondary">{skills.length} Skills</Badge>
|
||||||
|
<Badge variant="secondary">{mcpTools.length} MCP Tools</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{mcpServerCodes.length === 0 ? (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("aiAgent.noMcpServer")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
mcpServerCodes.map((serverCode) => (
|
||||||
|
<Badge key={serverCode} variant="outline">
|
||||||
|
{serverCode}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createDashboardStatusColumn<AIAgent, number>({
|
||||||
|
label: t("aiAgent.columnStatus"),
|
||||||
|
getStatus: (item) => item.status,
|
||||||
|
getLabel: (status) => getStatusLabel(String(status), t),
|
||||||
|
getBadgeVariant: (status) =>
|
||||||
|
status === Status.Ok ? "default" : "secondary",
|
||||||
|
isEnabled: (status) => status === Status.Ok,
|
||||||
|
toggle: {
|
||||||
|
getNextStatus,
|
||||||
|
updateStatus: (item, nextStatus) =>
|
||||||
|
updateAIAgentStatus(item.id, nextStatus),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t("aiAgent.statusChanged", {
|
||||||
|
name: item.name,
|
||||||
|
status:
|
||||||
|
nextStatus === Status.Ok
|
||||||
|
? t("aiAgent.enabled")
|
||||||
|
: t("aiAgent.stop"),
|
||||||
|
}),
|
||||||
|
errorMessage: t("aiAgent.statusUpdateFailed"),
|
||||||
|
ariaLabel: (item) => t("aiAgent.toggleStatus", { name: item.name }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardCrudPage<AIAgent, CreateAIAgentPayload>
|
||||||
|
filters={filters}
|
||||||
|
columns={columns}
|
||||||
|
fetchList={(query) =>
|
||||||
|
fetchAIAgents({
|
||||||
|
name: typeof query.name === "string" ? query.name : undefined,
|
||||||
|
status: typeof query.status === "string" ? query.status : undefined,
|
||||||
|
page: Number(query.page),
|
||||||
|
limit: Number(query.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createAIAgent}
|
||||||
|
updateItem={(item, payload) => updateAIAgent({ id: item.id, ...payload })}
|
||||||
|
onCreateItem={() => {
|
||||||
|
setConfigAgentId(null);
|
||||||
|
setConfigOpen(true);
|
||||||
|
}}
|
||||||
|
onEditItem={(item) => {
|
||||||
|
setConfigAgentId(item.id);
|
||||||
|
setConfigOpen(true);
|
||||||
|
}}
|
||||||
|
deleteItem={(item) => deleteAIAgent(item.id)}
|
||||||
|
rowActions={[
|
||||||
|
{
|
||||||
|
key: "workflow",
|
||||||
|
icon: <GitBranchIcon />,
|
||||||
|
label: t("aiAgent.configure"),
|
||||||
|
run: ({ item }) => {
|
||||||
|
setConfigAgentId(item.id);
|
||||||
|
setConfigOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createDashboardStatusToggleAction<AIAgent, number>({
|
||||||
|
icon: <PowerIcon />,
|
||||||
|
label: (item) =>
|
||||||
|
item.status === Status.Ok ? t("aiAgent.stop") : t("aiAgent.enabled"),
|
||||||
|
getNextStatus,
|
||||||
|
updateStatus: (item, nextStatus) =>
|
||||||
|
updateAIAgentStatus(item.id, nextStatus),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t("aiAgent.statusChanged", {
|
||||||
|
name: item.name,
|
||||||
|
status:
|
||||||
|
nextStatus === Status.Ok
|
||||||
|
? t("aiAgent.enabled")
|
||||||
|
: t("aiAgent.stop"),
|
||||||
|
}),
|
||||||
|
errorMessage: t("aiAgent.statusUpdateFailed"),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
sort={{
|
||||||
|
enabled: true,
|
||||||
|
onReorder: (items) => updateAIAgentSort(items.map((item) => item.id)),
|
||||||
|
successMessage: t("aiAgent.sortUpdated"),
|
||||||
|
errorMessage: t("aiAgent.sortUpdateFailed"),
|
||||||
|
handleLabel: t("aiAgent.dragSort", { name: "" }),
|
||||||
|
}}
|
||||||
|
onActionStateChange={setCrudActions}
|
||||||
|
labels={{
|
||||||
|
refresh: t("aiAgent.refresh"),
|
||||||
|
create: t("aiAgent.new"),
|
||||||
|
query: t("aiAgent.query"),
|
||||||
|
loading: t("aiAgent.loadingRows"),
|
||||||
|
empty: t("aiAgent.emptyRows"),
|
||||||
|
actions: t("aiAgent.columnActions"),
|
||||||
|
edit: t("aiAgent.configure"),
|
||||||
|
delete: t("aiAgent.delete"),
|
||||||
|
processing: t("aiAgent.processing"),
|
||||||
|
moreActions: (item) => t("aiAgent.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("aiAgent.loadFailed"),
|
||||||
|
saveFailed: t("aiAgent.saveFailed"),
|
||||||
|
deleteFailed: t("aiAgent.deleteFailed"),
|
||||||
|
created: (payload) => t("aiAgent.created", { name: payload.name }),
|
||||||
|
updated: (_item, payload) => t("aiAgent.updated", { name: payload.name }),
|
||||||
|
deleted: (item) => t("aiAgent.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProjectDialog
|
||||||
|
open={configOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setConfigOpen(open);
|
||||||
|
if (!open) setConfigAgentId(null);
|
||||||
|
}}
|
||||||
|
title={t("aiAgent.configure")}
|
||||||
|
size="xxl"
|
||||||
|
allowFullscreen
|
||||||
|
contentClassName="h-[800px] max-h-[calc(100vh-2rem)]"
|
||||||
|
bodyScrollable={false}
|
||||||
|
headerClassName="sr-only"
|
||||||
|
closeOnEsc={false}
|
||||||
|
>
|
||||||
|
{configOpen ? (
|
||||||
|
<AIAgentConfigWorkbench
|
||||||
|
agentId={configAgentId}
|
||||||
|
onAgentCreated={(agent) => setConfigAgentId(agent.id)}
|
||||||
|
onAgentSaved={() => crudActions?.onRefresh()}
|
||||||
|
onCancel={() => setConfigOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</ProjectDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
479
web/app/(dashboard)/dashboard/ai-configs/_components/edit.tsx
Normal file
479
web/app/(dashboard)/dashboard/ai-configs/_components/edit.tsx
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Controller, Resolver, useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { type AIConfig, type CreateAIConfigPayload, fetchAIConfig } from "@/lib/api/admin"
|
||||||
|
import {
|
||||||
|
AIModelType,
|
||||||
|
AIProvider,
|
||||||
|
} from "@/lib/generated/enums"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
|
||||||
|
type AIConfigEditDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
saving: boolean
|
||||||
|
itemId: number | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (payload: CreateAIConfigPayload) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
function getProviderOptions(t: TFunction) {
|
||||||
|
return [{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelTypeOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
|
||||||
|
{ value: String(AIModelType.Embedding), label: t("aiConfig.modelTypeEmbedding") },
|
||||||
|
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
name: "",
|
||||||
|
provider: AIProvider.OpenAI,
|
||||||
|
baseUrl: "",
|
||||||
|
apiKey: "",
|
||||||
|
modelType: AIModelType.LLM,
|
||||||
|
modelName: "",
|
||||||
|
dimension: "0",
|
||||||
|
maxContextTokens: "0",
|
||||||
|
maxOutputTokens: "0",
|
||||||
|
timeoutMs: "120000",
|
||||||
|
maxRetryCount: "0",
|
||||||
|
rpmLimit: "0",
|
||||||
|
tpmLimit: "0",
|
||||||
|
remark: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
modelType: string
|
||||||
|
modelName: string
|
||||||
|
dimension: string
|
||||||
|
maxContextTokens: string
|
||||||
|
maxOutputTokens: string
|
||||||
|
timeoutMs: string
|
||||||
|
maxRetryCount: string
|
||||||
|
rpmLimit: string
|
||||||
|
tpmLimit: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm(item: AIConfig | null): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return emptyForm
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
provider: item.provider,
|
||||||
|
baseUrl: item.baseUrl,
|
||||||
|
apiKey: "",
|
||||||
|
modelType: item.modelType,
|
||||||
|
modelName: item.modelName,
|
||||||
|
dimension: String(item.dimension),
|
||||||
|
maxContextTokens: String(item.maxContextTokens),
|
||||||
|
maxOutputTokens: String(item.maxOutputTokens),
|
||||||
|
timeoutMs: String(item.timeoutMs),
|
||||||
|
maxRetryCount: String(item.maxRetryCount),
|
||||||
|
rpmLimit: String(item.rpmLimit),
|
||||||
|
tpmLimit: String(item.tpmLimit),
|
||||||
|
remark: item.remark ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: EditForm): CreateAIConfigPayload {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
provider: form.provider,
|
||||||
|
baseUrl: form.baseUrl.trim(),
|
||||||
|
apiKey: form.apiKey.trim(),
|
||||||
|
modelType: form.modelType,
|
||||||
|
modelName: form.modelName.trim(),
|
||||||
|
dimension: Number(form.dimension),
|
||||||
|
maxContextTokens: Number(form.maxContextTokens),
|
||||||
|
maxOutputTokens: Number(form.maxOutputTokens),
|
||||||
|
timeoutMs: Number(form.timeoutMs),
|
||||||
|
maxRetryCount: Number(form.maxRetryCount),
|
||||||
|
rpmLimit: Number(form.rpmLimit),
|
||||||
|
tpmLimit: Number(form.tpmLimit),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AIConfigEditDialogProps) {
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIConfigEditDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigEditDialogBodyProps = AIConfigEditDialogProps
|
||||||
|
|
||||||
|
function AIConfigEditDialogBody({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AIConfigEditDialogBodyProps) {
|
||||||
|
const formId = "ai-config-edit-form"
|
||||||
|
const t = useI18n()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const providerOptions = useMemo(() => getProviderOptions(t), [t])
|
||||||
|
const modelTypeOptions = useMemo(() => getModelTypeOptions(t), [t])
|
||||||
|
const aiConfigFormSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1, t("aiConfig.nameRequired")),
|
||||||
|
provider: z.string().trim().min(1, t("aiConfig.providerRequired")),
|
||||||
|
baseUrl: z.string().trim().min(1, t("aiConfig.baseUrlRequired")),
|
||||||
|
apiKey: z.string().trim(),
|
||||||
|
modelType: z.string().trim().min(1, t("aiConfig.modelTypeRequired")),
|
||||||
|
modelName: z.string().trim().min(1, t("aiConfig.modelNameRequired")),
|
||||||
|
dimension: z.string().trim().regex(/^\d+$/, t("aiConfig.dimensionInvalid")),
|
||||||
|
maxContextTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxContextInvalid")),
|
||||||
|
maxOutputTokens: z.string().trim().regex(/^\d+$/, t("aiConfig.maxOutputInvalid")),
|
||||||
|
timeoutMs: z.string().trim().regex(/^\d+$/, t("aiConfig.timeoutInvalid")),
|
||||||
|
maxRetryCount: z.string().trim().regex(/^\d+$/, t("aiConfig.retryInvalid")),
|
||||||
|
rpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.rpmInvalid")),
|
||||||
|
tpmLimit: z.string().trim().regex(/^\d+$/, t("aiConfig.tpmInvalid")),
|
||||||
|
remark: z.string().trim(),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
const editFormResolver = useMemo(
|
||||||
|
() => zodResolver(aiConfigFormSchema as never) as Resolver<EditForm>,
|
||||||
|
[aiConfigFormSchema],
|
||||||
|
)
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver: editFormResolver,
|
||||||
|
defaultValues: emptyForm,
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
const modelType = watch("modelType")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!itemId) {
|
||||||
|
reset(emptyForm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAIConfig(itemId)
|
||||||
|
reset(buildForm(data))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load AI config:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDetail()
|
||||||
|
}, [itemId, reset])
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
await onSubmit(buildPayload(values))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={itemId ? t("aiConfig.editTitle") : t("aiConfig.createTitle")}
|
||||||
|
size="xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("aiConfig.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||||
|
{saving ? t("aiConfig.saving") : itemId ? t("aiConfig.save") : t("aiConfig.create")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-muted-foreground">{t("aiConfig.loading")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="ai-config-name">{t("aiConfig.name")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-name"
|
||||||
|
placeholder={t("aiConfig.namePlaceholder")}
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.name]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.provider}>
|
||||||
|
<FieldLabel>{t("aiConfig.provider")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={field.value}
|
||||||
|
options={providerOptions}
|
||||||
|
placeholder={t("aiConfig.selectProvider")}
|
||||||
|
searchPlaceholder={t("aiConfig.searchProvider")}
|
||||||
|
emptyText={t("aiConfig.emptyProvider")}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.provider]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.modelType}>
|
||||||
|
<FieldLabel>{t("aiConfig.modelType")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="modelType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={field.value}
|
||||||
|
options={modelTypeOptions}
|
||||||
|
placeholder={t("aiConfig.selectModelType")}
|
||||||
|
searchPlaceholder={t("aiConfig.searchModelType")}
|
||||||
|
emptyText={t("aiConfig.emptyModelType")}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.modelType]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.baseUrl}>
|
||||||
|
<FieldLabel htmlFor="ai-config-base-url">{t("aiConfig.baseUrl")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-base-url"
|
||||||
|
placeholder={t("aiConfig.baseUrlPlaceholder")}
|
||||||
|
aria-invalid={!!errors.baseUrl}
|
||||||
|
{...register("baseUrl")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.baseUrl]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.apiKey}>
|
||||||
|
<FieldLabel htmlFor="ai-config-api-key">API Key</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-api-key"
|
||||||
|
type="password"
|
||||||
|
placeholder={t("aiConfig.apiKeyPlaceholder")}
|
||||||
|
aria-invalid={!!errors.apiKey}
|
||||||
|
{...register("apiKey")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.apiKey]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.modelName}>
|
||||||
|
<FieldLabel htmlFor="ai-config-model-name">{t("aiConfig.modelName")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-model-name"
|
||||||
|
placeholder={t("aiConfig.modelNamePlaceholder")}
|
||||||
|
aria-invalid={!!errors.modelName}
|
||||||
|
{...register("modelName")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.modelName]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.dimension}>
|
||||||
|
<FieldLabel htmlFor="ai-config-dimension">{t("aiConfig.dimensionLabel")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-dimension"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
disabled={modelType !== AIModelType.Embedding}
|
||||||
|
aria-invalid={!!errors.dimension}
|
||||||
|
{...register("dimension")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.dimension]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.maxContextTokens}>
|
||||||
|
<FieldLabel htmlFor="ai-config-max-context">{t("aiConfig.maxContextTokens")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-max-context"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
aria-invalid={!!errors.maxContextTokens}
|
||||||
|
{...register("maxContextTokens")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.maxContextTokens]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.maxOutputTokens}>
|
||||||
|
<FieldLabel htmlFor="ai-config-max-output">{t("aiConfig.maxOutputTokens")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-max-output"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
aria-invalid={!!errors.maxOutputTokens}
|
||||||
|
{...register("maxOutputTokens")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.maxOutputTokens]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.timeoutMs}>
|
||||||
|
<FieldLabel htmlFor="ai-config-timeout">{t("aiConfig.timeoutMs")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-timeout"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
aria-invalid={!!errors.timeoutMs}
|
||||||
|
{...register("timeoutMs")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.timeoutMs]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.maxRetryCount}>
|
||||||
|
<FieldLabel htmlFor="ai-config-retry">{t("aiConfig.maxRetryCount")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-retry"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
aria-invalid={!!errors.maxRetryCount}
|
||||||
|
{...register("maxRetryCount")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.maxRetryCount]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Field data-invalid={!!errors.rpmLimit}>
|
||||||
|
<FieldLabel htmlFor="ai-config-rpm">{t("aiConfig.rpmLimit")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-rpm"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
aria-invalid={!!errors.rpmLimit}
|
||||||
|
{...register("rpmLimit")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.rpmLimit]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.tpmLimit}>
|
||||||
|
<FieldLabel htmlFor="ai-config-tpm">{t("aiConfig.tpmLimit")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="ai-config-tpm"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
aria-invalid={!!errors.tpmLimit}
|
||||||
|
{...register("tpmLimit")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.tpmLimit]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.remark}>
|
||||||
|
<FieldLabel htmlFor="ai-config-remark">{t("aiConfig.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="ai-config-remark"
|
||||||
|
placeholder={t("aiConfig.remarkPlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
aria-invalid={!!errors.remark}
|
||||||
|
{...register("remark")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.remark]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
299
web/app/(dashboard)/dashboard/ai-configs/page.tsx
Normal file
299
web/app/(dashboard)/dashboard/ai-configs/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardCrudPage,
|
||||||
|
createDashboardStatusColumn,
|
||||||
|
type DashboardCrudColumn,
|
||||||
|
type DashboardCrudFilter,
|
||||||
|
} from "@/components/dashboard/crud";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
createAIConfig,
|
||||||
|
deleteAIConfig,
|
||||||
|
fetchAIConfigs,
|
||||||
|
updateAIConfig,
|
||||||
|
updateAIConfigSort,
|
||||||
|
updateAIConfigStatus,
|
||||||
|
type AIConfig,
|
||||||
|
type CreateAIConfigPayload,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { AIModelType, AIProvider, Status } from "@/lib/generated/enums";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { EditDialog } from "./_components/edit";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function getStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("aiConfig.allStatuses") },
|
||||||
|
{ value: String(Status.Ok), label: t("aiConfig.enabled") },
|
||||||
|
{ value: String(Status.Disabled), label: t("aiConfig.disabled") },
|
||||||
|
{ value: String(Status.Deleted), label: t("aiConfig.deletedStatus") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderOptions(t: TFunction, includeAll = true) {
|
||||||
|
const options = [
|
||||||
|
{ value: String(AIProvider.OpenAI), label: t("aiConfig.providerOpenAI") },
|
||||||
|
];
|
||||||
|
return includeAll
|
||||||
|
? [{ value: "all", label: t("aiConfig.allProviders") }, ...options]
|
||||||
|
: options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelTypeOptions(t: TFunction, includeAll = true) {
|
||||||
|
const options = [
|
||||||
|
{ value: String(AIModelType.LLM), label: t("aiConfig.modelTypeLlm") },
|
||||||
|
{
|
||||||
|
value: String(AIModelType.Embedding),
|
||||||
|
label: t("aiConfig.modelTypeEmbedding"),
|
||||||
|
},
|
||||||
|
{ value: String(AIModelType.Rerank), label: t("aiConfig.modelTypeRerank") },
|
||||||
|
];
|
||||||
|
return includeAll
|
||||||
|
? [{ value: "all", label: t("aiConfig.allTypes") }, ...options]
|
||||||
|
: options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(value: Status, t: TFunction) {
|
||||||
|
return (
|
||||||
|
getStatusOptions(t).find((item) => item.value === String(value))?.label ??
|
||||||
|
String(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderLabel(value: AIProvider, t: TFunction) {
|
||||||
|
return (
|
||||||
|
getProviderOptions(t, false).find((item) => item.value === String(value))
|
||||||
|
?.label ?? String(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelTypeLabel(value: AIModelType, t: TFunction) {
|
||||||
|
return (
|
||||||
|
getModelTypeOptions(t, false).find((item) => item.value === String(value))
|
||||||
|
?.label ?? String(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextStatus(item: AIConfig) {
|
||||||
|
return item.status === Status.Ok ? Status.Disabled : Status.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardAIConfigsPage() {
|
||||||
|
const t = useI18n();
|
||||||
|
const listStatusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||||
|
const providerFilterOptions = useMemo(() => getProviderOptions(t), [t]);
|
||||||
|
const modelTypeFilterOptions = useMemo(() => getModelTypeOptions(t), [t]);
|
||||||
|
|
||||||
|
const filters = useMemo<DashboardCrudFilter[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("aiConfig.filterName"),
|
||||||
|
placeholder: t("aiConfig.filterName"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modelType",
|
||||||
|
label: t("aiConfig.allTypes"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: modelTypeFilterOptions,
|
||||||
|
className: "w-full sm:w-40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "provider",
|
||||||
|
label: t("aiConfig.allProviders"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: providerFilterOptions,
|
||||||
|
className: "w-full sm:w-40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("aiConfig.allStatuses"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: listStatusOptions,
|
||||||
|
className: "w-full sm:w-32",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[listStatusOptions, modelTypeFilterOptions, providerFilterOptions, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<DashboardCrudColumn<AIConfig>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "config",
|
||||||
|
label: t("aiConfig.columnConfig"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="space-y-1 text-sm font-medium">{item.name}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "provider",
|
||||||
|
label: t("aiConfig.columnProvider"),
|
||||||
|
render: (item) => (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{getProviderLabel(item.provider as AIProvider, t)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "model",
|
||||||
|
label: t("aiConfig.columnModel"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{getModelTypeLabel(item.modelType as AIModelType, t)}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-sm">{item.modelName}</div>
|
||||||
|
{item.dimension > 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("aiConfig.dimension", { count: item.dimension })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "access",
|
||||||
|
label: t("aiConfig.columnAccess"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div className="line-clamp-1">{item.baseUrl}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("aiConfig.apiKey", { key: item.hasApiKey ? "****" : "-" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "limits",
|
||||||
|
label: t("aiConfig.columnLimits"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
{t("aiConfig.contextTokens", {
|
||||||
|
count: item.maxContextTokens || 0,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("aiConfig.outputTokens", {
|
||||||
|
count: item.maxOutputTokens || 0,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("aiConfig.timeoutRetry", {
|
||||||
|
timeout: item.timeoutMs,
|
||||||
|
retries: item.maxRetryCount,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
RPM {item.rpmLimit || 0} / TPM {item.tpmLimit || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
createDashboardStatusColumn<AIConfig, number>({
|
||||||
|
label: t("aiConfig.columnStatus"),
|
||||||
|
getStatus: (item) => item.status,
|
||||||
|
getLabel: (status) => getStatusLabel(status as Status, t),
|
||||||
|
getBadgeVariant: (status) =>
|
||||||
|
status === Status.Ok ? "default" : "outline",
|
||||||
|
isEnabled: (status) => status === Status.Ok,
|
||||||
|
toggle: {
|
||||||
|
getNextStatus,
|
||||||
|
updateStatus: (item, nextStatus) =>
|
||||||
|
updateAIConfigStatus(item.id, nextStatus),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t("aiConfig.statusChanged", {
|
||||||
|
name: item.name,
|
||||||
|
status:
|
||||||
|
nextStatus === Status.Ok
|
||||||
|
? t("aiConfig.enabled")
|
||||||
|
: t("aiConfig.disabled"),
|
||||||
|
}),
|
||||||
|
errorMessage: t("aiConfig.statusUpdateFailed"),
|
||||||
|
ariaLabel: (item) => t("aiConfig.toggleStatus", { name: item.name }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCrudPage<AIConfig, CreateAIConfigPayload>
|
||||||
|
filters={filters}
|
||||||
|
columns={columns}
|
||||||
|
fetchList={(query) =>
|
||||||
|
fetchAIConfigs({
|
||||||
|
name: typeof query.name === "string" ? query.name : undefined,
|
||||||
|
status: typeof query.status === "string" ? query.status : undefined,
|
||||||
|
provider:
|
||||||
|
typeof query.provider === "string" ? query.provider : undefined,
|
||||||
|
modelType:
|
||||||
|
typeof query.modelType === "string" ? query.modelType : undefined,
|
||||||
|
page: Number(query.page),
|
||||||
|
limit: Number(query.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createAIConfig}
|
||||||
|
updateItem={(item, payload) => updateAIConfig({ id: item.id, ...payload })}
|
||||||
|
deleteItem={(item) => deleteAIConfig(item.id)}
|
||||||
|
canDelete={(item) => item.status !== Status.Ok}
|
||||||
|
deleteConfirm={(item) => ({
|
||||||
|
title: t("aiConfig.confirmDeleteTitle"),
|
||||||
|
description: t("aiConfig.confirmDeleteDescription", {
|
||||||
|
name: item.name,
|
||||||
|
}),
|
||||||
|
confirmText: t("aiConfig.confirmDelete"),
|
||||||
|
cancelText: t("aiConfig.cancel"),
|
||||||
|
variant: "destructive",
|
||||||
|
})}
|
||||||
|
sort={{
|
||||||
|
enabled: true,
|
||||||
|
onReorder: (items) => updateAIConfigSort(items.map((item) => item.id)),
|
||||||
|
successMessage: t("aiConfig.sortUpdated"),
|
||||||
|
errorMessage: t("aiConfig.sortUpdateFailed"),
|
||||||
|
handleLabel: t("aiConfig.dragSort", { name: "" }),
|
||||||
|
}}
|
||||||
|
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||||
|
<EditDialog
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
labels={{
|
||||||
|
refresh: t("aiConfig.refresh"),
|
||||||
|
create: t("aiConfig.new"),
|
||||||
|
query: t("aiConfig.query"),
|
||||||
|
loading: t("aiConfig.loadingRows"),
|
||||||
|
empty: t("aiConfig.emptyRows"),
|
||||||
|
actions: t("aiConfig.columnActions"),
|
||||||
|
edit: t("aiConfig.edit"),
|
||||||
|
delete: t("aiConfig.delete"),
|
||||||
|
processing: t("aiConfig.deleting"),
|
||||||
|
moreActions: (item) => t("aiConfig.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("aiConfig.loadFailed"),
|
||||||
|
saveFailed: t("aiConfig.saveFailed"),
|
||||||
|
deleteFailed: t("aiConfig.deleteFailed"),
|
||||||
|
created: (payload) => t("aiConfig.created", { name: payload.name }),
|
||||||
|
updated: (item) => t("aiConfig.updated", { name: item.name }),
|
||||||
|
deleted: (item) => t("aiConfig.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { OfficialWorkflowEditor } from "../../ai-workflows/_components/official-workflow-editor"
|
||||||
|
|
||||||
|
export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) {
|
||||||
|
const t = useI18n()
|
||||||
|
const nodeRuns = useMemo(() => run.nodes ?? [], [run.nodes])
|
||||||
|
const firstNodeId = nodeRuns[0]?.nodeId ?? run.definition?.nodes?.[0]?.id ?? ""
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId)
|
||||||
|
const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0]
|
||||||
|
const executedNodeIds = useMemo(() => new Set(nodeRuns.map((item) => item.nodeId)), [nodeRuns])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<OfficialWorkflowEditor
|
||||||
|
documentKey={`run-${run.id}`}
|
||||||
|
definition={run.definition ?? { nodes: [], edges: [] }}
|
||||||
|
onDefinitionChange={() => undefined}
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<CheckCircle2Icon className="size-3" />
|
||||||
|
{t("aiWorkflow.executedCount", { count: String(executedNodeIds.size) })}
|
||||||
|
</Badge>
|
||||||
|
{run.errorMessage ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangleIcon className="size-3" />
|
||||||
|
{t("aiWorkflow.errorStatus")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="flex min-h-0 flex-col border-l bg-background">
|
||||||
|
<div className="border-b p-3">
|
||||||
|
<div className="text-sm font-medium">{t("aiWorkflow.nodeTraceTitle")}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{t("aiWorkflow.nodeTraceHint")}</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{nodeRuns.map((node) => (
|
||||||
|
<button
|
||||||
|
key={node.id || node.nodeId}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border p-2 text-left text-xs hover:bg-muted",
|
||||||
|
selectedNodeId === node.nodeId ? "border-primary bg-primary/5" : "bg-background"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedNodeId(node.nodeId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-medium">{node.nodeId}</span>
|
||||||
|
<Badge variant={node.errorMessage ? "destructive" : "secondary"}>{node.statusName}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||||
|
<TimerIcon className="size-3" />
|
||||||
|
{node.durationMs ?? 0}ms
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="max-h-72 overflow-auto border-t p-3">
|
||||||
|
<NodeRunPreview nodeRun={selectedNodeRun} t={t} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeRunPreview({ nodeRun, t }: { nodeRun?: AIWorkflowNodeRun; t: (key: string, values?: Record<string, string | number>) => string }) {
|
||||||
|
if (!nodeRun) {
|
||||||
|
return <div className="text-xs text-muted-foreground">{t("aiWorkflow.noNodeRuns")}</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
{nodeRun.errorMessage ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2 text-destructive">
|
||||||
|
{nodeRun.errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<PreviewBlock title={t("aiWorkflow.inputTitle")} value={nodeRun.inputPreview} emptyText={t("aiWorkflow.noneValue")} />
|
||||||
|
<PreviewBlock title={t("aiWorkflow.outputTitle")} value={nodeRun.outputPreview} emptyText={t("aiWorkflow.noneValue")} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewBlock({ title, value, emptyText }: { title: string; value?: string; emptyText?: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 font-medium">{title}</div>
|
||||||
|
{value ? <JsonTreeViewer value={parsePreview(value)} /> : <div className="text-muted-foreground">{emptyText || "None"}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePreview(value: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
523
web/app/(dashboard)/dashboard/ai-workflow-runs/page.tsx
Normal file
523
web/app/(dashboard)/dashboard/ai-workflow-runs/page.tsx
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
Clock3Icon,
|
||||||
|
MessageCircleMoreIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
|
WorkflowIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { ConversationDetailDialog } from "@/app/(dashboard)/dashboard/conversation-monitor/_components/detail"
|
||||||
|
import { DashboardListPage } from "@/components/dashboard/list"
|
||||||
|
import { JsonTreeViewer } from "@/components/json-tree-viewer"
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
fetchAIAgentsAll,
|
||||||
|
fetchAIWorkflowRun,
|
||||||
|
fetchAIWorkflowRuns,
|
||||||
|
fetchConversationDetail,
|
||||||
|
fetchConversationMessages,
|
||||||
|
type AdminConversationDetail,
|
||||||
|
type AdminMessage,
|
||||||
|
type AIAgent,
|
||||||
|
type AIWorkflowNodeRun,
|
||||||
|
type AIWorkflowRun,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { WorkflowRunAuditGraph } from "./_components/workflow-run-audit-graph"
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
function getStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("workflowRun.allStatus") },
|
||||||
|
{ value: "1", label: t("workflowRun.completed") },
|
||||||
|
{ value: "2", label: t("workflowRun.interrupted") },
|
||||||
|
{ value: "3", label: t("workflowRun.failed") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeVariant(statusName?: string) {
|
||||||
|
switch ((statusName || "").trim()) {
|
||||||
|
case "failed":
|
||||||
|
return "destructive" as const
|
||||||
|
case "interrupted":
|
||||||
|
return "outline" as const
|
||||||
|
case "completed":
|
||||||
|
return "default" as const
|
||||||
|
default:
|
||||||
|
return "secondary" as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRunStatus(item: Pick<AIWorkflowRun, "status" | "statusName">) {
|
||||||
|
return item.statusName || (item.status ? String(item.status) : "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardAIWorkflowRunsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [agents, setAgents] = useState<AIAgent[]>([])
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [activeRun, setActiveRun] = useState<AIWorkflowRun | null>(null)
|
||||||
|
const [conversationOpen, setConversationOpen] = useState(false)
|
||||||
|
const [conversationLoading, setConversationLoading] = useState(false)
|
||||||
|
const [activeConversation, setActiveConversation] = useState<AdminConversationDetail | null>(null)
|
||||||
|
const [conversationMessages, setConversationMessages] = useState<AdminMessage[]>([])
|
||||||
|
const statusOptions = useMemo(() => getStatusOptions(t), [t])
|
||||||
|
const agentOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "all", label: t("workflowRun.allAgents") },
|
||||||
|
...agents.map((agent) => ({
|
||||||
|
value: String(agent.id),
|
||||||
|
label: agent.name,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[agents, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
try {
|
||||||
|
const data = await fetchAIAgentsAll()
|
||||||
|
if (!cancelled) {
|
||||||
|
setAgents(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("workflowRun.loadAgentsFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadAgents()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
async function openDetail(runId: number) {
|
||||||
|
setDetailOpen(true)
|
||||||
|
setDetailLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAIWorkflowRun(runId)
|
||||||
|
setActiveRun(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("workflowRun.loadDetailFailed"))
|
||||||
|
setDetailOpen(false)
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConversation(conversationId: number) {
|
||||||
|
if (!conversationId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConversationOpen(true)
|
||||||
|
setConversationLoading(true)
|
||||||
|
try {
|
||||||
|
const [detail, messagePage] = await Promise.all([
|
||||||
|
fetchConversationDetail(conversationId),
|
||||||
|
fetchConversationMessages({ conversationId, limit: 20 }),
|
||||||
|
])
|
||||||
|
setActiveConversation(detail)
|
||||||
|
setConversationMessages(messagePage.results ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("workflowRun.loadConversationFailed"))
|
||||||
|
setConversationOpen(false)
|
||||||
|
} finally {
|
||||||
|
setConversationLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardListPage<AIWorkflowRun>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "conversationId",
|
||||||
|
label: t("workflowRun.conversationId"),
|
||||||
|
placeholder: t("workflowRun.conversationId"),
|
||||||
|
defaultValue: "",
|
||||||
|
valueType: "number",
|
||||||
|
className: "w-full sm:w-44",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "messageId",
|
||||||
|
label: t("workflowRun.messageId"),
|
||||||
|
placeholder: t("workflowRun.messageId"),
|
||||||
|
defaultValue: "",
|
||||||
|
valueType: "number",
|
||||||
|
className: "w-full sm:w-40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "workflowVersionId",
|
||||||
|
label: t("workflowRun.workflowVersionId"),
|
||||||
|
placeholder: t("workflowRun.workflowVersionId"),
|
||||||
|
defaultValue: "",
|
||||||
|
valueType: "number",
|
||||||
|
className: "w-full sm:w-48",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aiAgentId",
|
||||||
|
label: t("workflowRun.agent"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
valueType: "number",
|
||||||
|
options: agentOptions,
|
||||||
|
placeholder: t("workflowRun.agent"),
|
||||||
|
searchPlaceholder: t("workflowRun.searchAgent"),
|
||||||
|
emptyText: t("workflowRun.emptyAgent"),
|
||||||
|
className: "w-full sm:w-56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("workflowRun.status"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
valueType: "number",
|
||||||
|
options: statusOptions,
|
||||||
|
placeholder: t("workflowRun.status"),
|
||||||
|
className: "w-full sm:w-44",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={fetchAIWorkflowRuns}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
getRowClassName={() => "cursor-pointer"}
|
||||||
|
onRowClick={(item) => void openDetail(item.id)}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "time",
|
||||||
|
label: t("workflowRun.startedAt"),
|
||||||
|
className: "w-42 text-xs text-muted-foreground",
|
||||||
|
render: (item) => formatDateTime(item.startedAt || item.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workflow",
|
||||||
|
label: t("workflowRun.workflow"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
{item.workflowName || `Workflow #${item.workflowId}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>v{item.workflowVersion || "-"}</span>
|
||||||
|
<span>#{item.workflowVersionId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agent",
|
||||||
|
label: t("workflowRun.agent"),
|
||||||
|
className: "w-48",
|
||||||
|
render: (item) => item.aiAgentName || `#${item.aiAgentId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "message",
|
||||||
|
label: t("workflowRun.message"),
|
||||||
|
className: "w-48",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto max-w-full justify-start px-0 py-0 text-sm font-normal hover:bg-transparent hover:text-primary"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
void openConversation(item.conversationId)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircleMoreIcon className="size-3.5" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t("workflowRun.conversationShort", { id: item.conversationId || "-" })}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("workflowRun.messageShort", { id: item.messageId || "-" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: t("workflowRun.status"),
|
||||||
|
className: "w-32",
|
||||||
|
render: (item) => (
|
||||||
|
<Badge variant={statusBadgeVariant(item.statusName)}>
|
||||||
|
{formatRunStatus(item)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
label: t("workflowRun.duration"),
|
||||||
|
className: "w-28 text-right",
|
||||||
|
render: (item) => `${item.durationMs || 0} ms`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "error",
|
||||||
|
label: t("workflowRun.error"),
|
||||||
|
className: "w-72 max-w-72",
|
||||||
|
render: (item) =>
|
||||||
|
item.errorMessage ? (
|
||||||
|
<ErrorMessagePreview message={item.errorMessage} />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
labels={{
|
||||||
|
refresh: t("workflowRun.refresh"),
|
||||||
|
query: t("workflowRun.query"),
|
||||||
|
loading: t("workflowRun.loading"),
|
||||||
|
empty: t("workflowRun.empty"),
|
||||||
|
loadFailed: t("workflowRun.loadFailed"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WorkflowRunDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
loading={detailLoading}
|
||||||
|
run={activeRun}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDetailOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setActiveRun(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
t={t}
|
||||||
|
onOpenConversation={openConversation}
|
||||||
|
/>
|
||||||
|
<ConversationDetailDialog
|
||||||
|
open={conversationOpen}
|
||||||
|
loading={conversationLoading}
|
||||||
|
saving={false}
|
||||||
|
readOnly
|
||||||
|
item={activeConversation}
|
||||||
|
detail={activeConversation}
|
||||||
|
messages={conversationMessages}
|
||||||
|
messagesHasMore={false}
|
||||||
|
loadingMoreMessages={false}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setConversationOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setActiveConversation(null)
|
||||||
|
setConversationMessages([])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenAssign={() => undefined}
|
||||||
|
onDispatch={async () => undefined}
|
||||||
|
onOpenTransfer={() => undefined}
|
||||||
|
onRead={async () => undefined}
|
||||||
|
onOpenClose={() => undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorMessagePreview({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full min-w-0 items-center gap-1.5 text-left text-xs text-destructive"
|
||||||
|
title={message}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="block min-w-0 truncate">{message}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowRunDetailDialog({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
run,
|
||||||
|
onOpenChange,
|
||||||
|
t,
|
||||||
|
onOpenConversation,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
loading: boolean
|
||||||
|
run: AIWorkflowRun | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
t: TFunction
|
||||||
|
onOpenConversation: (conversationId: number) => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<WorkflowIcon className="size-4" />
|
||||||
|
{t("workflowRun.detailTitle")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={run ? `Run #${run.id}` : t("workflowRun.detailDescription")}
|
||||||
|
size="xl"
|
||||||
|
allowFullscreen
|
||||||
|
defaultFullscreen
|
||||||
|
bodyClassName="min-h-0"
|
||||||
|
footer={
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("workflowRun.close")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-10 text-sm text-muted-foreground">{t("workflowRun.loadingDetail")}</div>
|
||||||
|
) : run ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2 text-xs">
|
||||||
|
<CompactRunMeta label={t("workflowRun.workflow")} value={run.workflowName || `#${run.workflowId}`} />
|
||||||
|
<CompactRunMeta label={t("workflowRun.version")} value={`v${run.workflowVersion || "-"} / #${run.workflowVersionId}`} />
|
||||||
|
<CompactRunMeta label={t("workflowRun.agent")} value={run.aiAgentName || `#${run.aiAgentId}`} />
|
||||||
|
<CompactRunMeta label={t("workflowRun.status")} value={formatRunStatus(run)} />
|
||||||
|
<CompactRunMeta
|
||||||
|
label={t("workflowRun.conversationId")}
|
||||||
|
value={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
className="h-auto px-0 py-0 text-xs font-medium"
|
||||||
|
onClick={() => void onOpenConversation(run.conversationId)}
|
||||||
|
>
|
||||||
|
#{run.conversationId}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CompactRunMeta label={t("workflowRun.messageId")} value={`#${run.messageId}`} />
|
||||||
|
<CompactRunMeta label={t("workflowRun.startedAt")} value={run.startedAt ? formatDateTime(run.startedAt) : "-"} />
|
||||||
|
<CompactRunMeta label={t("workflowRun.duration")} value={`${run.durationMs || 0} ms`} />
|
||||||
|
{run.interruptNodeId ? <CompactRunMeta label={t("workflowRun.interruptNode")} value={run.interruptNodeId} /> : null}
|
||||||
|
</div>
|
||||||
|
{run.errorMessage ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{run.errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<WorkflowRunAuditGraph run={run} />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium">{t("workflowRun.nodeDetails")}</div>
|
||||||
|
{(run.nodes ?? []).map((node, index) => (
|
||||||
|
<WorkflowNodeRunBlock key={node.id || node.nodeId || index} node={node} t={t} />
|
||||||
|
))}
|
||||||
|
{!run.nodes || run.nodes.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("workflowRun.emptyNodes")}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-10 text-sm text-muted-foreground">{t("workflowRun.notFound")}</div>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowNodeRunBlock({ node, t }: { node: AIWorkflowNodeRun; t: TFunction }) {
|
||||||
|
const inputPreview = node.inputPreview || ""
|
||||||
|
const outputPreview = node.outputPreview || ""
|
||||||
|
const inputValue = safeParseJSON(inputPreview)
|
||||||
|
const outputValue = safeParseJSON(outputPreview)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-background p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<MessageSquareTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-sm font-medium">{node.nodeId || `#${node.id}`}</span>
|
||||||
|
<Badge variant={statusBadgeVariant(node.statusName)}>{node.statusName || node.status || "-"}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{node.nodeType || "unknown"}</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Clock3Icon className="size-3.5" />
|
||||||
|
{node.durationMs} ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{node.errorMessage ? (
|
||||||
|
<div className="flex max-w-xl items-start gap-1.5 text-xs text-destructive">
|
||||||
|
<AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<span className="line-clamp-2 break-all">{node.errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||||||
|
<PreviewBlock title={t("workflowRun.input")} raw={inputPreview} value={inputValue} />
|
||||||
|
<PreviewBlock title={t("workflowRun.output")} raw={outputPreview} value={outputValue} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewBlock({
|
||||||
|
title,
|
||||||
|
raw,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
raw: string
|
||||||
|
value: unknown
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1 text-xs font-medium text-muted-foreground">{title}</div>
|
||||||
|
{value !== null ? (
|
||||||
|
<JsonTreeViewer value={value} collapsed={2} />
|
||||||
|
) : raw.trim() ? (
|
||||||
|
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all">
|
||||||
|
{raw}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
-
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="min-w-0 truncate font-medium">{value || "-"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactRunMeta({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex max-w-full items-center gap-1.5 rounded-md border bg-background px-2 py-1">
|
||||||
|
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||||
|
<span className="min-w-0 truncate font-medium">{value || "-"}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJSON(raw: string): unknown | null {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
|
||||||
|
|
||||||
|
const MESSAGE_SOURCE = "agent-desk"
|
||||||
|
const EMPTY_NODE_SPECS: AIWorkflowNodeSpec[] = []
|
||||||
|
|
||||||
|
function definitionsEqual(
|
||||||
|
left: AIWorkflowDefinition,
|
||||||
|
right: AIWorkflowDefinition
|
||||||
|
) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorMessage =
|
||||||
|
| {
|
||||||
|
source: typeof MESSAGE_SOURCE
|
||||||
|
type: "workflow:ready"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
source: typeof MESSAGE_SOURCE
|
||||||
|
type: "workflow:change"
|
||||||
|
document: AIWorkflowDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfficialWorkflowEditor({
|
||||||
|
documentKey,
|
||||||
|
definition,
|
||||||
|
nodeSpecs = EMPTY_NODE_SPECS,
|
||||||
|
onDefinitionChange,
|
||||||
|
readonly = false,
|
||||||
|
}: {
|
||||||
|
documentKey: string
|
||||||
|
definition: AIWorkflowDefinition
|
||||||
|
nodeSpecs?: AIWorkflowNodeSpec[]
|
||||||
|
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
||||||
|
readonly?: boolean
|
||||||
|
}) {
|
||||||
|
const t = useI18n()
|
||||||
|
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const definitionRef = useRef(definition)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
definitionRef.current = definition
|
||||||
|
}, [definition])
|
||||||
|
|
||||||
|
const loadDocument = useCallback(() => {
|
||||||
|
frameRef.current?.contentWindow?.postMessage(
|
||||||
|
{
|
||||||
|
source: MESSAGE_SOURCE,
|
||||||
|
type: "workflow:load",
|
||||||
|
documentKey,
|
||||||
|
document: definitionRef.current,
|
||||||
|
nodeSpecs,
|
||||||
|
readonly,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
)
|
||||||
|
}, [documentKey, nodeSpecs, readonly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent<EditorMessage>) => {
|
||||||
|
if (
|
||||||
|
event.origin !== window.location.origin ||
|
||||||
|
event.source !== frameRef.current?.contentWindow ||
|
||||||
|
event.data?.source !== MESSAGE_SOURCE
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.data.type === "workflow:ready") {
|
||||||
|
loadDocument()
|
||||||
|
} else if (event.data.type === "workflow:change") {
|
||||||
|
if (definitionsEqual(event.data.document, definitionRef.current)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
definitionRef.current = event.data.document
|
||||||
|
onDefinitionChange(event.data.document)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("message", handleMessage)
|
||||||
|
return () => window.removeEventListener("message", handleMessage)
|
||||||
|
}, [loadDocument, onDefinitionChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDocument()
|
||||||
|
}, [loadDocument])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
ref={frameRef}
|
||||||
|
src="/flowgram-editor/index.html"
|
||||||
|
title={t("aiWorkflow.editorTitle")}
|
||||||
|
className="h-full min-h-[560px] w-full border-0 bg-white"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
fetchAIWorkflowUsage,
|
||||||
|
type AIWorkflow,
|
||||||
|
type AIWorkflowUsage,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
|
||||||
|
export function WorkflowUsageDialog({
|
||||||
|
workflow,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
workflow: AIWorkflow | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [usage, setUsage] = useState<AIWorkflowUsage[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!workflow || !open) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setUsage((await fetchAIWorkflowUsage(workflow.id)) ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [open, t, workflow])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("aiWorkflow.usageTitle")}
|
||||||
|
description={workflow?.name}
|
||||||
|
size="lg"
|
||||||
|
bodyClassName="min-h-[360px]"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("aiWorkflow.loadingUsage")}
|
||||||
|
</div>
|
||||||
|
) : usage.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{usage.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.aiAgentId}-${item.workflowVersionId}`}
|
||||||
|
className="flex items-center justify-between rounded-md border p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{item.aiAgentName}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("aiWorkflow.fixedBoundVersion", { version: String(item.workflowVersion) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={item.enabled ? "secondary" : "outline"}>
|
||||||
|
{item.enabled ? t("aiWorkflow.enabledStatus") : t("aiWorkflow.disabledStatus")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("aiWorkflow.notUsedByAnyAgent")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
fetchAIWorkflowVersions,
|
||||||
|
restoreAIWorkflowVersion,
|
||||||
|
type AIWorkflow,
|
||||||
|
type AIWorkflowVersion,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function WorkflowVersionsDialog({
|
||||||
|
workflow,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onRestored,
|
||||||
|
}: {
|
||||||
|
workflow: AIWorkflow | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onRestored: () => void
|
||||||
|
}) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [versions, setVersions] = useState<AIWorkflowVersion[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [restoringId, setRestoringId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!workflow || !open) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const page = await fetchAIWorkflowVersions({
|
||||||
|
workflowId: workflow.id,
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
setVersions(page.results ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [open, t, workflow])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function restore(version: AIWorkflowVersion) {
|
||||||
|
if (!workflow || restoringId !== null) return
|
||||||
|
setRestoringId(version.id)
|
||||||
|
try {
|
||||||
|
await restoreAIWorkflowVersion(workflow.id, version.id)
|
||||||
|
onRestored()
|
||||||
|
toast.success(t("aiWorkflow.restoredDraftSuccess", { version: String(version.version) }))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.restoreFailed"))
|
||||||
|
} finally {
|
||||||
|
setRestoringId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("aiWorkflow.versionsTitle")}
|
||||||
|
description={workflow?.name}
|
||||||
|
size="lg"
|
||||||
|
bodyClassName="min-h-[360px]"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("aiWorkflow.loadingVersions")}
|
||||||
|
</div>
|
||||||
|
) : versions.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{versions.map((version) => (
|
||||||
|
<div
|
||||||
|
key={version.id}
|
||||||
|
className="flex items-center gap-4 rounded-md border p-4"
|
||||||
|
>
|
||||||
|
<Badge>v{version.version}</Badge>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{formatDateTime(version.publishedAt || version.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("aiWorkflow.publisher", { name: version.publishedByName || "-" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={restoringId !== null}
|
||||||
|
onClick={() => void restore(version)}
|
||||||
|
>
|
||||||
|
{restoringId === version.id ? t("aiWorkflow.restoring") : t("aiWorkflow.restoreToDraft")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("aiWorkflow.noVersionsYet")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
import { PencilIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
createAIWorkflow,
|
||||||
|
fetchAIWorkflow,
|
||||||
|
fetchAIWorkflowDefaultDefinition,
|
||||||
|
fetchAIWorkflowNodeSpecs,
|
||||||
|
fetchAIWorkflowTemplates,
|
||||||
|
publishAIWorkflow,
|
||||||
|
updateAIWorkflow,
|
||||||
|
type AIWorkflow,
|
||||||
|
type AIWorkflowDefinition,
|
||||||
|
type AIWorkflowNodeSpec,
|
||||||
|
type AIWorkflowTemplate,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
|
||||||
|
import { OfficialWorkflowEditor } from "./official-workflow-editor"
|
||||||
|
|
||||||
|
const emptyDefinition: AIWorkflowDefinition = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "start_1",
|
||||||
|
type: "start",
|
||||||
|
meta: { position: { x: 0, y: 80 } },
|
||||||
|
data: { title: "Start", config: {}, inputsValues: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "end_1",
|
||||||
|
type: "end",
|
||||||
|
meta: { position: { x: 260, y: 80 } },
|
||||||
|
data: { title: "End", config: {}, inputsValues: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
sourceNodeID: "start_1",
|
||||||
|
targetNodeID: "end_1",
|
||||||
|
sourcePortID: "edge_start_end",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowWorkbenchProps = {
|
||||||
|
workflowID?: number
|
||||||
|
onSaved?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowWorkbench({
|
||||||
|
workflowID,
|
||||||
|
onSaved,
|
||||||
|
}: WorkflowWorkbenchProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [active, setActive] = useState<AIWorkflow | null>(null)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [definition, setDefinition] =
|
||||||
|
useState<AIWorkflowDefinition>(emptyDefinition)
|
||||||
|
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
|
||||||
|
const [templates, setTemplates] = useState<AIWorkflowTemplate[]>([])
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [editingField, setEditingField] = useState<
|
||||||
|
"name" | "description" | null
|
||||||
|
>(null)
|
||||||
|
const editSnapshotRef = useRef({ value: "", dirty: false })
|
||||||
|
const cancellingEditRef = useRef(false)
|
||||||
|
const savingRef = useRef(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoaded(false)
|
||||||
|
const [specs, availableTemplates] = await Promise.all([
|
||||||
|
fetchAIWorkflowNodeSpecs(),
|
||||||
|
fetchAIWorkflowTemplates(),
|
||||||
|
])
|
||||||
|
setNodeSpecs(specs)
|
||||||
|
setTemplates(availableTemplates)
|
||||||
|
if (!workflowID) {
|
||||||
|
setDefinition(await fetchAIWorkflowDefaultDefinition())
|
||||||
|
setLoaded(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await fetchAIWorkflow(workflowID)
|
||||||
|
setActive(item)
|
||||||
|
setName(item.name)
|
||||||
|
setDescription(item.description)
|
||||||
|
setDefinition(item.draftDefinition)
|
||||||
|
setDirty(false)
|
||||||
|
setLoaded(true)
|
||||||
|
}, [workflowID])
|
||||||
|
|
||||||
|
const handleDefinitionChange = useCallback(
|
||||||
|
(next: AIWorkflowDefinition) => {
|
||||||
|
setDefinition(next)
|
||||||
|
setDirty(true)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load().catch((error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.loadFailed"))
|
||||||
|
)
|
||||||
|
}, [load, t])
|
||||||
|
|
||||||
|
function beginEditing(field: "name" | "description") {
|
||||||
|
cancellingEditRef.current = false
|
||||||
|
editSnapshotRef.current = {
|
||||||
|
value: field === "name" ? name : description,
|
||||||
|
dirty,
|
||||||
|
}
|
||||||
|
setEditingField(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishEditing(field: "name" | "description") {
|
||||||
|
if (cancellingEditRef.current) {
|
||||||
|
cancellingEditRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditingField(null)
|
||||||
|
const currentValue = field === "name" ? name.trim() : description.trim()
|
||||||
|
if (currentValue === editSnapshotRef.current.value) {
|
||||||
|
setDirty(editSnapshotRef.current.dirty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (field === "name" && !currentValue) {
|
||||||
|
setName(editSnapshotRef.current.value)
|
||||||
|
setDirty(editSnapshotRef.current.dirty)
|
||||||
|
toast.error(t("aiWorkflow.nameRequired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextName = field === "name" ? currentValue : name.trim()
|
||||||
|
const nextDescription =
|
||||||
|
field === "description" ? currentValue : description.trim()
|
||||||
|
if (savingRef.current) return
|
||||||
|
savingRef.current = true
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (active) {
|
||||||
|
await updateAIWorkflow({
|
||||||
|
id: active.id,
|
||||||
|
name: nextName,
|
||||||
|
description: nextDescription,
|
||||||
|
definition: active.draftDefinition,
|
||||||
|
})
|
||||||
|
setActive((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
name: nextName,
|
||||||
|
description: nextDescription,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const created = await createAIWorkflow({
|
||||||
|
name: nextName,
|
||||||
|
description: nextDescription,
|
||||||
|
definition,
|
||||||
|
})
|
||||||
|
setActive(created)
|
||||||
|
}
|
||||||
|
setName(nextName)
|
||||||
|
setDescription(nextDescription)
|
||||||
|
setDirty(active ? editSnapshotRef.current.dirty : false)
|
||||||
|
onSaved?.()
|
||||||
|
toast.success(field === "name" ? t("aiWorkflow.nameSaved") : t("aiWorkflow.descriptionSaved"))
|
||||||
|
} catch (error) {
|
||||||
|
setDirty(true)
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing(field: "name" | "description") {
|
||||||
|
cancellingEditRef.current = true
|
||||||
|
if (field === "name") {
|
||||||
|
setName(editSnapshotRef.current.value)
|
||||||
|
} else {
|
||||||
|
setDescription(editSnapshotRef.current.value)
|
||||||
|
}
|
||||||
|
setDirty(editSnapshotRef.current.dirty)
|
||||||
|
setEditingField(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (savingRef.current) return
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error(t("aiWorkflow.nameRequired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const savedName = name.trim()
|
||||||
|
const savedDescription = description.trim()
|
||||||
|
savingRef.current = true
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (active) {
|
||||||
|
await updateAIWorkflow({
|
||||||
|
id: active.id,
|
||||||
|
name: savedName,
|
||||||
|
description: savedDescription,
|
||||||
|
definition,
|
||||||
|
})
|
||||||
|
setActive((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
name: savedName,
|
||||||
|
description: savedDescription,
|
||||||
|
draftDefinition: definition,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
setName(savedName)
|
||||||
|
setDescription(savedDescription)
|
||||||
|
setDirty(false)
|
||||||
|
} else {
|
||||||
|
const created = await createAIWorkflow({
|
||||||
|
name: savedName,
|
||||||
|
description: savedDescription,
|
||||||
|
definition,
|
||||||
|
})
|
||||||
|
setActive(created)
|
||||||
|
setName(created.name)
|
||||||
|
setDescription(created.description)
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
onSaved?.()
|
||||||
|
toast.success(t("aiWorkflow.savedSuccess"))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.saveFailed"))
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
if (savingRef.current) return
|
||||||
|
if (!active) {
|
||||||
|
toast.error(t("aiWorkflow.saveFirst"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
savingRef.current = true
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const version = await publishAIWorkflow(active.id, definition)
|
||||||
|
setActive((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
draftDefinition: definition,
|
||||||
|
publishedVersionId: version.id,
|
||||||
|
updatedAt: version.updatedAt,
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
setDirty(false)
|
||||||
|
onSaved?.()
|
||||||
|
toast.success(t("aiWorkflow.publishedVersionSuccess", { version: String(version.version) }))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiWorkflow.publishFailed"))
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||||
|
<header className="shrink-0 border-b px-4 py-1.5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingField === "name" ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label={t("aiWorkflow.nameLabel")}
|
||||||
|
value={name}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
onChange={(event) => {
|
||||||
|
setName(event.target.value)
|
||||||
|
setDirty(true)
|
||||||
|
}}
|
||||||
|
onBlur={() => void finishEditing("name")}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
event.currentTarget.blur()
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
cancelEditing("name")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-7 min-w-48 max-w-xl flex-1 px-2 text-lg font-semibold"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Workflow Name"
|
||||||
|
onClick={() => beginEditing("name")}
|
||||||
|
className="group flex h-7 min-w-0 max-w-xl items-center gap-1.5 rounded-md px-1 text-left text-lg font-semibold hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<span className="truncate">{name || t("aiWorkflow.createTitle")}</span>
|
||||||
|
<PencilIcon className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant={active?.publishedVersionId ? "secondary" : "outline"}
|
||||||
|
>
|
||||||
|
{active?.publishedVersionId ? t("aiWorkflow.published") : t("aiWorkflow.draft")}
|
||||||
|
</Badge>
|
||||||
|
{dirty ? (
|
||||||
|
<span className="text-xs text-amber-600">{t("supportHelpWorkbench.unsaved")}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{editingField === "description" ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label="Workflow Description"
|
||||||
|
value={description}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDescription(event.target.value)
|
||||||
|
setDirty(true)
|
||||||
|
}}
|
||||||
|
onBlur={() => void finishEditing("description")}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
event.currentTarget.blur()
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
cancelEditing("description")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Workflow description..."
|
||||||
|
className="mt-0.5 h-6 max-w-2xl px-1 text-sm font-normal"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Workflow Description"
|
||||||
|
onClick={() => beginEditing("description")}
|
||||||
|
className="group mt-0.5 flex h-6 max-w-2xl items-center gap-1.5 rounded-md px-1 text-left text-sm text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{description || t("aiWorkflow.noDescription")}
|
||||||
|
</span>
|
||||||
|
<PencilIcon className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{!active ? (
|
||||||
|
<OptionCombobox
|
||||||
|
value={selectedTemplate}
|
||||||
|
placeholder="Template"
|
||||||
|
searchPlaceholder="Search template..."
|
||||||
|
emptyText="No templates"
|
||||||
|
triggerClassName="w-48"
|
||||||
|
options={templates.map((template) => ({
|
||||||
|
value: template.code,
|
||||||
|
label: template.name,
|
||||||
|
description: template.description,
|
||||||
|
}))}
|
||||||
|
onChange={(code) => {
|
||||||
|
const template = templates.find((item) => item.code === code)
|
||||||
|
if (!template) return
|
||||||
|
setSelectedTemplate(code)
|
||||||
|
setDefinition(structuredClone(template.definition))
|
||||||
|
setName(template.name)
|
||||||
|
setDescription(template.description)
|
||||||
|
setDirty(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving || !active}
|
||||||
|
onClick={() => void publish()}
|
||||||
|
>
|
||||||
|
{t("aiAgent.publishAgent")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 shrink-0" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{loaded ? (
|
||||||
|
<OfficialWorkflowEditor
|
||||||
|
documentKey={
|
||||||
|
workflowID
|
||||||
|
? `workflow-${workflowID}`
|
||||||
|
: `new-${selectedTemplate || "blank"}`
|
||||||
|
}
|
||||||
|
definition={definition}
|
||||||
|
nodeSpecs={nodeSpecs}
|
||||||
|
onDefinitionChange={handleDefinitionChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("aiWorkflow.loadingNodeCapabilities")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
web/app/(dashboard)/dashboard/ai-workflows/page.tsx
Normal file
168
web/app/(dashboard)/dashboard/ai-workflows/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
GitBranchIcon,
|
||||||
|
HistoryIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { DashboardCrudPage } from "@/components/dashboard/crud"
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
deleteAIWorkflow,
|
||||||
|
fetchAIWorkflows,
|
||||||
|
type AIWorkflow,
|
||||||
|
type CreateAIWorkflowPayload,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { WorkflowUsageDialog } from "./_components/workflow-usage-dialog"
|
||||||
|
import { WorkflowVersionsDialog } from "./_components/workflow-versions-dialog"
|
||||||
|
import { WorkflowWorkbench } from "./_components/workflow-workbench"
|
||||||
|
|
||||||
|
export default function DashboardAIWorkflowsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
|
const [versionsWorkflow, setVersionsWorkflow] =
|
||||||
|
useState<AIWorkflow | null>(null)
|
||||||
|
const [usageWorkflow, setUsageWorkflow] = useState<AIWorkflow | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardCrudPage<AIWorkflow, CreateAIWorkflowPayload>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("aiWorkflow.filterName"),
|
||||||
|
placeholder: t("aiWorkflow.searchName"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "workflow",
|
||||||
|
label: t("aiWorkflow.columnWorkflow"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<GitBranchIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">{item.name}</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{item.description || t("aiWorkflow.noDescription")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: t("aiWorkflow.columnStatus"),
|
||||||
|
render: (item) => (
|
||||||
|
<Badge
|
||||||
|
variant={item.publishedVersionId ? "secondary" : "outline"}
|
||||||
|
>
|
||||||
|
{item.publishedVersionId ? t("aiWorkflow.published") : t("aiWorkflow.draft")}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "version",
|
||||||
|
label: t("aiWorkflow.currentVersion"),
|
||||||
|
render: (item) =>
|
||||||
|
item.publishedVersionId
|
||||||
|
? t("aiWorkflow.publishedVersion", { version: item.publishedVersionId })
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: t("common.actions"),
|
||||||
|
render: (item) => formatDateTime(item.updatedAt),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={(query) =>
|
||||||
|
fetchAIWorkflows({
|
||||||
|
name: typeof query.name === "string" ? query.name : undefined,
|
||||||
|
page: Number(query.page),
|
||||||
|
limit: Number(query.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={async () => undefined}
|
||||||
|
updateItem={async () => undefined}
|
||||||
|
deleteItem={(item) => deleteAIWorkflow(item.id)}
|
||||||
|
rowActions={[
|
||||||
|
{
|
||||||
|
key: "versions",
|
||||||
|
label: t("aiWorkflow.versionHistory"),
|
||||||
|
icon: <HistoryIcon />,
|
||||||
|
run: ({ item }) => setVersionsWorkflow(item),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "usage",
|
||||||
|
label: t("aiWorkflow.usage"),
|
||||||
|
icon: <UsersIcon />,
|
||||||
|
run: ({ item }) => setUsageWorkflow(item),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
reloadKey={reloadKey}
|
||||||
|
renderEditDialog={({ open, item, itemId, onOpenChange }) => (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={item ? t("aiWorkflow.editTitle") : t("aiWorkflow.createTitle")}
|
||||||
|
defaultFullscreen
|
||||||
|
bodyScrollable={false}
|
||||||
|
headerClassName="sr-only"
|
||||||
|
>
|
||||||
|
<WorkflowWorkbench
|
||||||
|
workflowID={itemId ?? undefined}
|
||||||
|
onSaved={() => setReloadKey((value) => value + 1)}
|
||||||
|
/>
|
||||||
|
</ProjectDialog>
|
||||||
|
)}
|
||||||
|
labels={{
|
||||||
|
refresh: t("aiWorkflow.refresh"),
|
||||||
|
create: t("aiWorkflow.create"),
|
||||||
|
query: t("aiWorkflow.query"),
|
||||||
|
loading: t("aiWorkflow.loading"),
|
||||||
|
empty: t("aiWorkflow.empty"),
|
||||||
|
actions: t("aiWorkflow.actions"),
|
||||||
|
edit: t("aiWorkflow.edit"),
|
||||||
|
delete: t("aiWorkflow.delete"),
|
||||||
|
processing: t("aiWorkflow.processing"),
|
||||||
|
moreActions: (item) => t("aiWorkflow.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("aiWorkflow.loadFailed"),
|
||||||
|
saveFailed: t("aiWorkflow.saveFailed"),
|
||||||
|
deleteFailed: t("aiWorkflow.deleteFailed"),
|
||||||
|
created: (payload) => t("aiWorkflow.created", { name: payload.name }),
|
||||||
|
updated: (item) => t("aiWorkflow.updated", { name: item.name }),
|
||||||
|
deleted: (item) => t("aiWorkflow.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkflowVersionsDialog
|
||||||
|
workflow={versionsWorkflow}
|
||||||
|
open={versionsWorkflow !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setVersionsWorkflow(null)
|
||||||
|
}}
|
||||||
|
onRestored={() => setReloadKey((value) => value + 1)}
|
||||||
|
/>
|
||||||
|
<WorkflowUsageDialog
|
||||||
|
workflow={usageWorkflow}
|
||||||
|
open={usageWorkflow !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setUsageWorkflow(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
1514
web/app/(dashboard)/dashboard/channels/_components/edit.tsx
Normal file
1514
web/app/(dashboard)/dashboard/channels/_components/edit.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { PlugZapIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { testChannelEmail } from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type TestEmailConnectionButtonProps = {
|
||||||
|
/** Undefined for a channel that has not been saved yet. */
|
||||||
|
channelId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the channel's bound mail server is reachable before anyone relies
|
||||||
|
* on it. Saving a channel cannot tell a wrong host from a right one, and the
|
||||||
|
* alternative is finding out by filing a real request and watching the outbox
|
||||||
|
* retry five times.
|
||||||
|
*/
|
||||||
|
export function TestEmailConnectionButton({
|
||||||
|
channelId,
|
||||||
|
}: TestEmailConnectionButtonProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
if (testing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!channelId) {
|
||||||
|
toast.error(t("channel.smtpTestSaveFirst"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTesting(true)
|
||||||
|
try {
|
||||||
|
const result = await testChannelEmail(channelId)
|
||||||
|
toast.success(
|
||||||
|
t("channel.smtpTestSuccess", {
|
||||||
|
host: `${result.host}:${result.port}`,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t("channel.smtpTestFailed")
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={testing}
|
||||||
|
onClick={() => void handleTest()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<PlugZapIcon className="size-4" />
|
||||||
|
{testing ? t("channel.smtpTesting") : t("channel.smtpTest")}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
web/app/(dashboard)/dashboard/channels/page.tsx
Normal file
228
web/app/(dashboard)/dashboard/channels/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Building2Icon,
|
||||||
|
Gamepad2Icon,
|
||||||
|
MailIcon,
|
||||||
|
SendIcon,
|
||||||
|
SlackIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardStatusColumn,
|
||||||
|
DashboardCrudPage,
|
||||||
|
} from "@/components/dashboard/crud"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
createChannel,
|
||||||
|
deleteChannel,
|
||||||
|
fetchChannels,
|
||||||
|
updateChannel,
|
||||||
|
updateChannelStatus,
|
||||||
|
type AdminChannel,
|
||||||
|
type CreateAdminChannelPayload,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { getEnumOptions } from "@/lib/enums"
|
||||||
|
import { Status, StatusLabels } from "@/lib/generated/enums"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { EditDialog } from "./_components/edit"
|
||||||
|
|
||||||
|
function getChannelTypeLabel(channelType: string, t: (key: string) => string) {
|
||||||
|
if (channelType === "email") {
|
||||||
|
return t("channel.typeEmail")
|
||||||
|
}
|
||||||
|
if (channelType === "telegram") {
|
||||||
|
return t("channel.typeTelegram")
|
||||||
|
}
|
||||||
|
if (channelType === "zalo_oa") {
|
||||||
|
return t("channel.typeZaloOa")
|
||||||
|
}
|
||||||
|
if (channelType === "slack") {
|
||||||
|
return t("channel.typeSlack")
|
||||||
|
}
|
||||||
|
if (channelType === "discord") {
|
||||||
|
return t("channel.typeDiscord")
|
||||||
|
}
|
||||||
|
return t("channel.typeWeb")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: Status, t: (key: string) => string) {
|
||||||
|
if (status === Status.Disabled) {
|
||||||
|
return t("status.disabled")
|
||||||
|
}
|
||||||
|
if (status === Status.Deleted) {
|
||||||
|
return t("status.deleted")
|
||||||
|
}
|
||||||
|
return t("status.ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelIcon({ channelType }: { channelType: string }) {
|
||||||
|
if (channelType === "email") {
|
||||||
|
return <MailIcon className="size-4" />
|
||||||
|
}
|
||||||
|
if (channelType === "telegram" || channelType === "zalo_oa") {
|
||||||
|
return <SendIcon className="size-4" />
|
||||||
|
}
|
||||||
|
if (channelType === "slack") {
|
||||||
|
return <SlackIcon className="size-4" />
|
||||||
|
}
|
||||||
|
if (channelType === "discord") {
|
||||||
|
return <Gamepad2Icon className="size-4" />
|
||||||
|
}
|
||||||
|
return <Building2Icon className="size-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardChannelsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "all", label: t("status.all") },
|
||||||
|
...getEnumOptions(StatusLabels).map((option) => ({
|
||||||
|
value: String(option.value),
|
||||||
|
label: getStatusLabel(option.value as Status, t),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const channelTypeOptions = [
|
||||||
|
{ value: "all", label: t("channel.allTypes") },
|
||||||
|
{ value: "web", label: t("channel.typeWeb") },
|
||||||
|
{ value: "email", label: t("channel.typeEmail") },
|
||||||
|
{ value: "telegram", label: t("channel.typeTelegram") },
|
||||||
|
{ value: "discord", label: t("channel.typeDiscord") },
|
||||||
|
{ value: "slack", label: t("channel.typeSlack") },
|
||||||
|
{ value: "zalo_oa", label: t("channel.typeZaloOa") },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCrudPage<AdminChannel, CreateAdminChannelPayload>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("channel.filterName"),
|
||||||
|
placeholder: t("channel.filterName"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "channelId",
|
||||||
|
label: t("channel.filterChannelId"),
|
||||||
|
placeholder: t("channel.filterChannelId"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "channelType",
|
||||||
|
label: t("channel.allTypes"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: channelTypeOptions,
|
||||||
|
className: "w-full sm:w-40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("status.all"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: statusOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "channel",
|
||||||
|
label: t("channel.columnChannel"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||||
|
<ChannelIcon channelType={item.channelType} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{item.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{getChannelTypeLabel(item.channelType, t)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
label: t("channel.columnType"),
|
||||||
|
render: (item) => (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{getChannelTypeLabel(item.channelType, t)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channelId",
|
||||||
|
label: "ChannelID",
|
||||||
|
render: (item) => (
|
||||||
|
<span className="font-mono text-xs">{item.channelId || "-"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "agent",
|
||||||
|
label: t("channel.columnAgent"),
|
||||||
|
render: (item) => item.aiAgentName || "-",
|
||||||
|
},
|
||||||
|
createDashboardStatusColumn<AdminChannel, Status>({
|
||||||
|
label: t("channel.columnStatus"),
|
||||||
|
getStatus: (item) => item.status as Status,
|
||||||
|
getLabel: (status) => getStatusLabel(status, t),
|
||||||
|
getBadgeVariant: (status) => (status === Status.Ok ? "default" : "outline"),
|
||||||
|
isEnabled: (status) => status === Status.Ok,
|
||||||
|
toggle: {
|
||||||
|
getNextStatus: (item) =>
|
||||||
|
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||||
|
updateStatus: (item, nextStatus) =>
|
||||||
|
updateChannelStatus(item.id, nextStatus),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t(
|
||||||
|
nextStatus === Status.Ok
|
||||||
|
? "channel.statusEnabled"
|
||||||
|
: "channel.statusDisabled",
|
||||||
|
{ name: item.name }
|
||||||
|
),
|
||||||
|
errorMessage: t("channel.statusUpdateFailed"),
|
||||||
|
ariaLabel: (item) => t("channel.toggleStatus", { name: item.name }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
fetchList={fetchChannels}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createChannel}
|
||||||
|
updateItem={(item, payload) => updateChannel({ id: item.id, ...payload })}
|
||||||
|
deleteItem={(item) => deleteChannel(item.id)}
|
||||||
|
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||||
|
<EditDialog
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
labels={{
|
||||||
|
refresh: t("channel.refresh"),
|
||||||
|
create: t("channel.new"),
|
||||||
|
query: t("channel.query"),
|
||||||
|
loading: t("channel.loading"),
|
||||||
|
empty: t("channel.empty"),
|
||||||
|
actions: t("channel.columnActions"),
|
||||||
|
edit: t("channel.edit"),
|
||||||
|
delete: t("channel.delete"),
|
||||||
|
processing: t("channel.processing"),
|
||||||
|
moreActions: (item) => t("channel.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("channel.loadFailed"),
|
||||||
|
saveFailed: t("channel.saveFailed"),
|
||||||
|
deleteFailed: t("channel.deleteFailed"),
|
||||||
|
created: (payload) => t("channel.created", { name: payload.name }),
|
||||||
|
updated: (_item, payload) => t("channel.updated", { name: payload.name }),
|
||||||
|
deleted: (item) => t("channel.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
211
web/app/(dashboard)/dashboard/companies/page.tsx
Normal file
211
web/app/(dashboard)/dashboard/companies/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { BanIcon, CheckCircle2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardStatusColumn,
|
||||||
|
createDashboardStatusToggleAction,
|
||||||
|
DashboardCrudPage,
|
||||||
|
} from "@/components/dashboard/crud"
|
||||||
|
import {
|
||||||
|
createCompany,
|
||||||
|
deleteCompany,
|
||||||
|
fetchCompanies,
|
||||||
|
fetchCompany,
|
||||||
|
updateCompany,
|
||||||
|
updateCompanyStatus,
|
||||||
|
type AdminCompany,
|
||||||
|
type CreateAdminCompanyPayload,
|
||||||
|
} from "@/lib/api/company"
|
||||||
|
import { getEnumOptions } from "@/lib/enums"
|
||||||
|
import { Status, StatusLabels } from "@/lib/generated/enums"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
function getStatusLabel(status: Status, t: (key: string) => string) {
|
||||||
|
if (status === Status.Disabled) {
|
||||||
|
return t("status.disabled")
|
||||||
|
}
|
||||||
|
if (status === Status.Deleted) {
|
||||||
|
return t("status.deleted")
|
||||||
|
}
|
||||||
|
return t("status.ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardCompaniesPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const listStatusOptions = [
|
||||||
|
{ value: "all", label: t("status.all") },
|
||||||
|
...getEnumOptions(StatusLabels)
|
||||||
|
.filter((item) => Number(item.value) !== Status.Deleted)
|
||||||
|
.map((item) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: getStatusLabel(item.value as Status, t),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCrudPage<AdminCompany, CreateAdminCompanyPayload>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("company.filterName"),
|
||||||
|
placeholder: t("company.filterName"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "code",
|
||||||
|
label: t("company.filterCode"),
|
||||||
|
placeholder: t("company.filterCode"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-44",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("status.all"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: listStatusOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "id",
|
||||||
|
label: "ID",
|
||||||
|
className: "w-20",
|
||||||
|
render: (item) => item.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: t("company.columnName"),
|
||||||
|
render: (item) => <span className="font-medium">{item.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "code",
|
||||||
|
label: t("company.columnCode"),
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-muted-foreground">{item.code || "-"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "customerCount",
|
||||||
|
label: t("company.columnCustomerCount"),
|
||||||
|
className: "w-28",
|
||||||
|
render: (item) => item.customerCount,
|
||||||
|
},
|
||||||
|
createDashboardStatusColumn<AdminCompany, Status>({
|
||||||
|
label: t("company.columnStatus"),
|
||||||
|
className: "w-24",
|
||||||
|
getStatus: (item) => item.status as Status,
|
||||||
|
getLabel: (status) =>
|
||||||
|
StatusLabels[status] ? getStatusLabel(status, t) : t("company.unknownStatus"),
|
||||||
|
getBadgeVariant: (status) =>
|
||||||
|
status === Status.Ok
|
||||||
|
? "default"
|
||||||
|
: status === Status.Deleted
|
||||||
|
? "outline"
|
||||||
|
: "secondary",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
key: "remark",
|
||||||
|
label: t("company.columnRemark"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="line-clamp-2 max-w-[320px] text-muted-foreground">
|
||||||
|
{item.remark || "-"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={fetchCompanies}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createCompany}
|
||||||
|
updateItem={(item, payload) => updateCompany({ id: item.id, ...payload })}
|
||||||
|
deleteItem={(item) => deleteCompany(item.id)}
|
||||||
|
canDelete={(item) => item.status !== Status.Deleted}
|
||||||
|
form={{
|
||||||
|
fetchDetail: fetchCompany,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("company.columnName"),
|
||||||
|
placeholder: t("company.namePlaceholder"),
|
||||||
|
required: true,
|
||||||
|
requiredMessage: t("company.nameRequired"),
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "code",
|
||||||
|
label: t("company.columnCode"),
|
||||||
|
placeholder: t("company.optional"),
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remark",
|
||||||
|
label: t("company.columnRemark"),
|
||||||
|
placeholder: t("company.remarkPlaceholder"),
|
||||||
|
type: "textarea",
|
||||||
|
rows: 4,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transformSubmitValues: (values) => ({
|
||||||
|
name: String(values.name ?? ""),
|
||||||
|
code: String(values.code ?? ""),
|
||||||
|
remark: String(values.remark ?? ""),
|
||||||
|
}),
|
||||||
|
labels: {
|
||||||
|
createTitle: t("company.createTitle"),
|
||||||
|
editTitle: t("company.editTitle"),
|
||||||
|
create: t("company.create"),
|
||||||
|
save: t("company.save"),
|
||||||
|
saving: t("company.saving"),
|
||||||
|
cancel: t("company.cancel"),
|
||||||
|
loadingDetail: t("company.loadingDetail"),
|
||||||
|
required: t("company.nameRequired"),
|
||||||
|
invalidNumber: t("company.nameRequired"),
|
||||||
|
minValue: () => t("company.nameRequired"),
|
||||||
|
maxValue: () => t("company.nameRequired"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
rowActions={[
|
||||||
|
createDashboardStatusToggleAction<AdminCompany, Status>({
|
||||||
|
disabled: (item) => item.status === Status.Deleted,
|
||||||
|
icon: (item) =>
|
||||||
|
item.status === Status.Ok ? <BanIcon /> : <CheckCircle2Icon />,
|
||||||
|
label: (item) =>
|
||||||
|
item.status === Status.Ok ? t("company.disable") : t("company.enable"),
|
||||||
|
getNextStatus: (item) =>
|
||||||
|
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||||
|
updateStatus: (item, nextStatus) => updateCompanyStatus(item.id, nextStatus),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t(nextStatus === Status.Ok ? "company.enabled" : "company.disabled", {
|
||||||
|
name: item.name,
|
||||||
|
}),
|
||||||
|
errorMessage: t("company.statusUpdateFailed"),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
labels={{
|
||||||
|
refresh: t("company.refresh"),
|
||||||
|
create: t("company.new"),
|
||||||
|
query: t("company.query"),
|
||||||
|
loading: t("company.loading"),
|
||||||
|
empty: t("company.empty"),
|
||||||
|
actions: t("company.columnActions"),
|
||||||
|
edit: t("company.edit"),
|
||||||
|
delete: t("company.delete"),
|
||||||
|
processing: t("company.processing"),
|
||||||
|
moreActions: (item) => t("company.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("company.loadFailed"),
|
||||||
|
saveFailed: t("company.saveFailed"),
|
||||||
|
deleteFailed: t("company.deleteFailed"),
|
||||||
|
created: (payload) => t("company.created", { name: payload.name }),
|
||||||
|
updated: (item) => t("company.updated", { name: item.name }),
|
||||||
|
deleted: (item) => t("company.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCheckIcon, EyeIcon, MessageCircleMoreIcon } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble";
|
||||||
|
import {
|
||||||
|
ConversationMessageRow,
|
||||||
|
} from "@/components/chat/conversation-message-row";
|
||||||
|
import {
|
||||||
|
ConversationMessageScroller,
|
||||||
|
ConversationMessageScrollerItem,
|
||||||
|
} from "@/components/chat/conversation-message-scroller";
|
||||||
|
import { ImMessageHTML } from "@/components/im-message-html";
|
||||||
|
import { useImageLightbox } from "@/components/image-lightbox";
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
type AdminConversation,
|
||||||
|
type AdminConversationDetail,
|
||||||
|
type AdminMessage,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import {
|
||||||
|
parseMessageAssetPayload,
|
||||||
|
renderIMMessageHTML,
|
||||||
|
} from "@/lib/im-message";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
|
||||||
|
type ConversationDetailDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
item: AdminConversation | null;
|
||||||
|
detail: AdminConversationDetail | null;
|
||||||
|
messages?: AdminMessage[] | null;
|
||||||
|
/** Whether older messages are available through cursor pagination. */
|
||||||
|
messagesHasMore?: boolean;
|
||||||
|
loadingMoreMessages?: boolean;
|
||||||
|
onLoadMoreMessages?: () => void | Promise<void>;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onOpenAssign: () => void;
|
||||||
|
onDispatch: () => Promise<void>;
|
||||||
|
onOpenTransfer: () => void;
|
||||||
|
onRead: () => Promise<void>;
|
||||||
|
onOpenClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusMeta(
|
||||||
|
status: number,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return { label: t("conversation.filterAiServing"), variant: "secondary" as const };
|
||||||
|
case 2:
|
||||||
|
return { label: t("conversation.filterPending"), variant: "outline" as const };
|
||||||
|
case 3:
|
||||||
|
return { label: t("conversation.filterActive"), variant: "secondary" as const };
|
||||||
|
case 4:
|
||||||
|
return { label: t("conversation.filterClosed"), variant: "outline" as const };
|
||||||
|
default:
|
||||||
|
return { label: t("conversationMonitor.unknown"), variant: "outline" as const };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServiceModeLabel(
|
||||||
|
mode: number,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
) {
|
||||||
|
switch (mode) {
|
||||||
|
case 1:
|
||||||
|
return t("conversationMonitor.serviceAi");
|
||||||
|
case 2:
|
||||||
|
return t("conversationMonitor.serviceHuman");
|
||||||
|
case 3:
|
||||||
|
return t("conversationMonitor.serviceAiFirst");
|
||||||
|
default:
|
||||||
|
return t("conversationMonitor.serviceUndefined");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSenderLabel(
|
||||||
|
message: AdminMessage,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
) {
|
||||||
|
switch (message.senderType) {
|
||||||
|
case "agent":
|
||||||
|
return message.senderName || t("conversationMonitor.senderAgent");
|
||||||
|
case "customer":
|
||||||
|
return message.senderName || t("conversationMonitor.senderCustomer");
|
||||||
|
case "ai":
|
||||||
|
return "AI";
|
||||||
|
case "system":
|
||||||
|
return t("conversationMonitor.senderSystem");
|
||||||
|
default:
|
||||||
|
return message.senderType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageContent(message: AdminMessage) {
|
||||||
|
return message.content || message.payload || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageMessageUrl(message: AdminMessage) {
|
||||||
|
return parseMessageAssetPayload(message.payload)?.url || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParticipantIdentity(
|
||||||
|
participant: NonNullable<AdminConversationDetail["participants"]>[number],
|
||||||
|
) {
|
||||||
|
return participant.participantId || participant.externalParticipantId || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageAlign(message: AdminMessage): "start" | "end" {
|
||||||
|
return message.senderType === "customer" || message.senderType === "system"
|
||||||
|
? "start"
|
||||||
|
: "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageVariant(message: AdminMessage) {
|
||||||
|
switch (message.senderType) {
|
||||||
|
case "agent":
|
||||||
|
return "agent" as const;
|
||||||
|
case "ai":
|
||||||
|
return "ai" as const;
|
||||||
|
case "system":
|
||||||
|
return "system" as const;
|
||||||
|
default:
|
||||||
|
return "customer" as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageHtmlClassName(message: AdminMessage, isRecalled: boolean) {
|
||||||
|
if (isRecalled) {
|
||||||
|
return message.senderType === "agent" || message.senderType === "ai"
|
||||||
|
? "text-emerald-800 [&_p]:text-emerald-800"
|
||||||
|
: "text-muted-foreground [&_p]:text-muted-foreground";
|
||||||
|
}
|
||||||
|
if (message.senderType === "agent") {
|
||||||
|
return "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md";
|
||||||
|
}
|
||||||
|
return "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecalledBubbleClassName(message: AdminMessage) {
|
||||||
|
return message.senderType === "agent" || message.senderType === "ai"
|
||||||
|
? "border-dashed border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
|
: "border-dashed border-border/70 bg-muted/40 text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationDetailDialog({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
readOnly = false,
|
||||||
|
item,
|
||||||
|
detail,
|
||||||
|
messages: rawMessages,
|
||||||
|
messagesHasMore = false,
|
||||||
|
loadingMoreMessages = false,
|
||||||
|
onLoadMoreMessages,
|
||||||
|
onOpenChange,
|
||||||
|
onOpenAssign,
|
||||||
|
onDispatch,
|
||||||
|
onOpenTransfer,
|
||||||
|
onRead,
|
||||||
|
onOpenClose,
|
||||||
|
}: ConversationDetailDialogProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const messages = Array.isArray(rawMessages) ? rawMessages : [];
|
||||||
|
const currentConversation = detail ?? item;
|
||||||
|
const isClosedConversation = currentConversation?.status === 4;
|
||||||
|
const isPendingConversation = currentConversation?.status === 2;
|
||||||
|
const statusMeta = currentConversation
|
||||||
|
? getStatusMeta(currentConversation.status, t)
|
||||||
|
: null;
|
||||||
|
const { open: openImageLightbox, close: closeImageLightbox } =
|
||||||
|
useImageLightbox();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
closeImageLightbox();
|
||||||
|
}
|
||||||
|
}, [open, closeImageLightbox]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>{currentConversation?.customerName || t("conversationMonitor.detailFallbackTitle")}</span>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{statusMeta ? (
|
||||||
|
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
size="xl"
|
||||||
|
// allowFullscreen
|
||||||
|
defaultFullscreen
|
||||||
|
bodyScrollable={false}
|
||||||
|
bodyClassName="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
|
||||||
|
contentClassName="h-[calc(100vh-40px)] max-h-[calc(100vh-40px)]"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{currentConversation
|
||||||
|
? t("conversationMonitor.lastActivePrefix", {
|
||||||
|
time: formatDateTime(currentConversation.lastMessageAt),
|
||||||
|
})
|
||||||
|
: t("conversationMonitor.noConversationInfo")}
|
||||||
|
</div>
|
||||||
|
{readOnly ? (
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onOpenAssign}
|
||||||
|
disabled={saving || !currentConversation || currentConversation.status !== 2}
|
||||||
|
>
|
||||||
|
<MessageCircleMoreIcon />
|
||||||
|
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.assign")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void onDispatch()}
|
||||||
|
disabled={
|
||||||
|
saving || !currentConversation || !isPendingConversation
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MessageCircleMoreIcon />
|
||||||
|
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.retryDispatch")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void onRead()}
|
||||||
|
disabled={saving || !currentConversation}
|
||||||
|
>
|
||||||
|
<CheckCheckIcon />
|
||||||
|
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.markRead")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onOpenTransfer}
|
||||||
|
disabled={saving || !currentConversation || currentConversation.status !== 3}
|
||||||
|
>
|
||||||
|
<MessageCircleMoreIcon />
|
||||||
|
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.transfer")}
|
||||||
|
</Button>
|
||||||
|
{!isClosedConversation ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onOpenClose}
|
||||||
|
disabled={saving || !currentConversation}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
{saving ? t("conversationMonitor.processing") : t("conversationMonitor.close")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("conversationMonitor.loadingDetail")}
|
||||||
|
</div>
|
||||||
|
) : currentConversation ? (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-t md:flex-row">
|
||||||
|
<aside className="flex max-h-[46%] w-full shrink-0 flex-col overflow-hidden border-b bg-muted/20 md:h-full md:max-h-none md:w-90 md:border-r md:border-b-0">
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<InfoItem
|
||||||
|
label={t("conversationMonitor.columnServiceMode")}
|
||||||
|
value={getServiceModeLabel(currentConversation.serviceMode, t)}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("conversationMonitor.currentAssignee")}
|
||||||
|
value={currentConversation.currentAssigneeName || "-"}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("conversation.channelId")}
|
||||||
|
value={`${currentConversation.channelId || "-"}`}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("conversationMonitor.agentUnread")}
|
||||||
|
value={`${currentConversation.agentUnreadCount}`}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("conversationMonitor.customerUnread")}
|
||||||
|
value={`${currentConversation.customerUnreadCount}`}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("conversationMonitor.columnLastActive")}
|
||||||
|
value={formatDateTime(currentConversation.lastMessageAt)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("conversationMonitor.closedAt")}
|
||||||
|
value={formatDateTime(currentConversation.closedAt)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("conversationMonitor.participants")}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("conversationMonitor.participantCount", {
|
||||||
|
count: detail?.participants?.length ?? 0,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{detail?.participants?.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{detail.participants.map((participant) => (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className="rounded-lg border bg-background p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{participant.participantType || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("conversationMonitor.participantIdentity", {
|
||||||
|
value: getParticipantIdentity(participant),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("conversationMonitor.participantJoinedAt", {
|
||||||
|
time: formatDateTime(participant.joinedAt),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||||
|
{t("conversationMonitor.emptyParticipants")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||||
|
<ConversationMessageScroller
|
||||||
|
key={currentConversation.id}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
hasMoreOlder={messagesHasMore}
|
||||||
|
loadingOlder={loadingMoreMessages}
|
||||||
|
onLoadOlder={onLoadMoreMessages}
|
||||||
|
topSlot={
|
||||||
|
messagesHasMore ? (
|
||||||
|
<div className="flex min-h-8 flex-col items-center justify-center py-2 text-xs text-muted-foreground">
|
||||||
|
{loadingMoreMessages
|
||||||
|
? t("conversationMonitor.loadingOlder")
|
||||||
|
: t("conversationMonitor.loadOlderHint")}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{messages.length ? (
|
||||||
|
messages.map((message) => (
|
||||||
|
<ConversationMessageScrollerItem
|
||||||
|
key={message.id}
|
||||||
|
messageId={`${message.id}`}
|
||||||
|
>
|
||||||
|
<ConversationMonitorMessage
|
||||||
|
message={message}
|
||||||
|
onImagePreview={openImageLightbox}
|
||||||
|
/>
|
||||||
|
</ConversationMessageScrollerItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-80 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
|
||||||
|
{t("conversationMonitor.emptyMessages")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConversationMessageScroller>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("conversationMonitor.emptyDetail")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversationMonitorMessageProps = {
|
||||||
|
message: AdminMessage;
|
||||||
|
onImagePreview: (src: string, alt?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConversationMonitorMessage({
|
||||||
|
message,
|
||||||
|
onImagePreview,
|
||||||
|
}: ConversationMonitorMessageProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6;
|
||||||
|
const isHtmlMessage =
|
||||||
|
!isRecalled &&
|
||||||
|
(message.messageType === "html" || message.messageType === "attachment");
|
||||||
|
const isImageMessage = !isRecalled && message.messageType === "image";
|
||||||
|
const isSystem = message.senderType === "system";
|
||||||
|
const align = getMessageAlign(message);
|
||||||
|
const variant = isRecalled ? "recalled" : getMessageVariant(message);
|
||||||
|
const htmlClassName = getMessageHtmlClassName(message, isRecalled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConversationMessageRow
|
||||||
|
align={align}
|
||||||
|
centered={isSystem}
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<span>{getSenderLabel(message, t)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatDateTime(message.sentAt)}</span>
|
||||||
|
{isRecalled ? (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{t("conversationMonitor.messageRecalled")}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={t("conversationMonitor.readStatus", {
|
||||||
|
agent: message.agentRead
|
||||||
|
? t("conversationMonitor.read")
|
||||||
|
: t("conversationMonitor.unread"),
|
||||||
|
customer: message.customerRead
|
||||||
|
? t("conversationMonitor.read")
|
||||||
|
: t("conversationMonitor.unread"),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ConversationMessageBubble
|
||||||
|
variant={variant}
|
||||||
|
className={isRecalled ? getRecalledBubbleClassName(message) : undefined}
|
||||||
|
>
|
||||||
|
{isRecalled ? (
|
||||||
|
<div className={htmlClassName}>
|
||||||
|
{t("conversationMonitor.messageRecalledBody")}
|
||||||
|
</div>
|
||||||
|
) : isHtmlMessage ? (
|
||||||
|
<ImMessageHTML
|
||||||
|
html={renderIMMessageHTML(message)}
|
||||||
|
className={`${htmlClassName} [&_img]:max-w-full [&_img]:cursor-zoom-in`}
|
||||||
|
onImageClick={onImagePreview}
|
||||||
|
/>
|
||||||
|
) : isImageMessage ? (
|
||||||
|
<MessageImage
|
||||||
|
src={getImageMessageUrl(message)}
|
||||||
|
alt={getMessageContent(message)}
|
||||||
|
onPreview={onImagePreview}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="whitespace-pre-wrap break-words">
|
||||||
|
{getMessageContent(message)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConversationMessageBubble>
|
||||||
|
</ConversationMessageRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type InfoItemProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoItem({ label, value, fullWidth = false }: InfoItemProps) {
|
||||||
|
return (
|
||||||
|
<div className={fullWidth ? "col-span-2" : undefined}>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 break-all font-medium">{value || "-"}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageImageProps = {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
onPreview: (src: string, alt?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MessageImage({ src, alt, onPreview }: MessageImageProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm whitespace-pre-wrap break-words">
|
||||||
|
{alt || t("conversationMonitor.imageFallback")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block cursor-zoom-in"
|
||||||
|
onClick={() => onPreview(src, alt)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt || t("conversationMonitor.messageImageAlt")}
|
||||||
|
width={480}
|
||||||
|
height={360}
|
||||||
|
className="max-h-64 w-auto max-w-full rounded-md object-contain"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
927
web/app/(dashboard)/dashboard/conversation-monitor/page.tsx
Normal file
927
web/app/(dashboard)/dashboard/conversation-monitor/page.tsx
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckCheckIcon,
|
||||||
|
MessageCircleMoreIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"
|
||||||
|
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"
|
||||||
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
DashboardTableShell,
|
||||||
|
DashboardTableStateRow,
|
||||||
|
DashboardToolbar,
|
||||||
|
} from "@/components/dashboard-page"
|
||||||
|
import { ListPagination } from "@/components/list-pagination"
|
||||||
|
import {
|
||||||
|
OptionCombobox,
|
||||||
|
type ComboboxOption,
|
||||||
|
} from "@/components/option-combobox"
|
||||||
|
import { TagSelector } from "@/components/tag-selector"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ButtonGroup } from "@/components/ui/button-group"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import {
|
||||||
|
createAdminWebSocketUrl,
|
||||||
|
dispatchConversation,
|
||||||
|
fetchAgentProfilesAll,
|
||||||
|
fetchAgentTeamsAll,
|
||||||
|
fetchConversationDetail,
|
||||||
|
fetchConversationMessages,
|
||||||
|
fetchConversations,
|
||||||
|
fetchTagsAll,
|
||||||
|
markConversationRead,
|
||||||
|
type AdminAgentProfile,
|
||||||
|
type AdminAgentTeam,
|
||||||
|
type AdminConversation,
|
||||||
|
type AdminConversationDetail,
|
||||||
|
type AdminMessage,
|
||||||
|
type PageResult,
|
||||||
|
type TagTree,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { ConversationDetailDialog } from "./_components/detail"
|
||||||
|
|
||||||
|
const RECONNECT_BASE_DELAY = 2000
|
||||||
|
const RECONNECT_MAX_DELAY = 30000
|
||||||
|
|
||||||
|
function getStatusMeta(
|
||||||
|
status: number,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return { label: t("conversation.filterAiServing"), variant: "secondary" as const }
|
||||||
|
case 2:
|
||||||
|
return { label: t("conversation.filterPending"), variant: "outline" as const }
|
||||||
|
case 3:
|
||||||
|
return { label: t("conversation.filterActive"), variant: "secondary" as const }
|
||||||
|
case 4:
|
||||||
|
return { label: t("conversation.filterClosed"), variant: "outline" as const }
|
||||||
|
default:
|
||||||
|
return { label: t("conversationMonitor.unknown"), variant: "outline" as const }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServiceModeLabel(
|
||||||
|
mode: number,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
) {
|
||||||
|
switch (mode) {
|
||||||
|
case 1:
|
||||||
|
return t("conversationMonitor.serviceAi")
|
||||||
|
case 2:
|
||||||
|
return t("conversationMonitor.serviceHuman")
|
||||||
|
case 3:
|
||||||
|
return t("conversationMonitor.serviceAiFirst")
|
||||||
|
default:
|
||||||
|
return t("conversationMonitor.serviceUndefined")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusOptions(
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("conversationMonitor.allStatuses") },
|
||||||
|
{ value: "1", label: t("conversation.filterAiServing") },
|
||||||
|
{ value: "2", label: t("conversation.filterPending") },
|
||||||
|
{ value: "3", label: t("conversation.filterActive") },
|
||||||
|
{ value: "4", label: t("conversation.filterClosed") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardConversationsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const statusOptions = useMemo(() => getStatusOptions(t), [t])
|
||||||
|
const [keywordInput, setKeywordInput] = useState("")
|
||||||
|
const [statusFilterInput, setStatusFilterInput] = useState("all")
|
||||||
|
const [tagFilterInput, setTagFilterInput] = useState("0")
|
||||||
|
const [assigneeFilterInput, setAssigneeFilterInput] = useState("0")
|
||||||
|
const [agentTeamFilterInput, setAgentTeamFilterInput] = useState("0")
|
||||||
|
const [keyword, setKeyword] = useState("")
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all")
|
||||||
|
const [tagFilter, setTagFilter] = useState("0")
|
||||||
|
const [assigneeFilter, setAssigneeFilter] = useState("0")
|
||||||
|
const [agentTeamFilter, setAgentTeamFilter] = useState("0")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [limit, setLimit] = useState(20)
|
||||||
|
const [tags, setTags] = useState<TagTree[]>([])
|
||||||
|
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([
|
||||||
|
{ value: "0", label: t("conversationMonitor.allAssignees") },
|
||||||
|
])
|
||||||
|
const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([
|
||||||
|
{ value: "0", label: t("conversationMonitor.allTeams") },
|
||||||
|
])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailItem, setDetailItem] = useState<AdminConversation | null>(null)
|
||||||
|
const [detailData, setDetailData] = useState<AdminConversationDetail | null>(null)
|
||||||
|
const [detailMessages, setDetailMessages] = useState<AdminMessage[]>([])
|
||||||
|
const [detailMessagesNextCursor, setDetailMessagesNextCursor] = useState("")
|
||||||
|
const [detailMessagesHasMore, setDetailMessagesHasMore] = useState(false)
|
||||||
|
const [detailMessagesLoadingMore, setDetailMessagesLoadingMore] = useState(false)
|
||||||
|
const [assignOpen, setAssignOpen] = useState(false)
|
||||||
|
const [assignItem, setAssignItem] = useState<AdminConversation | null>(null)
|
||||||
|
const [closeOpen, setCloseOpen] = useState(false)
|
||||||
|
const [closeItem, setCloseItem] = useState<AdminConversation | null>(null)
|
||||||
|
const [transferOpen, setTransferOpen] = useState(false)
|
||||||
|
const [transferItem, setTransferItem] = useState<AdminConversation | null>(null)
|
||||||
|
const [result, setResult] = useState<PageResult<AdminConversation>>({
|
||||||
|
results: [],
|
||||||
|
page: { page: 1, limit: 20, total: 0 },
|
||||||
|
})
|
||||||
|
const websocketRef = useRef<WebSocket | null>(null)
|
||||||
|
const reconnectTimerRef = useRef<number | null>(null)
|
||||||
|
const pingTimerRef = useRef<number | null>(null)
|
||||||
|
const reconnectAttemptRef = useRef(0)
|
||||||
|
const detailItemRef = useRef<AdminConversation | null>(null)
|
||||||
|
const subscribedConversationIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const loadConversations = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchConversations({
|
||||||
|
keyword: keyword.trim() || undefined,
|
||||||
|
status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
|
tagId: tagFilter === "0" ? undefined : tagFilter,
|
||||||
|
currentAssigneeId: assigneeFilter === "0" ? undefined : assigneeFilter,
|
||||||
|
agentTeamId: agentTeamFilter === "0" ? undefined : agentTeamFilter,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
setResult(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadListFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
try {
|
||||||
|
const [tagData, assigneeData, teamData] = await Promise.all([
|
||||||
|
fetchTagsAll(),
|
||||||
|
fetchAgentProfilesAll(),
|
||||||
|
fetchAgentTeamsAll(),
|
||||||
|
])
|
||||||
|
if (!cancelled) {
|
||||||
|
setTags(Array.isArray(tagData) ? tagData : [])
|
||||||
|
setAssigneeOptions([
|
||||||
|
{ value: "0", label: t("conversationMonitor.allAssignees") },
|
||||||
|
...assigneeData.map((item: AdminAgentProfile) => ({
|
||||||
|
value: String(item.userId),
|
||||||
|
label: item.displayName || item.nickname || item.username || `#${item.userId}`,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
setAgentTeamOptions([
|
||||||
|
{ value: "0", label: t("conversationMonitor.allTeams") },
|
||||||
|
...teamData.map((item: AdminAgentTeam) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadFiltersFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadFilterOptions()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
detailItemRef.current = detailItem
|
||||||
|
}, [detailItem])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConversations()
|
||||||
|
}, [loadConversations])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (reconnectTimerRef.current) {
|
||||||
|
window.clearTimeout(reconnectTimerRef.current)
|
||||||
|
reconnectTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (pingTimerRef.current) {
|
||||||
|
window.clearInterval(pingTimerRef.current)
|
||||||
|
pingTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (cancelled || reconnectTimerRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const delay = Math.min(
|
||||||
|
RECONNECT_BASE_DELAY * 2 ** reconnectAttemptRef.current,
|
||||||
|
RECONNECT_MAX_DELAY
|
||||||
|
)
|
||||||
|
reconnectTimerRef.current = window.setTimeout(() => {
|
||||||
|
reconnectTimerRef.current = null
|
||||||
|
reconnectAttemptRef.current += 1
|
||||||
|
connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let socket: WebSocket
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(createAdminWebSocketUrl())
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversationMonitor.realtimeConnectFailed"))
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websocketRef.current = socket
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
reconnectAttemptRef.current = 0
|
||||||
|
if (pingTimerRef.current) {
|
||||||
|
window.clearInterval(pingTimerRef.current)
|
||||||
|
}
|
||||||
|
pingTimerRef.current = window.setInterval(() => {
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: "ping" }))
|
||||||
|
}
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
const conversationId = detailItemRef.current?.id
|
||||||
|
if (conversationId) {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "subscribe",
|
||||||
|
topics: [`conversation:${conversationId}`],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
subscribedConversationIdRef.current = conversationId
|
||||||
|
} else {
|
||||||
|
subscribedConversationIdRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as {
|
||||||
|
eventId?: string
|
||||||
|
type?: string
|
||||||
|
data?: { conversationId?: number }
|
||||||
|
}
|
||||||
|
const eventType = payload.type ?? ""
|
||||||
|
const conversationId = payload.data?.conversationId ?? 0
|
||||||
|
const eventId = payload.eventId?.trim() ?? ""
|
||||||
|
|
||||||
|
if (
|
||||||
|
eventType === "" ||
|
||||||
|
eventType === "connected" ||
|
||||||
|
eventType === "pong" ||
|
||||||
|
eventType === "subscribed" ||
|
||||||
|
eventType === "unsubscribed"
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventId && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: "ack", eventId }))
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadConversations()
|
||||||
|
const currentDetail = detailItemRef.current
|
||||||
|
if (conversationId > 0 && currentDetail?.id === conversationId) {
|
||||||
|
void loadDetail(currentDetail)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid ws payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (pingTimerRef.current) {
|
||||||
|
window.clearInterval(pingTimerRef.current)
|
||||||
|
pingTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (websocketRef.current === socket) {
|
||||||
|
websocketRef.current = null
|
||||||
|
}
|
||||||
|
subscribedConversationIdRef.current = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimers()
|
||||||
|
reconnectAttemptRef.current = 0
|
||||||
|
const socket = websocketRef.current
|
||||||
|
websocketRef.current = null
|
||||||
|
if (socket) {
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
subscribedConversationIdRef.current = null
|
||||||
|
}
|
||||||
|
}, [loadConversations, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = websocketRef.current
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousConversationId = subscribedConversationIdRef.current
|
||||||
|
const nextConversationId = detailOpen ? detailItem?.id ?? null : null
|
||||||
|
if (previousConversationId && previousConversationId !== nextConversationId) {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "unsubscribe",
|
||||||
|
topics: [`conversation:${previousConversationId}`],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (nextConversationId && nextConversationId !== previousConversationId) {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "subscribe",
|
||||||
|
topics: [`conversation:${nextConversationId}`],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
subscribedConversationIdRef.current = nextConversationId
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nextConversationId) {
|
||||||
|
subscribedConversationIdRef.current = null
|
||||||
|
}
|
||||||
|
}, [detailItem, detailOpen])
|
||||||
|
|
||||||
|
function handleStatusFilterChange(value: string | null) {
|
||||||
|
setStatusFilterInput(value ?? "all")
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
setKeyword(keywordInput)
|
||||||
|
setStatusFilter(statusFilterInput)
|
||||||
|
setTagFilter(tagFilterInput)
|
||||||
|
setAssigneeFilter(assigneeFilterInput)
|
||||||
|
setAgentTeamFilter(agentTeamFilterInput)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key !== "Enter") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
applyFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(nextPage: number) {
|
||||||
|
if (nextPage < 1 || nextPage === page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPage(nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLimitChange(nextLimit: number) {
|
||||||
|
if (nextLimit <= 0 || nextLimit === limit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLimit(nextLimit)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(item: AdminConversation) {
|
||||||
|
setDetailLoading(true)
|
||||||
|
setDetailMessagesNextCursor("")
|
||||||
|
setDetailMessagesHasMore(false)
|
||||||
|
try {
|
||||||
|
const [detail, messages] = await Promise.all([
|
||||||
|
fetchConversationDetail(item.id),
|
||||||
|
fetchConversationMessages({ conversationId: item.id, limit: 20 }),
|
||||||
|
])
|
||||||
|
setDetailData(detail)
|
||||||
|
setDetailMessages(Array.isArray(messages.results) ? messages.results : [])
|
||||||
|
setDetailMessagesNextCursor(messages.cursor ?? "")
|
||||||
|
setDetailMessagesHasMore(Boolean(messages.hasMore))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadDetailFailed"))
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreDetailMessages = useCallback(async () => {
|
||||||
|
if (!detailItem || detailMessagesLoadingMore || !detailMessagesHasMore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cursor = Number.parseInt(detailMessagesNextCursor, 10)
|
||||||
|
if (!detailMessagesNextCursor.trim() || !Number.isFinite(cursor) || cursor <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDetailMessagesLoadingMore(true)
|
||||||
|
try {
|
||||||
|
const page = await fetchConversationMessages({
|
||||||
|
conversationId: detailItem.id,
|
||||||
|
cursor,
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
const nextResults = Array.isArray(page.results) ? page.results : []
|
||||||
|
setDetailMessages((prev) => [...nextResults, ...prev])
|
||||||
|
setDetailMessagesNextCursor(page.cursor ?? "")
|
||||||
|
setDetailMessagesHasMore(Boolean(page.hasMore))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversationMonitor.loadMoreMessagesFailed"))
|
||||||
|
} finally {
|
||||||
|
setDetailMessagesLoadingMore(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
detailItem,
|
||||||
|
detailMessagesHasMore,
|
||||||
|
detailMessagesLoadingMore,
|
||||||
|
detailMessagesNextCursor,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
|
async function openDetail(item: AdminConversation) {
|
||||||
|
setDetailItem(item)
|
||||||
|
setDetailData(null)
|
||||||
|
setDetailMessages([])
|
||||||
|
setDetailMessagesNextCursor("")
|
||||||
|
setDetailMessagesHasMore(false)
|
||||||
|
setDetailMessagesLoadingMore(false)
|
||||||
|
setDetailOpen(true)
|
||||||
|
await loadDetail(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetailOpenChange(open: boolean) {
|
||||||
|
if (actionLoadingId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setDetailOpen(false)
|
||||||
|
setDetailItem(null)
|
||||||
|
setDetailData(null)
|
||||||
|
setDetailMessages([])
|
||||||
|
setDetailMessagesNextCursor("")
|
||||||
|
setDetailMessagesHasMore(false)
|
||||||
|
setDetailMessagesLoadingMore(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDetailOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssign(item: AdminConversation) {
|
||||||
|
setAssignItem(item)
|
||||||
|
setAssignOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssignOpenChange(open: boolean) {
|
||||||
|
if (actionLoadingId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setAssignOpen(false)
|
||||||
|
setAssignItem(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAssignOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTransfer(item: AdminConversation) {
|
||||||
|
setTransferItem(item)
|
||||||
|
setTransferOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openClose(item: AdminConversation) {
|
||||||
|
setCloseItem(item)
|
||||||
|
setCloseOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseOpenChange(open: boolean) {
|
||||||
|
if (actionLoadingId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setCloseOpen(false)
|
||||||
|
setCloseItem(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCloseOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransferOpenChange(open: boolean) {
|
||||||
|
if (actionLoadingId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setTransferOpen(false)
|
||||||
|
setTransferItem(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTransferOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDetail() {
|
||||||
|
if (!detailOpen || !detailItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadDetail(detailItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
await loadConversations()
|
||||||
|
await refreshDetail()
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRead(item: AdminConversation) {
|
||||||
|
setActionLoadingId(item.id)
|
||||||
|
try {
|
||||||
|
await markConversationRead(item.id)
|
||||||
|
toast.success(t("conversationMonitor.markedRead", { name: item.customerName || `#${item.id}` }))
|
||||||
|
await loadConversations()
|
||||||
|
if (detailItem?.id === item.id) {
|
||||||
|
await refreshDetail()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversationMonitor.markReadFailed"))
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDispatch(item: AdminConversation) {
|
||||||
|
setActionLoadingId(item.id)
|
||||||
|
try {
|
||||||
|
await dispatchConversation(item.id)
|
||||||
|
toast.success(t("conversationMonitor.dispatchTriggered", { name: item.customerName || `#${item.id}` }))
|
||||||
|
await loadConversations()
|
||||||
|
if (detailItem?.id === item.id) {
|
||||||
|
await refreshDetail()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversationMonitor.dispatchFailed"))
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConversationChanged(conversationId: number) {
|
||||||
|
await loadConversations()
|
||||||
|
if (detailItem?.id === conversationId) {
|
||||||
|
await refreshDetail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardPage>
|
||||||
|
<DashboardToolbar
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleRefresh()}
|
||||||
|
disabled={loading || refreshing}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} />
|
||||||
|
{t("conversationMonitor.refresh")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(event) => setKeywordInput(event.target.value)}
|
||||||
|
onKeyDown={handleFilterKeyDown}
|
||||||
|
placeholder={t("conversationMonitor.keywordPlaceholder")}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-36">
|
||||||
|
<OptionCombobox
|
||||||
|
value={statusFilterInput}
|
||||||
|
options={statusOptions}
|
||||||
|
placeholder={t("conversationMonitor.allStatuses")}
|
||||||
|
onChange={handleStatusFilterChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<TagSelector
|
||||||
|
mode="single"
|
||||||
|
value={Number(tagFilterInput)}
|
||||||
|
onChange={(value) => setTagFilterInput(String(value))}
|
||||||
|
tags={tags}
|
||||||
|
placeholder={t("conversationMonitor.selectTag")}
|
||||||
|
searchPlaceholder={t("conversationMonitor.searchTagPath")}
|
||||||
|
emptyText={t("conversationMonitor.emptyTags")}
|
||||||
|
rootOption={{ value: 0, label: t("conversationMonitor.allTags") }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-56">
|
||||||
|
<OptionCombobox
|
||||||
|
value={assigneeFilterInput}
|
||||||
|
options={assigneeOptions}
|
||||||
|
placeholder={t("conversationMonitor.selectAssignee")}
|
||||||
|
searchPlaceholder={t("conversationMonitor.searchAssignee")}
|
||||||
|
emptyText={t("conversationMonitor.emptyAssignees")}
|
||||||
|
onChange={setAssigneeFilterInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-56">
|
||||||
|
<OptionCombobox
|
||||||
|
value={agentTeamFilterInput}
|
||||||
|
options={agentTeamOptions}
|
||||||
|
placeholder={t("conversationMonitor.selectTeam")}
|
||||||
|
searchPlaceholder={t("conversationMonitor.searchTeam")}
|
||||||
|
emptyText={t("conversationMonitor.emptyTeams")}
|
||||||
|
onChange={setAgentTeamFilterInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||||
|
<SearchIcon />
|
||||||
|
{t("conversationMonitor.query")}
|
||||||
|
</Button>
|
||||||
|
</DashboardToolbar>
|
||||||
|
|
||||||
|
<DashboardTableShell
|
||||||
|
pagination={
|
||||||
|
<ListPagination
|
||||||
|
page={result.page.page}
|
||||||
|
total={result.page.total}
|
||||||
|
limit={result.page.limit}
|
||||||
|
loading={loading || refreshing}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onLimitChange={handleLimitChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("conversationMonitor.columnConversation")}</TableHead>
|
||||||
|
<TableHead>{t("conversationMonitor.columnStatus")}</TableHead>
|
||||||
|
<TableHead>{t("conversationMonitor.columnServiceMode")}</TableHead>
|
||||||
|
<TableHead>{t("conversationMonitor.columnAssignee")}</TableHead>
|
||||||
|
<TableHead>{t("conversationMonitor.columnUnread")}</TableHead>
|
||||||
|
<TableHead>{t("conversationMonitor.columnLastActive")}</TableHead>
|
||||||
|
<TableHead className="w-28 text-right">
|
||||||
|
{t("conversationMonitor.columnActions")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.results.map((item) => {
|
||||||
|
const statusMeta = getStatusMeta(item.status, t)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="max-w-60">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">
|
||||||
|
{item.customerName ||
|
||||||
|
t("conversationMonitor.customerFallback", {
|
||||||
|
id: item.customerId || item.id,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("conversationMonitor.channelIdValue", {
|
||||||
|
id: item.channelId || "-",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{item.lastMessageSummary || t("conversationMonitor.noLatestSummary")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getServiceModeLabel(item.serviceMode, t)}</TableCell>
|
||||||
|
<TableCell>{item.currentAssigneeName || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{t("conversationMonitor.unreadSummary", {
|
||||||
|
agent: item.agentUnreadCount,
|
||||||
|
customer: item.customerUnreadCount,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDateTime(item.lastMessageAt)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ButtonGroup className="ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void openDetail(item)}
|
||||||
|
disabled={actionLoadingId === item.id}
|
||||||
|
>
|
||||||
|
{t("conversationMonitor.view")}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button variant="outline" size="icon-sm" />}
|
||||||
|
aria-label={t("conversationMonitor.moreActions", {
|
||||||
|
name: item.customerName || item.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44 min-w-44">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => openAssign(item)}
|
||||||
|
disabled={actionLoadingId === item.id || item.status !== 2}
|
||||||
|
>
|
||||||
|
<MessageCircleMoreIcon />
|
||||||
|
{actionLoadingId === item.id
|
||||||
|
? t("conversationMonitor.processing")
|
||||||
|
: t("conversationMonitor.assign")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => void handleDispatch(item)}
|
||||||
|
disabled={actionLoadingId === item.id || item.status !== 2}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon />
|
||||||
|
{actionLoadingId === item.id
|
||||||
|
? t("conversationMonitor.processing")
|
||||||
|
: t("conversationMonitor.retryDispatch")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => void handleRead(item)}
|
||||||
|
disabled={actionLoadingId === item.id}
|
||||||
|
>
|
||||||
|
<CheckCheckIcon />
|
||||||
|
{actionLoadingId === item.id
|
||||||
|
? t("conversationMonitor.processing")
|
||||||
|
: t("conversationMonitor.markRead")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => openTransfer(item)}
|
||||||
|
disabled={actionLoadingId === item.id || item.status !== 3}
|
||||||
|
>
|
||||||
|
<MessageCircleMoreIcon />
|
||||||
|
{actionLoadingId === item.id
|
||||||
|
? t("conversationMonitor.processing")
|
||||||
|
: t("conversationMonitor.transfer")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{item.status !== 4 ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => openClose(item)}
|
||||||
|
disabled={actionLoadingId === item.id}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon />
|
||||||
|
{actionLoadingId === item.id
|
||||||
|
? t("conversationMonitor.processing")
|
||||||
|
: t("conversationMonitor.close")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonGroup>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{loading || result.results.length === 0 ? (
|
||||||
|
<DashboardTableStateRow
|
||||||
|
colSpan={7}
|
||||||
|
loading={loading}
|
||||||
|
loadingText={t("conversationMonitor.loadingRows")}
|
||||||
|
emptyText={t("conversationMonitor.emptyRows")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DashboardTableShell>
|
||||||
|
</DashboardPage>
|
||||||
|
|
||||||
|
<ConversationDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
loading={detailLoading}
|
||||||
|
saving={actionLoadingId === detailItem?.id}
|
||||||
|
item={detailItem}
|
||||||
|
detail={detailData}
|
||||||
|
messages={detailMessages}
|
||||||
|
messagesHasMore={detailMessagesHasMore}
|
||||||
|
loadingMoreMessages={detailMessagesLoadingMore}
|
||||||
|
onLoadMoreMessages={loadMoreDetailMessages}
|
||||||
|
onOpenChange={handleDetailOpenChange}
|
||||||
|
onOpenAssign={() => {
|
||||||
|
if (!detailItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openAssign(detailItem)
|
||||||
|
}}
|
||||||
|
onDispatch={async () => {
|
||||||
|
if (!detailItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await handleDispatch(detailItem)
|
||||||
|
}}
|
||||||
|
onOpenTransfer={() => {
|
||||||
|
if (!detailItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openTransfer(detailItem)
|
||||||
|
}}
|
||||||
|
onRead={async () => {
|
||||||
|
if (!detailItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await handleRead(detailItem)
|
||||||
|
}}
|
||||||
|
onOpenClose={() => {
|
||||||
|
if (!detailItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openClose(detailItem)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConversationCloseDialog
|
||||||
|
open={closeOpen}
|
||||||
|
conversationId={closeItem?.id ?? null}
|
||||||
|
onOpenChange={handleCloseOpenChange}
|
||||||
|
onSuccess={async () => {
|
||||||
|
const conversationId = closeItem?.id
|
||||||
|
setCloseOpen(false)
|
||||||
|
setCloseItem(null)
|
||||||
|
if (conversationId) {
|
||||||
|
await handleConversationChanged(conversationId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConversationTransferDialog
|
||||||
|
open={assignOpen}
|
||||||
|
mode="assign"
|
||||||
|
conversationId={assignItem?.id ?? null}
|
||||||
|
onOpenChange={handleAssignOpenChange}
|
||||||
|
onSuccess={async () => {
|
||||||
|
const conversationId = assignItem?.id
|
||||||
|
setAssignOpen(false)
|
||||||
|
setAssignItem(null)
|
||||||
|
if (conversationId) {
|
||||||
|
await handleConversationChanged(conversationId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConversationTransferDialog
|
||||||
|
open={transferOpen}
|
||||||
|
mode="transfer"
|
||||||
|
conversationId={transferItem?.id ?? null}
|
||||||
|
onOpenChange={handleTransferOpenChange}
|
||||||
|
onSuccess={async () => {
|
||||||
|
const conversationId = transferItem?.id
|
||||||
|
setTransferOpen(false)
|
||||||
|
setTransferItem(null)
|
||||||
|
if (conversationId) {
|
||||||
|
await handleConversationChanged(conversationId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
SharedMessageEditor,
|
||||||
|
type UploadedMessageEditorImage,
|
||||||
|
} from "@/components/chat/shared-message-editor"
|
||||||
|
import {
|
||||||
|
sendOperatorPresence,
|
||||||
|
useAgentConversationsStore,
|
||||||
|
} from "@/hooks/use-agent-conversation-realtime"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
|
||||||
|
import { suggestAgentReply } from "@/lib/api/agent"
|
||||||
|
|
||||||
|
type AgentMessageEditorProps = {
|
||||||
|
disabled?: boolean
|
||||||
|
uploadingAsset?: boolean
|
||||||
|
onSend: (html: string) => Promise<void>
|
||||||
|
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
||||||
|
onSendAttachment: (file: File) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent message editor for dashboard conversations.
|
||||||
|
* Supports quick replies picker and slash command (/) autocomplete.
|
||||||
|
*/
|
||||||
|
export function AgentMessageEditor({
|
||||||
|
disabled = false,
|
||||||
|
uploadingAsset = false,
|
||||||
|
onSend,
|
||||||
|
onUploadImage,
|
||||||
|
onSendAttachment,
|
||||||
|
}: AgentMessageEditorProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const selectedConversationId = useAgentConversationsStore(
|
||||||
|
(state) => state.selectedConversationId
|
||||||
|
)
|
||||||
|
const lastTypingSentAtRef = useRef<number>(0)
|
||||||
|
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
|
||||||
|
const [loadingQuickReplies, setLoadingQuickReplies] = useState(true)
|
||||||
|
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleTyping = () => {
|
||||||
|
if (!selectedConversationId) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastTypingSentAtRef.current >= 3000) {
|
||||||
|
lastTypingSentAtRef.current = now
|
||||||
|
sendOperatorPresence(selectedConversationId, "typing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void fetchQuickReplyListAll()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setQuickReplies(list)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversation.loadQuickRepliesFailed"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingQuickReplies(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedMessageEditor
|
||||||
|
variant="agent"
|
||||||
|
conversationId={selectedConversationId}
|
||||||
|
disabled={disabled}
|
||||||
|
uploadingAsset={uploadingAsset}
|
||||||
|
quickReplies={{
|
||||||
|
open: quickReplyPickerOpen,
|
||||||
|
loading: loadingQuickReplies,
|
||||||
|
items: quickReplies,
|
||||||
|
onOpenChange: setQuickReplyPickerOpen,
|
||||||
|
}}
|
||||||
|
aiCopilot={
|
||||||
|
selectedConversationId
|
||||||
|
? {
|
||||||
|
onSuggest: async () => {
|
||||||
|
try {
|
||||||
|
const text = await suggestAgentReply(selectedConversationId)
|
||||||
|
if (!text) {
|
||||||
|
toast.info(t("aiCopilot.errorGenerating"))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("aiCopilot.errorGenerating")
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSend={onSend}
|
||||||
|
onUploadImage={onUploadImage}
|
||||||
|
onSendAttachment={onSendAttachment}
|
||||||
|
onTyping={handleTyping}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { UserIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { IMConversationStatus } from "@/lib/generated/enums";
|
||||||
|
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
|
||||||
|
type ConversationListProps = {
|
||||||
|
onAfterSelect?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationList({ onAfterSelect }: ConversationListProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const conversations = useAgentConversationsStore((state) => state.conversations)
|
||||||
|
const loading = useAgentConversationsStore((state) => state.conversationsLoading)
|
||||||
|
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
|
||||||
|
const selectConversation = useAgentConversationsStore((state) => state.selectConversation)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("conversation.loading")}
|
||||||
|
</div>
|
||||||
|
) : conversations.length > 0 ? (
|
||||||
|
conversations.map((conversation) => {
|
||||||
|
const isSelected = selectedId === conversation.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={conversation.id}
|
||||||
|
className={`cursor-pointer border-b border-border/80 px-3 py-2 transition-colors hover:bg-muted/40 ${
|
||||||
|
isSelected ? "bg-accent/70" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
void selectConversation(conversation.id).then(
|
||||||
|
() => {
|
||||||
|
onAfterSelect?.()
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="size-7 shrink-0">
|
||||||
|
<AvatarImage src="" />
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary">
|
||||||
|
<UserIcon className="size-3.5 text-primary" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-sm leading-4">
|
||||||
|
{conversation.customerName ||
|
||||||
|
t("conversation.customerFallback", {
|
||||||
|
id: conversation.customerId || conversation.id,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{conversation.agentUnreadCount > 0 ? (
|
||||||
|
<div className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||||
|
{conversation.agentUnreadCount > 99
|
||||||
|
? "99+"
|
||||||
|
: conversation.agentUnreadCount}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{conversation.lastMessageAt
|
||||||
|
? formatDateTime(conversation.lastMessageAt)
|
||||||
|
: t("conversation.noTime")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
|
||||||
|
{conversation.lastMessageSummary || t("conversation.noLatestMessage")}
|
||||||
|
</div>
|
||||||
|
{conversation.status === IMConversationStatus.Pending &&
|
||||||
|
conversation.currentTeamName ? (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<span className="rounded-md bg-muted px-1.5 py-0.5">
|
||||||
|
{t("conversation.teamOnDuty", {
|
||||||
|
name: conversation.currentTeamName,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("conversation.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { CopyIcon, Loader2Icon, SparklesIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { summarizeAgentConversation } from "@/lib/api/agent"
|
||||||
|
|
||||||
|
export type ConversationSummaryDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
conversationId: number | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationSummaryDialog({
|
||||||
|
open,
|
||||||
|
conversationId,
|
||||||
|
onOpenChange,
|
||||||
|
}: ConversationSummaryDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{open ? (
|
||||||
|
<ConversationSummaryDialogBody
|
||||||
|
key={conversationId ? `summary-${conversationId}` : "summary"}
|
||||||
|
conversationId={conversationId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversationSummaryDialogBodyProps = {
|
||||||
|
conversationId: number | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConversationSummaryDialogBody({
|
||||||
|
conversationId,
|
||||||
|
onOpenChange,
|
||||||
|
}: ConversationSummaryDialogBodyProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [summary, setSummary] = useState("")
|
||||||
|
const [copying, setCopying] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversationId) {
|
||||||
|
setSummary("")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
summarizeAgentConversation(conversationId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!active) return
|
||||||
|
setSummary(res || "")
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!active) return
|
||||||
|
toast.error(error instanceof Error ? error.message : t("aiCopilot.errorGenerating"))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!active) return
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [conversationId, t])
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (!summary || copying) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopying(true)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(summary)
|
||||||
|
toast.success(t("aiCopilot.copied"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("aiCopilot.errorGenerating"))
|
||||||
|
} finally {
|
||||||
|
setCopying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<SparklesIcon className="size-4 text-primary" />
|
||||||
|
{t("aiCopilot.summaryTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t("aiCopilot.summaryTitle")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-[160px] max-h-[60vh] overflow-y-auto rounded-md border bg-muted/30 p-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-36 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2Icon className="size-5 animate-spin text-primary" />
|
||||||
|
<span className="text-sm">{t("aiCopilot.summarizing")}</span>
|
||||||
|
</div>
|
||||||
|
) : summary ? (
|
||||||
|
<div className="whitespace-pre-wrap text-sm leading-6 select-text text-foreground">
|
||||||
|
{summary}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("aiCopilot.noSummary")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
disabled={loading || !summary || copying}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
{copying ? t("aiCopilot.copied") : t("aiCopilot.copy")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { TagBadges, TagSelector } from "@/components/tag-selector"
|
||||||
|
import {
|
||||||
|
addConversationTag,
|
||||||
|
removeConversationTag,
|
||||||
|
type AgentConversation,
|
||||||
|
type AgentConversationTag,
|
||||||
|
} from "@/lib/api/agent"
|
||||||
|
import { type TagTree } from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type ConversationTagPickerProps = {
|
||||||
|
conversation: AgentConversation
|
||||||
|
availableTags: TagTree[]
|
||||||
|
loading?: boolean
|
||||||
|
onTagsChange: (tags: AgentConversationTag[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationTagPicker({
|
||||||
|
conversation,
|
||||||
|
availableTags,
|
||||||
|
loading = false,
|
||||||
|
onTagsChange,
|
||||||
|
}: ConversationTagPickerProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [pendingTagId, setPendingTagId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const selectedValues = useMemo(
|
||||||
|
() => (conversation.tags ?? []).map((item) => item.id),
|
||||||
|
[conversation.tags]
|
||||||
|
)
|
||||||
|
const selectedTagIds = useMemo(
|
||||||
|
() => new Set(selectedValues),
|
||||||
|
[selectedValues]
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleChange(nextTagIds: number[]) {
|
||||||
|
if (pendingTagId !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagId =
|
||||||
|
nextTagIds.find((id) => !selectedTagIds.has(id)) ??
|
||||||
|
selectedValues.find((id) => !nextTagIds.includes(id))
|
||||||
|
|
||||||
|
if (!tagId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = selectedTagIds.has(tagId)
|
||||||
|
const currentTags = conversation.tags ?? []
|
||||||
|
const nextTags = exists
|
||||||
|
? currentTags.filter((item) => item.id !== tagId)
|
||||||
|
: [...currentTags, { id: tagId, name: "" }]
|
||||||
|
|
||||||
|
setPendingTagId(tagId)
|
||||||
|
try {
|
||||||
|
if (exists) {
|
||||||
|
await removeConversationTag({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
tagId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await addConversationTag({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
tagId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onTagsChange(nextTags)
|
||||||
|
toast.success(exists ? t("conversation.tagRemoved") : t("conversation.tagAdded"))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversation.tagUpdateFailed"))
|
||||||
|
} finally {
|
||||||
|
setPendingTagId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagSelector
|
||||||
|
mode="multiple"
|
||||||
|
value={selectedValues}
|
||||||
|
onChange={(value) => void handleChange(value)}
|
||||||
|
tags={availableTags}
|
||||||
|
loading={loading}
|
||||||
|
pendingTagId={pendingTagId}
|
||||||
|
placeholder={t("conversation.edit")}
|
||||||
|
triggerText={t("conversation.edit")}
|
||||||
|
searchPlaceholder={t("conversation.searchTags")}
|
||||||
|
loadingText={t("conversation.loadingTags")}
|
||||||
|
emptyText={t("conversation.emptyTags")}
|
||||||
|
align="end"
|
||||||
|
showSelectedBadges={false}
|
||||||
|
triggerVariant="ghost"
|
||||||
|
triggerSize="sm"
|
||||||
|
triggerClassName="h-7 w-auto shrink-0 justify-start gap-1 px-2 text-xs"
|
||||||
|
contentClassName="w-72"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversationTagBadgesProps = {
|
||||||
|
tags?: AgentConversationTag[]
|
||||||
|
availableTags?: TagTree[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationTagBadges({
|
||||||
|
tags,
|
||||||
|
availableTags = [],
|
||||||
|
}: ConversationTagBadgesProps) {
|
||||||
|
if (!tags || tags.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagBadges
|
||||||
|
ids={tags.map((tag) => tag.id)}
|
||||||
|
tags={availableTags}
|
||||||
|
fallbackTags={tags}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowRightLeftIcon,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsUpDown,
|
||||||
|
CircleUserRoundIcon,
|
||||||
|
CircleXIcon,
|
||||||
|
FilePlus2Icon,
|
||||||
|
Menu,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { ConversationSummaryDialog } from "./conversation-summary-dialog";
|
||||||
|
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog";
|
||||||
|
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import {
|
||||||
|
agentConversationFilterOptions,
|
||||||
|
agentConversationSelectors,
|
||||||
|
type AgentConversationFilterKey,
|
||||||
|
useAgentConversationsStore,
|
||||||
|
} from "@/lib/stores/agent-conversations";
|
||||||
|
import { CreateTicketFromConversationDialog } from "../../tickets/_components/create-ticket-from-conversation-dialog";
|
||||||
|
import { ChatPanel } from "./chat-panel";
|
||||||
|
import { ConversationInfoPanel } from "./conversation-info-panel";
|
||||||
|
import { ConversationList } from "./conversation-list";
|
||||||
|
|
||||||
|
const workbenchIconButtonClassName =
|
||||||
|
"size-8 text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||||
|
|
||||||
|
function getCustomerOnlineClassName(online?: boolean) {
|
||||||
|
return online
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300"
|
||||||
|
: "border-border bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerOnlineDotClassName(online?: boolean) {
|
||||||
|
return online ? "bg-emerald-500" : "bg-muted-foreground/70";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationWorkbench() {
|
||||||
|
const t = useI18n();
|
||||||
|
const conversation = useAgentConversationsStore(
|
||||||
|
agentConversationSelectors.selectedConversation,
|
||||||
|
);
|
||||||
|
const conversationFilter = useAgentConversationsStore(
|
||||||
|
(state) => state.conversationFilter,
|
||||||
|
);
|
||||||
|
const setConversationFilter = useAgentConversationsStore(
|
||||||
|
(state) => state.setConversationFilter,
|
||||||
|
);
|
||||||
|
const loadConversations = useAgentConversationsStore(
|
||||||
|
(state) => state.loadConversations,
|
||||||
|
);
|
||||||
|
const loadMessages = useAgentConversationsStore(
|
||||||
|
(state) => state.loadMessages,
|
||||||
|
);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [infoPanelCollapsed, setInfoPanelCollapsed] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [mobileCustomerSheetOpen, setMobileCustomerSheetOpen] = useState(false);
|
||||||
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
|
const [closeOpen, setCloseOpen] = useState(false);
|
||||||
|
const [createTicketOpen, setCreateTicketOpen] = useState(false);
|
||||||
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||||
|
const sidebarPanelRef = useRef<PanelImperativeHandle | null>(null);
|
||||||
|
const infoPanelRef = useRef<PanelImperativeHandle | null>(null);
|
||||||
|
const filterContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const filterMeasureRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = filterContainerRef.current;
|
||||||
|
const measure = filterMeasureRef.current;
|
||||||
|
if (!container || !measure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFilterMode = () => {
|
||||||
|
setShowFilterDropdown(measure.scrollWidth > container.clientWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFilterMode();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
updateFilterMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
observer.observe(measure);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentFilterOption =
|
||||||
|
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
|
||||||
|
agentConversationFilterOptions[0];
|
||||||
|
const getFilterLabel = (labelKey: string) => t(labelKey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConversations().catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("conversation.loadListFailed"));
|
||||||
|
});
|
||||||
|
}, [loadConversations, conversationFilter, t]);
|
||||||
|
|
||||||
|
async function handleConversationChanged(conversationId: number) {
|
||||||
|
await loadConversations();
|
||||||
|
await loadMessages(conversationId, {
|
||||||
|
forceLoading: false,
|
||||||
|
reset: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSidebarToggle = () => {
|
||||||
|
const panel = sidebarPanelRef.current;
|
||||||
|
if (!panel) {
|
||||||
|
setSidebarCollapsed((current) => !current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panel.isCollapsed()) {
|
||||||
|
panel.expand();
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.collapse();
|
||||||
|
setSidebarCollapsed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInfoPanelToggle = () => {
|
||||||
|
const panel = infoPanelRef.current;
|
||||||
|
if (!panel) {
|
||||||
|
setInfoPanelCollapsed((current) => !current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panel.isCollapsed()) {
|
||||||
|
panel.expand();
|
||||||
|
setInfoPanelCollapsed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.collapse();
|
||||||
|
setInfoPanelCollapsed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderConversationSidebar = (opts?: { onListAfterSelect?: () => void }) => (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col bg-inherit">
|
||||||
|
<div className="flex h-12.5 shrink-0 items-start justify-between gap-2 border-b border-border/80 bg-card px-2 py-2">
|
||||||
|
<div ref={filterContainerRef} className="relative min-w-0 flex-1">
|
||||||
|
{showFilterDropdown ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8.5 w-full min-w-0 justify-between gap-2 px-3 text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{currentFilterOption
|
||||||
|
? getFilterLabel(currentFilterOption.labelKey)
|
||||||
|
: t("conversation.filterPlaceholder")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-44 min-w-44">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={conversationFilter}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConversationFilter(value as AgentConversationFilterKey)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{agentConversationFilterOptions.map((opt) => (
|
||||||
|
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
||||||
|
{getFilterLabel(opt.labelKey)}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Tabs
|
||||||
|
value={conversationFilter}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setConversationFilter(value as AgentConversationFilterKey)
|
||||||
|
}
|
||||||
|
className="min-w-0 flex-1 gap-0"
|
||||||
|
>
|
||||||
|
<TabsList
|
||||||
|
className="w-full min-w-0 justify-start"
|
||||||
|
>
|
||||||
|
{agentConversationFilterOptions.map((opt) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
className="shrink-0 px-2.5 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{getFilterLabel(opt.labelKey)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={filterMeasureRef}
|
||||||
|
className="pointer-events-none absolute whitespace-nowrap opacity-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="inline-flex">
|
||||||
|
{agentConversationFilterOptions.map((opt) => (
|
||||||
|
<span
|
||||||
|
key={opt.value}
|
||||||
|
className="shrink-0 px-2.5 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{getFilterLabel(opt.labelKey)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`${workbenchIconButtonClassName} mt-0.5 shrink-0 lg:hidden`}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ConversationList onAfterSelect={opts?.onListAfterSelect} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceContent = (
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-card text-card-foreground">
|
||||||
|
<div className="flex h-12.5 shrink-0 items-center justify-between gap-3 border-b border-border/80 bg-card px-3 py-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`${workbenchIconButtonClassName} lg:hidden`}
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`${workbenchIconButtonClassName} hidden lg:flex`}
|
||||||
|
onClick={handleSidebarToggle}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{conversation ? (
|
||||||
|
<>
|
||||||
|
<Avatar className="size-8 shrink-0 lg:size-9">
|
||||||
|
<AvatarImage src="" />
|
||||||
|
<AvatarFallback className="bg-primary/10 text-sm text-primary">
|
||||||
|
{t("conversation.customerAvatar")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="min-w-0 truncate text-sm font-medium leading-tight">
|
||||||
|
{conversation.customerName ||
|
||||||
|
t("conversation.customerFallback", {
|
||||||
|
id: conversation.customerId || conversation.id,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] leading-none ${getCustomerOnlineClassName(
|
||||||
|
conversation.customerOnline,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`size-1.5 rounded-full ${getCustomerOnlineDotClassName(
|
||||||
|
conversation.customerOnline,
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
{conversation.customerOnline
|
||||||
|
? t("conversation.customerOnline")
|
||||||
|
: t("conversation.customerOffline")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
<span>{t("conversation.channelNumber", { id: conversation.channelId || "-" })}</span>
|
||||||
|
{conversation.customerId ? (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/60"> / </span>
|
||||||
|
<span>{t("conversation.linkedCustomer")}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-[14px] leading-tight">
|
||||||
|
{t("conversation.workbenchTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden">
|
||||||
|
{t("conversation.openMenuSelectConversation")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
|
||||||
|
{t("conversation.selectConversationFromSidebar")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`${workbenchIconButtonClassName} lg:hidden`}
|
||||||
|
disabled={!conversation}
|
||||||
|
aria-label={t("conversation.conversationInfo")}
|
||||||
|
onClick={() => setMobileCustomerSheetOpen(true)}
|
||||||
|
>
|
||||||
|
<CircleUserRoundIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={workbenchIconButtonClassName}
|
||||||
|
disabled={!conversation}
|
||||||
|
title={t("aiCopilot.summarize")}
|
||||||
|
aria-label={t("aiCopilot.summarize")}
|
||||||
|
onClick={() => setSummaryOpen(true)}
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={workbenchIconButtonClassName}
|
||||||
|
disabled={!conversation}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44 min-w-44">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setSummaryOpen(true)}
|
||||||
|
disabled={!conversation}
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
{t("aiCopilot.summarize")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setCreateTicketOpen(true)}
|
||||||
|
disabled={!conversation}
|
||||||
|
>
|
||||||
|
<FilePlus2Icon />
|
||||||
|
{t("conversation.createTicket")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setTransferOpen(true)}
|
||||||
|
disabled={!conversation || conversation.status !== 3}
|
||||||
|
>
|
||||||
|
<ArrowRightLeftIcon />
|
||||||
|
{t("conversation.transferConversation")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setCloseOpen(true)}
|
||||||
|
disabled={!conversation || conversation.status === 4}
|
||||||
|
>
|
||||||
|
<CircleXIcon />
|
||||||
|
{t("conversation.closeConversation")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`${workbenchIconButtonClassName} hidden lg:flex`}
|
||||||
|
onClick={handleInfoPanelToggle}
|
||||||
|
aria-label={
|
||||||
|
infoPanelCollapsed
|
||||||
|
? t("conversation.expandConversationInfo")
|
||||||
|
: t("conversation.collapseConversationInfo")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{infoPanelCollapsed ? (
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 w-full flex-1 overflow-hidden">
|
||||||
|
<ChatPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100dvh-var(--header-height))] min-h-0 w-full min-w-0 flex-col overflow-hidden lg:h-full">
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("conversation.closeConversationList")}
|
||||||
|
className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`fixed top-12 bottom-0 left-0 z-40 flex w-[min(22rem,calc(100vw-0.75rem))] max-w-[min(22rem,calc(100vw-0.75rem))] flex-col overflow-hidden border-r border-border/80 bg-card text-card-foreground shadow-xl transition-transform duration-300 ease-out will-change-transform touch-manipulation overscroll-contain supports-[padding:max(0px)]:pb-[env(safe-area-inset-bottom)] lg:hidden ${
|
||||||
|
mobileMenuOpen ? "translate-x-0" : "-translate-x-full pointer-events-none"
|
||||||
|
}`}
|
||||||
|
aria-hidden={!mobileMenuOpen}
|
||||||
|
>
|
||||||
|
{renderConversationSidebar({
|
||||||
|
onListAfterSelect: () => setMobileMenuOpen(false),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden lg:hidden">
|
||||||
|
{workspaceContent}
|
||||||
|
</div>
|
||||||
|
<div className="hidden min-h-0 w-full flex-1 overflow-hidden lg:flex">
|
||||||
|
<ResizablePanelGroup orientation="horizontal">
|
||||||
|
<ResizablePanel
|
||||||
|
panelRef={sidebarPanelRef}
|
||||||
|
defaultSize="20%"
|
||||||
|
minSize="10%"
|
||||||
|
maxSize="40%"
|
||||||
|
collapsedSize="0%"
|
||||||
|
collapsible
|
||||||
|
onResize={(panelSize: { asPercentage: number }) => {
|
||||||
|
setSidebarCollapsed(panelSize.asPercentage <= 1);
|
||||||
|
}}
|
||||||
|
className="min-h-0 bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
|
||||||
|
{renderConversationSidebar()}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel defaultSize="50%" minSize="32%" className="min-h-0 bg-card">
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
{workspaceContent}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel
|
||||||
|
panelRef={infoPanelRef}
|
||||||
|
defaultSize="500px"
|
||||||
|
minSize="20%"
|
||||||
|
maxSize="40%"
|
||||||
|
collapsedSize="0%"
|
||||||
|
collapsible
|
||||||
|
onResize={(panelSize: { asPercentage: number }) => {
|
||||||
|
setInfoPanelCollapsed(panelSize.asPercentage <= 1);
|
||||||
|
}}
|
||||||
|
className="min-h-0 bg-card"
|
||||||
|
>
|
||||||
|
<ConversationInfoPanel conversation={conversation} className="h-full" />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
<ConversationTransferDialog
|
||||||
|
open={transferOpen}
|
||||||
|
mode="transfer"
|
||||||
|
conversationId={conversation?.id ?? null}
|
||||||
|
onOpenChange={setTransferOpen}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setTransferOpen(false);
|
||||||
|
if (conversation?.id) {
|
||||||
|
await handleConversationChanged(conversation.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConversationCloseDialog
|
||||||
|
open={closeOpen}
|
||||||
|
conversationId={conversation?.id ?? null}
|
||||||
|
onOpenChange={setCloseOpen}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setCloseOpen(false);
|
||||||
|
if (conversation?.id) {
|
||||||
|
await handleConversationChanged(conversation.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CreateTicketFromConversationDialog
|
||||||
|
open={createTicketOpen}
|
||||||
|
onOpenChange={setCreateTicketOpen}
|
||||||
|
conversation={
|
||||||
|
conversation
|
||||||
|
? {
|
||||||
|
id: conversation.id,
|
||||||
|
customerName: conversation.customerName,
|
||||||
|
customerId: conversation.customerId ?? 0,
|
||||||
|
lastMessageSummary: conversation.lastMessageSummary,
|
||||||
|
currentAssigneeId: conversation.currentAssigneeId,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onSuccess={() => {
|
||||||
|
setCreateTicketOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConversationSummaryDialog
|
||||||
|
open={summaryOpen}
|
||||||
|
conversationId={conversation?.id ?? null}
|
||||||
|
onOpenChange={setSummaryOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Sheet open={mobileCustomerSheetOpen} onOpenChange={setMobileCustomerSheetOpen}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
|
||||||
|
showCloseButton
|
||||||
|
>
|
||||||
|
<ConversationInfoPanel
|
||||||
|
conversation={conversation}
|
||||||
|
variant="embedded"
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
web/app/(dashboard)/dashboard/conversations/page.tsx
Normal file
12
web/app/(dashboard)/dashboard/conversations/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { AgentRealtimeProvider } from "@/components/agent-realtime-provider";
|
||||||
|
|
||||||
|
import { ConversationWorkbench } from "./_components/conversation-workbench";
|
||||||
|
|
||||||
|
export default function ConversationsPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AgentRealtimeProvider />
|
||||||
|
<ConversationWorkbench />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
export { CustomerFormDialog, type CustomerFormDialogProps } from "@/components/customer-form-dialog"
|
||||||
|
export { CustomerFormDialog as EditDialog } from "@/components/customer-form-dialog"
|
||||||
266
web/app/(dashboard)/dashboard/customers/page.tsx
Normal file
266
web/app/(dashboard)/dashboard/customers/page.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BanIcon, CheckCircle2Icon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { type CustomerFormSavePayload } from "@/components/customer-form";
|
||||||
|
import {
|
||||||
|
DashboardCrudPage,
|
||||||
|
createDashboardStatusColumn,
|
||||||
|
createDashboardStatusToggleAction,
|
||||||
|
type DashboardCrudColumn,
|
||||||
|
type DashboardCrudFilter,
|
||||||
|
} from "@/components/dashboard/crud";
|
||||||
|
import { type ComboboxOption } from "@/components/option-combobox";
|
||||||
|
import { fetchCompanies, type AdminCompany } from "@/lib/api/company";
|
||||||
|
import {
|
||||||
|
deleteCustomer,
|
||||||
|
fetchCustomers,
|
||||||
|
saveCustomerProfile,
|
||||||
|
updateCustomerStatus,
|
||||||
|
type AdminCustomer,
|
||||||
|
} from "@/lib/api/customer";
|
||||||
|
import { Gender, Status } from "@/lib/generated/enums";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { EditDialog } from "./_components/edit";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function getGenderText(gender: number, t: TFunction) {
|
||||||
|
if (gender === Gender.Male) return t("customerForm.genderMale");
|
||||||
|
if (gender === Gender.Female) return t("customerForm.genderFemale");
|
||||||
|
return t("customerForm.genderUnknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardCustomersPage() {
|
||||||
|
const t = useI18n();
|
||||||
|
const [companyOptions, setCompanyOptions] = useState<ComboboxOption[]>([
|
||||||
|
{ value: "0", label: t("customer.allCompanies") },
|
||||||
|
]);
|
||||||
|
const [companyNameMap, setCompanyNameMap] = useState<Record<number, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const listStatusOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "all", label: t("status.all") },
|
||||||
|
{ value: String(Status.Ok), label: t("status.ok") },
|
||||||
|
{ value: String(Status.Disabled), label: t("status.disabled") },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
const genderOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "all", label: t("customer.allGenders") },
|
||||||
|
{ value: String(Gender.Unknown), label: t("customerForm.genderUnknown") },
|
||||||
|
{ value: String(Gender.Male), label: t("customerForm.genderMale") },
|
||||||
|
{ value: String(Gender.Female), label: t("customerForm.genderFemale") },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCompanies() {
|
||||||
|
try {
|
||||||
|
const data = await fetchCompanies({ status: 0, page: 1, limit: 500 });
|
||||||
|
setCompanyOptions([
|
||||||
|
{ value: "0", label: t("customer.allCompanies") },
|
||||||
|
...data.results.map((item) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
const map: Record<number, string> = {};
|
||||||
|
data.results.forEach((item: AdminCompany) => {
|
||||||
|
map[item.id] = item.name;
|
||||||
|
});
|
||||||
|
setCompanyNameMap(map);
|
||||||
|
} catch {
|
||||||
|
// Company names are optional display enrichment for this list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadCompanies();
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const filters = useMemo<DashboardCrudFilter[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "keyword",
|
||||||
|
label: t("customer.columnName"),
|
||||||
|
placeholder: t("customer.keywordPlaceholder"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gender",
|
||||||
|
label: t("customer.columnGender"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
valueType: "number",
|
||||||
|
options: genderOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "companyId",
|
||||||
|
label: t("customer.columnCompany"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "0",
|
||||||
|
allValue: "0",
|
||||||
|
valueType: "number",
|
||||||
|
options: companyOptions,
|
||||||
|
className: "w-full sm:w-56",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("customer.columnStatus"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
valueType: "number",
|
||||||
|
options: listStatusOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[companyOptions, genderOptions, listStatusOptions, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<DashboardCrudColumn<AdminCustomer>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "id",
|
||||||
|
label: "ID",
|
||||||
|
className: "w-20",
|
||||||
|
render: (item) => item.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: t("customer.columnName"),
|
||||||
|
render: (item) => <span className="font-medium">{item.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gender",
|
||||||
|
label: t("customer.columnGender"),
|
||||||
|
className: "w-20",
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{getGenderText(item.gender, t)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "company",
|
||||||
|
label: t("customer.columnCompany"),
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{item.companyId > 0
|
||||||
|
? (companyNameMap[item.companyId] ?? String(item.companyId))
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mobile",
|
||||||
|
label: t("customer.columnMobile"),
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{item.primaryMobile || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
label: t("customer.columnEmail"),
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{item.primaryEmail || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
createDashboardStatusColumn<AdminCustomer, number>({
|
||||||
|
label: t("customer.columnStatus"),
|
||||||
|
className: "w-24",
|
||||||
|
getStatus: (item) => item.status,
|
||||||
|
getLabel: (status) =>
|
||||||
|
status === Status.Ok ? t("status.ok") : t("status.disabled"),
|
||||||
|
getBadgeVariant: (status) =>
|
||||||
|
status === Status.Ok ? "default" : "secondary",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[companyNameMap, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCrudPage<AdminCustomer, CustomerFormSavePayload>
|
||||||
|
filters={filters}
|
||||||
|
columns={columns}
|
||||||
|
fetchList={(query) =>
|
||||||
|
fetchCustomers({
|
||||||
|
keyword:
|
||||||
|
typeof query.keyword === "string" ? query.keyword : undefined,
|
||||||
|
status:
|
||||||
|
typeof query.status === "number" ? query.status : undefined,
|
||||||
|
gender:
|
||||||
|
typeof query.gender === "number" ? query.gender : undefined,
|
||||||
|
companyId:
|
||||||
|
typeof query.companyId === "number" ? query.companyId : undefined,
|
||||||
|
page: Number(query.page),
|
||||||
|
limit: Number(query.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={saveCustomerProfile}
|
||||||
|
updateItem={(_item, payload) => saveCustomerProfile(payload)}
|
||||||
|
deleteItem={(item) => deleteCustomer(item.id)}
|
||||||
|
canDelete={(item) => item.status !== Status.Deleted}
|
||||||
|
rowActions={[
|
||||||
|
createDashboardStatusToggleAction<AdminCustomer, number>({
|
||||||
|
icon: (item) =>
|
||||||
|
item.status === Status.Ok ? <BanIcon /> : <CheckCircle2Icon />,
|
||||||
|
label: (item) =>
|
||||||
|
item.status === Status.Ok
|
||||||
|
? t("customer.disable")
|
||||||
|
: t("customer.enable"),
|
||||||
|
disabled: (item) => item.status === Status.Deleted,
|
||||||
|
getNextStatus: (item) =>
|
||||||
|
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||||
|
updateStatus: (item, nextStatus) =>
|
||||||
|
updateCustomerStatus(item.id, nextStatus),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t(nextStatus === Status.Ok ? "customer.enabled" : "customer.disabled", {
|
||||||
|
name: item.name,
|
||||||
|
}),
|
||||||
|
errorMessage: t("customer.statusUpdateFailed"),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||||
|
<EditDialog
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSave={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
labels={{
|
||||||
|
refresh: t("customer.refresh"),
|
||||||
|
create: t("customer.new"),
|
||||||
|
query: t("customer.query"),
|
||||||
|
loading: t("customer.loading"),
|
||||||
|
empty: t("customer.empty"),
|
||||||
|
actions: t("customer.columnActions"),
|
||||||
|
edit: t("customer.edit"),
|
||||||
|
delete: t("customer.delete"),
|
||||||
|
processing: t("customer.processing"),
|
||||||
|
moreActions: (item) => t("customer.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("customer.loadFailed"),
|
||||||
|
saveFailed: t("customer.saveFailed"),
|
||||||
|
deleteFailed: t("customer.deleteFailed"),
|
||||||
|
created: (payload) => t("customer.created", { name: payload.name }),
|
||||||
|
updated: (item) => t("customer.updated", { name: item.name }),
|
||||||
|
deleted: (item) => t("customer.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
web/app/(dashboard)/dashboard/docs/page.tsx
Normal file
21
web/app/(dashboard)/dashboard/docs/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { DashboardPlaceholder } from "@/components/dashboard-placeholder"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
export default function DashboardDocsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPlaceholder
|
||||||
|
eyebrow="Docs"
|
||||||
|
title={t("docs.title")}
|
||||||
|
description={t("docs.description")}
|
||||||
|
nextSteps={[
|
||||||
|
t("docs.step1"),
|
||||||
|
t("docs.step2"),
|
||||||
|
t("docs.step3"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BotIcon, SearchIcon, SparklesIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
debugKnowledgeAnswer,
|
||||||
|
debugKnowledgeSearch,
|
||||||
|
type KnowledgeAnswerResponse,
|
||||||
|
type KnowledgeSearchResponse,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { getKnowledgeAnswerStatusLabel } from "@/lib/knowledge-i18n";
|
||||||
|
|
||||||
|
type DebugPanelProps = {
|
||||||
|
knowledgeBaseId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const [question, setQuestion] = useState("");
|
||||||
|
const [topK, setTopK] = useState("5");
|
||||||
|
const [scoreThreshold, setScoreThreshold] = useState("0.2");
|
||||||
|
const [rerankLimit, setRerankLimit] = useState("5");
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [answering, setAnswering] = useState(false);
|
||||||
|
const [searchResult, setSearchResult] = useState<KnowledgeSearchResponse | null>(null);
|
||||||
|
const [answerResult, setAnswerResult] = useState<KnowledgeAnswerResponse | null>(null);
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!knowledgeBaseId) {
|
||||||
|
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!question.trim()) {
|
||||||
|
toast.error(t("knowledge.debugQuestionRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const data = await debugKnowledgeSearch({
|
||||||
|
knowledgeBaseIds: [knowledgeBaseId],
|
||||||
|
question: question.trim(),
|
||||||
|
topK: Number(topK) || undefined,
|
||||||
|
scoreThreshold: Number(scoreThreshold) || undefined,
|
||||||
|
rerankLimit: Number(rerankLimit) || undefined,
|
||||||
|
});
|
||||||
|
setSearchResult(data);
|
||||||
|
toast.success(t("knowledge.searchCompleted", { count: data.hitCount }));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.searchFailed"));
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAnswer() {
|
||||||
|
if (!knowledgeBaseId) {
|
||||||
|
toast.error(t("knowledge.selectKnowledgeBaseFirst"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!question.trim()) {
|
||||||
|
toast.error(t("knowledge.debugQuestionRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnswering(true);
|
||||||
|
try {
|
||||||
|
const data = await debugKnowledgeAnswer({
|
||||||
|
knowledgeBaseIds: [knowledgeBaseId],
|
||||||
|
question: question.trim(),
|
||||||
|
topK: Number(topK) || undefined,
|
||||||
|
scoreThreshold: Number(scoreThreshold) || undefined,
|
||||||
|
rerankLimit: Number(rerankLimit) || undefined,
|
||||||
|
});
|
||||||
|
setAnswerResult(data);
|
||||||
|
toast.success(t("knowledge.answerCompleted", {
|
||||||
|
status: getKnowledgeAnswerStatusLabel(data.answerStatus, data.answerStatusName, t),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.answerFailed"));
|
||||||
|
} finally {
|
||||||
|
setAnswering(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Textarea
|
||||||
|
value={question}
|
||||||
|
onChange={(event) => setQuestion(event.target.value)}
|
||||||
|
placeholder={t("knowledge.debugQuestionPlaceholder")}
|
||||||
|
rows={5}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="topk" className="text-xs">TopK</Label>
|
||||||
|
<Input id="topk" value={topK} onChange={(event) => setTopK(event.target.value)} placeholder={t("knowledge.topKPlaceholder")} className="h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="threshold" className="text-xs">{t("knowledge.scoreThreshold")}</Label>
|
||||||
|
<Input id="threshold" value={scoreThreshold} onChange={(event) => setScoreThreshold(event.target.value)} placeholder={t("knowledge.scoreThresholdPlaceholder")} className="h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="rerank" className="text-xs">{t("knowledge.rerankLimitLabel")}</Label>
|
||||||
|
<Input id="rerank" value={rerankLimit} onChange={(event) => setRerankLimit(event.target.value)} placeholder={t("knowledge.rerankLimitPlaceholder")} className="h-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1" variant="outline" onClick={() => void handleSearch()} disabled={searching}>
|
||||||
|
<SearchIcon className="mr-2 size-4" />
|
||||||
|
{searching ? t("knowledge.searching") : t("knowledge.debugSearch")}
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={() => void handleAnswer()} disabled={answering}>
|
||||||
|
<SparklesIcon className="mr-2 size-4" />
|
||||||
|
{answering ? t("knowledge.generating") : t("knowledge.debugAnswer")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{answerResult ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<BotIcon className="size-4" />
|
||||||
|
{t("knowledge.answerResult")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-background p-3 whitespace-pre-wrap">
|
||||||
|
{answerResult.answer}
|
||||||
|
</div>
|
||||||
|
{answerResult.citations.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">{t("knowledge.citationSources")}</div>
|
||||||
|
{answerResult.citations.map((citation) => (
|
||||||
|
<div
|
||||||
|
key={`${citation.documentId}-${citation.chunkNo}-${citation.sectionPath}`}
|
||||||
|
className="rounded-md border bg-muted/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate text-xs font-medium">
|
||||||
|
{getSearchResultLabel(citation)}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{citation.score.toFixed(4)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{citation.sectionPath || citation.title || `Chunk #${citation.chunkNo}`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{citation.snippet}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchResult ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{t("knowledge.searchHits")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("knowledge.hitsSummary", { count: searchResult.hitCount, latency: searchResult.latencyMs })}
|
||||||
|
</div>
|
||||||
|
{searchResult.results.map((item) => (
|
||||||
|
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{getSearchResultLabel(item)}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{item.score.toFixed(4)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.sectionPath || item.title || `Chunk #${item.chunkNo}`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchResultLabel(item: {
|
||||||
|
faqQuestion?: string
|
||||||
|
faqId?: number
|
||||||
|
documentTitle?: string
|
||||||
|
documentId?: number
|
||||||
|
}) {
|
||||||
|
if (item.faqQuestion) {
|
||||||
|
return item.faqQuestion
|
||||||
|
}
|
||||||
|
if (item.documentTitle) {
|
||||||
|
return item.documentTitle
|
||||||
|
}
|
||||||
|
if (item.faqId && item.faqId > 0) {
|
||||||
|
return `FAQ ${item.faqId}`
|
||||||
|
}
|
||||||
|
return `Document ${item.documentId ?? 0}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Controller, type Resolver, useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { ContentEditor } from "@/components/content-editor"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
type KnowledgeDocument,
|
||||||
|
type CreateKnowledgeDocumentPayload,
|
||||||
|
type KnowledgeDirectory,
|
||||||
|
fetchKnowledgeDocument,
|
||||||
|
fetchKnowledgeDirectories,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import {
|
||||||
|
KnowledgeDocumentContentType,
|
||||||
|
} from "@/lib/generated/enums"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type DocumentEditDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
saving: boolean
|
||||||
|
itemId: number | null
|
||||||
|
knowledgeBaseId: number | null
|
||||||
|
initialDirectoryId?: number
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
directoryId: "0",
|
||||||
|
title: "",
|
||||||
|
contentType: KnowledgeDocumentContentType.Markdown,
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
function createKnowledgeDocumentFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
directoryId: z.string().trim(),
|
||||||
|
title: z.string().trim().min(1, t("knowledge.documentTitleRequired")).max(255, t("knowledge.documentTitleMax")),
|
||||||
|
contentType: z.string().trim().min(1, t("knowledge.contentTypeRequired")),
|
||||||
|
content: z.string().trim().min(1, t("knowledge.contentRequired")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
directoryId: string
|
||||||
|
title: string
|
||||||
|
contentType: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectoryOption = { value: string; label: string }
|
||||||
|
|
||||||
|
function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] {
|
||||||
|
return items.flatMap((item) => [
|
||||||
|
{ value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` },
|
||||||
|
...flattenDirectoryOptions(item.children || [], depth + 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm(item: KnowledgeDocument | null, initialDirectoryId = 0): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return { ...emptyForm, directoryId: String(initialDirectoryId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
directoryId: String(item.directoryId || 0),
|
||||||
|
title: item.title,
|
||||||
|
contentType: item.contentType || KnowledgeDocumentContentType.Markdown,
|
||||||
|
content: item.content || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeDocumentPayload {
|
||||||
|
return {
|
||||||
|
knowledgeBaseId,
|
||||||
|
directoryId: Number(form.directoryId),
|
||||||
|
title: form.title.trim(),
|
||||||
|
contentType: form.contentType,
|
||||||
|
content: form.content.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentEditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
knowledgeBaseId,
|
||||||
|
initialDirectoryId = 0,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: DocumentEditDialogProps) {
|
||||||
|
if (!open || !knowledgeBaseId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentFormDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
itemId={itemId}
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
initialDirectoryId={initialDirectoryId}
|
||||||
|
saving={saving}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentFormDialogBodyProps = {
|
||||||
|
saving: boolean
|
||||||
|
itemId: number | null
|
||||||
|
knowledgeBaseId: number
|
||||||
|
initialDirectoryId: number
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentFormDialogBody({
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
knowledgeBaseId,
|
||||||
|
initialDirectoryId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: DocumentFormDialogBodyProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const formId = "knowledge-document-edit-form"
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([])
|
||||||
|
const knowledgeDocumentFormSchema = useMemo(() => createKnowledgeDocumentFormSchema(t), [t])
|
||||||
|
const editFormResolver = useMemo(
|
||||||
|
() => zodResolver(knowledgeDocumentFormSchema) as Resolver<EditForm>,
|
||||||
|
[knowledgeDocumentFormSchema],
|
||||||
|
)
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver: editFormResolver,
|
||||||
|
defaultValues: emptyForm,
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
const contentType = watch("contentType")
|
||||||
|
const content = watch("content")
|
||||||
|
const directoryOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "0", label: t("knowledge.rootContent") },
|
||||||
|
...flattenDirectoryOptions(directories),
|
||||||
|
],
|
||||||
|
[directories, t],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!itemId) {
|
||||||
|
reset(buildForm(null, initialDirectoryId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchKnowledgeDocument(itemId)
|
||||||
|
reset(buildForm(data))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load knowledge document:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDetail()
|
||||||
|
}, [itemId, initialDirectoryId, reset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function loadDirectories() {
|
||||||
|
try {
|
||||||
|
const data = await fetchKnowledgeDirectories(knowledgeBaseId)
|
||||||
|
if (!cancelled) {
|
||||||
|
setDirectories(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load knowledge directories:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDirectories()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [knowledgeBaseId])
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
const payload = buildPayload({ ...values, contentType, content }, knowledgeBaseId)
|
||||||
|
await onSubmit(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={itemId ? t("knowledge.editDocumentTitle") : t("knowledge.createDocumentTitle")}
|
||||||
|
size="xl"
|
||||||
|
allowFullscreen
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("knowledge.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||||
|
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-muted-foreground">{t("knowledge.loading")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<Field data-invalid={!!errors.directoryId}>
|
||||||
|
<FieldLabel>{t("knowledge.directory")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="directoryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => field.onChange(value ?? "0")}
|
||||||
|
options={directoryOptions}
|
||||||
|
placeholder={t("knowledge.selectDirectory")}
|
||||||
|
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||||
|
emptyText={t("knowledge.emptyDirectory")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.directoryId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.title}>
|
||||||
|
<FieldLabel htmlFor="doc-title">{t("knowledge.documentTitle")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="doc-title"
|
||||||
|
placeholder={t("knowledge.documentTitlePlaceholder")}
|
||||||
|
aria-invalid={!!errors.title}
|
||||||
|
{...register("title")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.title]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.content}>
|
||||||
|
<FieldLabel htmlFor="doc-content">{t("knowledge.content")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="content"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ContentEditor
|
||||||
|
value={{
|
||||||
|
mode:
|
||||||
|
contentType === KnowledgeDocumentContentType.HTML
|
||||||
|
? KnowledgeDocumentContentType.HTML
|
||||||
|
: KnowledgeDocumentContentType.Markdown,
|
||||||
|
raw: field.value ?? "",
|
||||||
|
}}
|
||||||
|
onChange={(next) => {
|
||||||
|
field.onChange(next.raw)
|
||||||
|
setValue("contentType", next.mode, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder={t("knowledge.contentPlaceholder")}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.content]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,814 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileTextIcon,
|
||||||
|
FolderInputIcon,
|
||||||
|
EyeIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
|
SearchIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
WrenchIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useConfirm } from "@/components/confirm-provider";
|
||||||
|
import {
|
||||||
|
useDashboardPagedList,
|
||||||
|
type DashboardPagedListFilter,
|
||||||
|
} from "@/components/dashboard/list";
|
||||||
|
import { ListPagination } from "@/components/list-pagination";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
batchDeleteKnowledgeDocuments,
|
||||||
|
batchMoveKnowledgeDocuments,
|
||||||
|
buildKnowledgeDocumentIndex,
|
||||||
|
createKnowledgeDocument,
|
||||||
|
deleteKnowledgeDocument,
|
||||||
|
fetchKnowledgeDocuments,
|
||||||
|
updateKnowledgeDocument,
|
||||||
|
type CreateKnowledgeDocumentPayload,
|
||||||
|
type KnowledgeDocumentListItem,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import {
|
||||||
|
KnowledgeDocumentIndexStatus,
|
||||||
|
Status,
|
||||||
|
} from "@/lib/generated/enums";
|
||||||
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
|
import { DocumentEditDialog } from "./document-edit";
|
||||||
|
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
|
||||||
|
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
|
||||||
|
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
|
||||||
|
import { SelectionCheckbox } from "./selection-checkbox";
|
||||||
|
|
||||||
|
type DocumentListProps = {
|
||||||
|
knowledgeBaseId: number | null;
|
||||||
|
onActionStateChange?: (state: DocumentListActionState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentListActionState = {
|
||||||
|
onRefresh: () => void;
|
||||||
|
onChangeViewMode: (mode: "list" | "grid") => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
viewMode: "list" | "grid";
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function getStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allStatus") },
|
||||||
|
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
|
||||||
|
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
|
||||||
|
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allIndexStatus") },
|
||||||
|
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
|
||||||
|
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
|
||||||
|
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexStatusLabel(status: string, t: TFunction) {
|
||||||
|
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
|
||||||
|
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
|
||||||
|
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
|
||||||
|
return status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexStatusBadgeVariant(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case KnowledgeDocumentIndexStatus.Indexed:
|
||||||
|
return "secondary" as const;
|
||||||
|
case KnowledgeDocumentIndexStatus.Failed:
|
||||||
|
return "destructive" as const;
|
||||||
|
default:
|
||||||
|
return "outline" as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
|
||||||
|
const badge = (
|
||||||
|
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
|
||||||
|
{getIndexStatusLabel(item.indexStatus, t)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
|
||||||
|
!item.indexError
|
||||||
|
) {
|
||||||
|
return badge
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<span className="inline-flex">{badge}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||||
|
{item.indexError}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
|
||||||
|
|
||||||
|
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [moving, setMoving] = useState(false);
|
||||||
|
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [moveTargetIds, setMoveTargetIds] = useState<number[]>([]);
|
||||||
|
const [contextMenuDocumentId, setContextMenuDocumentId] = useState<number | null>(null);
|
||||||
|
const [detailItemId, setDetailItemId] = useState<number | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
|
||||||
|
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<KnowledgeDocumentListItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [viewMode, setViewMode] = useState<"list" | "grid">(() => {
|
||||||
|
if (typeof window === "undefined") return "grid";
|
||||||
|
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY);
|
||||||
|
return saved === "list" || saved === "grid" ? saved : "grid";
|
||||||
|
});
|
||||||
|
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
|
||||||
|
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedDirectoryId(null);
|
||||||
|
setSelectedIds([]);
|
||||||
|
}, [knowledgeBaseId]);
|
||||||
|
|
||||||
|
const filters = useMemo<DashboardPagedListFilter[]>(() => [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "indexStatus",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
|
||||||
|
return fetchKnowledgeDocuments({
|
||||||
|
title: typeof query.title === "string" ? query.title : undefined,
|
||||||
|
status: typeof query.status === "string" ? query.status : undefined,
|
||||||
|
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
|
||||||
|
knowledgeBaseId: knowledgeBaseId ?? 0,
|
||||||
|
directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId,
|
||||||
|
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
|
||||||
|
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
|
||||||
|
});
|
||||||
|
}, [knowledgeBaseId, selectedDirectoryId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
draftFilters,
|
||||||
|
setDraftFilter,
|
||||||
|
applyFilter,
|
||||||
|
applyFilters: applyDraftFilters,
|
||||||
|
loading,
|
||||||
|
result: documents,
|
||||||
|
loadData,
|
||||||
|
handlePageChange,
|
||||||
|
handleLimitChange,
|
||||||
|
} = useDashboardPagedList<KnowledgeDocumentListItem>({
|
||||||
|
filters,
|
||||||
|
fetchList,
|
||||||
|
enabled: Boolean(knowledgeBaseId),
|
||||||
|
reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`,
|
||||||
|
loadFailed: t("knowledge.loadDocumentsFailed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentPageIds = useMemo(
|
||||||
|
() => documents.results.map((item) => item.id),
|
||||||
|
[documents.results],
|
||||||
|
);
|
||||||
|
const currentPageSelectedCount = useMemo(
|
||||||
|
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
|
||||||
|
[currentPageIds, selectedIds],
|
||||||
|
);
|
||||||
|
const allCurrentPageSelected =
|
||||||
|
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
|
||||||
|
}, [currentPageIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
|
function handleStatusFilterChange(value: string | null) {
|
||||||
|
applyFilter("status", value ?? "all");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIndexStatusFilterChange(value: string | null) {
|
||||||
|
applyFilter("indexStatus", value ?? "all");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
applyDraftFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(id: number, checked: boolean) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
return prev.includes(id) ? prev : [...prev, id];
|
||||||
|
}
|
||||||
|
return prev.filter((currentId) => currentId !== id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCurrentPage(checked: boolean) {
|
||||||
|
setSelectedIds(checked ? currentPageIds : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveDialog(ids: number[]) {
|
||||||
|
setMoveTargetIds(ids);
|
||||||
|
setBulkMoveOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateDialog = useCallback(() => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onActionStateChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionStateChange({
|
||||||
|
onRefresh: () => void loadData(),
|
||||||
|
onChangeViewMode: setViewMode,
|
||||||
|
onCreate: openCreateDialog,
|
||||||
|
viewMode,
|
||||||
|
loading,
|
||||||
|
});
|
||||||
|
}, [onActionStateChange, loadData, openCreateDialog, viewMode, loading]);
|
||||||
|
|
||||||
|
function openEditDialog(item: KnowledgeDocumentListItem) {
|
||||||
|
setEditingItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetailDialog(item: KnowledgeDocumentListItem) {
|
||||||
|
setDetailItemId(item.id);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogOpenChange(open: boolean) {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setEditingItem(null);
|
||||||
|
}
|
||||||
|
setDialogOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: CreateKnowledgeDocumentPayload) {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateKnowledgeDocument({
|
||||||
|
id: editingItem.id,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
toast.success(t("knowledge.documentUpdated", { title: editingItem.title }));
|
||||||
|
} else {
|
||||||
|
await createKnowledgeDocument(payload);
|
||||||
|
toast.success(t("knowledge.documentCreated", { title: payload.title }));
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.documentSaveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: KnowledgeDocumentListItem) {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
|
||||||
|
try {
|
||||||
|
await deleteKnowledgeDocument(item.id);
|
||||||
|
toast.success(t("knowledge.documentDeleted", { title: item.title }));
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.documentDeleteFailed"));
|
||||||
|
} finally {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: t("knowledge.batchDelete"),
|
||||||
|
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
|
||||||
|
confirmText: t("knowledge.delete"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await batchDeleteKnowledgeDocuments(selectedIds);
|
||||||
|
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchMove(directoryId: number) {
|
||||||
|
const ids = moveTargetIds.length > 0 ? moveTargetIds : selectedIds;
|
||||||
|
if (!knowledgeBaseId || ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMoving(true);
|
||||||
|
try {
|
||||||
|
await batchMoveKnowledgeDocuments({
|
||||||
|
knowledgeBaseId,
|
||||||
|
directoryId,
|
||||||
|
ids,
|
||||||
|
});
|
||||||
|
toast.success(t("knowledge.batchMoved", { count: ids.length }));
|
||||||
|
setBulkMoveOpen(false);
|
||||||
|
setMoveTargetIds([]);
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
|
||||||
|
} finally {
|
||||||
|
setMoving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBuildIndex(item: KnowledgeDocumentListItem) {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
|
||||||
|
try {
|
||||||
|
await buildKnowledgeDocumentIndex(item.id);
|
||||||
|
toast.success(t("knowledge.documentIndexRebuilt", { title: item.title }));
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.documentIndexRebuildFailed"));
|
||||||
|
} finally {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!knowledgeBaseId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<FileTextIcon className="mb-2 size-12 opacity-50" />
|
||||||
|
<p>{t("knowledge.selectKnowledgeBaseForDocuments")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
<KnowledgeDirectoryPanel
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
selectedDirectoryId={selectedDirectoryId}
|
||||||
|
onSelectDirectory={setSelectedDirectoryId}
|
||||||
|
onChanged={() => void loadData()}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
|
||||||
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={String(draftFilters.title ?? "")}
|
||||||
|
onChange={(event) => setDraftFilter("title", event.target.value)}
|
||||||
|
onKeyDown={handleFilterKeyDown}
|
||||||
|
placeholder={t("knowledge.searchDocumentTitle")}
|
||||||
|
className="h-8 pl-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-28 shrink-0">
|
||||||
|
<OptionCombobox
|
||||||
|
value={String(draftFilters.status ?? "all")}
|
||||||
|
onChange={handleStatusFilterChange}
|
||||||
|
options={statusOptions}
|
||||||
|
placeholder={t("knowledge.allStatus")}
|
||||||
|
searchPlaceholder={t("knowledge.searchStatus")}
|
||||||
|
emptyText={t("knowledge.emptyStatus")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 shrink-0">
|
||||||
|
<OptionCombobox
|
||||||
|
value={String(draftFilters.indexStatus ?? "all")}
|
||||||
|
onChange={handleIndexStatusFilterChange}
|
||||||
|
options={indexStatusOptions}
|
||||||
|
placeholder={t("knowledge.allIndexStatus")}
|
||||||
|
searchPlaceholder={t("knowledge.searchStatus")}
|
||||||
|
emptyText={t("knowledge.emptyStatus")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
onClick={applyFilters}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-3.5" />
|
||||||
|
{t("knowledge.query")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative min-h-0 flex-1">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className={viewMode === "grid" ? "p-2 space-y-1" : "p-2 space-y-0.5"}>
|
||||||
|
{documents.results.length > 0 ? (
|
||||||
|
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
|
||||||
|
<Checkbox
|
||||||
|
checked={allCurrentPageSelected}
|
||||||
|
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
|
||||||
|
aria-label={t("knowledge.selectCurrentPage")}
|
||||||
|
/>
|
||||||
|
<span>{t("knowledge.selectCurrentPage")}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{documents.results.map((item) => (
|
||||||
|
viewMode === "grid" ? (
|
||||||
|
<ContextMenu
|
||||||
|
key={item.id}
|
||||||
|
onOpenChange={(open) => setContextMenuDocumentId(open ? item.id : null)}
|
||||||
|
>
|
||||||
|
<ContextMenuTrigger className="w-full">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-background p-3 transition-colors hover:bg-accent w-full cursor-pointer",
|
||||||
|
contextMenuDocumentId === item.id && "bg-accent text-accent-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => openDetailDialog(item)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||||
|
<SelectionCheckbox
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
|
||||||
|
aria-label={t("knowledge.selectItem", { name: item.title })}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
{/* <FileTextIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium">{item.title}</div>
|
||||||
|
{renderIndexStatusBadge(item, t)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{item.createUserName || "-"}</span>
|
||||||
|
<span>{formatDateTime(item.createdAt)}</span>
|
||||||
|
<span className={cn(item.indexStatus === KnowledgeDocumentIndexStatus.Failed && "text-destructive")}>
|
||||||
|
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
|
||||||
|
? t("knowledge.indexedAt", { time: formatDateTime(item.indexedAt) })
|
||||||
|
: getIndexStatusLabel(item.indexStatus, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
aria-label={t("knowledge.moreActions", { name: item.title })}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-3.5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-32 min-w-32">
|
||||||
|
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
|
||||||
|
<EyeIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.view")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||||
|
<FolderInputIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
|
||||||
|
<WrenchIcon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-40">
|
||||||
|
<ContextMenuItem onClick={() => openDetailDialog(item)}>
|
||||||
|
<EyeIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.view")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => openEditDialog(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||||
|
<FolderInputIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
|
||||||
|
<WrenchIcon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={actionLoadingMap[item.id]?.delete}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
) : (
|
||||||
|
<ContextMenu
|
||||||
|
key={item.id}
|
||||||
|
onOpenChange={(open) => setContextMenuDocumentId(open ? item.id : null)}
|
||||||
|
>
|
||||||
|
<ContextMenuTrigger className="w-full">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full cursor-pointer",
|
||||||
|
contextMenuDocumentId === item.id && "bg-accent text-accent-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => openDetailDialog(item)}
|
||||||
|
>
|
||||||
|
<SelectionCheckbox
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
|
||||||
|
aria-label={t("knowledge.selectItem", { name: item.title })}
|
||||||
|
/>
|
||||||
|
{/* <FileTextIcon className="size-4 shrink-0 text-muted-foreground" /> */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||||
|
{renderIndexStatusBadge(item, t)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
|
||||||
|
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
|
||||||
|
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(item.createdAt)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
aria-label={t("knowledge.moreActions", { name: item.title })}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-3.5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-32 min-w-32">
|
||||||
|
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
|
||||||
|
<EyeIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.view")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||||
|
<FolderInputIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
|
||||||
|
<WrenchIcon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-40">
|
||||||
|
<ContextMenuItem onClick={() => openDetailDialog(item)}>
|
||||||
|
<EyeIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.view")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => openEditDialog(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||||
|
<FolderInputIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
|
||||||
|
<WrenchIcon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={actionLoadingMap[item.id]?.delete}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{!loading && documents.results.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t("knowledge.emptyDocuments")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
{selectedIds.length > 0 ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("knowledge.selectedCount", { count: selectedIds.length })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
onClick={() => openMoveDialog(selectedIds)}
|
||||||
|
disabled={saving || moving}
|
||||||
|
>
|
||||||
|
<FolderInputIcon className="size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => void handleBatchDelete()}
|
||||||
|
disabled={saving || moving}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
{t("knowledge.delete")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => setSelectedIds([])}
|
||||||
|
disabled={saving || moving}
|
||||||
|
aria-label={t("knowledge.clearSelection")}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="border-t px-6 py-3">
|
||||||
|
<ListPagination
|
||||||
|
page={documents.page.page}
|
||||||
|
limit={documents.page.limit}
|
||||||
|
total={documents.page.total}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onLimitChange={handleLimitChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DocumentEditDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
saving={saving}
|
||||||
|
itemId={editingItem?.id ?? null}
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
initialDirectoryId={selectedDirectoryId ?? 0}
|
||||||
|
onOpenChange={handleDialogOpenChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<KnowledgeContentDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
type="document"
|
||||||
|
itemId={detailItemId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDetailOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setDetailItemId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KnowledgeBulkMoveDialog
|
||||||
|
open={bulkMoveOpen}
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
moving={moving}
|
||||||
|
selectedCount={moveTargetIds.length || selectedIds.length}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setBulkMoveOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setMoveTargetIds([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
web/app/(dashboard)/dashboard/knowledge/_components/faq-edit.tsx
Normal file
289
web/app/(dashboard)/dashboard/knowledge/_components/faq-edit.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm, type Resolver } from "react-hook-form";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
fetchKnowledgeFAQ,
|
||||||
|
fetchKnowledgeDirectories,
|
||||||
|
type CreateKnowledgeFAQPayload,
|
||||||
|
type KnowledgeDirectory,
|
||||||
|
type KnowledgeFAQ,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
|
||||||
|
type FAQEditDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
knowledgeBaseId: number | null;
|
||||||
|
initialDirectoryId?: number;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function createFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
directoryId: z.string().trim(),
|
||||||
|
question: z.string().trim().min(1, t("knowledge.faqQuestionRequired")).max(500, t("knowledge.faqQuestionMax")),
|
||||||
|
answer: z.string().trim().min(1, t("knowledge.faqAnswerRequired")),
|
||||||
|
similarQuestionsText: z.string(),
|
||||||
|
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
directoryId: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
similarQuestionsText: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
directoryId: "0",
|
||||||
|
question: "",
|
||||||
|
answer: "",
|
||||||
|
similarQuestionsText: "",
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectoryOption = { value: string; label: string };
|
||||||
|
|
||||||
|
function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] {
|
||||||
|
return items.flatMap((item) => [
|
||||||
|
{ value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` },
|
||||||
|
...flattenDirectoryOptions(item.children || [], depth + 1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm(item: KnowledgeFAQ | null, initialDirectoryId = 0): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return { ...emptyForm, directoryId: String(initialDirectoryId) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
directoryId: String(item.directoryId || 0),
|
||||||
|
question: item.question,
|
||||||
|
answer: item.answer,
|
||||||
|
similarQuestionsText: (item.similarQuestions ?? []).join("\n"),
|
||||||
|
remark: item.remark ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeFAQPayload {
|
||||||
|
return {
|
||||||
|
knowledgeBaseId,
|
||||||
|
directoryId: Number(form.directoryId),
|
||||||
|
question: form.question.trim(),
|
||||||
|
answer: form.answer.trim(),
|
||||||
|
similarQuestions: form.similarQuestionsText
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FAQEditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
knowledgeBaseId,
|
||||||
|
initialDirectoryId = 0,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: FAQEditDialogProps) {
|
||||||
|
if (!open || !knowledgeBaseId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FAQEditDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
initialDirectoryId={initialDirectoryId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FAQEditDialogBodyProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
knowledgeBaseId: number;
|
||||||
|
initialDirectoryId: number;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FAQEditDialogBody({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
knowledgeBaseId,
|
||||||
|
initialDirectoryId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: FAQEditDialogBodyProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||||
|
const formId = "knowledge-faq-edit-form";
|
||||||
|
const formSchema = useMemo(() => createFormSchema(t), [t]);
|
||||||
|
const resolver = useMemo(
|
||||||
|
() => zodResolver(formSchema) as Resolver<EditForm>,
|
||||||
|
[formSchema],
|
||||||
|
);
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver,
|
||||||
|
defaultValues: emptyForm,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
const directoryOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "0", label: t("knowledge.rootContent") },
|
||||||
|
...flattenDirectoryOptions(directories),
|
||||||
|
],
|
||||||
|
[directories, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!itemId) {
|
||||||
|
reset(buildForm(null, initialDirectoryId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchKnowledgeFAQ(itemId);
|
||||||
|
reset(buildForm(data));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
void loadDetail();
|
||||||
|
}
|
||||||
|
}, [itemId, initialDirectoryId, open, reset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadDirectories() {
|
||||||
|
try {
|
||||||
|
const data = await fetchKnowledgeDirectories(knowledgeBaseId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setDirectories(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load knowledge directories:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDirectories();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [knowledgeBaseId, open]);
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
await onSubmit(buildPayload(values, knowledgeBaseId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={itemId ? t("knowledge.editFAQTitle") : t("knowledge.createFAQTitle")}
|
||||||
|
allowFullscreen
|
||||||
|
size="xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
|
{t("knowledge.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||||
|
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">{t("knowledge.loading")}</div>
|
||||||
|
) : (
|
||||||
|
<form id={formId} onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<Field data-invalid={!!errors.directoryId}>
|
||||||
|
<FieldLabel>{t("knowledge.directory")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<OptionCombobox
|
||||||
|
value={form.watch("directoryId")}
|
||||||
|
onChange={(value) => form.setValue("directoryId", value ?? "0", { shouldDirty: true })}
|
||||||
|
options={directoryOptions}
|
||||||
|
placeholder={t("knowledge.selectDirectory")}
|
||||||
|
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||||
|
emptyText={t("knowledge.emptyDirectory")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.directoryId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.question}>
|
||||||
|
<FieldLabel htmlFor="faq-question">{t("knowledge.standardQuestion")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input id="faq-question" placeholder={t("knowledge.questionPlaceholder")} {...register("question")} />
|
||||||
|
<FieldError errors={[errors.question]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.answer}>
|
||||||
|
<FieldLabel htmlFor="faq-answer">{t("knowledge.answer")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea id="faq-answer" rows={8} placeholder={t("knowledge.answerPlaceholder")} {...register("answer")} />
|
||||||
|
<FieldError errors={[errors.answer]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="faq-similar-questions">{t("knowledge.similarQuestions")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="faq-similar-questions"
|
||||||
|
rows={5}
|
||||||
|
placeholder={t("knowledge.similarQuestionsPlaceholder")}
|
||||||
|
{...register("similarQuestionsText")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.remark}>
|
||||||
|
<FieldLabel htmlFor="faq-remark">{t("knowledge.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea id="faq-remark" rows={3} placeholder={t("knowledge.remarkPlaceholder")} {...register("remark")} />
|
||||||
|
<FieldError errors={[errors.remark]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"
|
||||||
|
import { useMemo, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
downloadKnowledgeFAQImportTemplate,
|
||||||
|
importKnowledgeFAQs,
|
||||||
|
type KnowledgeFAQImportMode,
|
||||||
|
type KnowledgeFAQImportResult,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type FAQImportDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
knowledgeBaseId: number | null
|
||||||
|
importing: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onImportingChange: (importing: boolean) => void
|
||||||
|
onImported: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FAQImportDialog({
|
||||||
|
open,
|
||||||
|
knowledgeBaseId,
|
||||||
|
importing,
|
||||||
|
onOpenChange,
|
||||||
|
onImportingChange,
|
||||||
|
onImported,
|
||||||
|
}: FAQImportDialogProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [mode, setMode] = useState<KnowledgeFAQImportMode>("append")
|
||||||
|
const [result, setResult] = useState<KnowledgeFAQImportResult | null>(null)
|
||||||
|
|
||||||
|
const modeOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "append", label: t("knowledge.importModeAppend") },
|
||||||
|
{ value: "overwrite", label: t("knowledge.importModeOverwrite") },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
setFile(null)
|
||||||
|
setMode("append")
|
||||||
|
setResult(null)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const nextFile = event.target.files?.[0] ?? null
|
||||||
|
setResult(null)
|
||||||
|
if (!nextFile) {
|
||||||
|
setFile(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nextFile.name.toLowerCase().endsWith(".xlsx")) {
|
||||||
|
setFile(null)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
toast.error(t("knowledge.importXlsxOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFile(nextFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadTemplate() {
|
||||||
|
try {
|
||||||
|
await downloadKnowledgeFAQImportTemplate()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!knowledgeBaseId || !file || importing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onImportingChange(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const data = await importKnowledgeFAQs({
|
||||||
|
knowledgeBaseId,
|
||||||
|
mode,
|
||||||
|
file,
|
||||||
|
})
|
||||||
|
setResult(data)
|
||||||
|
await onImported()
|
||||||
|
toast.success(
|
||||||
|
t("knowledge.importFAQResultToast", {
|
||||||
|
created: data.created,
|
||||||
|
updated: data.updated,
|
||||||
|
skipped: data.skipped,
|
||||||
|
failed: data.failed,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (data.failed === 0) {
|
||||||
|
resetState()
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.importFailed"))
|
||||||
|
} finally {
|
||||||
|
onImportingChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen && !importing) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
onOpenChange(nextOpen)
|
||||||
|
}}
|
||||||
|
title={t("knowledge.importFAQTitle")}
|
||||||
|
description={t("knowledge.importFAQDescription")}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={() => void handleDownloadTemplate()}>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
{t("knowledge.downloadTemplate")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
|
||||||
|
{t("knowledge.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void handleImport()} disabled={importing || !file}>
|
||||||
|
{importing ? t("knowledge.importing") : t("knowledge.startImport")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("knowledge.importMode")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<OptionCombobox
|
||||||
|
value={mode}
|
||||||
|
options={modeOptions}
|
||||||
|
placeholder={t("knowledge.selectImportMode")}
|
||||||
|
searchPlaceholder={t("knowledge.searchImportMode")}
|
||||||
|
emptyText={t("knowledge.emptyImportMode")}
|
||||||
|
disabled={importing}
|
||||||
|
onChange={(value) => setMode(value as KnowledgeFAQImportMode)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{mode === "overwrite"
|
||||||
|
? t("knowledge.importModeOverwriteDescription")
|
||||||
|
: t("knowledge.importModeAppendDescription")}
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="faq-import-file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
disabled={importing}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={importing}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileUpIcon className="size-4" />
|
||||||
|
{t("knowledge.chooseFile")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FieldDescription>{t("knowledge.importFileDescription")}</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{file ? (
|
||||||
|
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||||
|
{t("knowledge.currentFile", { name: file.name })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
{t("knowledge.importHint")}
|
||||||
|
</div>
|
||||||
|
<div>{t("knowledge.importXlsxHint")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="border-b px-4 py-3 text-sm font-medium">
|
||||||
|
{t("knowledge.importResult")}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-4 text-sm sm:grid-cols-5">
|
||||||
|
<ImportMetric label={t("knowledge.importTotal")} value={result.total} />
|
||||||
|
<ImportMetric label={t("knowledge.importCreated")} value={result.created} />
|
||||||
|
<ImportMetric label={t("knowledge.importUpdated")} value={result.updated} />
|
||||||
|
<ImportMetric label={t("knowledge.importSkipped")} value={result.skipped} />
|
||||||
|
<ImportMetric label={t("knowledge.importFailedCount")} value={result.failed} />
|
||||||
|
</div>
|
||||||
|
{result.errors.length > 0 ? (
|
||||||
|
<ScrollArea className="max-h-64 border-t">
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{result.errors.map((item, index) => (
|
||||||
|
<li key={`${item.row}-${index}`} className="px-4 py-2">
|
||||||
|
{t("knowledge.importRowFailed", {
|
||||||
|
row: item.row,
|
||||||
|
message: item.message,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</ProjectDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportMetric({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-muted/30 px-3 py-2">
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
653
web/app/(dashboard)/dashboard/knowledge/_components/faq-list.tsx
Normal file
653
web/app/(dashboard)/dashboard/knowledge/_components/faq-list.tsx
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EyeIcon,
|
||||||
|
FolderInputIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
|
SearchIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
WrenchIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useConfirm } from "@/components/confirm-provider";
|
||||||
|
import {
|
||||||
|
useDashboardPagedList,
|
||||||
|
type DashboardPagedListFilter,
|
||||||
|
} from "@/components/dashboard/list";
|
||||||
|
import { ListPagination } from "@/components/list-pagination";
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
batchDeleteKnowledgeFAQs,
|
||||||
|
batchMoveKnowledgeFAQs,
|
||||||
|
buildKnowledgeFAQIndex,
|
||||||
|
createKnowledgeFAQ,
|
||||||
|
deleteKnowledgeFAQ,
|
||||||
|
fetchKnowledgeFAQs,
|
||||||
|
updateKnowledgeFAQ,
|
||||||
|
type CreateKnowledgeFAQPayload,
|
||||||
|
type KnowledgeFAQ,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { KnowledgeDocumentIndexStatus } from "@/lib/generated/enums";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
|
import { FAQEditDialog } from "./faq-edit";
|
||||||
|
import { FAQImportDialog } from "./faq-import-dialog";
|
||||||
|
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
|
||||||
|
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
|
||||||
|
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
|
||||||
|
import { SelectionCheckbox } from "./selection-checkbox";
|
||||||
|
|
||||||
|
type FAQListProps = {
|
||||||
|
knowledgeBaseId: number | null;
|
||||||
|
onActionStateChange?: (state: FAQListActionState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FAQListActionState = {
|
||||||
|
onRefresh: () => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
importing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function getIndexStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allIndexStatus") },
|
||||||
|
{ value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") },
|
||||||
|
{ value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") },
|
||||||
|
{ value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexStatusLabel(status: string, t: TFunction) {
|
||||||
|
if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending");
|
||||||
|
if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed");
|
||||||
|
if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed");
|
||||||
|
return status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexStatusBadgeVariant(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case KnowledgeDocumentIndexStatus.Indexed:
|
||||||
|
return "secondary" as const;
|
||||||
|
case KnowledgeDocumentIndexStatus.Failed:
|
||||||
|
return "destructive" as const;
|
||||||
|
default:
|
||||||
|
return "outline" as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
|
||||||
|
const badge = (
|
||||||
|
<Badge variant={getIndexStatusBadgeVariant(item.indexStatus)}>
|
||||||
|
{getIndexStatusLabel(item.indexStatus, t)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
item.indexStatus !== KnowledgeDocumentIndexStatus.Failed ||
|
||||||
|
!item.indexError
|
||||||
|
) {
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<span className="inline-flex">{badge}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||||
|
{item.indexError}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FAQList({
|
||||||
|
knowledgeBaseId,
|
||||||
|
onActionStateChange,
|
||||||
|
}: FAQListProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [moving, setMoving] = useState(false);
|
||||||
|
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [moveTargetIds, setMoveTargetIds] = useState<number[]>([]);
|
||||||
|
const [contextMenuFAQId, setContextMenuFAQId] = useState<number | null>(null);
|
||||||
|
const [detailItemId, setDetailItemId] = useState<number | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<KnowledgeFAQ | null>(null);
|
||||||
|
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
|
||||||
|
const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedDirectoryId(null);
|
||||||
|
setSelectedIds([]);
|
||||||
|
}, [knowledgeBaseId]);
|
||||||
|
|
||||||
|
const filters = useMemo<DashboardPagedListFilter[]>(() => [
|
||||||
|
{
|
||||||
|
name: "question",
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "indexStatus",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async (query: Record<string, string | number | undefined>) => {
|
||||||
|
return fetchKnowledgeFAQs({
|
||||||
|
knowledgeBaseId: knowledgeBaseId ?? 0,
|
||||||
|
directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId,
|
||||||
|
question: typeof query.question === "string" ? query.question : undefined,
|
||||||
|
indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined,
|
||||||
|
page: typeof query.page === "number" ? query.page : Number(query.page ?? 1),
|
||||||
|
limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20),
|
||||||
|
});
|
||||||
|
}, [knowledgeBaseId, selectedDirectoryId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
draftFilters,
|
||||||
|
setDraftFilter,
|
||||||
|
applyFilter,
|
||||||
|
applyFilters,
|
||||||
|
loading,
|
||||||
|
result: faqs,
|
||||||
|
loadData,
|
||||||
|
handlePageChange,
|
||||||
|
handleLimitChange,
|
||||||
|
} = useDashboardPagedList<KnowledgeFAQ>({
|
||||||
|
filters,
|
||||||
|
fetchList,
|
||||||
|
enabled: Boolean(knowledgeBaseId),
|
||||||
|
reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`,
|
||||||
|
loadFailed: t("knowledge.loadFAQFailed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentPageIds = useMemo(
|
||||||
|
() => faqs.results.map((item) => item.id),
|
||||||
|
[faqs.results],
|
||||||
|
);
|
||||||
|
const currentPageSelectedCount = useMemo(
|
||||||
|
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
|
||||||
|
[currentPageIds, selectedIds],
|
||||||
|
);
|
||||||
|
const allCurrentPageSelected =
|
||||||
|
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
|
||||||
|
}, [currentPageIds]);
|
||||||
|
|
||||||
|
const openCreateDialog = useCallback(() => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onActionStateChange?.({
|
||||||
|
onRefresh: () => void loadData(),
|
||||||
|
onCreate: openCreateDialog,
|
||||||
|
onImport: () => setImportDialogOpen(true),
|
||||||
|
loading,
|
||||||
|
importing,
|
||||||
|
});
|
||||||
|
}, [loadData, loading, importing, onActionStateChange, openCreateDialog]);
|
||||||
|
|
||||||
|
function handleIndexStatusFilterChange(value: string | null) {
|
||||||
|
applyFilter("indexStatus", value ?? "all");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(id: number, checked: boolean) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
return prev.includes(id) ? prev : [...prev, id];
|
||||||
|
}
|
||||||
|
return prev.filter((currentId) => currentId !== id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCurrentPage(checked: boolean) {
|
||||||
|
setSelectedIds(checked ? currentPageIds : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveDialog(ids: number[]) {
|
||||||
|
setMoveTargetIds(ids);
|
||||||
|
setBulkMoveOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(item: KnowledgeFAQ) {
|
||||||
|
setEditingItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetailDialog(item: KnowledgeFAQ) {
|
||||||
|
setDetailItemId(item.id);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogOpenChange(open: boolean) {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setEditingItem(null);
|
||||||
|
}
|
||||||
|
setDialogOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(payload: CreateKnowledgeFAQPayload) {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateKnowledgeFAQ({ id: editingItem.id, ...payload });
|
||||||
|
toast.success(t("knowledge.faqSaved"));
|
||||||
|
} else {
|
||||||
|
await createKnowledgeFAQ(payload);
|
||||||
|
toast.success(t("knowledge.faqSaved"));
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: KnowledgeFAQ) {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } }));
|
||||||
|
try {
|
||||||
|
await deleteKnowledgeFAQ(item.id);
|
||||||
|
toast.success(t("knowledge.faqDeleted"));
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed"));
|
||||||
|
} finally {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: t("knowledge.batchDelete"),
|
||||||
|
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
|
||||||
|
confirmText: t("knowledge.delete"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await batchDeleteKnowledgeFAQs(selectedIds);
|
||||||
|
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchMove(directoryId: number) {
|
||||||
|
const ids = moveTargetIds.length > 0 ? moveTargetIds : selectedIds;
|
||||||
|
if (!knowledgeBaseId || ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMoving(true);
|
||||||
|
try {
|
||||||
|
await batchMoveKnowledgeFAQs({
|
||||||
|
knowledgeBaseId,
|
||||||
|
directoryId,
|
||||||
|
ids,
|
||||||
|
});
|
||||||
|
toast.success(t("knowledge.batchMoved", { count: ids.length }));
|
||||||
|
setBulkMoveOpen(false);
|
||||||
|
setMoveTargetIds([]);
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
|
||||||
|
} finally {
|
||||||
|
setMoving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBuildIndex(item: KnowledgeFAQ) {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
|
||||||
|
try {
|
||||||
|
await buildKnowledgeFAQIndex(item.id);
|
||||||
|
toast.success(t("knowledge.faqIndexRebuilt"));
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed"));
|
||||||
|
} finally {
|
||||||
|
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!knowledgeBaseId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("knowledge.selectFAQBase")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
<KnowledgeDirectoryPanel
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
selectedDirectoryId={selectedDirectoryId}
|
||||||
|
onSelectDirectory={setSelectedDirectoryId}
|
||||||
|
onChanged={() => void loadData()}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-col gap-2 border-b bg-background px-6 py-2">
|
||||||
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={String(draftFilters.question ?? "")}
|
||||||
|
onChange={(event) => setDraftFilter("question", event.target.value)}
|
||||||
|
onKeyDown={handleFilterKeyDown}
|
||||||
|
placeholder={t("knowledge.searchFAQ")}
|
||||||
|
className="h-8 pl-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 shrink-0">
|
||||||
|
<OptionCombobox
|
||||||
|
value={String(draftFilters.indexStatus ?? "all")}
|
||||||
|
onChange={handleIndexStatusFilterChange}
|
||||||
|
options={indexStatusOptions}
|
||||||
|
placeholder={t("knowledge.allIndexStatus")}
|
||||||
|
searchPlaceholder={t("knowledge.searchStatus")}
|
||||||
|
emptyText={t("knowledge.emptyStatus")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
onClick={applyFilters}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-3.5" />
|
||||||
|
{t("knowledge.query")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative min-h-0 flex-1">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{faqs.results.length > 0 ? (
|
||||||
|
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
|
||||||
|
<Checkbox
|
||||||
|
checked={allCurrentPageSelected}
|
||||||
|
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
|
||||||
|
aria-label={t("knowledge.selectCurrentPage")}
|
||||||
|
/>
|
||||||
|
<span>{t("knowledge.selectCurrentPage")}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{faqs.results.map((item) => (
|
||||||
|
<ContextMenu
|
||||||
|
key={item.id}
|
||||||
|
onOpenChange={(open) => setContextMenuFAQId(open ? item.id : null)}
|
||||||
|
>
|
||||||
|
<ContextMenuTrigger className="w-full">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full cursor-pointer",
|
||||||
|
contextMenuFAQId === item.id && "bg-accent text-accent-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => openDetailDialog(item)}
|
||||||
|
>
|
||||||
|
<SelectionCheckbox
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
|
||||||
|
aria-label={t("knowledge.selectItem", { name: item.question })}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="truncate text-sm font-medium">{item.question}</div>
|
||||||
|
{renderIndexStatusBadge(item, t)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||||
|
{item.answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden shrink-0 text-xs text-muted-foreground sm:block">
|
||||||
|
{formatDateTime(item.updatedAt)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
render={<Button variant="ghost" size="icon" className="size-6" />}
|
||||||
|
aria-label={t("knowledge.moreActions", { name: item.question })}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-3.5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-32 min-w-32">
|
||||||
|
<DropdownMenuItem onClick={() => openDetailDialog(item)}>
|
||||||
|
<EyeIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.view")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||||
|
<FolderInputIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => void handleBuildIndex(item)}>
|
||||||
|
<WrenchIcon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-40">
|
||||||
|
<ContextMenuItem onClick={() => openDetailDialog(item)}>
|
||||||
|
<EyeIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.view")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => openEditDialog(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => openMoveDialog([item.id])}>
|
||||||
|
<FolderInputIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}>
|
||||||
|
<WrenchIcon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={actionLoadingMap[item.id]?.delete}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
))}
|
||||||
|
{!loading && faqs.results.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t("knowledge.emptyFAQ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
{selectedIds.length > 0 ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("knowledge.selectedCount", { count: selectedIds.length })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
onClick={() => openMoveDialog(selectedIds)}
|
||||||
|
disabled={saving || moving}
|
||||||
|
>
|
||||||
|
<FolderInputIcon className="size-3.5" />
|
||||||
|
{t("knowledge.move")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => void handleBatchDelete()}
|
||||||
|
disabled={saving || moving}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
{t("knowledge.delete")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => setSelectedIds([])}
|
||||||
|
disabled={saving || moving}
|
||||||
|
aria-label={t("knowledge.clearSelection")}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="border-t px-6 py-3">
|
||||||
|
<ListPagination
|
||||||
|
page={faqs.page.page}
|
||||||
|
limit={faqs.page.limit}
|
||||||
|
total={faqs.page.total}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onLimitChange={handleLimitChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FAQEditDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
saving={saving}
|
||||||
|
itemId={editingItem?.id ?? null}
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
initialDirectoryId={selectedDirectoryId ?? 0}
|
||||||
|
onOpenChange={handleDialogOpenChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
<KnowledgeContentDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
type="faq"
|
||||||
|
itemId={detailItemId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDetailOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setDetailItemId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FAQImportDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
importing={importing}
|
||||||
|
onOpenChange={setImportDialogOpen}
|
||||||
|
onImportingChange={setImporting}
|
||||||
|
onImported={async () => {
|
||||||
|
await loadData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<KnowledgeBulkMoveDialog
|
||||||
|
open={bulkMoveOpen}
|
||||||
|
knowledgeBaseId={knowledgeBaseId}
|
||||||
|
moving={moving}
|
||||||
|
selectedCount={moveTargetIds.length || selectedIds.length}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setBulkMoveOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setMoveTargetIds([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
fetchKnowledgeBase,
|
||||||
|
type CreateKnowledgeBasePayload,
|
||||||
|
type KnowledgeBase,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import {
|
||||||
|
KnowledgeAnswerMode,
|
||||||
|
KnowledgeBaseType,
|
||||||
|
KnowledgeChunkProvider,
|
||||||
|
} from "@/lib/generated/enums";
|
||||||
|
|
||||||
|
type KnowledgeBaseEditDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
knowledgeType: KnowledgeBaseType.Document,
|
||||||
|
defaultTopK: "5",
|
||||||
|
defaultScoreThreshold: "0.2",
|
||||||
|
defaultRerankLimit: "10",
|
||||||
|
chunkProvider: KnowledgeChunkProvider.Structured,
|
||||||
|
chunkTargetTokens: "300",
|
||||||
|
chunkMaxTokens: "400",
|
||||||
|
chunkOverlapTokens: "40",
|
||||||
|
answerMode: String(KnowledgeAnswerMode.Strict),
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function createKnowledgeBaseFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
name: z.string().trim().min(1, t("knowledge.nameRequired")).max(100, t("knowledge.nameMax")),
|
||||||
|
description: z.string().trim().max(500, t("knowledge.descriptionMax")),
|
||||||
|
knowledgeType: z.string().trim().min(1, t("knowledge.typeRequired")),
|
||||||
|
defaultTopK: z.string().trim().min(1, t("knowledge.topKRequired")),
|
||||||
|
defaultScoreThreshold: z.string().trim().min(1, t("knowledge.scoreRequired")),
|
||||||
|
defaultRerankLimit: z.string().trim().min(1, t("knowledge.rerankRequired")),
|
||||||
|
chunkProvider: z.string().trim().min(1, t("knowledge.chunkProviderRequired")),
|
||||||
|
chunkTargetTokens: z.string().trim().min(1, t("knowledge.targetTokensRequired")),
|
||||||
|
chunkMaxTokens: z.string().trim().min(1, t("knowledge.maxTokensRequired")),
|
||||||
|
chunkOverlapTokens: z.string().trim().min(1, t("knowledge.overlapTokensRequired")),
|
||||||
|
answerMode: z.string().trim().min(1, t("knowledge.answerModeRequired")),
|
||||||
|
remark: z.string().trim().max(500, t("knowledge.remarkMax")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
knowledgeType: string;
|
||||||
|
defaultTopK: string;
|
||||||
|
defaultScoreThreshold: string;
|
||||||
|
defaultRerankLimit: string;
|
||||||
|
chunkProvider: string;
|
||||||
|
chunkTargetTokens: string;
|
||||||
|
chunkMaxTokens: string;
|
||||||
|
chunkOverlapTokens: string;
|
||||||
|
answerMode: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getKnowledgeTypeOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: KnowledgeBaseType.Document, label: t("knowledge.typeDocument") },
|
||||||
|
{ value: KnowledgeBaseType.FAQ, label: t("knowledge.typeFAQ") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChunkProviderOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: KnowledgeChunkProvider.Fixed, label: t("knowledge.chunkFixed") },
|
||||||
|
{ value: KnowledgeChunkProvider.Structured, label: t("knowledge.chunkStructured") },
|
||||||
|
{ value: KnowledgeChunkProvider.Semantic, label: t("knowledge.chunkSemantic") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnswerModeOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: String(KnowledgeAnswerMode.Strict), label: t("knowledge.answerStrict") },
|
||||||
|
{ value: String(KnowledgeAnswerMode.Assist), label: t("knowledge.answerAssist") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm(item: KnowledgeBase | null): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return emptyForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description || "",
|
||||||
|
knowledgeType: item.knowledgeType || KnowledgeBaseType.Document,
|
||||||
|
defaultTopK: String(item.defaultTopK),
|
||||||
|
defaultScoreThreshold: String(item.defaultScoreThreshold),
|
||||||
|
defaultRerankLimit: String(item.defaultRerankLimit),
|
||||||
|
chunkProvider: item.chunkProvider,
|
||||||
|
chunkTargetTokens: String(item.chunkTargetTokens),
|
||||||
|
chunkMaxTokens: String(item.chunkMaxTokens),
|
||||||
|
chunkOverlapTokens: String(item.chunkOverlapTokens),
|
||||||
|
answerMode: String(item.answerMode),
|
||||||
|
remark: item.remark || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: EditForm): CreateKnowledgeBasePayload {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
knowledgeType: form.knowledgeType,
|
||||||
|
defaultTopK: Number(form.defaultTopK),
|
||||||
|
defaultScoreThreshold: Number(form.defaultScoreThreshold),
|
||||||
|
defaultRerankLimit: Number(form.defaultRerankLimit),
|
||||||
|
chunkProvider: form.chunkProvider,
|
||||||
|
chunkTargetTokens: Number(form.chunkTargetTokens),
|
||||||
|
chunkMaxTokens: Number(form.chunkMaxTokens),
|
||||||
|
chunkOverlapTokens: Number(form.chunkOverlapTokens),
|
||||||
|
answerMode: Number(form.answerMode),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: KnowledgeBaseEditDialogProps) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KnowledgeBaseFormDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
open={open}
|
||||||
|
itemId={itemId}
|
||||||
|
saving={saving}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeBaseFormDialogBodyProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateKnowledgeBasePayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function KnowledgeBaseFormDialogBody({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: KnowledgeBaseFormDialogBodyProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const formId = "knowledge-base-edit-form";
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const knowledgeBaseFormSchema = useMemo(() => createKnowledgeBaseFormSchema(t), [t]);
|
||||||
|
const editFormResolver = useMemo(
|
||||||
|
() => zodResolver(knowledgeBaseFormSchema) as Resolver<EditForm>,
|
||||||
|
[knowledgeBaseFormSchema],
|
||||||
|
);
|
||||||
|
const knowledgeTypeOptions = useMemo(() => getKnowledgeTypeOptions(t), [t]);
|
||||||
|
const chunkProviderOptions = useMemo(() => getChunkProviderOptions(t), [t]);
|
||||||
|
const answerModeOptions = useMemo(() => getAnswerModeOptions(t), [t]);
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver: editFormResolver,
|
||||||
|
defaultValues: buildForm(null),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
const knowledgeType = watch("knowledgeType");
|
||||||
|
const isFAQKnowledgeBase = knowledgeType === KnowledgeBaseType.FAQ;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemId === null) {
|
||||||
|
reset(buildForm(null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadItem() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchKnowledgeBase(itemId!);
|
||||||
|
if (!cancelled) {
|
||||||
|
reset(buildForm(data));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error("Failed to load knowledge base:", error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadItem();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, itemId, reset]);
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
const payload = buildPayload(values);
|
||||||
|
await onSubmit(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={itemId ? t("knowledge.editBaseTitle") : t("knowledge.createBaseTitle")}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving || loading}
|
||||||
|
>
|
||||||
|
{t("knowledge.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||||
|
{saving ? t("knowledge.saving") : itemId ? t("knowledge.save") : t("knowledge.create")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-sm text-muted-foreground">{t("knowledge.loading")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<Field data-invalid={!!errors.knowledgeType}>
|
||||||
|
<FieldLabel htmlFor="kb-knowledge-type">{t("knowledge.knowledgeType")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="knowledgeType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={field.value}
|
||||||
|
options={knowledgeTypeOptions}
|
||||||
|
placeholder={t("knowledge.selectKnowledgeType")}
|
||||||
|
searchPlaceholder={t("knowledge.searchKnowledgeType")}
|
||||||
|
emptyText={t("knowledge.emptyKnowledgeType")}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.knowledgeType]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="kb-name">{t("knowledge.name")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="kb-name"
|
||||||
|
placeholder={t("knowledge.namePlaceholder")}
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.name]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.description}>
|
||||||
|
<FieldLabel htmlFor="kb-description">{t("knowledge.description")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="kb-description"
|
||||||
|
placeholder={t("knowledge.descriptionPlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
aria-invalid={!!errors.description}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.description]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!isFAQKnowledgeBase ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Field data-invalid={!!errors.chunkProvider}>
|
||||||
|
<FieldLabel htmlFor="kb-chunk-provider">{t("knowledge.chunkProvider")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="chunkProvider"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={field.value}
|
||||||
|
options={chunkProviderOptions}
|
||||||
|
placeholder={t("knowledge.selectChunkProvider")}
|
||||||
|
searchPlaceholder={t("knowledge.searchChunkProvider")}
|
||||||
|
emptyText={t("knowledge.emptyChunkProvider")}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.chunkProvider]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.chunkTargetTokens}>
|
||||||
|
<FieldLabel htmlFor="kb-chunk-target-tokens">
|
||||||
|
{t("knowledge.targetToken")}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="kb-chunk-target-tokens"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2000"
|
||||||
|
aria-invalid={!!errors.chunkTargetTokens}
|
||||||
|
{...register("chunkTargetTokens")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.chunkTargetTokens]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.chunkMaxTokens}>
|
||||||
|
<FieldLabel htmlFor="kb-chunk-max-tokens">{t("knowledge.maxToken")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="kb-chunk-max-tokens"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="4000"
|
||||||
|
aria-invalid={!!errors.chunkMaxTokens}
|
||||||
|
{...register("chunkMaxTokens")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.chunkMaxTokens]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.chunkOverlapTokens}>
|
||||||
|
<FieldLabel htmlFor="kb-chunk-overlap-tokens">
|
||||||
|
{t("knowledge.overlapToken")}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="kb-chunk-overlap-tokens"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="500"
|
||||||
|
aria-invalid={!!errors.chunkOverlapTokens}
|
||||||
|
{...register("chunkOverlapTokens")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.chunkOverlapTokens]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Field data-invalid={!!errors.defaultTopK}>
|
||||||
|
<FieldLabel htmlFor="kb-default-top-k">{t("knowledge.defaultTopK")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="kb-default-top-k"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
aria-invalid={!!errors.defaultTopK}
|
||||||
|
{...register("defaultTopK")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.defaultTopK]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.defaultScoreThreshold}>
|
||||||
|
<FieldLabel htmlFor="kb-default-score-threshold">
|
||||||
|
{t("knowledge.defaultScoreThreshold")}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="kb-default-score-threshold"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
aria-invalid={!!errors.defaultScoreThreshold}
|
||||||
|
{...register("defaultScoreThreshold")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.defaultScoreThreshold]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.defaultRerankLimit}>
|
||||||
|
<FieldLabel htmlFor="kb-default-rerank-limit">
|
||||||
|
{t("knowledge.defaultRerankLimit")}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="kb-default-rerank-limit"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
aria-invalid={!!errors.defaultRerankLimit}
|
||||||
|
{...register("defaultRerankLimit")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.defaultRerankLimit]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.answerMode}>
|
||||||
|
<FieldLabel htmlFor="kb-answer-mode">{t("knowledge.answerMode")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="answerMode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionCombobox
|
||||||
|
value={field.value}
|
||||||
|
options={answerModeOptions}
|
||||||
|
placeholder={t("knowledge.selectAnswerMode")}
|
||||||
|
searchPlaceholder={t("knowledge.searchAnswerMode")}
|
||||||
|
emptyText={t("knowledge.emptyAnswerMode")}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.answerMode]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.remark}>
|
||||||
|
<FieldLabel htmlFor="kb-remark">{t("knowledge.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="kb-remark"
|
||||||
|
placeholder={t("knowledge.remarkPlaceholder")}
|
||||||
|
rows={2}
|
||||||
|
aria-invalid={!!errors.remark}
|
||||||
|
{...register("remark")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.remark]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
fetchKnowledgeDirectories,
|
||||||
|
type KnowledgeDirectory,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
|
||||||
|
type KnowledgeBulkMoveDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
knowledgeBaseId: number;
|
||||||
|
moving: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (directoryId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenDirectoryOptions(
|
||||||
|
directories: KnowledgeDirectory[],
|
||||||
|
): Array<{ value: string; label: string }> {
|
||||||
|
const result: Array<{ value: string; label: string }> = [];
|
||||||
|
for (const item of directories) {
|
||||||
|
result.push({ value: String(item.id), label: item.name });
|
||||||
|
for (const child of item.children ?? []) {
|
||||||
|
result.push({ value: String(child.id), label: `${item.name} / ${child.name}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeBulkMoveDialog({
|
||||||
|
open,
|
||||||
|
knowledgeBaseId,
|
||||||
|
moving,
|
||||||
|
selectedCount,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: KnowledgeBulkMoveDialogProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||||
|
const [targetDirectoryId, setTargetDirectoryId] = useState("0");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTargetDirectoryId("0");
|
||||||
|
fetchKnowledgeDirectories(knowledgeBaseId)
|
||||||
|
.then(setDirectories)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setDirectories([]);
|
||||||
|
});
|
||||||
|
}, [knowledgeBaseId, open]);
|
||||||
|
|
||||||
|
const directoryOptions = useMemo(() => [
|
||||||
|
{ value: "0", label: t("knowledge.rootContent") },
|
||||||
|
...flattenDirectoryOptions(directories),
|
||||||
|
], [directories, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("knowledge.batchMove")}
|
||||||
|
description={t("knowledge.batchMoveDescription", { count: selectedCount })}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={moving}
|
||||||
|
>
|
||||||
|
{t("knowledge.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit(Number(targetDirectoryId))}
|
||||||
|
disabled={moving || selectedCount <= 0}
|
||||||
|
>
|
||||||
|
{moving ? t("knowledge.moving") : t("knowledge.move")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">{t("knowledge.targetDirectory")}</div>
|
||||||
|
<OptionCombobox
|
||||||
|
value={targetDirectoryId}
|
||||||
|
onChange={(value) => setTargetDirectoryId(value ?? "0")}
|
||||||
|
options={directoryOptions}
|
||||||
|
placeholder={t("knowledge.selectDirectory")}
|
||||||
|
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||||
|
emptyText={t("knowledge.emptyDirectory")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ProjectDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { SafeRichHTML } from "@/components/safe-rich-html";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
fetchKnowledgeDocument,
|
||||||
|
fetchKnowledgeFAQ,
|
||||||
|
type KnowledgeDocument,
|
||||||
|
type KnowledgeFAQ,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { markdownToHtml } from "@/components/content-editor/convert";
|
||||||
|
import { KnowledgeDocumentContentType } from "@/lib/generated/enums";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
|
||||||
|
type KnowledgeContentDetailDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
type: "document" | "faq";
|
||||||
|
itemId: number | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DetailFieldProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DetailField({ label, value }: DetailFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="truncate text-sm">{value || "-"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectoryLabel(item: KnowledgeDocument | KnowledgeFAQ, rootLabel: string) {
|
||||||
|
return item.directoryPath || item.directoryName || (item.directoryId === 0 ? rootLabel : "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocumentContent(item: KnowledgeDocument) {
|
||||||
|
if (item.contentType === KnowledgeDocumentContentType.Markdown) {
|
||||||
|
return markdownToHtml(item.content || "");
|
||||||
|
}
|
||||||
|
return item.content || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeContentDetailDialog({
|
||||||
|
open,
|
||||||
|
type,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
}: KnowledgeContentDetailDialogProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [documentDetail, setDocumentDetail] = useState<KnowledgeDocument | null>(null);
|
||||||
|
const [faqDetail, setFAQDetail] = useState<KnowledgeFAQ | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = itemId;
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadDetail() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (type === "document") {
|
||||||
|
const data = await fetchKnowledgeDocument(id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setDocumentDetail(data);
|
||||||
|
setFAQDetail(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await fetchKnowledgeFAQ(id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setFAQDetail(data);
|
||||||
|
setDocumentDetail(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadDetail();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [itemId, open, type]);
|
||||||
|
|
||||||
|
const item = type === "document" ? documentDetail : faqDetail;
|
||||||
|
const title =
|
||||||
|
type === "document"
|
||||||
|
? documentDetail?.title || t("knowledge.viewDocumentTitle")
|
||||||
|
: faqDetail?.question || t("knowledge.viewFAQTitle");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={title}
|
||||||
|
size="xl"
|
||||||
|
allowFullscreen
|
||||||
|
closeOnEsc
|
||||||
|
defaultFullscreen
|
||||||
|
footer={
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading || !item ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
|
{t("knowledge.loading")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<DetailField
|
||||||
|
label={t("knowledge.directory")}
|
||||||
|
value={getDirectoryLabel(item, t("knowledge.rootContent"))}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.status")}</div>
|
||||||
|
<Badge variant="outline">{item.statusName || item.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.indexStatus")}</div>
|
||||||
|
<Badge variant="secondary">{item.indexStatusName || item.indexStatus}</Badge>
|
||||||
|
</div>
|
||||||
|
<DetailField
|
||||||
|
label={t("knowledge.createdAt")}
|
||||||
|
value={formatDateTime(item.createdAt)}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("knowledge.updatedAt")}
|
||||||
|
value={formatDateTime(item.updatedAt)}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("knowledge.createUser")}
|
||||||
|
value={item.createUserName || "-"}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("knowledge.updateUser")}
|
||||||
|
value={item.updateUserName || "-"}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label={t("knowledge.indexedAtLabel")}
|
||||||
|
value={item.indexedAt ? formatDateTime(item.indexedAt) : "-"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.indexError ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||||
|
{item.indexError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === "document" && documentDetail ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium">{t("knowledge.content")}</div>
|
||||||
|
<Badge variant="outline">{documentDetail.contentType || "-"}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-background p-4">
|
||||||
|
<SafeRichHTML html={renderDocumentContent(documentDetail)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === "faq" && faqDetail ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">{t("knowledge.standardQuestion")}</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3 text-sm">{faqDetail.question}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">{t("knowledge.answer")}</div>
|
||||||
|
<pre className="whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6">
|
||||||
|
{faqDetail.answer || "-"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{faqDetail.similarQuestions.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">{t("knowledge.similarQuestions")}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{faqDetail.similarQuestions.map((question) => (
|
||||||
|
<Badge key={question} variant="outline">
|
||||||
|
{question}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{faqDetail.remark ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">{t("knowledge.remark")}</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-3 text-sm">{faqDetail.remark}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
closestCenter,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
FolderIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
LayersIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Field, FieldContent, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
createKnowledgeDirectory,
|
||||||
|
deleteKnowledgeDirectory,
|
||||||
|
fetchKnowledgeDirectories,
|
||||||
|
updateKnowledgeDirectory,
|
||||||
|
updateKnowledgeDirectorySort,
|
||||||
|
type KnowledgeDirectory,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
findDirectoryParentId,
|
||||||
|
moveDirectoryToParent,
|
||||||
|
moveDirectoryWithinParent,
|
||||||
|
} from "./knowledge-directory-sort";
|
||||||
|
|
||||||
|
type KnowledgeDirectoryPanelProps = {
|
||||||
|
knowledgeBaseId: number;
|
||||||
|
selectedDirectoryId: number | null;
|
||||||
|
onSelectDirectory: (directoryId: number | null) => void;
|
||||||
|
onChanged?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectoryDialogState = {
|
||||||
|
open: boolean;
|
||||||
|
id: number | null;
|
||||||
|
parentId: number;
|
||||||
|
name: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectoryOption = { value: string; label: string };
|
||||||
|
|
||||||
|
const DIRECTORY_PANEL_WIDTH_STORAGE_KEY = "knowledge-directory-panel-width";
|
||||||
|
const DIRECTORY_PANEL_MIN_WIDTH = 180;
|
||||||
|
const DIRECTORY_PANEL_MAX_WIDTH = 360;
|
||||||
|
const DIRECTORY_PANEL_DEFAULT_WIDTH = 224;
|
||||||
|
const ROOT_DIRECTORY_DROP_ID = "knowledge-directory-root";
|
||||||
|
|
||||||
|
function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] {
|
||||||
|
return items.map((item) => ({ value: String(item.id), label: item.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectExpandedIds(items: KnowledgeDirectory[]) {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
const walk = (nodes: KnowledgeDirectory[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
ids.add(node.id);
|
||||||
|
walk(node.children || []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(items);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeDirectoryPanel({
|
||||||
|
knowledgeBaseId,
|
||||||
|
selectedDirectoryId,
|
||||||
|
onSelectDirectory,
|
||||||
|
onChanged,
|
||||||
|
}: KnowledgeDirectoryPanelProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const directoryNameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const directoryNameFocusTimerRef = useRef<number | null>(null);
|
||||||
|
const [panelWidth, setPanelWidth] = useState(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DIRECTORY_PANEL_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
const saved = Number(localStorage.getItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY));
|
||||||
|
if (!Number.isFinite(saved)) {
|
||||||
|
return DIRECTORY_PANEL_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
return Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, saved));
|
||||||
|
});
|
||||||
|
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [sorting, setSorting] = useState(false);
|
||||||
|
const [dialog, setDialog] = useState<DirectoryDialogState>({
|
||||||
|
open: false,
|
||||||
|
id: null,
|
||||||
|
parentId: 0,
|
||||||
|
name: "",
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
const parentOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "0", label: t("knowledge.rootDirectory") },
|
||||||
|
...rootDirectoryOptions(directories).filter((item) => item.value !== String(dialog.id ?? "")),
|
||||||
|
],
|
||||||
|
[directories, dialog.id, t],
|
||||||
|
);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: { distance: 8 },
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: { delay: 150, tolerance: 8 },
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDirectories = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchKnowledgeDirectories(knowledgeBaseId);
|
||||||
|
setDirectories(data);
|
||||||
|
setExpandedIds(collectExpandedIds(data));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.loadDirectoriesFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [knowledgeBaseId, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDirectories();
|
||||||
|
}, [loadDirectories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY, String(panelWidth));
|
||||||
|
}, [panelWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialog.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
directoryNameFocusTimerRef.current = window.setTimeout(() => {
|
||||||
|
const input =
|
||||||
|
directoryNameInputRef.current ??
|
||||||
|
document.querySelector<HTMLInputElement>("[data-knowledge-directory-name-input='true']");
|
||||||
|
input?.focus({ preventScroll: true });
|
||||||
|
input?.select();
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
if (directoryNameFocusTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(directoryNameFocusTimerRef.current);
|
||||||
|
directoryNameFocusTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [dialog.open]);
|
||||||
|
|
||||||
|
function handleResizePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = panelWidth;
|
||||||
|
|
||||||
|
function handlePointerMove(moveEvent: globalThis.PointerEvent) {
|
||||||
|
const nextWidth = startWidth + moveEvent.clientX - startX;
|
||||||
|
setPanelWidth(
|
||||||
|
Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, nextWidth)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerUp);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
|
window.addEventListener("pointerup", handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDirectory(id: number) {
|
||||||
|
setExpandedIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(parentId = 0) {
|
||||||
|
setDialog({ open: true, id: null, parentId, name: "", remark: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: KnowledgeDirectory) {
|
||||||
|
setDialog({
|
||||||
|
open: true,
|
||||||
|
id: item.id,
|
||||||
|
parentId: item.parentId,
|
||||||
|
name: item.name,
|
||||||
|
remark: item.remark || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const name = dialog.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
toast.error(t("knowledge.directoryNameRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (dialog.id) {
|
||||||
|
await updateKnowledgeDirectory({
|
||||||
|
id: dialog.id,
|
||||||
|
knowledgeBaseId,
|
||||||
|
parentId: dialog.parentId,
|
||||||
|
name,
|
||||||
|
remark: dialog.remark.trim(),
|
||||||
|
});
|
||||||
|
toast.success(t("knowledge.directoryUpdated", { name }));
|
||||||
|
} else {
|
||||||
|
await createKnowledgeDirectory({
|
||||||
|
knowledgeBaseId,
|
||||||
|
parentId: dialog.parentId,
|
||||||
|
name,
|
||||||
|
remark: dialog.remark.trim(),
|
||||||
|
});
|
||||||
|
toast.success(t("knowledge.directoryCreated", { name }));
|
||||||
|
}
|
||||||
|
setDialog((current) => ({ ...current, open: false }));
|
||||||
|
await loadDirectories();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.directorySaveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: KnowledgeDirectory) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteKnowledgeDirectory(item.id);
|
||||||
|
if (selectedDirectoryId === item.id) {
|
||||||
|
onSelectDirectory(null);
|
||||||
|
}
|
||||||
|
toast.success(t("knowledge.directoryDeleted", { name: item.name }));
|
||||||
|
await loadDirectories();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.directoryDeleteFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDirectoryDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id || sorting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeId = Number(active.id);
|
||||||
|
if (!Number.isFinite(activeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (over.id === ROOT_DIRECTORY_DROP_ID) {
|
||||||
|
await handleDirectoryMoveToParent(activeId, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const overId = Number(over.id);
|
||||||
|
if (!Number.isFinite(overId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeParentId = findDirectoryParentId(directories, activeId);
|
||||||
|
const overParentId = findDirectoryParentId(directories, overId);
|
||||||
|
if (activeParentId === null || overParentId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeParentId !== overParentId) {
|
||||||
|
await handleDirectoryMoveToParent(activeId, overId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeParentId === 0 && overParentId === 0 && event.delta.x > 24) {
|
||||||
|
await handleDirectoryMoveToParent(activeId, overId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousDirectories = directories;
|
||||||
|
const moved = moveDirectoryWithinParent(previousDirectories, activeParentId, activeId, overId);
|
||||||
|
if (!moved.changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirectories(moved.items);
|
||||||
|
setSorting(true);
|
||||||
|
try {
|
||||||
|
await updateKnowledgeDirectorySort({
|
||||||
|
knowledgeBaseId,
|
||||||
|
parentId: moved.parentId,
|
||||||
|
ids: moved.orderedIds,
|
||||||
|
});
|
||||||
|
toast.success(t("knowledge.directorySortUpdated"));
|
||||||
|
await loadDirectories();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (error) {
|
||||||
|
setDirectories(previousDirectories);
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.directorySortUpdateFailed"));
|
||||||
|
} finally {
|
||||||
|
setSorting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDirectoryMoveToParent(activeId: number, targetParentId: number) {
|
||||||
|
const previousDirectories = directories;
|
||||||
|
const moved = moveDirectoryToParent(previousDirectories, activeId, targetParentId);
|
||||||
|
if (!moved.changed || !moved.item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirectories(moved.items);
|
||||||
|
if (moved.parentId > 0) {
|
||||||
|
setExpandedIds((current) => new Set(current).add(moved.parentId));
|
||||||
|
}
|
||||||
|
setSorting(true);
|
||||||
|
try {
|
||||||
|
await updateKnowledgeDirectory({
|
||||||
|
id: moved.item.id,
|
||||||
|
knowledgeBaseId,
|
||||||
|
parentId: moved.parentId,
|
||||||
|
name: moved.item.name,
|
||||||
|
remark: moved.item.remark || "",
|
||||||
|
});
|
||||||
|
await updateKnowledgeDirectorySort({
|
||||||
|
knowledgeBaseId,
|
||||||
|
parentId: moved.parentId,
|
||||||
|
ids: moved.orderedIds,
|
||||||
|
});
|
||||||
|
toast.success(t("knowledge.directoryMoved"));
|
||||||
|
await loadDirectories();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (error) {
|
||||||
|
setDirectories(previousDirectories);
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.directoryMoveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSorting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="relative flex h-full min-h-0 shrink-0 flex-col border-r bg-muted/20"
|
||||||
|
style={{ width: panelWidth }}
|
||||||
|
>
|
||||||
|
<div className="flex h-[49px] items-center justify-between border-b bg-background px-3">
|
||||||
|
<div className="text-sm font-medium">{t("knowledge.directory")}</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => openCreate(0)}
|
||||||
|
aria-label={t("knowledge.createDirectory")}
|
||||||
|
>
|
||||||
|
<FolderPlusIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div className="py-1">
|
||||||
|
<DirectoryStaticRow
|
||||||
|
icon={<LayersIcon className="size-4 text-muted-foreground" />}
|
||||||
|
label={t("knowledge.allContent")}
|
||||||
|
selected={selectedDirectoryId === null}
|
||||||
|
onClick={() => onSelectDirectory(null)}
|
||||||
|
/>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
|
||||||
|
>
|
||||||
|
<RootDirectoryRow
|
||||||
|
selected={selectedDirectoryId === 0}
|
||||||
|
onClick={() => onSelectDirectory(0)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<SortableContext
|
||||||
|
items={directories.map((item) => item.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{directories.map((item) => (
|
||||||
|
<DirectoryNode
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
depth={0}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
selectedDirectoryId={selectedDirectoryId}
|
||||||
|
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||||
|
saving={saving || sorting}
|
||||||
|
onToggle={toggleDirectory}
|
||||||
|
onSelect={onSelectDirectory}
|
||||||
|
onContextMenuOpenChange={(directoryId, open) =>
|
||||||
|
setContextMenuDirectoryId(open ? directoryId : null)
|
||||||
|
}
|
||||||
|
onCreate={openCreate}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={(directory) => void handleDelete(directory)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-[-3px] z-10 h-full w-1.5 cursor-col-resize transition-colors hover:bg-primary/30"
|
||||||
|
onPointerDown={handleResizePointerDown}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label={t("knowledge.resizeDirectoryPanel")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ProjectDialog
|
||||||
|
open={dialog.open}
|
||||||
|
onOpenChange={(open) => setDialog((current) => ({ ...current, open }))}
|
||||||
|
title={dialog.id ? t("knowledge.editDirectory") : t("knowledge.createDirectory")}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialog((current) => ({ ...current, open: false }))}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("knowledge.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void handleSubmit()} disabled={saving}>
|
||||||
|
{saving ? t("knowledge.saving") : t("knowledge.save")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("knowledge.parentDirectory")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<OptionCombobox
|
||||||
|
value={String(dialog.parentId)}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDialog((current) => ({ ...current, parentId: Number(value ?? 0) }))
|
||||||
|
}
|
||||||
|
options={parentOptions}
|
||||||
|
placeholder={t("knowledge.selectDirectory")}
|
||||||
|
searchPlaceholder={t("knowledge.searchDirectory")}
|
||||||
|
emptyText={t("knowledge.emptyDirectory")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("knowledge.directoryName")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
ref={directoryNameInputRef}
|
||||||
|
autoFocus
|
||||||
|
data-knowledge-directory-name-input="true"
|
||||||
|
value={dialog.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDialog((current) => ({ ...current, name: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t("knowledge.directoryNamePlaceholder")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("knowledge.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
value={dialog.remark}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDialog((current) => ({ ...current, remark: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
placeholder={t("knowledge.remarkPlaceholder")}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</ProjectDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectoryStaticRowProps = {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
draggingOver?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DirectoryStaticRow({ icon, label, selected, draggingOver, onClick }: DirectoryStaticRowProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-1 px-2 py-1.5 text-left text-sm hover:bg-accent",
|
||||||
|
selected && "bg-accent text-accent-foreground",
|
||||||
|
draggingOver && "bg-primary/10 text-accent-foreground",
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="size-5" />
|
||||||
|
{icon}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RootDirectoryRowProps = {
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RootDirectoryRow({ selected, onClick, t }: RootDirectoryRowProps) {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({
|
||||||
|
id: ROOT_DIRECTORY_DROP_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef}>
|
||||||
|
<DirectoryStaticRow
|
||||||
|
icon={<FolderIcon className="size-4 text-muted-foreground" />}
|
||||||
|
label={t("knowledge.rootContent")}
|
||||||
|
selected={selected}
|
||||||
|
draggingOver={isOver}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectoryNodeProps = {
|
||||||
|
item: KnowledgeDirectory;
|
||||||
|
depth: number;
|
||||||
|
expandedIds: Set<number>;
|
||||||
|
selectedDirectoryId: number | null;
|
||||||
|
contextMenuDirectoryId: number | null;
|
||||||
|
saving: boolean;
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
onContextMenuOpenChange: (id: number, open: boolean) => void;
|
||||||
|
onCreate: (parentId: number) => void;
|
||||||
|
onEdit: (item: KnowledgeDirectory) => void;
|
||||||
|
onDelete: (item: KnowledgeDirectory) => void;
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function DirectoryNode({
|
||||||
|
item,
|
||||||
|
depth,
|
||||||
|
expandedIds,
|
||||||
|
selectedDirectoryId,
|
||||||
|
contextMenuDirectoryId,
|
||||||
|
saving,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
onContextMenuOpenChange,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
t,
|
||||||
|
}: DirectoryNodeProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: item.id,
|
||||||
|
disabled: saving,
|
||||||
|
});
|
||||||
|
const expanded = expandedIds.has(item.id);
|
||||||
|
const hasChildren = (item.children || []).length > 0;
|
||||||
|
const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id;
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style}>
|
||||||
|
<ContextMenu onOpenChange={(open) => onContextMenuOpenChange(item.id, open)}>
|
||||||
|
<ContextMenuTrigger className="block">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex cursor-pointer items-center gap-1 px-2 py-1.5 text-sm hover:bg-accent",
|
||||||
|
active && "bg-accent text-accent-foreground",
|
||||||
|
isDragging && "bg-muted/60 shadow-sm opacity-80",
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: 8 + depth * 16 }}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-5 shrink-0"
|
||||||
|
disabled={!hasChildren}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggle(item.id);
|
||||||
|
}}
|
||||||
|
aria-label={expanded ? t("knowledge.collapseDirectory") : t("knowledge.expandDirectory")}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
<FolderIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 opacity-0 group-hover:opacity-100"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
aria-label={t("knowledge.moreActions", { name: item.name })}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-3.5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||||
|
{item.parentId === 0 ? (
|
||||||
|
<DropdownMenuItem onClick={() => onCreate(item.id)}>
|
||||||
|
<PlusIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.createSubDirectory")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-40">
|
||||||
|
{item.parentId === 0 ? (
|
||||||
|
<ContextMenuItem onClick={() => onCreate(item.id)}>
|
||||||
|
<PlusIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.createSubDirectory")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : null}
|
||||||
|
<ContextMenuItem onClick={() => onEdit(item)}>
|
||||||
|
<PencilIcon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.edit")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-3.5" />
|
||||||
|
{t("knowledge.delete")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
{expanded
|
||||||
|
? (
|
||||||
|
<SortableContext
|
||||||
|
items={(item.children || []).map((child) => child.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{(item.children || []).map((child) => (
|
||||||
|
<DirectoryNode
|
||||||
|
key={child.id}
|
||||||
|
item={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
selectedDirectoryId={selectedDirectoryId}
|
||||||
|
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||||
|
saving={saving}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
import ts from "typescript"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import vm from "node:vm"
|
||||||
|
|
||||||
|
function plain(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModule() {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("./knowledge-directory-sort.ts", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2017,
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
},
|
||||||
|
fileName: "knowledge-directory-sort.ts",
|
||||||
|
})
|
||||||
|
const sandbox = {
|
||||||
|
exports: {},
|
||||||
|
module: { exports: {} },
|
||||||
|
}
|
||||||
|
sandbox.exports = sandbox.module.exports
|
||||||
|
vm.runInNewContext(compiled.outputText, sandbox)
|
||||||
|
return sandbox.module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = [
|
||||||
|
{ id: 1, parentId: 0, name: "A", children: [
|
||||||
|
{ id: 11, parentId: 1, name: "A-1", children: [] },
|
||||||
|
{ id: 12, parentId: 1, name: "A-2", children: [] },
|
||||||
|
] },
|
||||||
|
{ id: 2, parentId: 0, name: "B", children: [] },
|
||||||
|
{ id: 3, parentId: 0, name: "C", children: [] },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe("knowledge directory sorting", () => {
|
||||||
|
it("moves root directories within the same parent", async () => {
|
||||||
|
const { moveDirectoryWithinParent } = await loadModule()
|
||||||
|
|
||||||
|
const next = moveDirectoryWithinParent(tree, 0, 3, 1)
|
||||||
|
|
||||||
|
assert.deepEqual(plain(next.items).map((item) => item.id), [3, 1, 2])
|
||||||
|
assert.equal(next.changed, true)
|
||||||
|
assert.equal(next.parentId, 0)
|
||||||
|
assert.deepEqual(plain(next.orderedIds), [3, 1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("moves child directories within their parent", async () => {
|
||||||
|
const { moveDirectoryWithinParent } = await loadModule()
|
||||||
|
|
||||||
|
const next = moveDirectoryWithinParent(tree, 1, 12, 11)
|
||||||
|
|
||||||
|
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12, 11])
|
||||||
|
assert.equal(next.changed, true)
|
||||||
|
assert.equal(next.parentId, 1)
|
||||||
|
assert.deepEqual(plain(next.orderedIds), [12, 11])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not move directories across parents", async () => {
|
||||||
|
const { findDirectoryParentId, moveDirectoryWithinParent } = await loadModule()
|
||||||
|
|
||||||
|
assert.equal(findDirectoryParentId(tree, 11), 1)
|
||||||
|
assert.equal(findDirectoryParentId(tree, 2), 0)
|
||||||
|
|
||||||
|
const next = moveDirectoryWithinParent(tree, 1, 11, 2)
|
||||||
|
|
||||||
|
assert.equal(next.changed, false)
|
||||||
|
assert.equal(next.items, tree)
|
||||||
|
assert.deepEqual(plain(next.orderedIds), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("moves a child directory back to the root level", async () => {
|
||||||
|
const { moveDirectoryToParent } = await loadModule()
|
||||||
|
|
||||||
|
const next = moveDirectoryToParent(tree, 11, 0)
|
||||||
|
|
||||||
|
assert.equal(next.changed, true)
|
||||||
|
assert.equal(next.parentId, 0)
|
||||||
|
assert.equal(next.item?.parentId, 0)
|
||||||
|
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2, 3, 11])
|
||||||
|
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12])
|
||||||
|
assert.deepEqual(plain(next.orderedIds), [1, 2, 3, 11])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("moves a root directory without children under another root directory", async () => {
|
||||||
|
const { moveDirectoryToParent } = await loadModule()
|
||||||
|
|
||||||
|
const next = moveDirectoryToParent(tree, 3, 2)
|
||||||
|
|
||||||
|
assert.equal(next.changed, true)
|
||||||
|
assert.equal(next.parentId, 2)
|
||||||
|
assert.equal(next.item?.parentId, 2)
|
||||||
|
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2])
|
||||||
|
assert.deepEqual(plain(next.items[1].children).map((item) => item.id), [3])
|
||||||
|
assert.deepEqual(plain(next.orderedIds), [3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects moving a directory with children under another directory", async () => {
|
||||||
|
const { moveDirectoryToParent } = await loadModule()
|
||||||
|
|
||||||
|
const next = moveDirectoryToParent(tree, 1, 2)
|
||||||
|
|
||||||
|
assert.equal(next.changed, false)
|
||||||
|
assert.equal(next.items, tree)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects moving a directory under a child directory", async () => {
|
||||||
|
const { moveDirectoryToParent } = await loadModule()
|
||||||
|
|
||||||
|
const next = moveDirectoryToParent(tree, 3, 11)
|
||||||
|
|
||||||
|
assert.equal(next.changed, false)
|
||||||
|
assert.equal(next.items, tree)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
export type SortableKnowledgeDirectory = {
|
||||||
|
id: number
|
||||||
|
parentId: number
|
||||||
|
children?: SortableKnowledgeDirectory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MoveDirectoryResult<T extends SortableKnowledgeDirectory> = {
|
||||||
|
items: T[]
|
||||||
|
changed: boolean
|
||||||
|
parentId: number
|
||||||
|
orderedIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MoveDirectoryToParentResult<T extends SortableKnowledgeDirectory> =
|
||||||
|
MoveDirectoryResult<T> & {
|
||||||
|
item: T | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
|
||||||
|
const next = [...items]
|
||||||
|
const [item] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(toIndex, 0, item)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDirectoryParentId<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
id: number,
|
||||||
|
): number | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) {
|
||||||
|
return item.parentId
|
||||||
|
}
|
||||||
|
const childParentId = findDirectoryParentId(item.children as T[] | undefined ?? [], id)
|
||||||
|
if (childParentId !== null) {
|
||||||
|
return childParentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDirectory<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
id: number,
|
||||||
|
): T | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
const child = findDirectory(item.children as T[] | undefined ?? [], id)
|
||||||
|
if (child !== null) {
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveDirectoryToParent<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
activeId: number,
|
||||||
|
targetParentId: number,
|
||||||
|
): MoveDirectoryToParentResult<T> {
|
||||||
|
if (activeId === targetParentId) {
|
||||||
|
return unchangedParentMove(items, targetParentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeItem = findDirectory(items, activeId)
|
||||||
|
const activeParentId = findDirectoryParentId(items, activeId)
|
||||||
|
if (!activeItem || activeParentId === null || activeParentId === targetParentId) {
|
||||||
|
return unchangedParentMove(items, targetParentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetParentId > 0) {
|
||||||
|
const targetParent = findDirectory(items, targetParentId)
|
||||||
|
if (!targetParent || targetParent.parentId !== 0 || (activeItem.children?.length ?? 0) > 0) {
|
||||||
|
return unchangedParentMove(items, targetParentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = removeDirectory(items, activeId)
|
||||||
|
if (!removed.item) {
|
||||||
|
return unchangedParentMove(items, targetParentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedItem = { ...removed.item, parentId: targetParentId } as T
|
||||||
|
const appended = appendDirectoryToParent(removed.items, targetParentId, movedItem)
|
||||||
|
if (!appended.changed) {
|
||||||
|
return unchangedParentMove(items, targetParentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: appended.items,
|
||||||
|
changed: true,
|
||||||
|
parentId: targetParentId,
|
||||||
|
orderedIds: appended.siblings.map((item) => item.id),
|
||||||
|
item: movedItem,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
parentId: number,
|
||||||
|
activeId: number,
|
||||||
|
overId: number,
|
||||||
|
): MoveDirectoryResult<T> {
|
||||||
|
if (activeId === overId) {
|
||||||
|
return { items, changed: false, parentId, orderedIds: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId === 0) {
|
||||||
|
return moveSiblingList(items, parentId, activeId, overId)
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
let orderedIds: number[] = []
|
||||||
|
const nextItems = items.map((item) => {
|
||||||
|
if (item.id === parentId) {
|
||||||
|
const moved = moveSiblingList((item.children as T[] | undefined) ?? [], parentId, activeId, overId)
|
||||||
|
changed = moved.changed
|
||||||
|
orderedIds = moved.orderedIds
|
||||||
|
return { ...item, children: moved.items }
|
||||||
|
}
|
||||||
|
if (item.children?.length) {
|
||||||
|
const moved = moveDirectoryWithinParent(item.children as T[], parentId, activeId, overId)
|
||||||
|
if (moved.changed) {
|
||||||
|
changed = true
|
||||||
|
orderedIds = moved.orderedIds
|
||||||
|
return { ...item, children: moved.items }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: changed ? nextItems : items,
|
||||||
|
changed,
|
||||||
|
parentId,
|
||||||
|
orderedIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unchangedParentMove<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
parentId: number,
|
||||||
|
): MoveDirectoryToParentResult<T> {
|
||||||
|
return { items, changed: false, parentId, orderedIds: [], item: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDirectory<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
id: number,
|
||||||
|
): { items: T[]; item: T | null } {
|
||||||
|
let removedItem: T | null = null
|
||||||
|
const nextItems: T[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) {
|
||||||
|
removedItem = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item.children?.length) {
|
||||||
|
const removed = removeDirectory(item.children as T[], id)
|
||||||
|
if (removed.item) {
|
||||||
|
removedItem = removed.item
|
||||||
|
nextItems.push({ ...item, children: removed.items } as T)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextItems.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: removedItem ? nextItems : items,
|
||||||
|
item: removedItem,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDirectoryToParent<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
parentId: number,
|
||||||
|
item: T,
|
||||||
|
): { items: T[]; changed: boolean; siblings: T[] } {
|
||||||
|
if (parentId === 0) {
|
||||||
|
const siblings = [...items, item]
|
||||||
|
return { items: siblings, changed: true, siblings }
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
let siblings: T[] = []
|
||||||
|
const nextItems = items.map((current) => {
|
||||||
|
if (current.id === parentId) {
|
||||||
|
siblings = [...((current.children as T[] | undefined) ?? []), item]
|
||||||
|
changed = true
|
||||||
|
return { ...current, children: siblings } as T
|
||||||
|
}
|
||||||
|
if (current.children?.length) {
|
||||||
|
const appended = appendDirectoryToParent(current.children as T[], parentId, item)
|
||||||
|
if (appended.changed) {
|
||||||
|
changed = true
|
||||||
|
siblings = appended.siblings
|
||||||
|
return { ...current, children: appended.items } as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: changed ? nextItems : items,
|
||||||
|
changed,
|
||||||
|
siblings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSiblingList<T extends SortableKnowledgeDirectory>(
|
||||||
|
items: T[],
|
||||||
|
parentId: number,
|
||||||
|
activeId: number,
|
||||||
|
overId: number,
|
||||||
|
): MoveDirectoryResult<T> {
|
||||||
|
const oldIndex = items.findIndex((item) => item.id === activeId)
|
||||||
|
const newIndex = items.findIndex((item) => item.id === overId)
|
||||||
|
if (oldIndex < 0 || newIndex < 0) {
|
||||||
|
return { items, changed: false, parentId, orderedIds: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = arrayMove(items, oldIndex, newIndex)
|
||||||
|
return {
|
||||||
|
items: nextItems,
|
||||||
|
changed: true,
|
||||||
|
parentId,
|
||||||
|
orderedIds: nextItems.map((item) => item.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchKnowledgeRetrieveLog,
|
||||||
|
type KnowledgeRetrieveHit,
|
||||||
|
type KnowledgeRetrieveLogDetail,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
getKnowledgeAnswerStatusLabel,
|
||||||
|
getKnowledgeChunkProviderLabel,
|
||||||
|
getKnowledgeRetrieveChannelLabel,
|
||||||
|
getKnowledgeRetrieveSceneLabel,
|
||||||
|
} from "@/lib/knowledge-i18n"
|
||||||
|
|
||||||
|
type RetrieveLogDetailDrawerProps = {
|
||||||
|
open: boolean
|
||||||
|
retrieveLogId: number | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJSON(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction }) {
|
||||||
|
const citations = hits.filter((item) => item.isCitation)
|
||||||
|
if (citations.length === 0) {
|
||||||
|
return <div className="text-sm text-muted-foreground">{t("knowledge.noCitations")}</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{citations.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
|
||||||
|
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 whitespace-pre-wrap text-foreground/90">
|
||||||
|
{item.snippet || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetrieveLogDetailDrawer({
|
||||||
|
open,
|
||||||
|
retrieveLogId,
|
||||||
|
onOpenChange,
|
||||||
|
}: RetrieveLogDetailDrawerProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [detail, setDetail] = useState<KnowledgeRetrieveLogDetail | null>(null)
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async () => {
|
||||||
|
if (!retrieveLogId) {
|
||||||
|
setDetail(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchKnowledgeRetrieveLog(retrieveLogId)
|
||||||
|
setDetail(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.loadRetrieveLogDetailFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [retrieveLogId, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && retrieveLogId) {
|
||||||
|
void loadDetail()
|
||||||
|
}
|
||||||
|
}, [open, retrieveLogId, loadDetail])
|
||||||
|
|
||||||
|
const traceData = useMemo(() => safeParseJSON(detail?.log.traceData ?? ""), [detail?.log.traceData])
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
|
||||||
|
<DrawerContent className="max-w-3xl">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{t("knowledge.retrieveLogDetail")}</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{detail?.log.question || (loading ? t("knowledge.loading") : t("knowledge.retrieveLogNotFound"))}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<ScrollArea className="h-[calc(100vh-6rem)] px-4 pb-6">
|
||||||
|
{!detail ? (
|
||||||
|
<div className="py-6 text-sm text-muted-foreground">
|
||||||
|
{loading ? t("knowledge.loadingDetail") : t("knowledge.emptyDetail")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 pb-6">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</h3>
|
||||||
|
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.title")}</div>
|
||||||
|
<div className="mt-1 text-sm">{detail.log.knowledgeBaseName || `#${detail.log.knowledgeBaseId}`}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.createdAt")}</div>
|
||||||
|
<div className="mt-1 text-sm">{formatDateTime(detail.log.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.channelScene")}</div>
|
||||||
|
<div className="mt-1 text-sm">
|
||||||
|
{getKnowledgeRetrieveChannelLabel(detail.log.channel, detail.log.channelName, t)} /{" "}
|
||||||
|
{getKnowledgeRetrieveSceneLabel(detail.log.scene, detail.log.sceneName, t)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Request ID</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.originalQuestion")}</div>
|
||||||
|
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.question || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.rewriteQuestion")}</div>
|
||||||
|
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.rewriteQuestion || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.answerContent")}</div>
|
||||||
|
<div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{detail.log.answer || "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">{t("knowledge.retrieveStrategy")}</h3>
|
||||||
|
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
|
||||||
|
<Metric
|
||||||
|
label="Chunk Provider"
|
||||||
|
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
|
||||||
|
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
|
||||||
|
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
|
||||||
|
<Metric label="Rerank" value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||||
|
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">{t("knowledge.resultSummary")}</h3>
|
||||||
|
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-4">
|
||||||
|
<Metric
|
||||||
|
label={t("knowledge.answerStatus")}
|
||||||
|
value={getKnowledgeAnswerStatusLabel(detail.log.answerStatus, detail.log.answerStatusName, t)}
|
||||||
|
/>
|
||||||
|
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
|
||||||
|
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
|
||||||
|
<Metric label={t("knowledge.contextChunks")} value={detail.log.usedChunkCount} />
|
||||||
|
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
|
||||||
|
<Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
|
||||||
|
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
|
||||||
|
<Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
|
||||||
|
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
|
||||||
|
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
|
||||||
|
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
|
||||||
|
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">{t("knowledge.citationSources")}</h3>
|
||||||
|
<CitationList hits={detail.hits} t={t} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold">{t("knowledge.hitDetails")}</h3>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("knowledge.itemsCount", { count: detail.hits.length })}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{detail.hits.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">#{item.rankNo}</Badge>
|
||||||
|
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
|
||||||
|
<Badge variant={item.usedInAnswer ? "default" : "secondary"}>
|
||||||
|
{item.usedInAnswer ? t("knowledge.usedInContext") : t("knowledge.notUsedInContext")}
|
||||||
|
</Badge>
|
||||||
|
{item.isCitation ? <Badge>{t("knowledge.citations")}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{t("knowledge.section", { section: item.sectionPath || item.title || "-" })}</span>
|
||||||
|
<span>Chunk #{item.chunkNo}</span>
|
||||||
|
<span>Provider:{item.provider || "-"}</span>
|
||||||
|
<span>Score:{item.score.toFixed(4)}</span>
|
||||||
|
<span>Rerank:{item.rerankScore ? item.rerankScore.toFixed(4) : "-"}</span>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
<div className="text-sm leading-6 whitespace-pre-wrap text-foreground/90">
|
||||||
|
{item.snippet || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">TraceData</h3>
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-4">
|
||||||
|
<pre className="overflow-x-auto text-xs leading-6 text-muted-foreground">
|
||||||
|
{traceData ? JSON.stringify(traceData, null, 2) : detail.log.traceData || "-"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHitSourceLabel(item: KnowledgeRetrieveHit, t: TFunction) {
|
||||||
|
if (item.faqQuestion) {
|
||||||
|
return item.faqQuestion
|
||||||
|
}
|
||||||
|
if (item.documentTitle) {
|
||||||
|
return item.documentTitle
|
||||||
|
}
|
||||||
|
if (item.faqId > 0) {
|
||||||
|
return `FAQ #${item.faqId}`
|
||||||
|
}
|
||||||
|
return `${t("knowledge.document")} #${item.documentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono = false,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
mono?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className={`mt-1 text-sm ${mono ? "font-mono" : ""}`}>{String(value || value === 0 ? value : "-")}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchKnowledgeRetrieveLogs,
|
||||||
|
type KnowledgeRetrieveLog,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import {
|
||||||
|
KnowledgeAnswerStatus,
|
||||||
|
KnowledgeRetrieveChannel,
|
||||||
|
KnowledgeRetrieveScene,
|
||||||
|
} from "@/lib/generated/enums"
|
||||||
|
import {
|
||||||
|
getKnowledgeAnswerStatusLabel,
|
||||||
|
getKnowledgeChunkProviderLabel,
|
||||||
|
getKnowledgeRetrieveChannelLabel,
|
||||||
|
getKnowledgeRetrieveSceneLabel,
|
||||||
|
} from "@/lib/knowledge-i18n"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { DashboardListPage } from "@/components/dashboard/list"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { RetrieveLogDetailDrawer } from "./retrieve-log-detail"
|
||||||
|
|
||||||
|
type RetrieveLogListProps = {
|
||||||
|
knowledgeBaseId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
function getChannelOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allChannels") },
|
||||||
|
{ value: KnowledgeRetrieveChannel.IM, label: t("knowledge.channelIM") },
|
||||||
|
{ value: KnowledgeRetrieveChannel.AgentAssist, label: t("knowledge.channelAgentAssist") },
|
||||||
|
{ value: KnowledgeRetrieveChannel.API, label: t("knowledge.channelAPI") },
|
||||||
|
{ value: KnowledgeRetrieveChannel.Debug, label: t("knowledge.channelDebug") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSceneOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allScenes") },
|
||||||
|
{ value: KnowledgeRetrieveScene.FirstResponse, label: t("knowledge.sceneFirstResponse") },
|
||||||
|
{ value: KnowledgeRetrieveScene.Assist, label: t("knowledge.sceneAssist") },
|
||||||
|
{ value: KnowledgeRetrieveScene.QA, label: t("knowledge.sceneQA") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnswerStatusOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allAnswerStatus") },
|
||||||
|
{ value: String(KnowledgeAnswerStatus.Normal), label: t("knowledge.answerNormal") },
|
||||||
|
{ value: String(KnowledgeAnswerStatus.NoAnswer), label: t("knowledge.answerNoAnswer") },
|
||||||
|
{ value: String(KnowledgeAnswerStatus.Fallback), label: t("knowledge.answerFallback") },
|
||||||
|
{ value: String(KnowledgeAnswerStatus.Blocked), label: t("knowledge.answerBlocked") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allChunkProviders") },
|
||||||
|
{ value: "fixed", label: t("knowledge.chunkFixed") },
|
||||||
|
{ value: "structured", label: t("knowledge.chunkStructured") },
|
||||||
|
{ value: "faq", label: t("knowledge.chunkFAQ") },
|
||||||
|
{ value: "semantic", label: t("knowledge.chunkSemantic") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRerankOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("knowledge.allRerank") },
|
||||||
|
{ value: "1", label: t("knowledge.rerankEnabled") },
|
||||||
|
{ value: "0", label: t("knowledge.rerankDisabled") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(value: string, fallback: string, t: TFunction) {
|
||||||
|
return getKnowledgeRetrieveChannelLabel(value, fallback, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneLabel(value: string, fallback: string, t: TFunction) {
|
||||||
|
return getKnowledgeRetrieveSceneLabel(value, fallback, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function answerStatusLabel(value: number, fallback: string, t: TFunction) {
|
||||||
|
return getKnowledgeAnswerStatusLabel(value, fallback, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(value: string, t: TFunction) {
|
||||||
|
return getKnowledgeChunkProviderLabel(value, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnswerStatusVariant(status: number): "default" | "secondary" | "outline" | "destructive" {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return "default"
|
||||||
|
case 2:
|
||||||
|
return "secondary"
|
||||||
|
case 3:
|
||||||
|
return "outline"
|
||||||
|
case 4:
|
||||||
|
return "destructive"
|
||||||
|
default:
|
||||||
|
return "outline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetrieveLogList({
|
||||||
|
knowledgeBaseId,
|
||||||
|
}: RetrieveLogListProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [selectedLogId, setSelectedLogId] = useState<number | null>(null)
|
||||||
|
const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState<number | null>(null)
|
||||||
|
const channelOptions = useMemo(() => getChannelOptions(t), [t])
|
||||||
|
const sceneOptions = useMemo(() => getSceneOptions(t), [t])
|
||||||
|
const answerStatusOptions = useMemo(() => getAnswerStatusOptions(t), [t])
|
||||||
|
const providerOptions = useMemo(() => getProviderOptions(t), [t])
|
||||||
|
const rerankOptions = useMemo(() => getRerankOptions(t), [t])
|
||||||
|
|
||||||
|
function handleOpenDetail(logId: number) {
|
||||||
|
setSelectedLogId(logId)
|
||||||
|
setSelectedKnowledgeBaseId(knowledgeBaseId)
|
||||||
|
setDetailOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!knowledgeBaseId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("knowledge.selectBaseForLogs")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-full flex-col gap-4 px-6 py-4">
|
||||||
|
<DashboardListPage<KnowledgeRetrieveLog>
|
||||||
|
layout="fragment"
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "question",
|
||||||
|
label: t("knowledge.filterQuestion"),
|
||||||
|
placeholder: t("knowledge.filterQuestion"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "min-w-0 xl:min-w-[280px]",
|
||||||
|
inputClassName: "pl-9",
|
||||||
|
icon: <SearchIcon className="size-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "channel",
|
||||||
|
label: t("knowledge.selectChannel"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: channelOptions,
|
||||||
|
placeholder: t("knowledge.selectChannel"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scene",
|
||||||
|
label: t("knowledge.selectScene"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: sceneOptions,
|
||||||
|
placeholder: t("knowledge.selectScene"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "answerStatus",
|
||||||
|
label: t("knowledge.answerStatus"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
valueType: "number",
|
||||||
|
options: answerStatusOptions,
|
||||||
|
placeholder: t("knowledge.answerStatus"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chunkProvider",
|
||||||
|
label: t("knowledge.chunkStrategy"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: providerOptions,
|
||||||
|
placeholder: t("knowledge.chunkStrategy"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rerankEnabled",
|
||||||
|
label: "Rerank",
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
valueType: "number",
|
||||||
|
options: rerankOptions,
|
||||||
|
placeholder: "Rerank",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
|
||||||
|
reloadKey={knowledgeBaseId}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
getRowClassName={() => "cursor-pointer"}
|
||||||
|
onRowClick={(item) => handleOpenDetail(item.id)}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "time",
|
||||||
|
label: t("knowledge.time"),
|
||||||
|
className: "w-42 text-xs text-muted-foreground",
|
||||||
|
render: (item) => formatDateTime(item.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "question",
|
||||||
|
label: t("knowledge.question"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="line-clamp-2 font-medium">{item.question || "-"}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{channelLabel(item.channel, item.channelName, t)}</span>
|
||||||
|
<span>{sceneLabel(item.scene, item.sceneName, t)}</span>
|
||||||
|
{item.knowledgeBaseName ? <span>{item.knowledgeBaseName}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "answerStatus",
|
||||||
|
label: t("knowledge.answerStatus"),
|
||||||
|
className: "w-28",
|
||||||
|
render: (item) => (
|
||||||
|
<Badge variant={getAnswerStatusVariant(item.answerStatus)}>
|
||||||
|
{answerStatusLabel(item.answerStatus, item.answerStatusName, t)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hitCount",
|
||||||
|
label: t("knowledge.hitCount"),
|
||||||
|
className: "w-24 text-right",
|
||||||
|
render: (item) => item.hitCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "topScore",
|
||||||
|
label: "TopScore",
|
||||||
|
className: "w-24 text-right font-mono text-xs",
|
||||||
|
render: (item) => item.topScore.toFixed(4),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "provider",
|
||||||
|
label: "Provider",
|
||||||
|
className: "w-28",
|
||||||
|
render: (item) =>
|
||||||
|
item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rerank",
|
||||||
|
label: "Rerank",
|
||||||
|
className: "w-24",
|
||||||
|
render: (item) =>
|
||||||
|
item.rerankEnabled
|
||||||
|
? `${t("knowledge.yes")} (${item.rerankLimit})`
|
||||||
|
: t("knowledge.no"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "citations",
|
||||||
|
label: t("knowledge.citations"),
|
||||||
|
className: "w-24 text-right",
|
||||||
|
render: (item) => item.citationCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
label: t("knowledge.duration"),
|
||||||
|
className: "w-28 text-right",
|
||||||
|
render: (item) => `${item.latencyMs} ms`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
labels={{
|
||||||
|
query: t("knowledge.filter"),
|
||||||
|
loading: t("knowledge.loadingRetrieveLogs"),
|
||||||
|
empty: t("knowledge.emptyRetrieveLogs"),
|
||||||
|
loadFailed: t("knowledge.loadRetrieveLogsFailed"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RetrieveLogDetailDrawer
|
||||||
|
open={detailOpen && selectedKnowledgeBaseId === knowledgeBaseId}
|
||||||
|
retrieveLogId={selectedLogId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDetailOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setSelectedLogId(null)
|
||||||
|
setSelectedKnowledgeBaseId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SelectionCheckboxProps = ComponentProps<typeof Checkbox> & {
|
||||||
|
wrapperClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SelectionCheckbox({
|
||||||
|
className,
|
||||||
|
wrapperClassName,
|
||||||
|
...props
|
||||||
|
}: SelectionCheckboxProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("inline-flex shrink-0", wrapperClassName)}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox className={className} {...props} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
web/app/(dashboard)/dashboard/knowledge/detail/page.tsx
Normal file
163
web/app/(dashboard)/dashboard/knowledge/detail/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { exportKnowledgeFAQs, fetchKnowledgeBase, type KnowledgeBase } from "@/lib/api/admin"
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
BugIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
LayoutListIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
UploadIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { DebugPanel } from "../_components/debug-panel"
|
||||||
|
import { DocumentList, type DocumentListActionState } from "../_components/document-list"
|
||||||
|
import { FAQList, type FAQListActionState } from "../_components/faq-list"
|
||||||
|
import { RetrieveLogList } from "../_components/retrieve-log-list"
|
||||||
|
|
||||||
|
export default function KnowledgeBaseDetailPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const knowledgeBaseId = Number(searchParams.get("id"))
|
||||||
|
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBase | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [activeTab, setActiveTab] = useState("documents")
|
||||||
|
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
|
||||||
|
const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null)
|
||||||
|
const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null)
|
||||||
|
const [exportingFAQ, setExportingFAQ] = useState(false)
|
||||||
|
|
||||||
|
const loadKnowledgeBase = useCallback(async () => {
|
||||||
|
if (!Number.isInteger(knowledgeBaseId) || knowledgeBaseId <= 0) {
|
||||||
|
setKnowledgeBase(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setKnowledgeBase(await fetchKnowledgeBase(knowledgeBaseId))
|
||||||
|
} catch (error) {
|
||||||
|
setKnowledgeBase(null)
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.loadBaseFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [knowledgeBaseId, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadKnowledgeBase()
|
||||||
|
}, [loadKnowledgeBase])
|
||||||
|
|
||||||
|
async function handleExportFAQ() {
|
||||||
|
if (!knowledgeBase || exportingFAQ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExportingFAQ(true)
|
||||||
|
try {
|
||||||
|
await exportKnowledgeFAQs(knowledgeBase.id)
|
||||||
|
toast.success(t("knowledge.exportFAQSuccess"))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("knowledge.exportFAQFailed"))
|
||||||
|
} finally {
|
||||||
|
setExportingFAQ(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && !knowledgeBase) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
|
||||||
|
<FileTextIcon className="size-10 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t("knowledge.baseNotFound")}</p>
|
||||||
|
<Button variant="outline" onClick={() => router.push("/dashboard/knowledge")}>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
{t("knowledge.backToList")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFAQKnowledgeBase = knowledgeBase?.knowledgeType === "faq"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="min-h-0 flex-1 gap-0">
|
||||||
|
<div className="flex shrink-0 items-center gap-3 border-b bg-background px-4 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
onClick={() => router.push("/dashboard/knowledge")}
|
||||||
|
aria-label={t("knowledge.backToList")}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<h1 className="shrink-0 truncate text-sm font-semibold">{knowledgeBase?.name ?? t("knowledge.title")}</h1>
|
||||||
|
{knowledgeBase?.description ? (
|
||||||
|
<>
|
||||||
|
<span className="h-3 w-px shrink-0 bg-border" aria-hidden="true" />
|
||||||
|
<p className="min-w-0 truncate text-xs text-muted-foreground">{knowledgeBase.description}</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{activeTab === "documents" && !isFAQKnowledgeBase && documentActionState ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={documentActionState.onRefresh} disabled={documentActionState.loading} aria-label={t("knowledge.refreshDocuments")}>
|
||||||
|
<RefreshCwIcon className={documentActionState.loading ? "size-4 animate-spin" : "size-4"} />
|
||||||
|
</Button>
|
||||||
|
<Button variant={documentActionState.viewMode === "list" ? "secondary" : "ghost"} size="icon" className="size-7" onClick={() => documentActionState.onChangeViewMode("list")} aria-label={t("knowledge.listLayout")}><LayoutListIcon className="size-4" /></Button>
|
||||||
|
<Button variant={documentActionState.viewMode === "grid" ? "secondary" : "ghost"} size="icon" className="size-7" onClick={() => documentActionState.onChangeViewMode("grid")} aria-label={t("knowledge.gridLayout")}><LayoutGridIcon className="size-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={() => setDebugPanelOpen(true)} aria-label={t("knowledge.openDebugPanel")}><BugIcon className="size-4" /></Button>
|
||||||
|
<Button size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={documentActionState.onCreate}>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
{t("knowledge.newDocument")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeTab === "documents" && isFAQKnowledgeBase && faqActionState ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={faqActionState.onRefresh} disabled={faqActionState.loading} aria-label={t("knowledge.refreshFAQ")}><RefreshCwIcon className={faqActionState.loading ? "size-4 animate-spin" : "size-4"} /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={faqActionState.onImport} disabled={faqActionState.importing} aria-label={t("knowledge.importFAQ")}><UploadIcon className="size-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={() => void handleExportFAQ()} disabled={exportingFAQ} aria-label={t("knowledge.exportFAQ")}><DownloadIcon className={exportingFAQ ? "size-4 animate-pulse" : "size-4"} /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={() => setDebugPanelOpen(true)} aria-label={t("knowledge.openDebugPanel")}><BugIcon className="size-4" /></Button>
|
||||||
|
<Button size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={faqActionState.onCreate}>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
{t("knowledge.newFAQ")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="documents">
|
||||||
|
{isFAQKnowledgeBase ? t("knowledge.faq") : t("knowledge.document")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="retrieveLogs">
|
||||||
|
{t("knowledge.retrieveLogs")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<TabsContent value="documents" className="min-h-0 flex-1">
|
||||||
|
{isFAQKnowledgeBase ? <FAQList knowledgeBaseId={knowledgeBase?.id ?? null} onActionStateChange={setFAQActionState} /> : <DocumentList knowledgeBaseId={knowledgeBase?.id ?? null} onActionStateChange={setDocumentActionState} />}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="retrieveLogs" className="min-h-0 flex-1">
|
||||||
|
<RetrieveLogList knowledgeBaseId={knowledgeBase?.id ?? null} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<Sheet open={debugPanelOpen} onOpenChange={setDebugPanelOpen}>
|
||||||
|
<SheetContent side="right" className="min-w-170">
|
||||||
|
<SheetHeader><SheetTitle>{t("knowledge.ragDebug")}</SheetTitle></SheetHeader>
|
||||||
|
<DebugPanel knowledgeBaseId={knowledgeBase?.id ?? null} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
170
web/app/(dashboard)/dashboard/knowledge/page.tsx
Normal file
170
web/app/(dashboard)/dashboard/knowledge/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CircleHelpIcon, FileTextIcon, RefreshCwIcon } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { DashboardCrudPage } from "@/components/dashboard/crud"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { EditDialog } from "./_components/knowledge-base-edit"
|
||||||
|
import {
|
||||||
|
createKnowledgeBase,
|
||||||
|
deleteKnowledgeBase,
|
||||||
|
fetchKnowledgeBases,
|
||||||
|
rebuildKnowledgeBaseIndex,
|
||||||
|
updateKnowledgeBase,
|
||||||
|
updateKnowledgeBaseSort,
|
||||||
|
type CreateKnowledgeBasePayload,
|
||||||
|
type KnowledgeBase,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { KnowledgeBaseType, Status } from "@/lib/generated/enums"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
|
||||||
|
export default function DashboardKnowledgePage() {
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "all", label: t("knowledge.allStatus") },
|
||||||
|
{ value: String(Status.Ok), label: t("knowledge.statusOk") },
|
||||||
|
{ value: String(Status.Disabled), label: t("knowledge.statusDisabled") },
|
||||||
|
{ value: String(Status.Deleted), label: t("knowledge.statusDeleted") },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCrudPage<KnowledgeBase, CreateKnowledgeBasePayload>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("knowledge.searchBase"),
|
||||||
|
placeholder: t("knowledge.searchBase"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("knowledge.allStatus"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: statusOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "knowledgeBase",
|
||||||
|
label: t("knowledge.columnBase"),
|
||||||
|
className: "w-[36rem] max-w-[36rem]",
|
||||||
|
render: (item) => {
|
||||||
|
const isFAQ = item.knowledgeType === KnowledgeBaseType.FAQ
|
||||||
|
const Icon = isFAQ ? CircleHelpIcon : FileTextIcon
|
||||||
|
return (
|
||||||
|
<div className="flex w-full min-w-0 items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/knowledge/detail?id=${item.id}`}
|
||||||
|
className="block truncate font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{item.description || t("knowledge.noDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
label: t("knowledge.columnType"),
|
||||||
|
render: (item) => <Badge variant="outline">{item.knowledgeTypeName}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contentCount",
|
||||||
|
label: t("knowledge.columnContent"),
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{item.knowledgeType === KnowledgeBaseType.FAQ
|
||||||
|
? `${item.faqCount} ${t("knowledge.faq")}`
|
||||||
|
: `${item.documentCount} ${t("knowledge.document")}`}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: t("knowledge.columnStatus"),
|
||||||
|
render: (item) => (
|
||||||
|
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||||
|
{item.statusName}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: t("knowledge.columnUpdatedAt"),
|
||||||
|
className: "whitespace-nowrap",
|
||||||
|
render: (item) => (
|
||||||
|
<span className="text-sm text-muted-foreground">{formatDateTime(item.updatedAt)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={fetchKnowledgeBases}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createKnowledgeBase}
|
||||||
|
updateItem={(item, payload) => updateKnowledgeBase({ id: item.id, ...payload })}
|
||||||
|
deleteItem={(item) => deleteKnowledgeBase(item.id)}
|
||||||
|
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||||
|
<EditDialog
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
rowActions={[
|
||||||
|
{
|
||||||
|
key: "rebuild-index",
|
||||||
|
label: (item) => t("knowledge.rebuildIndex"),
|
||||||
|
icon: <RefreshCwIcon />,
|
||||||
|
run: async ({ item }) => {
|
||||||
|
await rebuildKnowledgeBaseIndex(item.id)
|
||||||
|
toast.success(t("knowledge.rebuildStarted", { name: item.name }))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
sort={{
|
||||||
|
enabled: true,
|
||||||
|
onReorder: (items) => updateKnowledgeBaseSort(items.map((item) => item.id)),
|
||||||
|
successMessage: t("knowledge.sortUpdated"),
|
||||||
|
errorMessage: t("knowledge.sortUpdateFailed"),
|
||||||
|
handleLabel: t("knowledge.sortHandle"),
|
||||||
|
}}
|
||||||
|
pageSize={1000}
|
||||||
|
labels={{
|
||||||
|
refresh: t("knowledge.refreshBases"),
|
||||||
|
create: t("knowledge.createBaseTitle"),
|
||||||
|
query: t("knowledge.query"),
|
||||||
|
loading: t("knowledge.loading"),
|
||||||
|
empty: t("knowledge.emptyBases"),
|
||||||
|
actions: t("knowledge.columnActions"),
|
||||||
|
edit: t("knowledge.edit"),
|
||||||
|
delete: t("knowledge.delete"),
|
||||||
|
processing: t("knowledge.processing"),
|
||||||
|
moreActions: (item) => t("knowledge.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("knowledge.loadBasesFailed"),
|
||||||
|
saveFailed: t("knowledge.baseSaveFailed"),
|
||||||
|
deleteFailed: t("knowledge.baseDeleteFailed"),
|
||||||
|
created: (payload) => t("knowledge.baseCreated", { name: payload.name }),
|
||||||
|
updated: (item, payload) => t("knowledge.baseUpdated", { name: payload.name || item.name }),
|
||||||
|
deleted: (item) => t("knowledge.baseDeleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
web/app/(dashboard)/dashboard/layout.tsx
Normal file
75
web/app/(dashboard)/dashboard/layout.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
|
import { useAuth } from "@/components/auth-provider"
|
||||||
|
import { NotificationProvider } from "@/components/notification-provider"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const t = useI18n()
|
||||||
|
const { ready, session } = useAuth()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const isLoginRoute = pathname?.startsWith("/dashboard/login") ?? false
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready && !session && !isLoginRoute) {
|
||||||
|
router.replace("/dashboard/login")
|
||||||
|
}
|
||||||
|
}, [isLoginRoute, ready, router, session])
|
||||||
|
|
||||||
|
if (isLoginRoute) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready || !session) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<Loader2Icon className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-base font-medium">{t("auth.checkingSession")}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("auth.syncingProfile")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider
|
||||||
|
className="h-svh min-h-0 overflow-hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": "calc(var(--spacing) * 54)",
|
||||||
|
"--header-height": "calc(var(--spacing) * 12)",
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NotificationProvider>
|
||||||
|
<AppSidebar variant="inset" />
|
||||||
|
<SidebarInset className="overflow-hidden border-l border-border/70 bg-background">
|
||||||
|
<SiteHeader />
|
||||||
|
<div className="@container/main flex min-h-0 flex-1 flex-col overflow-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</NotificationProvider>
|
||||||
|
</SidebarProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
web/app/(dashboard)/dashboard/login/oidc/callback/page.tsx
Normal file
67
web/app/(dashboard)/dashboard/login/oidc/callback/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense, useEffect, useRef } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { exchangeOIDCTicket } from "@/lib/api/auth"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
export default function OIDCLoginCallbackPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<OIDCLoginCallbackFallback />}>
|
||||||
|
<OIDCLoginCallbackContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OIDCLoginCallbackContent() {
|
||||||
|
const t = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const ranRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ranRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ranRef.current = true
|
||||||
|
|
||||||
|
const ticket = searchParams.get("ticket")?.trim() ?? ""
|
||||||
|
const next = searchParams.get("next")
|
||||||
|
const nextPath = next && next.startsWith("/") ? next : "/dashboard"
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
toast.error(t("auth.oidcMissingTicket"))
|
||||||
|
router.replace("/dashboard/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void exchangeOIDCTicket(ticket)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(t("auth.loginSuccess"))
|
||||||
|
router.replace(nextPath)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("auth.oidcFailed"))
|
||||||
|
router.replace("/dashboard/login")
|
||||||
|
})
|
||||||
|
}, [router, searchParams, t])
|
||||||
|
|
||||||
|
return <OIDCLoginCallbackFallback />
|
||||||
|
}
|
||||||
|
|
||||||
|
function OIDCLoginCallbackFallback() {
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6">
|
||||||
|
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.oidcSigningIn")}</h1>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
{t("auth.checkingTicket")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
web/app/(dashboard)/dashboard/login/page.tsx
Normal file
14
web/app/(dashboard)/dashboard/login/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { LoginForm } from "@/components/login-form"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
|
||||||
|
<div className="w-full max-w-sm md:max-w-4xl">
|
||||||
|
<Suspense fallback={<div className="min-h-96" />}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
382
web/app/(dashboard)/dashboard/mcp/page.tsx
Normal file
382
web/app/(dashboard)/dashboard/mcp/page.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2Icon, PlugZapIcon, WrenchIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
DashboardTableShell,
|
||||||
|
DashboardToolbar,
|
||||||
|
} from "@/components/dashboard-page";
|
||||||
|
import { JsonCodeEditor } from "@/components/json-code-editor";
|
||||||
|
import { JsonViewer } from "@/components/json-viewer";
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
callMCPTool,
|
||||||
|
listMCPServers,
|
||||||
|
listMCPTools,
|
||||||
|
testMCPConnection,
|
||||||
|
type MCPConnectionResult,
|
||||||
|
type MCPServerInfo,
|
||||||
|
type MCPToolCallResult,
|
||||||
|
type MCPToolInfo,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
|
||||||
|
const defaultServerCode = "";
|
||||||
|
|
||||||
|
export default function MCPDashboardPage() {
|
||||||
|
const t = useI18n();
|
||||||
|
const [serverCode, setServerCode] = useState(defaultServerCode);
|
||||||
|
const [servers, setServers] = useState<MCPServerInfo[]>([]);
|
||||||
|
const [connection, setConnection] = useState<MCPConnectionResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [tools, setTools] = useState<MCPToolInfo[]>([]);
|
||||||
|
const [loadingServers, setLoadingServers] = useState(true);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [loadingTools, setLoadingTools] = useState(false);
|
||||||
|
const [callingTool, setCallingTool] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [activeTool, setActiveTool] = useState<MCPToolInfo | null>(null);
|
||||||
|
const [argumentsText, setArgumentsText] = useState("{}");
|
||||||
|
const [toolResult, setToolResult] = useState<MCPToolCallResult | null>(null);
|
||||||
|
const [argumentsError, setArgumentsError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const serverOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
servers.map((server) => ({
|
||||||
|
value: server.code,
|
||||||
|
label: server.enabled
|
||||||
|
? `${server.code} (${server.endpoint})`
|
||||||
|
: `${server.code} (${t("mcp.disabled")})`,
|
||||||
|
})),
|
||||||
|
[servers, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadServers() {
|
||||||
|
setLoadingServers(true);
|
||||||
|
try {
|
||||||
|
const result = await listMCPServers();
|
||||||
|
setServers(result);
|
||||||
|
const firstServer = result[0];
|
||||||
|
if (firstServer) {
|
||||||
|
setServerCode((current) => current || firstServer.code);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("mcp.loadServersFailed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingServers(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadServers();
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
async function handleTestConnection() {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const result = await testMCPConnection(serverCode.trim());
|
||||||
|
setConnection(result);
|
||||||
|
toast.success(t("mcp.connectSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("mcp.connectFailed"));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleListTools() {
|
||||||
|
setLoadingTools(true);
|
||||||
|
try {
|
||||||
|
const result = await listMCPTools(serverCode.trim());
|
||||||
|
setTools(result);
|
||||||
|
toast.success(t("mcp.toolsLoaded", { count: result.length }));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("mcp.loadToolsFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoadingTools(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCallTool() {
|
||||||
|
if (!activeTool) {
|
||||||
|
toast.error(t("mcp.selectToolFirst"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (argumentsError) {
|
||||||
|
toast.error(t("mcp.argumentsInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedArguments: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
parsedArguments = argumentsText.trim()
|
||||||
|
? (JSON.parse(argumentsText) as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
toast.error(t("mcp.argumentsMustBeJson"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallingTool(true);
|
||||||
|
try {
|
||||||
|
const result = await callMCPTool({
|
||||||
|
serverCode: serverCode.trim(),
|
||||||
|
toolName: activeTool.name,
|
||||||
|
arguments: parsedArguments,
|
||||||
|
});
|
||||||
|
setToolResult(result);
|
||||||
|
toast.success(result.isError ? t("mcp.toolReturnedError") : t("mcp.toolCallSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("mcp.toolCallFailed"));
|
||||||
|
} finally {
|
||||||
|
setCallingTool(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openToolDrawer(tool: MCPToolInfo) {
|
||||||
|
setActiveTool(tool);
|
||||||
|
setToolResult(null);
|
||||||
|
if (tool.name === "lorem") {
|
||||||
|
setArgumentsText('{\n "wordCount": 8\n}');
|
||||||
|
} else if (tool.name === "ping") {
|
||||||
|
setArgumentsText('{\n "message": "hello from dashboard"\n}');
|
||||||
|
} else {
|
||||||
|
setArgumentsText("{}");
|
||||||
|
}
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardPage className="gap-6">
|
||||||
|
<DashboardToolbar
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleTestConnection()}
|
||||||
|
disabled={testing || loadingServers || !serverCode}
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlugZapIcon className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
{t("mcp.testConnection")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleListTools()}
|
||||||
|
disabled={loadingTools || loadingServers || !serverCode}
|
||||||
|
>
|
||||||
|
{loadingTools ? (
|
||||||
|
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<WrenchIcon className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
{t("mcp.listTools")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<span className="shrink-0 text-sm font-medium">{t("mcp.serverConfig")}</span>
|
||||||
|
<div className="min-w-[280px] max-w-[520px] flex-1">
|
||||||
|
<OptionCombobox
|
||||||
|
value={serverCode}
|
||||||
|
options={serverOptions}
|
||||||
|
placeholder={t("mcp.selectServer")}
|
||||||
|
searchPlaceholder={t("mcp.searchServer")}
|
||||||
|
emptyText={t("mcp.emptyServer")}
|
||||||
|
disabled={loadingServers}
|
||||||
|
onChange={(value) => {
|
||||||
|
setServerCode(value);
|
||||||
|
setConnection(null);
|
||||||
|
setTools([]);
|
||||||
|
setToolResult(null);
|
||||||
|
setActiveTool(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{connection ? (
|
||||||
|
<div className="w-full rounded-lg border bg-muted/30 p-4 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge>{t("mcp.connected")}</Badge>
|
||||||
|
<span className="font-medium">
|
||||||
|
{connection.serverName || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-2 text-muted-foreground grid-cols-1 md:grid-cols-5">
|
||||||
|
<div>serverCode: {connection.serverCode}</div>
|
||||||
|
<div>protocol: {connection.protocol || "-"}</div>
|
||||||
|
<div className="break-all md:col-span-2">
|
||||||
|
endpoint: {connection.endpoint}
|
||||||
|
</div>
|
||||||
|
<div>version: {connection.version || "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DashboardToolbar>
|
||||||
|
|
||||||
|
{tools.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
|
{t("mcp.emptyToolsHint")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DashboardTableShell>
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||||
|
<div>{t("mcp.toolName")}</div>
|
||||||
|
<div>{t("mcp.description")}</div>
|
||||||
|
<div className="text-right">{t("mcp.actions")}</div>
|
||||||
|
</div>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<div
|
||||||
|
key={tool.name}
|
||||||
|
className="grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_88px] gap-4 border-b px-4 py-4 text-sm last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{tool.name}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{tool.description || "-"}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openToolDrawer(tool)}
|
||||||
|
>
|
||||||
|
{t("mcp.view")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DashboardTableShell>
|
||||||
|
)}
|
||||||
|
</DashboardPage>
|
||||||
|
|
||||||
|
<Drawer open={drawerOpen} direction="right" onOpenChange={setDrawerOpen}>
|
||||||
|
<DrawerContent className="min-w-3xl">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{activeTool?.name || t("mcp.toolDetail")}</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{t("mcp.detailDescription")}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4">
|
||||||
|
{activeTool ? (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">{activeTool.name}</Badge>
|
||||||
|
{activeTool.title ? (
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{activeTool.title}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
{activeTool.description || t("mcp.noDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Input Schema
|
||||||
|
</p>
|
||||||
|
<JsonViewer value={activeTool.inputSchema} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Output Schema
|
||||||
|
</p>
|
||||||
|
<JsonViewer value={activeTool.outputSchema} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="tool-arguments">Arguments JSON</Label>
|
||||||
|
<JsonCodeEditor
|
||||||
|
value={argumentsText}
|
||||||
|
onChange={setArgumentsText}
|
||||||
|
onValidationChange={setArgumentsError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleCallTool()}
|
||||||
|
disabled={
|
||||||
|
callingTool ||
|
||||||
|
loadingServers ||
|
||||||
|
!serverCode ||
|
||||||
|
!!argumentsError
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{callingTool ? (
|
||||||
|
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<WrenchIcon className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
{t("mcp.testTool")}
|
||||||
|
</Button>
|
||||||
|
{toolResult ? (
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
toolResult.isError ? "destructive" : "default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toolResult.isError ? t("mcp.returnedError") : t("mcp.callSuccess")}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{toolResult.toolName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Content
|
||||||
|
</p>
|
||||||
|
<JsonViewer value={toolResult.content} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Structured Content
|
||||||
|
</p>
|
||||||
|
<JsonViewer value={toolResult.structuredContent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDrawerOpen(false)}>
|
||||||
|
{t("mcp.close")}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
web/app/(dashboard)/dashboard/notifications/page.tsx
Normal file
128
web/app/(dashboard)/dashboard/notifications/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { BellIcon, CheckCheckIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { DashboardListPage } from "@/components/dashboard/list"
|
||||||
|
import { useNotifications } from "@/components/notification-provider"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
|
type NotificationItem,
|
||||||
|
type NotificationReadStatus,
|
||||||
|
} from "@/lib/api/notification"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
export default function DashboardNotificationsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const { refreshUnreadCount } = useNotifications()
|
||||||
|
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
|
||||||
|
{ value: "all", label: t("notification.all") },
|
||||||
|
{ value: "unread", label: t("notification.unread") },
|
||||||
|
{ value: "read", label: t("notification.read") },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function openNotification(item: NotificationItem) {
|
||||||
|
try {
|
||||||
|
if (!item.readAt) {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
await refreshUnreadCount()
|
||||||
|
}
|
||||||
|
if (item.actionUrl) {
|
||||||
|
router.push(item.actionUrl)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("notification.openFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead(reload: () => Promise<void>) {
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead()
|
||||||
|
await refreshUnreadCount()
|
||||||
|
await reload()
|
||||||
|
toast.success(t("notification.markAllReadSuccess"))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("notification.markAllReadFailed")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardListPage<NotificationItem>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "readStatus",
|
||||||
|
label: t("notification.all"),
|
||||||
|
type: "segment",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: readStatusOptions,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={fetchNotifications}
|
||||||
|
renderToolbarActions={({ result, reload }) => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void markAllRead(reload)}
|
||||||
|
disabled={result.page.total === 0}
|
||||||
|
>
|
||||||
|
<CheckCheckIcon />
|
||||||
|
{t("notification.markAllRead")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
renderContent={({ result, loading }) =>
|
||||||
|
result.results.length > 0 ? (
|
||||||
|
<div className="divide-y">
|
||||||
|
{result.results.map((item) => {
|
||||||
|
const unread = !item.readAt
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openNotification(item)}
|
||||||
|
className="grid w-full gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<BellIcon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.title || t("notification.fallbackTitle")}
|
||||||
|
</span>
|
||||||
|
{unread ? (
|
||||||
|
<Badge>{t("notification.unread")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{t("notification.read")}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-line text-sm text-muted-foreground">
|
||||||
|
{item.content || "-"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{loading ? t("notification.loading") : t("notification.empty")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
labels={{
|
||||||
|
refresh: t("notification.refresh"),
|
||||||
|
loading: t("notification.loading"),
|
||||||
|
empty: t("notification.empty"),
|
||||||
|
loadFailed: t("notification.loadFailed"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
web/app/(dashboard)/dashboard/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardHome } from "./_components/dashboard-home"
|
||||||
|
|
||||||
|
export default function DashboardHomePage() {
|
||||||
|
return <DashboardHome />
|
||||||
|
}
|
||||||
169
web/app/(dashboard)/dashboard/permissions/page.tsx
Normal file
169
web/app/(dashboard)/dashboard/permissions/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { KeyRoundIcon, RouteIcon, SearchIcon, ShieldCheckIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { useConfirm } from "@/components/confirm-provider"
|
||||||
|
import { DashboardListPage } from "@/components/dashboard/list"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { fetchPermissions, syncPermissions, type AdminPermission } from "@/lib/api/admin"
|
||||||
|
import { Status } from "@/lib/generated/enums"
|
||||||
|
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||||
|
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n"
|
||||||
|
|
||||||
|
export default function DashboardPermissionsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const { locale } = useAppLocale()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
const listStatusOptions = [
|
||||||
|
{ value: "all", label: t("status.all") },
|
||||||
|
{ value: String(Status.Ok), label: t("status.ok") },
|
||||||
|
{ value: String(Status.Disabled), label: t("status.disabled") },
|
||||||
|
{ value: String(Status.Deleted), label: t("status.deleted") },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function handleSync(reload: () => Promise<void>) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: t("permission.syncTitle"),
|
||||||
|
description: t("permission.syncDescription"),
|
||||||
|
confirmText: t("permission.sync"),
|
||||||
|
})
|
||||||
|
if (!confirmed || syncing) return
|
||||||
|
|
||||||
|
setSyncing(true)
|
||||||
|
try {
|
||||||
|
const result = await syncPermissions()
|
||||||
|
toast.success(t("permission.syncSuccess", {
|
||||||
|
created: result.created,
|
||||||
|
updated: result.updated,
|
||||||
|
rolePermissionsAdded: result.rolePermissionsAdded,
|
||||||
|
}))
|
||||||
|
await reload()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("permission.syncFailed"))
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardListPage<AdminPermission>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "keyword",
|
||||||
|
label: t("permission.filterKeyword"),
|
||||||
|
placeholder: t("permission.filterKeyword"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
inputClassName: "pl-9",
|
||||||
|
icon: <SearchIcon className="size-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groupName",
|
||||||
|
label: t("permission.filterGroup"),
|
||||||
|
placeholder: t("permission.filterGroup"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-44",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("status.all"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: listStatusOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={fetchPermissions}
|
||||||
|
renderToolbarActions={({ reload }) => (
|
||||||
|
<Button onClick={() => void handleSync(reload)} disabled={syncing}>
|
||||||
|
<ShieldCheckIcon className={syncing ? "animate-pulse" : undefined} />
|
||||||
|
{syncing ? t("permission.syncing") : t("permission.sync")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "permission",
|
||||||
|
label: t("permission.columnPermission"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<KeyRoundIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{getPermissionDisplayName(item.code, item.name, locale)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{item.type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "code",
|
||||||
|
label: t("permission.columnCode"),
|
||||||
|
render: (item) => <Badge variant="outline">{item.code}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "group",
|
||||||
|
label: t("permission.columnGroup"),
|
||||||
|
render: (item) => getPermissionGroupName(item.groupName, locale),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "api",
|
||||||
|
label: t("permission.columnApi"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Badge variant="secondary">{item.method || "ANY"}</Badge>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<RouteIcon className="size-3.5" />
|
||||||
|
{item.apiPath || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: t("permission.columnStatus"),
|
||||||
|
render: (item) => (
|
||||||
|
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||||
|
{getStatusLabel(item.status, t)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
labels={{
|
||||||
|
refresh: t("permission.refresh"),
|
||||||
|
query: t("permission.query"),
|
||||||
|
loading: t("permission.loading"),
|
||||||
|
empty: t("permission.empty"),
|
||||||
|
loadFailed: t("permission.loadFailed"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(
|
||||||
|
status: number,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
) {
|
||||||
|
if (status === Status.Ok) {
|
||||||
|
return t("status.ok")
|
||||||
|
}
|
||||||
|
if (status === Status.Disabled) {
|
||||||
|
return t("status.disabled")
|
||||||
|
}
|
||||||
|
if (status === Status.Deleted) {
|
||||||
|
return t("status.deleted")
|
||||||
|
}
|
||||||
|
return String(status)
|
||||||
|
}
|
||||||
244
web/app/(dashboard)/dashboard/quick-replies/page.tsx
Normal file
244
web/app/(dashboard)/dashboard/quick-replies/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { FileTextIcon, RefreshCwIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardStatusColumn,
|
||||||
|
createDashboardStatusToggleAction,
|
||||||
|
DashboardCrudPage,
|
||||||
|
} from "@/components/dashboard/crud"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
createQuickReply,
|
||||||
|
deleteQuickReply,
|
||||||
|
fetchQuickReply,
|
||||||
|
fetchQuickReplies,
|
||||||
|
updateQuickReply,
|
||||||
|
type AdminQuickReply,
|
||||||
|
type CreateAdminQuickReplyPayload,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { getEnumOptions } from "@/lib/enums"
|
||||||
|
import { Status, StatusLabels } from "@/lib/generated/enums"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
function getStatusLabel(status: Status, t: (key: string) => string) {
|
||||||
|
if (status === Status.Disabled) {
|
||||||
|
return t("status.disabled")
|
||||||
|
}
|
||||||
|
if (status === Status.Deleted) {
|
||||||
|
return t("status.deleted")
|
||||||
|
}
|
||||||
|
return t("status.ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardQuickRepliesPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const listStatusOptions = [
|
||||||
|
{ value: "all", label: t("status.all") },
|
||||||
|
...getEnumOptions(StatusLabels)
|
||||||
|
.filter((item) => Number(item.value) !== Status.Deleted)
|
||||||
|
.map((item) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: getStatusLabel(item.value as Status, t),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCrudPage<AdminQuickReply, CreateAdminQuickReplyPayload>
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
label: t("quickReply.filterTitle"),
|
||||||
|
placeholder: t("quickReply.filterTitle"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groupName",
|
||||||
|
label: t("quickReply.filterGroup"),
|
||||||
|
placeholder: t("quickReply.filterGroup"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-44",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("status.all"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
options: listStatusOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "quickReply",
|
||||||
|
label: t("quickReply.columnQuickReply"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<FileTextIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">{item.title}</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "groupName",
|
||||||
|
label: t("quickReply.columnGroup"),
|
||||||
|
render: (item) => <Badge variant="outline">{item.groupName}</Badge>,
|
||||||
|
},
|
||||||
|
createDashboardStatusColumn<AdminQuickReply, Status>({
|
||||||
|
label: t("quickReply.columnStatus"),
|
||||||
|
getStatus: (item) => item.status as Status,
|
||||||
|
getLabel: (status) => getStatusLabel(status, t),
|
||||||
|
getBadgeVariant: (status) => (status === Status.Ok ? "default" : "outline"),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
key: "sortNo",
|
||||||
|
label: t("quickReply.columnSort"),
|
||||||
|
render: (item) => item.sortNo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdBy",
|
||||||
|
label: t("quickReply.columnCreator"),
|
||||||
|
render: (item) => item.createdBy || "-",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fetchList={fetchQuickReplies}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createQuickReply}
|
||||||
|
updateItem={(item, payload) => updateQuickReply({ id: item.id, ...payload })}
|
||||||
|
deleteItem={(item) => deleteQuickReply(item.id)}
|
||||||
|
form={{
|
||||||
|
fetchDetail: fetchQuickReply,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "groupName",
|
||||||
|
label: t("quickReply.groupName"),
|
||||||
|
placeholder: t("quickReply.groupNamePlaceholder"),
|
||||||
|
required: true,
|
||||||
|
requiredMessage: t("quickReply.groupNameRequired"),
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
label: t("quickReply.title"),
|
||||||
|
placeholder: t("quickReply.titlePlaceholder"),
|
||||||
|
required: true,
|
||||||
|
requiredMessage: t("quickReply.titleRequired"),
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
label: t("quickReply.content"),
|
||||||
|
placeholder: t("quickReply.contentPlaceholder"),
|
||||||
|
type: "textarea",
|
||||||
|
rows: 6,
|
||||||
|
required: true,
|
||||||
|
requiredMessage: t("quickReply.contentRequired"),
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("quickReply.columnStatus"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: String(Status.Ok),
|
||||||
|
valueType: "number",
|
||||||
|
required: true,
|
||||||
|
requiredMessage: t("quickReply.statusRequired"),
|
||||||
|
options: listStatusOptions.filter((item) => item.value !== "all"),
|
||||||
|
valueFromItem: (item) => String(item.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sortNo",
|
||||||
|
label: t("quickReply.columnSort"),
|
||||||
|
placeholder: t("quickReply.sortPlaceholder"),
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "0",
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
required: true,
|
||||||
|
requiredMessage: t("quickReply.sortRequired"),
|
||||||
|
pattern: /^\d+$/,
|
||||||
|
patternMessage: t("quickReply.sortInvalid"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transformSubmitValues: (values) => ({
|
||||||
|
groupName: String(values.groupName ?? ""),
|
||||||
|
title: String(values.title ?? ""),
|
||||||
|
content: String(values.content ?? ""),
|
||||||
|
status: Number(values.status),
|
||||||
|
sortNo: Number(values.sortNo),
|
||||||
|
}),
|
||||||
|
labels: {
|
||||||
|
createTitle: t("quickReply.createTitle"),
|
||||||
|
editTitle: t("quickReply.editTitle"),
|
||||||
|
create: t("quickReply.create"),
|
||||||
|
save: t("quickReply.save"),
|
||||||
|
saving: t("quickReply.saving"),
|
||||||
|
cancel: t("quickReply.cancel"),
|
||||||
|
loadingDetail: t("quickReply.loadingDetail"),
|
||||||
|
required: t("quickReply.titleRequired"),
|
||||||
|
invalidNumber: t("quickReply.sortInvalid"),
|
||||||
|
minValue: () => t("quickReply.sortInvalid"),
|
||||||
|
maxValue: () => t("quickReply.sortInvalid"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
rowActions={[
|
||||||
|
createDashboardStatusToggleAction<AdminQuickReply, Status>({
|
||||||
|
icon: <RefreshCwIcon />,
|
||||||
|
label: (item) =>
|
||||||
|
item.status === Status.Ok
|
||||||
|
? t("quickReply.disable")
|
||||||
|
: t("quickReply.enable"),
|
||||||
|
getNextStatus: (item) =>
|
||||||
|
item.status === Status.Ok ? Status.Disabled : Status.Ok,
|
||||||
|
updateStatus: (item, nextStatus) =>
|
||||||
|
updateQuickReply({
|
||||||
|
id: item.id,
|
||||||
|
groupName: item.groupName,
|
||||||
|
title: item.title,
|
||||||
|
content: item.content,
|
||||||
|
sortNo: item.sortNo,
|
||||||
|
status: nextStatus,
|
||||||
|
}),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t(
|
||||||
|
nextStatus === Status.Ok
|
||||||
|
? "quickReply.enabled"
|
||||||
|
: "quickReply.disabled",
|
||||||
|
{ title: item.title }
|
||||||
|
),
|
||||||
|
errorMessage: t("quickReply.statusUpdateFailed"),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
labels={{
|
||||||
|
refresh: t("quickReply.refresh"),
|
||||||
|
create: t("quickReply.new"),
|
||||||
|
query: t("quickReply.query"),
|
||||||
|
loading: t("quickReply.loading"),
|
||||||
|
empty: t("quickReply.empty"),
|
||||||
|
actions: t("quickReply.columnActions"),
|
||||||
|
edit: t("quickReply.edit"),
|
||||||
|
delete: t("quickReply.delete"),
|
||||||
|
processing: t("quickReply.processing"),
|
||||||
|
moreActions: (item) =>
|
||||||
|
t("quickReply.moreActions", { title: item.title }),
|
||||||
|
loadFailed: t("quickReply.loadFailed"),
|
||||||
|
saveFailed: t("quickReply.saveFailed"),
|
||||||
|
deleteFailed: t("quickReply.deleteFailed"),
|
||||||
|
created: (payload) => t("quickReply.created", { title: payload.title }),
|
||||||
|
updated: (item) => t("quickReply.updated", { title: item.title }),
|
||||||
|
deleted: (item) => t("quickReply.deleted", { title: item.title }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, Resolver, useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { AdminPermission, AdminRole } from "@/lib/api/admin";
|
||||||
|
import { useAppLocale, useI18n } from "@/i18n/provider";
|
||||||
|
import { getPermissionDisplayName, getPermissionGroupName } from "@/lib/permission-i18n";
|
||||||
|
import { getRoleDisplayName } from "@/lib/role-i18n";
|
||||||
|
|
||||||
|
type AssignPermissionsDrawerProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
item: AdminRole | null;
|
||||||
|
permissions: AdminPermission[];
|
||||||
|
selectedPermissionIds: number[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (permissionIds: number[]) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignPermissionsSchema = z.object({
|
||||||
|
permissionIds: z.array(z.number().int().positive()),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AssignPermissionsForm = z.infer<typeof assignPermissionsSchema>;
|
||||||
|
|
||||||
|
const assignPermissionsResolver = zodResolver(
|
||||||
|
assignPermissionsSchema as never,
|
||||||
|
) as Resolver<
|
||||||
|
z.input<typeof assignPermissionsSchema>,
|
||||||
|
undefined,
|
||||||
|
z.output<typeof assignPermissionsSchema>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function buildForm(selectedPermissionIds: number[]): AssignPermissionsForm {
|
||||||
|
return {
|
||||||
|
permissionIds: selectedPermissionIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignPermissionsDrawer({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
loading,
|
||||||
|
item,
|
||||||
|
permissions,
|
||||||
|
selectedPermissionIds,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AssignPermissionsDrawerProps) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
|
||||||
|
{open ? (
|
||||||
|
<AssignPermissionsDrawerBody
|
||||||
|
key={item ? `assign-permissions-${item.id}` : "assign-permissions"}
|
||||||
|
saving={saving}
|
||||||
|
loading={loading}
|
||||||
|
item={item}
|
||||||
|
permissions={permissions}
|
||||||
|
selectedPermissionIds={selectedPermissionIds}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignPermissionsDrawerBodyProps = {
|
||||||
|
saving: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
item: AdminRole | null;
|
||||||
|
permissions: AdminPermission[];
|
||||||
|
selectedPermissionIds: number[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (permissionIds: number[]) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AssignPermissionsDrawerBody({
|
||||||
|
saving,
|
||||||
|
loading,
|
||||||
|
item,
|
||||||
|
permissions,
|
||||||
|
selectedPermissionIds,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AssignPermissionsDrawerBodyProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const { locale } = useAppLocale();
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const form = useForm<
|
||||||
|
z.input<typeof assignPermissionsSchema>,
|
||||||
|
undefined,
|
||||||
|
z.output<typeof assignPermissionsSchema>
|
||||||
|
>({
|
||||||
|
resolver: assignPermissionsResolver,
|
||||||
|
defaultValues: buildForm(selectedPermissionIds),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset(buildForm(selectedPermissionIds));
|
||||||
|
}, [reset, selectedPermissionIds]);
|
||||||
|
|
||||||
|
const groupedPermissions = useMemo(() => {
|
||||||
|
const output = keyword.trim().toLowerCase();
|
||||||
|
const filtered = output
|
||||||
|
? permissions.filter((permission) =>
|
||||||
|
`${permission.name} ${getPermissionDisplayName(permission.code, permission.name, locale)} ${permission.code} ${permission.groupName} ${getPermissionGroupName(permission.groupName, locale)} ${permission.apiPath}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(output),
|
||||||
|
)
|
||||||
|
: permissions;
|
||||||
|
|
||||||
|
const groups = new Map<string, AdminPermission[]>();
|
||||||
|
filtered.forEach((permission) => {
|
||||||
|
const groupName = permission.groupName || "default";
|
||||||
|
const list = groups.get(groupName) ?? [];
|
||||||
|
list.push(permission);
|
||||||
|
groups.set(groupName, list);
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).sort(([left], [right]) =>
|
||||||
|
getPermissionGroupName(left, locale).localeCompare(getPermissionGroupName(right, locale)),
|
||||||
|
);
|
||||||
|
}, [keyword, locale, permissions]);
|
||||||
|
|
||||||
|
async function onFormSubmit(values: AssignPermissionsForm) {
|
||||||
|
await onSubmit(values.permissionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContent className="min-w-3xl">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{t("role.assignTitle")}</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{t("role.currentRole", {
|
||||||
|
name: item ? getRoleDisplayName(item.code, item.name, locale) : "-",
|
||||||
|
code: item?.code ? `(${item.code})` : "",
|
||||||
|
})}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<form
|
||||||
|
className="flex h-full min-h-0 flex-col"
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 space-y-4 px-4 pb-4">
|
||||||
|
<Field data-invalid={!!errors.permissionIds} className="flex-1 flex flex-col min-h-0">
|
||||||
|
<FieldLabel>{t("role.permissionList")}</FieldLabel>
|
||||||
|
<FieldContent className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="relative mb-2">
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder={t("role.searchPermission")}
|
||||||
|
className="pl-9"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="permissionIds"
|
||||||
|
render={({ field }) => {
|
||||||
|
const value = field.value || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 space-y-4 overflow-y-auto rounded-md border p-3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t("role.loadingPermissions")}
|
||||||
|
</div>
|
||||||
|
) : groupedPermissions.length > 0 ? (
|
||||||
|
groupedPermissions.map(([groupName, list]) => (
|
||||||
|
<section key={groupName} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{getPermissionGroupName(groupName, locale)}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("role.permissionCount", { count: list.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{list.map((permission) => {
|
||||||
|
const checked = value.includes(permission.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={permission.id}
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-lg border border-transparent px-3 py-2 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(nextChecked) => {
|
||||||
|
if (nextChecked) {
|
||||||
|
field.onChange([
|
||||||
|
...value,
|
||||||
|
permission.id,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.onChange(
|
||||||
|
value.filter(
|
||||||
|
(currentId) =>
|
||||||
|
currentId !== permission.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium">
|
||||||
|
{getPermissionDisplayName(permission.code, permission.name, locale)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{permission.code}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{permission.method || "ANY"}{" "}
|
||||||
|
{permission.apiPath || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
permission.status === 0
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{permission.status === 1
|
||||||
|
? t("status.disabled")
|
||||||
|
: t("status.ok")}
|
||||||
|
</Badge>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t("role.noPermissions")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.permissionIds]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DrawerFooter className="border-t">
|
||||||
|
<Button type="submit" disabled={saving || loading || !item}>
|
||||||
|
{saving ? t("role.saving") : t("role.savePermissions")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("role.cancel")}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</form>
|
||||||
|
</DrawerContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
web/app/(dashboard)/dashboard/roles/_components/create.tsx
Normal file
192
web/app/(dashboard)/dashboard/roles/_components/create.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { Resolver, useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
|
import { type CreateAdminRolePayload } from "@/lib/api/admin"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type CreateRoleDrawerProps = {
|
||||||
|
open: boolean
|
||||||
|
saving: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (payload: CreateAdminRolePayload) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateForm = {
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: CreateForm = {
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
remark: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyForm(): CreateForm {
|
||||||
|
return {
|
||||||
|
...emptyForm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(form: CreateForm): CreateAdminRolePayload {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
code: form.code.trim(),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateRoleDrawer({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: CreateRoleDrawerProps) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
|
||||||
|
{open ? (
|
||||||
|
<CreateRoleDrawerBody
|
||||||
|
key="create-role"
|
||||||
|
saving={saving}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRoleDrawerBodyProps = {
|
||||||
|
saving: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (payload: CreateAdminRolePayload) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateRoleDrawerBody({
|
||||||
|
saving,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: CreateRoleDrawerBodyProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const createFormSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1, t("role.nameRequired")),
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, t("role.codeRequired"))
|
||||||
|
.regex(/^[A-Za-z][A-Za-z0-9:_-]*$/, t("role.codeInvalid")),
|
||||||
|
remark: z.string().trim(),
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
const createFormResolver = useMemo(
|
||||||
|
() => zodResolver(createFormSchema as never) as Resolver<CreateForm>,
|
||||||
|
[createFormSchema]
|
||||||
|
)
|
||||||
|
const form = useForm<CreateForm>({
|
||||||
|
resolver: createFormResolver,
|
||||||
|
defaultValues: buildEmptyForm(),
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
async function onFormSubmit(values: CreateForm) {
|
||||||
|
await onSubmit(buildPayload(values))
|
||||||
|
reset(buildEmptyForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContent className="min-w-2xl">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{t("role.createTitle")}</DrawerTitle>
|
||||||
|
<DrawerDescription>{t("role.createDescription")}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<form
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 overflow-y-auto px-4 pb-4">
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="create-role-name">{t("role.name")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="create-role-name"
|
||||||
|
placeholder={t("role.namePlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.name]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.code}>
|
||||||
|
<FieldLabel htmlFor="create-role-code">{t("role.code")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="create-role-code"
|
||||||
|
placeholder={t("role.codePlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-invalid={!!errors.code}
|
||||||
|
{...register("code")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.code]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.remark}>
|
||||||
|
<FieldLabel htmlFor="create-role-remark">{t("role.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="create-role-remark"
|
||||||
|
placeholder={t("role.optional")}
|
||||||
|
aria-invalid={!!errors.remark}
|
||||||
|
{...register("remark")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.remark]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DrawerFooter className="border-t">
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? t("role.creating") : t("role.create")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("role.cancel")}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</form>
|
||||||
|
</DrawerContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
427
web/app/(dashboard)/dashboard/roles/page.tsx
Normal file
427
web/app/(dashboard)/dashboard/roles/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import type { CSSProperties } from "react"
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import {
|
||||||
|
GripVerticalIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
assignRolePermissions,
|
||||||
|
createRole,
|
||||||
|
fetchPermissions,
|
||||||
|
fetchRoleDetail,
|
||||||
|
fetchRoles,
|
||||||
|
type AdminPermission,
|
||||||
|
type AdminRole,
|
||||||
|
type CreateAdminRolePayload,
|
||||||
|
type PageResult,
|
||||||
|
updateRoleSort,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import {
|
||||||
|
DashboardPage,
|
||||||
|
DashboardTableShell,
|
||||||
|
DashboardTableStateRow,
|
||||||
|
DashboardTableSummary,
|
||||||
|
DashboardToolbar,
|
||||||
|
} from "@/components/dashboard-page"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { AssignPermissionsDrawer } from "./_components/assign-permissions"
|
||||||
|
import { CreateRoleDrawer } from "./_components/create"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Status } from "@/lib/generated/enums"
|
||||||
|
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||||
|
import { getRoleDisplayName } from "@/lib/role-i18n"
|
||||||
|
|
||||||
|
type SortableRoleRowProps = {
|
||||||
|
item: AdminRole
|
||||||
|
disabled: boolean
|
||||||
|
actionLoading: boolean
|
||||||
|
onAssignPermissions: (item: AdminRole) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRoleRow({
|
||||||
|
item,
|
||||||
|
disabled,
|
||||||
|
actionLoading,
|
||||||
|
onAssignPermissions,
|
||||||
|
}: SortableRoleRowProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const { locale } = useAppLocale()
|
||||||
|
const displayName = getRoleDisplayName(item.code, item.name, locale)
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: item.id,
|
||||||
|
disabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
isDragging && "relative z-10 bg-muted/60 shadow-sm",
|
||||||
|
!disabled && "cursor-move"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TableCell className="w-14">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 cursor-grab active:cursor-grabbing"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t("role.dragSort", { name: displayName })}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<ShieldCheckIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">{displayName}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
<ShieldIcon className="size-3" />
|
||||||
|
{item.code}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||||
|
{item.status === Status.Ok ? t("status.ok") : t("status.disabled")}
|
||||||
|
</Badge>
|
||||||
|
{item.isSystem ? (
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{t("role.system")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.sortNo}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAssignPermissions(item)}
|
||||||
|
disabled={disabled || actionLoading}
|
||||||
|
>
|
||||||
|
{actionLoading ? t("role.processing") : t("role.assignPermissions")}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardRolesPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [sorting, setSorting] = useState(false)
|
||||||
|
const [creatingOpen, setCreatingOpen] = useState(false)
|
||||||
|
const [savingCreate, setSavingCreate] = useState(false)
|
||||||
|
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||||
|
const [assignPermissionsLoading, setAssignPermissionsLoading] = useState(false)
|
||||||
|
const [assigningRole, setAssigningRole] = useState<AdminRole | null>(null)
|
||||||
|
const [assignPermissionOptions, setAssignPermissionOptions] = useState<
|
||||||
|
AdminPermission[]
|
||||||
|
>([])
|
||||||
|
const [assignPermissionIds, setAssignPermissionIds] = useState<number[]>([])
|
||||||
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||||
|
const [result, setResult] = useState<PageResult<AdminRole>>({
|
||||||
|
results: [],
|
||||||
|
page: { page: 1, limit: 20, total: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: { distance: 8 },
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: { delay: 150, tolerance: 8 },
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setResult(await fetchRoles({ limit: 200 }))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("role.loadFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateDrawerOpenChange(open: boolean) {
|
||||||
|
if (savingCreate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreatingOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateRole(payload: CreateAdminRolePayload) {
|
||||||
|
if (savingCreate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingCreate(true)
|
||||||
|
try {
|
||||||
|
const role = await createRole(payload)
|
||||||
|
toast.success(t("role.created", { name: role.name }))
|
||||||
|
setCreatingOpen(false)
|
||||||
|
await loadRoles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("role.createFailed"))
|
||||||
|
} finally {
|
||||||
|
setSavingCreate(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAssignPermissionsDrawer(role: AdminRole) {
|
||||||
|
setActionLoadingId(role.id)
|
||||||
|
setAssigningRole(role)
|
||||||
|
setAssignPermissionsLoading(true)
|
||||||
|
try {
|
||||||
|
const [permissionsResult, roleDetail] = await Promise.all([
|
||||||
|
fetchPermissions({ limit: 500 }),
|
||||||
|
fetchRoleDetail(role.id),
|
||||||
|
])
|
||||||
|
const permissionCodeSet = new Set(roleDetail.permissions || [])
|
||||||
|
setAssignPermissionOptions(permissionsResult.results)
|
||||||
|
setAssignPermissionIds(
|
||||||
|
permissionsResult.results
|
||||||
|
.filter((permission) => permissionCodeSet.has(permission.code))
|
||||||
|
.map((permission) => permission.id)
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
setAssigningRole(null)
|
||||||
|
toast.error(error instanceof Error ? error.message : t("role.loadAssignFailed"))
|
||||||
|
} finally {
|
||||||
|
setAssignPermissionsLoading(false)
|
||||||
|
setActionLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id || sorting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousResults = result.results
|
||||||
|
const oldIndex = previousResults.findIndex((item) => item.id === active.id)
|
||||||
|
const newIndex = previousResults.findIndex((item) => item.id === over.id)
|
||||||
|
if (oldIndex < 0 || newIndex < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextResults = arrayMove(previousResults, oldIndex, newIndex)
|
||||||
|
setResult((current) => ({
|
||||||
|
...current,
|
||||||
|
results: nextResults,
|
||||||
|
}))
|
||||||
|
setSorting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateRoleSort(nextResults.map((item) => item.id))
|
||||||
|
toast.success(t("role.sortUpdated"))
|
||||||
|
await loadRoles()
|
||||||
|
} catch (error) {
|
||||||
|
setResult((current) => ({
|
||||||
|
...current,
|
||||||
|
results: previousResults,
|
||||||
|
}))
|
||||||
|
toast.error(error instanceof Error ? error.message : t("role.sortUpdateFailed"))
|
||||||
|
} finally {
|
||||||
|
setSorting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssignPermissionsOpenChange(open: boolean) {
|
||||||
|
if (savingPermissions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
setAssigningRole(null)
|
||||||
|
setAssignPermissionOptions([])
|
||||||
|
setAssignPermissionIds([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssignPermissions(permissionIds: number[]) {
|
||||||
|
if (!assigningRole || savingPermissions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingPermissions(true)
|
||||||
|
try {
|
||||||
|
await assignRolePermissions(assigningRole.id, permissionIds)
|
||||||
|
toast.success(t("role.permissionsUpdated", { name: assigningRole.name }))
|
||||||
|
setAssigningRole(null)
|
||||||
|
setAssignPermissionOptions([])
|
||||||
|
setAssignPermissionIds([])
|
||||||
|
await loadRoles()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("role.savePermissionsFailed"))
|
||||||
|
} finally {
|
||||||
|
setSavingPermissions(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRoles()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPage>
|
||||||
|
<DashboardToolbar
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreatingOpen(true)}
|
||||||
|
disabled={loading || sorting}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
{t("role.add")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void loadRoles()}
|
||||||
|
disabled={loading || sorting}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn((loading || sorting) && "animate-spin")} />
|
||||||
|
{t("role.refresh")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("role.dragHint")}
|
||||||
|
</div>
|
||||||
|
</DashboardToolbar>
|
||||||
|
<DashboardTableShell
|
||||||
|
pagination={
|
||||||
|
<DashboardTableSummary>
|
||||||
|
<span>
|
||||||
|
{t("role.pageSummary", { page: result.page.page, limit: result.page.limit })}
|
||||||
|
</span>
|
||||||
|
<span>{t("pagination.total", { total: result.page.total })}</span>
|
||||||
|
</DashboardTableSummary>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(event) => void handleDragEnd(event)}
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-14"></TableHead>
|
||||||
|
<TableHead>{t("role.columnRole")}</TableHead>
|
||||||
|
<TableHead>{t("role.columnCode")}</TableHead>
|
||||||
|
<TableHead>{t("role.columnStatus")}</TableHead>
|
||||||
|
<TableHead>{t("role.columnSort")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("role.columnActions")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<SortableContext
|
||||||
|
items={result.results.map((item) => item.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{result.results.map((item) => (
|
||||||
|
<SortableRoleRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
disabled={loading || sorting}
|
||||||
|
actionLoading={actionLoadingId === item.id}
|
||||||
|
onAssignPermissions={(current) =>
|
||||||
|
void openAssignPermissionsDrawer(current)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
{loading || result.results.length === 0 ? (
|
||||||
|
<DashboardTableStateRow
|
||||||
|
colSpan={6}
|
||||||
|
loading={loading}
|
||||||
|
loadingText={t("role.loading")}
|
||||||
|
emptyText={t("role.empty")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DndContext>
|
||||||
|
</DashboardTableShell>
|
||||||
|
<AssignPermissionsDrawer
|
||||||
|
open={!!assigningRole}
|
||||||
|
saving={savingPermissions}
|
||||||
|
loading={assignPermissionsLoading}
|
||||||
|
item={assigningRole}
|
||||||
|
permissions={assignPermissionOptions}
|
||||||
|
selectedPermissionIds={assignPermissionIds}
|
||||||
|
onOpenChange={handleAssignPermissionsOpenChange}
|
||||||
|
onSubmit={handleAssignPermissions}
|
||||||
|
/>
|
||||||
|
<CreateRoleDrawer
|
||||||
|
open={creatingOpen}
|
||||||
|
saving={savingCreate}
|
||||||
|
onOpenChange={handleCreateDrawerOpenChange}
|
||||||
|
onSubmit={handleCreateRole}
|
||||||
|
/>
|
||||||
|
</DashboardPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
web/app/(dashboard)/dashboard/settings/page.tsx
Normal file
156
web/app/(dashboard)/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
fetchDashboardBranding,
|
||||||
|
saveDashboardBranding,
|
||||||
|
type DashboardBranding,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames the deployment.
|
||||||
|
*
|
||||||
|
* The name applies to the visitor portal, the dashboard chrome and the widget,
|
||||||
|
* because all three read the same resolved branding. Clearing a field hands
|
||||||
|
* control back to configuration rather than blanking the name, so a deployment
|
||||||
|
* that sets COMPANY_NAME in its environment never ends up nameless.
|
||||||
|
*/
|
||||||
|
export default function DashboardSettingsPage() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [branding, setBranding] = useState<DashboardBranding | null>(null)
|
||||||
|
const [companyName, setCompanyName] = useState("")
|
||||||
|
const [companyLogoUrl, setCompanyLogoUrl] = useState("")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [loadError, setLoadError] = useState("")
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setLoadError("")
|
||||||
|
try {
|
||||||
|
const result = await fetchDashboardBranding()
|
||||||
|
setBranding(result)
|
||||||
|
setCompanyName(result.companyName ?? "")
|
||||||
|
setCompanyLogoUrl(result.companyLogoUrl ?? "")
|
||||||
|
} catch (error) {
|
||||||
|
setLoadError(error instanceof Error ? error.message : t("settings.brandingLoadFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (saving) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await saveDashboardBranding({
|
||||||
|
companyName,
|
||||||
|
companyLogoUrl,
|
||||||
|
})
|
||||||
|
setBranding(result)
|
||||||
|
setCompanyName(result.companyName ?? "")
|
||||||
|
setCompanyLogoUrl(result.companyLogoUrl ?? "")
|
||||||
|
toast.success(t("settings.brandingSaved"))
|
||||||
|
// The name appears in the portal header too, which caches the public config
|
||||||
|
// for the session, so a reload is the honest way to show the new value.
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("settings.brandingSaveFailed"))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("settings.brandingTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("settings.brandingDescription")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{loadError ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="branding-company-name">
|
||||||
|
{t("systemConfig.branding.companyName.title")}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="branding-company-name"
|
||||||
|
value={companyName}
|
||||||
|
maxLength={120}
|
||||||
|
disabled={loading || saving}
|
||||||
|
placeholder={t("app.brand")}
|
||||||
|
onChange={(event) => setCompanyName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{t("systemConfig.branding.companyName.description")}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="branding-company-logo">
|
||||||
|
{t("systemConfig.branding.companyLogoUrl.title")}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="branding-company-logo"
|
||||||
|
value={companyLogoUrl}
|
||||||
|
maxLength={500}
|
||||||
|
disabled={loading || saving}
|
||||||
|
placeholder="/images/logo.svg"
|
||||||
|
onChange={(event) => setCompanyLogoUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
{t("systemConfig.branding.companyLogoUrl.description")}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{branding?.usesConfiguredFallback ? (
|
||||||
|
<p className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{t("settings.brandingConfiguredFallback", {
|
||||||
|
name: branding.configuredCompanyName,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="gap-2">
|
||||||
|
<Button type="button" disabled={loading || saving} onClick={() => void handleSave()}>
|
||||||
|
{saving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
{t("common.reset")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
PlayIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
ShieldAlertIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
UserCheckIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { DashboardPage } from "@/components/dashboard-page"
|
||||||
|
import {
|
||||||
|
notifySetupReadinessUpdated,
|
||||||
|
SETUP_READINESS_UPDATED_EVENT,
|
||||||
|
} from "@/components/operator-readiness-indicator"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
applySetupQuickFix,
|
||||||
|
fetchSetupReadiness,
|
||||||
|
simulateSetupRoute,
|
||||||
|
type SetupQuickFixAction,
|
||||||
|
type SetupReadinessResponse,
|
||||||
|
type SetupSimulateRouteResponse,
|
||||||
|
type SetupStepStatus,
|
||||||
|
} from "@/lib/api/setup-readiness"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: SetupStepStatus }) {
|
||||||
|
if (status === "ok") {
|
||||||
|
return (
|
||||||
|
<CheckCircle2Icon className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === "warning") {
|
||||||
|
return (
|
||||||
|
<AlertTriangleIcon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<XCircleIcon className="size-4 shrink-0 text-destructive" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: SetupStepStatus }) {
|
||||||
|
const t = useI18n()
|
||||||
|
if (status === "ok") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
{t("setupWizard.stepBadgeOk")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === "warning") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{t("setupWizard.stepBadgeWarning")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md border-destructive/40 bg-destructive/10 text-destructive"
|
||||||
|
>
|
||||||
|
{t("setupWizard.stepBadgeError")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupWizardView() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [fixingAction, setFixingAction] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState<string>("")
|
||||||
|
const [simulation, setSimulation] =
|
||||||
|
useState<SetupSimulateRouteResponse | null>(null)
|
||||||
|
const [simulating, setSimulating] = useState(false)
|
||||||
|
|
||||||
|
const runSimulation = useCallback(async (channelIdNum?: number) => {
|
||||||
|
setSimulating(true)
|
||||||
|
try {
|
||||||
|
const sim = await simulateSetupRoute({
|
||||||
|
channel_id: channelIdNum && channelIdNum > 0 ? channelIdNum : undefined,
|
||||||
|
bypass_ai: true,
|
||||||
|
})
|
||||||
|
setSimulation(sim)
|
||||||
|
if (sim.channel_id > 0) {
|
||||||
|
setSelectedChannelId(String(sim.channel_id))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSimulating(false)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const loadAll = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchSetupReadiness()
|
||||||
|
setReadiness(data)
|
||||||
|
const currentNum = Number(selectedChannelId)
|
||||||
|
const targetChannelId =
|
||||||
|
currentNum > 0
|
||||||
|
? currentNum
|
||||||
|
: data.channels.find((ch) => ch.status === 0)?.id ??
|
||||||
|
data.channels[0]?.id ??
|
||||||
|
0
|
||||||
|
if (targetChannelId > 0) {
|
||||||
|
setSelectedChannelId(String(targetChannelId))
|
||||||
|
}
|
||||||
|
await runSimulation(targetChannelId)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("common.loadFailed"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [runSimulation, selectedChannelId, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAll()
|
||||||
|
const handleUpdated = () => {
|
||||||
|
void loadAll()
|
||||||
|
}
|
||||||
|
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||||
|
}
|
||||||
|
}, [loadAll])
|
||||||
|
|
||||||
|
async function handleQuickFix(
|
||||||
|
action: SetupQuickFixAction,
|
||||||
|
entityId?: number,
|
||||||
|
) {
|
||||||
|
const key = entityId ? `${action}:${entityId}` : action
|
||||||
|
setFixingAction(key)
|
||||||
|
try {
|
||||||
|
const res = await applySetupQuickFix({ action, entity_id: entityId })
|
||||||
|
setReadiness(res.readiness)
|
||||||
|
notifySetupReadinessUpdated()
|
||||||
|
toast.success(t("setupWizard.quickFixSuccess"))
|
||||||
|
const targetChannelId =
|
||||||
|
Number(selectedChannelId) ||
|
||||||
|
res.readiness.channels.find((ch) => ch.status === 0)?.id ||
|
||||||
|
0
|
||||||
|
await runSimulation(targetChannelId)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setFixingAction(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canQuickSetupAll = useMemo(() => {
|
||||||
|
return (
|
||||||
|
readiness?.steps.some((s) =>
|
||||||
|
s.quick_fix_actions.includes("quick_setup_all"),
|
||||||
|
) ?? false
|
||||||
|
)
|
||||||
|
}, [readiness])
|
||||||
|
|
||||||
|
const canEnableMyProfile = useMemo(() => {
|
||||||
|
return (
|
||||||
|
readiness?.steps.some((s) =>
|
||||||
|
s.quick_fix_actions.includes("enable_my_operator_profile"),
|
||||||
|
) ?? false
|
||||||
|
)
|
||||||
|
}, [readiness])
|
||||||
|
|
||||||
|
const channelOptions = useMemo(() => {
|
||||||
|
if (!readiness) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return readiness.channels.map((ch) => ({
|
||||||
|
value: String(ch.id),
|
||||||
|
label: `${ch.name} (${ch.channel_type})`,
|
||||||
|
}))
|
||||||
|
}, [readiness])
|
||||||
|
|
||||||
|
if (loading && !readiness) {
|
||||||
|
return (
|
||||||
|
<DashboardPage>
|
||||||
|
<div className="flex min-h-64 items-center justify-center rounded-md border border-border/70 bg-card p-8">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<span>{t("common.loadingData")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readiness) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = Math.round(
|
||||||
|
(readiness.ready_steps / Math.max(readiness.total_steps, 1)) * 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPage className="space-y-5">
|
||||||
|
{/* Top Overview Header */}
|
||||||
|
<div className="rounded-md border border-border/70 bg-card p-4 lg:p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">
|
||||||
|
{t("setupWizard.title")}
|
||||||
|
</h1>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md text-xs",
|
||||||
|
readiness.can_receive_requests_now
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{readiness.can_receive_requests_now
|
||||||
|
? t("setupWizard.statusReady")
|
||||||
|
: t("setupWizard.statusNeedsSetup", {
|
||||||
|
ready: readiness.ready_steps,
|
||||||
|
total: readiness.total_steps,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 rounded-md"
|
||||||
|
disabled={loading || fixingAction !== null}
|
||||||
|
onClick={() => void loadAll()}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={cn("size-4", loading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
<span>{t("setupWizard.refresh")}</span>
|
||||||
|
</Button>
|
||||||
|
{canQuickSetupAll ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 rounded-md"
|
||||||
|
disabled={fixingAction !== null}
|
||||||
|
onClick={() => void handleQuickFix("quick_setup_all")}
|
||||||
|
>
|
||||||
|
{fixingAction === "quick_setup_all" ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{fixingAction === "quick_setup_all"
|
||||||
|
? t("setupWizard.quickSetupRunning")
|
||||||
|
: t("setupWizard.quickSetupAll")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("setupWizard.stepsProgress", {
|
||||||
|
ready: readiness.ready_steps,
|
||||||
|
total: readiness.total_steps,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>{progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-md bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all duration-300",
|
||||||
|
readiness.can_receive_requests_now
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: "bg-amber-500",
|
||||||
|
)}
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Operator Personal Diagnostic Card */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border p-4",
|
||||||
|
readiness.current_operator.can_receive_now
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/5"
|
||||||
|
: "border-amber-500/40 bg-amber-500/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<UserCheckIcon
|
||||||
|
className={cn(
|
||||||
|
"size-4 shrink-0",
|
||||||
|
readiness.current_operator.can_receive_now
|
||||||
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "text-amber-600 dark:text-amber-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
|
{t("setupWizard.currentOperatorTitle")}:{" "}
|
||||||
|
{readiness.current_operator.nickname ||
|
||||||
|
readiness.current_operator.username}
|
||||||
|
</h2>
|
||||||
|
<Badge variant="outline" className="rounded-md text-xs">
|
||||||
|
{t("setupWizard.currentOperatorTeam", {
|
||||||
|
team:
|
||||||
|
readiness.current_operator.team_name ||
|
||||||
|
t("setupWizard.currentOperatorNoTeam"),
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="rounded-md text-xs">
|
||||||
|
{t("setupWizard.currentOperatorLoad", {
|
||||||
|
active: readiness.current_operator.active_conversation_count,
|
||||||
|
max: readiness.current_operator.max_concurrent_count,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{readiness.current_operator.can_receive_now ? (
|
||||||
|
<p className="text-xs text-emerald-700 dark:text-emerald-300">
|
||||||
|
{t("setupWizard.currentOperatorReady")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
{t("setupWizard.currentOperatorBlocked")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||||
|
{readiness.current_operator.blocker_codes.map((code) => (
|
||||||
|
<Badge
|
||||||
|
key={code}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 rounded-md text-xs font-normal"
|
||||||
|
>
|
||||||
|
<ShieldAlertIcon className="size-3 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span>{t(`setupWizard.operatorBlockers.${code}`)}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readiness.current_operator.can_receive_now && canEnableMyProfile ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 rounded-md"
|
||||||
|
disabled={fixingAction !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void handleQuickFix("enable_my_operator_profile")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{fixingAction === "enable_my_operator_profile" ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
<span>{t("setupWizard.enableMyReceptionBtn")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6-Step Checklist Grid */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
{t("setupWizard.checklistSectionTitle")}
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{readiness.steps.map((step) => {
|
||||||
|
const primaryQuickFix = step.quick_fix_actions[0]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.key}
|
||||||
|
className="flex flex-col justify-between rounded-md border border-border/70 bg-card p-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={step.status} />
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{step.step_number}.{" "}
|
||||||
|
{t(`setupWizard.steps.${step.key}.title`)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-md text-xs font-normal"
|
||||||
|
>
|
||||||
|
{t("setupWizard.readyCountLabel", {
|
||||||
|
ready: step.ready_count,
|
||||||
|
total: step.total_count,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
<StatusBadge status={step.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(`setupWizard.steps.${step.key}.description`)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{step.issues.length > 0 ? (
|
||||||
|
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/30 p-2.5">
|
||||||
|
{step.issues.map((issue, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${issue.code}-${issue.entity_id}-${idx}`}
|
||||||
|
className="flex items-start justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-1.5 text-foreground">
|
||||||
|
<AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span>
|
||||||
|
{issue.entity_name ? (
|
||||||
|
<strong className="font-medium">
|
||||||
|
[{issue.entity_name}]{" "}
|
||||||
|
</strong>
|
||||||
|
) : null}
|
||||||
|
{t(`setupWizard.issues.${issue.code}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/50 pt-3">
|
||||||
|
{step.key !== "end_to_end" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 rounded-md text-xs"
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
step.key === "schedules"
|
||||||
|
? "/dashboard/agent-team-schedules"
|
||||||
|
: step.key === "ai_agents"
|
||||||
|
? "/dashboard/ai-agents"
|
||||||
|
: step.key === "channels"
|
||||||
|
? "/dashboard/channels"
|
||||||
|
: "/dashboard/agents"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{t("setupWizard.openSettings")}</span>
|
||||||
|
<ExternalLinkIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{primaryQuickFix ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 rounded-md text-xs"
|
||||||
|
disabled={fixingAction !== null}
|
||||||
|
onClick={() => void handleQuickFix(primaryQuickFix)}
|
||||||
|
>
|
||||||
|
{fixingAction === primaryQuickFix ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SparklesIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{t(`setupWizard.quickFixLabels.${primaryQuickFix}`)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Route Simulator */}
|
||||||
|
<div className="space-y-4 rounded-md border border-border/70 bg-card p-4 lg:p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
{t("setupWizard.simulator.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("setupWizard.simulator.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{channelOptions.length > 0 ? (
|
||||||
|
<OptionCombobox
|
||||||
|
options={channelOptions}
|
||||||
|
value={selectedChannelId}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSelectedChannelId(val)
|
||||||
|
void runSimulation(Number(val))
|
||||||
|
}}
|
||||||
|
placeholder={t("setupWizard.simulator.selectChannelPlaceholder")}
|
||||||
|
triggerClassName="w-64 rounded-md"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5 rounded-md"
|
||||||
|
disabled={simulating}
|
||||||
|
onClick={() => void runSimulation(Number(selectedChannelId))}
|
||||||
|
>
|
||||||
|
{simulating ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{simulating
|
||||||
|
? t("setupWizard.simulator.running")
|
||||||
|
: t("setupWizard.simulator.runSimulation")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{simulation ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Outcome Banner */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between",
|
||||||
|
simulation.will_reach_operator
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200"
|
||||||
|
: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{simulation.will_reach_operator ? (
|
||||||
|
<CheckCircle2Icon className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="size-4 shrink-0 text-destructive" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{simulation.will_reach_operator
|
||||||
|
? t("setupWizard.simulator.outcomeSuccess", {
|
||||||
|
operator:
|
||||||
|
simulation.matched_operator?.display_name ?? "",
|
||||||
|
})
|
||||||
|
: t("setupWizard.simulator.outcomeBlocked")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs opacity-90">
|
||||||
|
{t(
|
||||||
|
`setupWizard.simulator.outcomes.${simulation.final_outcome_code}`,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5-Hop Visual Trace Pipeline */}
|
||||||
|
<div className="grid gap-2.5 lg:grid-cols-5">
|
||||||
|
{simulation.hops.map((hop, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${hop.hop_key}-${idx}`}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col justify-between rounded-md border p-3 text-xs",
|
||||||
|
hop.status === "ok"
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/5"
|
||||||
|
: hop.status === "warning"
|
||||||
|
? "border-amber-500/40 bg-amber-500/5"
|
||||||
|
: "border-destructive/40 bg-destructive/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{idx + 1}.{" "}
|
||||||
|
{t(`setupWizard.simulator.hops.${hop.hop_key}`)}
|
||||||
|
</span>
|
||||||
|
<StatusIcon status={hop.status} />
|
||||||
|
</div>
|
||||||
|
{hop.entity_name ? (
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{hop.entity_name}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(`setupWizard.simulator.hopReasons.${hop.reason_code}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hop.quick_fix_action && hop.status !== "ok" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2.5 h-7 w-full gap-1 rounded-md text-[11px]"
|
||||||
|
disabled={fixingAction !== null}
|
||||||
|
onClick={() =>
|
||||||
|
void handleQuickFix(
|
||||||
|
hop.quick_fix_action!,
|
||||||
|
hop.entity_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-3" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t(
|
||||||
|
`setupWizard.quickFixLabels.${hop.quick_fix_action}`,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Candidate Operators Evaluation Table */}
|
||||||
|
{simulation.candidate_operators.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t("setupWizard.simulator.candidateTableTitle")}
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-hidden rounded-md border border-border/70">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
{t("setupWizard.simulator.colOperator")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("setupWizard.simulator.colTeam")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("setupWizard.simulator.colStatus")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("setupWizard.simulator.colAutoAssign")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("setupWizard.simulator.colLoad")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("setupWizard.simulator.colResult")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{simulation.candidate_operators.map((cand) => (
|
||||||
|
<TableRow key={cand.user_id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{cand.display_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{cand.team_name ||
|
||||||
|
t("setupWizard.currentOperatorNoTeam")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md text-xs"
|
||||||
|
>
|
||||||
|
{cand.service_status === 0
|
||||||
|
? t("setupWizard.simulator.statusIdle")
|
||||||
|
: t("setupWizard.simulator.statusBusy")}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{cand.auto_assign_enabled
|
||||||
|
? t("common.enabled")
|
||||||
|
: t("common.disabled")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{cand.active_count} / {cand.max_concurrent_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{cand.eligible ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-md border-emerald-500/30 bg-emerald-500/10 text-xs text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
{t("setupWizard.simulator.eligibleBadge")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{t(
|
||||||
|
`setupWizard.simulator.skipReasons.${cand.skip_reason_code}`,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DashboardPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ArrowRightIcon }
|
||||||
5
web/app/(dashboard)/dashboard/setup/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/setup/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetupWizardView } from "./_components/setup-wizard-view"
|
||||||
|
|
||||||
|
export default function SetupWizardPage() {
|
||||||
|
return <SetupWizardView />
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { type Resolver, useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod/v4"
|
||||||
|
import { LoaderCircleIcon, PlayIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
debugResumeSkillDefinition,
|
||||||
|
debugRunSkillDefinition,
|
||||||
|
fetchAIAgentsAll,
|
||||||
|
type AIAgent,
|
||||||
|
type SkillDebugResumePayload,
|
||||||
|
type SkillDebugRunPayload,
|
||||||
|
type SkillDebugRunResult,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
|
||||||
|
type DebugDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
skillDefinitionId: number
|
||||||
|
skillName: string
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
function createDebugFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
aiAgentId: z.string().trim().min(1, t("skillDefinition.agentRequired")),
|
||||||
|
conversationId: z.string().trim(),
|
||||||
|
userMessage: z.string().trim().min(1, t("skillDefinition.messageRequired")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type DebugForm = {
|
||||||
|
aiAgentId: string
|
||||||
|
conversationId: string
|
||||||
|
userMessage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: DebugForm = {
|
||||||
|
aiAgentId: "",
|
||||||
|
conversationId: "",
|
||||||
|
userMessage: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuickResumeActions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ label: t("skillDefinition.confirm"), value: t("skillDefinition.confirm") },
|
||||||
|
{ label: t("skillDefinition.reject"), value: t("skillDefinition.reject") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultBlock({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
emptyText,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value?: string
|
||||||
|
emptyText?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{value ? (
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-muted/50 p-3 text-xs leading-5">
|
||||||
|
{value}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">{emptyText}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugDialog({
|
||||||
|
open,
|
||||||
|
skillDefinitionId,
|
||||||
|
skillName,
|
||||||
|
onOpenChange,
|
||||||
|
}: DebugDialogProps) {
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DebugDialogBody
|
||||||
|
key={skillDefinitionId}
|
||||||
|
open={open}
|
||||||
|
skillDefinitionId={skillDefinitionId}
|
||||||
|
skillName={skillName}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DebugDialogBody({
|
||||||
|
open,
|
||||||
|
skillDefinitionId,
|
||||||
|
skillName,
|
||||||
|
onOpenChange,
|
||||||
|
}: DebugDialogProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const formId = `skill-debug-form-${skillDefinitionId}`
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [resuming, setResuming] = useState(false)
|
||||||
|
const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
|
||||||
|
const [result, setResult] = useState<SkillDebugRunResult | null>(null)
|
||||||
|
const [resumeResult, setResumeResult] = useState<SkillDebugRunResult | null>(null)
|
||||||
|
const [resumeMessage, setResumeMessage] = useState("")
|
||||||
|
const debugFormSchema = useMemo(() => createDebugFormSchema(t), [t])
|
||||||
|
const debugFormResolver = useMemo(
|
||||||
|
() => zodResolver(debugFormSchema) as Resolver<DebugForm>,
|
||||||
|
[debugFormSchema],
|
||||||
|
)
|
||||||
|
const form = useForm<DebugForm>({
|
||||||
|
resolver: debugFormResolver,
|
||||||
|
defaultValues: emptyForm,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = form
|
||||||
|
|
||||||
|
const selectedAgentId = watch("aiAgentId")
|
||||||
|
const quickResumeActions = useMemo(() => getQuickResumeActions(t), [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadAIAgents() {
|
||||||
|
try {
|
||||||
|
const data = await fetchAIAgentsAll({ status: 1 })
|
||||||
|
setAiAgents(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load AI agents:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadAIAgents()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset(emptyForm)
|
||||||
|
setResult(null)
|
||||||
|
setResumeResult(null)
|
||||||
|
setResumeMessage("")
|
||||||
|
}, [open, reset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || aiAgents.length === 0 || selectedAgentId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setValue("aiAgentId", String(aiAgents[0].id), { shouldValidate: true })
|
||||||
|
}, [aiAgents, open, selectedAgentId, setValue])
|
||||||
|
|
||||||
|
const aiAgentOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
aiAgents.map((item) => ({
|
||||||
|
value: String(item.id),
|
||||||
|
label: item.name,
|
||||||
|
})),
|
||||||
|
[aiAgents],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedAgent = useMemo(
|
||||||
|
() => aiAgents.find((item) => String(item.id) === selectedAgentId) ?? null,
|
||||||
|
[aiAgents, selectedAgentId],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function onSubmit(values: DebugForm) {
|
||||||
|
const payload: SkillDebugRunPayload = {
|
||||||
|
aiAgentId: Number(values.aiAgentId),
|
||||||
|
skillDefinitionId,
|
||||||
|
userMessage: values.userMessage.trim(),
|
||||||
|
}
|
||||||
|
const conversationId = Number(values.conversationId)
|
||||||
|
if (conversationId > 0) {
|
||||||
|
payload.conversationId = conversationId
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true)
|
||||||
|
try {
|
||||||
|
const data = await debugRunSkillDefinition(payload)
|
||||||
|
setResult(data)
|
||||||
|
setResumeResult(null)
|
||||||
|
setResumeMessage("")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("skillDefinition.debugFailed"))
|
||||||
|
setResult(null)
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResumeDebug(messageText?: string) {
|
||||||
|
const nextMessage = (messageText ?? resumeMessage).trim()
|
||||||
|
if (!result?.checkPointId || !result.interrupted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nextMessage) {
|
||||||
|
toast.error(t("skillDefinition.resumeMessageRequired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload: SkillDebugResumePayload = {
|
||||||
|
aiAgentId: Number(selectedAgentId || result.aiAgentId),
|
||||||
|
checkPointId: result.checkPointId,
|
||||||
|
userMessage: nextMessage,
|
||||||
|
}
|
||||||
|
const conversationId = result.conversationId || Number(watch("conversationId"))
|
||||||
|
if (conversationId > 0) {
|
||||||
|
payload.conversationId = conversationId
|
||||||
|
}
|
||||||
|
|
||||||
|
setResuming(true)
|
||||||
|
try {
|
||||||
|
const data = await debugResumeSkillDefinition(payload)
|
||||||
|
setResumeResult(data)
|
||||||
|
setResumeMessage(nextMessage)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("skillDefinition.resumeFailed"))
|
||||||
|
setResumeResult(null)
|
||||||
|
} finally {
|
||||||
|
setResuming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={t("skillDefinition.debugTitle", { name: skillName })}
|
||||||
|
description={t("skillDefinition.debugDescription")}
|
||||||
|
size="xl"
|
||||||
|
allowFullscreen
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={running}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t("skillDefinition.close")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={running}>
|
||||||
|
{running ? <LoaderCircleIcon className="animate-spin" /> : <PlayIcon />}
|
||||||
|
{running ? t("skillDefinition.debugging") : t("skillDefinition.startDebug")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">{t("skillDefinition.debugInput")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form id={formId} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Field data-invalid={!!errors.aiAgentId}>
|
||||||
|
<FieldLabel>AI Agent</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<OptionCombobox
|
||||||
|
value={selectedAgentId}
|
||||||
|
options={aiAgentOptions}
|
||||||
|
placeholder={t("skillDefinition.selectAgent")}
|
||||||
|
searchPlaceholder={t("skillDefinition.searchAgent")}
|
||||||
|
emptyText={t("skillDefinition.emptyAgent")}
|
||||||
|
onChange={(value) =>
|
||||||
|
setValue("aiAgentId", value, { shouldValidate: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.aiAgentId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.conversationId}>
|
||||||
|
<FieldLabel htmlFor="skill-debug-conversation-id">
|
||||||
|
Conversation ID
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="skill-debug-conversation-id"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder={t("skillDefinition.conversationPlaceholder")}
|
||||||
|
aria-invalid={!!errors.conversationId}
|
||||||
|
{...register("conversationId")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.conversationId]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Skill</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input value={skillName} disabled />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("skillDefinition.matchedAgent")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
value={selectedAgent?.name || t("skillDefinition.noAgentSelected")}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field data-invalid={!!errors.userMessage}>
|
||||||
|
<FieldLabel htmlFor="skill-debug-user-message">{t("skillDefinition.userMessage")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="skill-debug-user-message"
|
||||||
|
rows={5}
|
||||||
|
placeholder={t("skillDefinition.userMessagePlaceholder")}
|
||||||
|
aria-invalid={!!errors.userMessage}
|
||||||
|
{...register("userMessage")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.userMessage]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">{t("skillDefinition.debugSummary")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">{result?.skillName || skillName}</Badge>
|
||||||
|
{result?.interruptType ? (
|
||||||
|
<Badge variant="secondary">{result.interruptType}</Badge>
|
||||||
|
) : null}
|
||||||
|
{result?.interrupted ? (
|
||||||
|
<Badge>{t("skillDefinition.interrupted")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{t("skillDefinition.notInterrupted")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("skillDefinition.skillName")}</div>
|
||||||
|
<div className="mt-1 font-medium">{result?.skillName || skillName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Reply</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{result?.replyText || t("skillDefinition.none")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Checkpoint</div>
|
||||||
|
<div className="mt-1 break-all">
|
||||||
|
{result?.checkPointId || t("skillDefinition.none")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("skillDefinition.errorMessage")}</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{result?.errorMessage || t("skillDefinition.none")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">{t("skillDefinition.toolView")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("skillDefinition.skillToolWhitelist")}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{(result?.toolWhitelist ?? []).length > 0 ? (
|
||||||
|
result?.toolWhitelist.map((toolCode) => (
|
||||||
|
<Badge key={toolCode} variant="outline">
|
||||||
|
{toolCode}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("skillDefinition.invokedTools")}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{(result?.invokedToolCodes ?? []).length > 0 ? (
|
||||||
|
result?.invokedToolCodes.map((toolCode) => (
|
||||||
|
<Badge key={toolCode} variant="secondary">
|
||||||
|
{toolCode}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<ResultBlock title={t("skillDefinition.traceData")} value={result?.traceData} emptyText={t("skillDefinition.emptyData")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result?.interrupted && result.checkPointId ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">{t("skillDefinition.resumeDebug")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3 text-sm">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("skillDefinition.currentCheckpoint")}</div>
|
||||||
|
<div className="mt-1 break-all">{result.checkPointId}</div>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="skill-debug-resume-message">{t("skillDefinition.resumeMessage")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="skill-debug-resume-message"
|
||||||
|
rows={3}
|
||||||
|
placeholder={t("skillDefinition.resumePlaceholder")}
|
||||||
|
value={resumeMessage}
|
||||||
|
onChange={(event) => setResumeMessage(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{quickResumeActions.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={resuming}
|
||||||
|
onClick={() => void handleResumeDebug(item.value)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={resuming}
|
||||||
|
onClick={() => void handleResumeDebug()}
|
||||||
|
>
|
||||||
|
{resuming ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayIcon />
|
||||||
|
)}
|
||||||
|
{resuming ? t("skillDefinition.resuming") : t("skillDefinition.resumeDebugAction")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{resumeResult ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">{t("skillDefinition.resumeResult")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">{resumeResult.skillName || skillName}</Badge>
|
||||||
|
{resumeResult.interruptType ? (
|
||||||
|
<Badge variant="secondary">{resumeResult.interruptType}</Badge>
|
||||||
|
) : null}
|
||||||
|
{resumeResult.interrupted ? (
|
||||||
|
<Badge>{t("skillDefinition.stillWaiting")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{t("skillDefinition.resumeCompleted")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("skillDefinition.resumeMessage")}</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{resumeMessage || t("skillDefinition.none")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("skillDefinition.resumeReply")}</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{resumeResult.replyText || t("skillDefinition.none")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<ResultBlock title={t("skillDefinition.resumeTraceData")} value={resumeResult.traceData} emptyText={t("skillDefinition.emptyData")} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ProjectDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, type Resolver, useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { ContentEditor } from "@/components/content-editor";
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox";
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
fetchMCPCatalog,
|
||||||
|
fetchSkillDefinition,
|
||||||
|
type CreateSkillDefinitionPayload,
|
||||||
|
type MCPToolCatalogItem,
|
||||||
|
type SkillDefinition,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
type SkillEditDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
itemId: number | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: CreateSkillDefinitionPayload) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: EditForm = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
instruction: "",
|
||||||
|
examplesText: "",
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSkillFormSchema(t: TFunction) {
|
||||||
|
return z.object({
|
||||||
|
name: z.string().trim().min(1, t("skillDefinition.nameRequired")),
|
||||||
|
description: z.string().trim(),
|
||||||
|
instruction: z.string().trim().min(1, t("skillDefinition.instructionRequired")),
|
||||||
|
examplesText: z.string().trim(),
|
||||||
|
remark: z.string().trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditForm = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
instruction: string;
|
||||||
|
examplesText: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildForm(item: SkillDefinition | null): EditForm {
|
||||||
|
if (!item) {
|
||||||
|
return emptyForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description ?? "",
|
||||||
|
instruction: item.instruction ?? "",
|
||||||
|
examplesText: (item.examples ?? []).join("\n"),
|
||||||
|
remark: item.remark ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(
|
||||||
|
form: EditForm,
|
||||||
|
toolWhitelist: string[],
|
||||||
|
): CreateSkillDefinitionPayload {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
instruction: form.instruction.trim(),
|
||||||
|
examples: form.examplesText
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
toolWhitelist,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDialog({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: SkillEditDialogProps) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SkillEditDialogBody
|
||||||
|
key={itemId ? `edit-${itemId}` : "create"}
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillEditDialogBodyProps = SkillEditDialogProps;
|
||||||
|
|
||||||
|
function SkillEditDialogBody({
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
itemId,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: SkillEditDialogBodyProps) {
|
||||||
|
const t = useI18n();
|
||||||
|
const formId = "skill-definition-edit-form";
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
|
||||||
|
const [selectedToolWhitelist, setSelectedToolWhitelist] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [toolCodeToAdd, setToolCodeToAdd] = useState("");
|
||||||
|
const skillFormSchema = useMemo(() => createSkillFormSchema(t), [t]);
|
||||||
|
const editFormResolver = useMemo(
|
||||||
|
() => zodResolver(skillFormSchema) as Resolver<EditForm>,
|
||||||
|
[skillFormSchema],
|
||||||
|
);
|
||||||
|
const form = useForm<EditForm>({
|
||||||
|
resolver: editFormResolver,
|
||||||
|
defaultValues: emptyForm,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!itemId) {
|
||||||
|
reset(emptyForm);
|
||||||
|
setSelectedToolWhitelist([]);
|
||||||
|
setToolCodeToAdd("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchSkillDefinition(itemId);
|
||||||
|
reset(buildForm(data));
|
||||||
|
setSelectedToolWhitelist(data.toolWhitelist ?? []);
|
||||||
|
setToolCodeToAdd("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load skill definition:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDetail();
|
||||||
|
}, [itemId, reset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadToolCatalog() {
|
||||||
|
try {
|
||||||
|
const data = await fetchMCPCatalog();
|
||||||
|
setToolCatalog(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load MCP tool catalog:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadToolCatalog();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onFormSubmit(values: EditForm) {
|
||||||
|
await onSubmit(buildPayload(values, selectedToolWhitelist));
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
toolCatalog.map((item) => ({
|
||||||
|
value: item.toolCode,
|
||||||
|
label: `${item.title || item.toolName} · ${item.toolCode}`,
|
||||||
|
})),
|
||||||
|
[toolCatalog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addableToolOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
toolOptions.filter(
|
||||||
|
(option) => !selectedToolWhitelist.includes(option.value),
|
||||||
|
),
|
||||||
|
[selectedToolWhitelist, toolOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedToolOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedToolWhitelist
|
||||||
|
.map((toolCode) => toolOptions.find((option) => option.value === toolCode))
|
||||||
|
.filter(
|
||||||
|
(option): option is { value: string; label: string } => !!option,
|
||||||
|
),
|
||||||
|
[selectedToolWhitelist, toolOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleAddToolWhitelist(toolCode: string) {
|
||||||
|
if (!toolCode || selectedToolWhitelist.includes(toolCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedToolWhitelist((prev) => [...prev, toolCode]);
|
||||||
|
setToolCodeToAdd("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveToolWhitelist(toolCode: string) {
|
||||||
|
setSelectedToolWhitelist((prev) =>
|
||||||
|
prev.filter((item) => item !== toolCode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={itemId ? t("skillDefinition.editTitle") : t("skillDefinition.createTitle")}
|
||||||
|
size="xl"
|
||||||
|
allowFullscreen
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("skillDefinition.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={saving || loading}>
|
||||||
|
{saving ? t("skillDefinition.saving") : itemId ? t("skillDefinition.save") : t("skillDefinition.create")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-muted-foreground">{t("skillDefinition.loading")}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="skill-name">{t("skillDefinition.name")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="skill-name"
|
||||||
|
placeholder={t("skillDefinition.namePlaceholder")}
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.name]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.description}>
|
||||||
|
<FieldLabel htmlFor="skill-description">{t("skillDefinition.description")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="skill-description"
|
||||||
|
rows={3}
|
||||||
|
placeholder={t("skillDefinition.descriptionPlaceholder")}
|
||||||
|
aria-invalid={!!errors.description}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.description]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.instruction}>
|
||||||
|
<FieldLabel>{t("skillDefinition.instruction")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="instruction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ContentEditor
|
||||||
|
value={{ mode: "markdown", raw: field.value ?? "" }}
|
||||||
|
onChange={(next) => field.onChange(next.raw)}
|
||||||
|
placeholder={t("skillDefinition.instructionPlaceholder")}
|
||||||
|
disabled={saving || loading}
|
||||||
|
allowedModes={["markdown"]}
|
||||||
|
height={360}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.instruction]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.examplesText}>
|
||||||
|
<FieldLabel htmlFor="skill-examples">{t("skillDefinition.examples")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="skill-examples"
|
||||||
|
rows={5}
|
||||||
|
placeholder={t("skillDefinition.examplesPlaceholder")}
|
||||||
|
aria-invalid={!!errors.examplesText}
|
||||||
|
{...register("examplesText")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.examplesText]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t("skillDefinition.toolWhitelist")}</FieldLabel>
|
||||||
|
<FieldContent className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<OptionCombobox
|
||||||
|
value={toolCodeToAdd}
|
||||||
|
options={addableToolOptions}
|
||||||
|
placeholder={t("skillDefinition.selectTool")}
|
||||||
|
searchPlaceholder={t("skillDefinition.searchTool")}
|
||||||
|
emptyText={t("skillDefinition.emptyTool")}
|
||||||
|
onChange={handleAddToolWhitelist}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!toolCodeToAdd}
|
||||||
|
onClick={() => handleAddToolWhitelist(toolCodeToAdd)}
|
||||||
|
>
|
||||||
|
{t("skillDefinition.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedToolOptions.length === 0 ? (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("skillDefinition.inheritAgentTools")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
selectedToolOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveToolWhitelist(option.value)}
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={!!errors.remark}>
|
||||||
|
<FieldLabel htmlFor="skill-remark">{t("skillDefinition.remark")}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Textarea
|
||||||
|
id="skill-remark"
|
||||||
|
rows={3}
|
||||||
|
placeholder={t("skillDefinition.remarkPlaceholder")}
|
||||||
|
aria-invalid={!!errors.remark}
|
||||||
|
{...register("remark")}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.remark]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ProjectDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
web/app/(dashboard)/dashboard/skill-definition/page.tsx
Normal file
254
web/app/(dashboard)/dashboard/skill-definition/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BrainCircuitIcon, BugIcon, RotateCcwIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardCrudPage,
|
||||||
|
createDashboardStatusColumn,
|
||||||
|
type DashboardCrudColumn,
|
||||||
|
type DashboardCrudFilter,
|
||||||
|
} from "@/components/dashboard/crud";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
createSkillDefinition,
|
||||||
|
deleteSkillDefinition,
|
||||||
|
fetchSkillDefinitions,
|
||||||
|
restoreSkillDefinition,
|
||||||
|
updateSkillDefinition,
|
||||||
|
updateSkillDefinitionStatus,
|
||||||
|
type CreateSkillDefinitionPayload,
|
||||||
|
type SkillDefinition,
|
||||||
|
} from "@/lib/api/admin";
|
||||||
|
import { useI18n } from "@/i18n/provider";
|
||||||
|
import { Status } from "@/lib/generated/enums";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import { EditDialog } from "./_components/edit";
|
||||||
|
import { DebugDialog } from "./_components/debug-dialog";
|
||||||
|
|
||||||
|
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function statusLabel(status: number, t: TFunction) {
|
||||||
|
if (status === Status.Ok) return t("skillDefinition.statusOk");
|
||||||
|
if (status === Status.Disabled) return t("skillDefinition.statusDisabled");
|
||||||
|
if (status === Status.Deleted) return t("skillDefinition.statusDeleted");
|
||||||
|
return String(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusFilterOptions(t: TFunction) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("skillDefinition.allStatus") },
|
||||||
|
{ value: String(Status.Ok), label: t("skillDefinition.statusOk") },
|
||||||
|
{ value: String(Status.Disabled), label: t("skillDefinition.statusDisabled") },
|
||||||
|
{ value: String(Status.Deleted), label: t("skillDefinition.statusDeleted") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeVariant(status: number) {
|
||||||
|
if (status === Status.Deleted) return "destructive";
|
||||||
|
if (status === Status.Ok) return "default";
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextStatus(item: SkillDefinition) {
|
||||||
|
return item.status === Status.Ok ? Status.Disabled : Status.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardSkillsPage() {
|
||||||
|
const t = useI18n();
|
||||||
|
const [debugDialogOpen, setDebugDialogOpen] = useState(false);
|
||||||
|
const [debuggingItem, setDebuggingItem] = useState<SkillDefinition | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const statusFilterOptions = useMemo(() => getStatusFilterOptions(t), [t]);
|
||||||
|
|
||||||
|
const filters = useMemo<DashboardCrudFilter[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: t("skillDefinition.filterName"),
|
||||||
|
placeholder: t("skillDefinition.filterName"),
|
||||||
|
defaultValue: "",
|
||||||
|
trim: true,
|
||||||
|
className: "w-full sm:w-72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: t("skillDefinition.allStatus"),
|
||||||
|
type: "select",
|
||||||
|
defaultValue: "all",
|
||||||
|
allValue: "all",
|
||||||
|
valueType: "number",
|
||||||
|
options: statusFilterOptions,
|
||||||
|
className: "w-full sm:w-36",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[statusFilterOptions, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<DashboardCrudColumn<SkillDefinition>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "skill",
|
||||||
|
label: "Skill",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<BrainCircuitIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="font-medium">{item.name}</div>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{t("skillDefinition.whitelistCount", {
|
||||||
|
count: item.toolWhitelist.length,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{t("skillDefinition.exampleCount", {
|
||||||
|
count: item.examples.length,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
{item.description || t("skillDefinition.noDescription")}
|
||||||
|
</div>
|
||||||
|
{item.toolWhitelist.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{item.toolWhitelist.slice(0, 3).map((toolCode) => (
|
||||||
|
<Badge key={toolCode} variant="outline">
|
||||||
|
{toolCode}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{item.toolWhitelist.length > 3 ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
+{item.toolWhitelist.length - 3}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
createDashboardStatusColumn<SkillDefinition, number>({
|
||||||
|
label: t("skillDefinition.status"),
|
||||||
|
getStatus: (item) => item.status,
|
||||||
|
getLabel: (status) => statusLabel(status, t),
|
||||||
|
getBadgeVariant: statusBadgeVariant,
|
||||||
|
isEnabled: (status) => status === Status.Ok,
|
||||||
|
toggle: {
|
||||||
|
disabled: (item) => item.status === Status.Deleted,
|
||||||
|
getNextStatus,
|
||||||
|
updateStatus: (item, nextStatus) =>
|
||||||
|
updateSkillDefinitionStatus(item.id, nextStatus),
|
||||||
|
successMessage: (item, nextStatus) =>
|
||||||
|
t(nextStatus === Status.Ok ? "skillDefinition.enabled" : "skillDefinition.disabled", {
|
||||||
|
name: item.name,
|
||||||
|
}),
|
||||||
|
errorMessage: t("skillDefinition.statusUpdateFailed"),
|
||||||
|
ariaLabel: (item) =>
|
||||||
|
t("skillDefinition.toggleStatus", { name: item.name }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: t("skillDefinition.updatedAt"),
|
||||||
|
render: (item) => (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div>{formatDateTime(item.updatedAt)}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.updateUserName || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardCrudPage<SkillDefinition, CreateSkillDefinitionPayload>
|
||||||
|
filters={filters}
|
||||||
|
columns={columns}
|
||||||
|
fetchList={(query) =>
|
||||||
|
fetchSkillDefinitions({
|
||||||
|
name: typeof query.name === "string" ? query.name : undefined,
|
||||||
|
status: typeof query.status === "number" ? query.status : undefined,
|
||||||
|
page: Number(query.page),
|
||||||
|
limit: Number(query.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={createSkillDefinition}
|
||||||
|
updateItem={(item, payload) =>
|
||||||
|
updateSkillDefinition({ id: item.id, ...payload })
|
||||||
|
}
|
||||||
|
deleteItem={(item) => deleteSkillDefinition(item.id)}
|
||||||
|
canDelete={(item) => item.status !== Status.Deleted}
|
||||||
|
rowActions={[
|
||||||
|
{
|
||||||
|
key: "debug",
|
||||||
|
icon: <BugIcon />,
|
||||||
|
label: t("skillDefinition.debug"),
|
||||||
|
run: ({ item }) => {
|
||||||
|
setDebuggingItem(item);
|
||||||
|
setDebugDialogOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "restore",
|
||||||
|
icon: <RotateCcwIcon />,
|
||||||
|
label: t("skillDefinition.restore"),
|
||||||
|
visible: (item) => item.status === Status.Deleted,
|
||||||
|
run: async ({ item, reload }) => {
|
||||||
|
await restoreSkillDefinition(item.id);
|
||||||
|
toast.success(t("skillDefinition.restored", { name: item.name }));
|
||||||
|
await reload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderEditDialog={({ open, saving, itemId, onOpenChange, onSubmit }) => (
|
||||||
|
<EditDialog
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
itemId={itemId}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
labels={{
|
||||||
|
refresh: t("skillDefinition.refresh"),
|
||||||
|
create: t("skillDefinition.new"),
|
||||||
|
query: t("skillDefinition.query"),
|
||||||
|
loading: t("skillDefinition.loadingRows"),
|
||||||
|
empty: t("skillDefinition.emptyRows"),
|
||||||
|
actions: t("skillDefinition.actions"),
|
||||||
|
edit: t("skillDefinition.edit"),
|
||||||
|
delete: t("skillDefinition.delete"),
|
||||||
|
processing: t("skillDefinition.processing"),
|
||||||
|
moreActions: (item) =>
|
||||||
|
t("skillDefinition.moreActions", { name: item.name }),
|
||||||
|
loadFailed: t("skillDefinition.loadFailed"),
|
||||||
|
saveFailed: t("skillDefinition.saveFailed"),
|
||||||
|
deleteFailed: t("skillDefinition.deleteFailed"),
|
||||||
|
created: (payload) =>
|
||||||
|
t("skillDefinition.created", { name: payload.name }),
|
||||||
|
updated: (item) => t("skillDefinition.updated", { name: item.name }),
|
||||||
|
deleted: (item) => t("skillDefinition.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DebugDialog
|
||||||
|
open={debugDialogOpen}
|
||||||
|
skillDefinitionId={debuggingItem?.id ?? 0}
|
||||||
|
skillName={debuggingItem?.name ?? ""}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDebuggingItem(null);
|
||||||
|
setDebugDialogOpen(open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
type CollisionDetection,
|
||||||
|
type DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import {
|
||||||
|
ChevronRightIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
EyeOffIcon,
|
||||||
|
FilePlus2Icon,
|
||||||
|
GripVerticalIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
RocketIcon,
|
||||||
|
SaveIcon,
|
||||||
|
SearchIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { useApiErrorHandler } from "@/components/api-error-provider"
|
||||||
|
import { useConfirm } from "@/components/confirm-provider"
|
||||||
|
import { ContentEditor } from "@/components/content-editor"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "@/components/ui/context-menu"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { normalizeSupportSlug } from "@/lib/support-slug"
|
||||||
|
import {
|
||||||
|
changeDocPageStatusAdmin,
|
||||||
|
deleteDocPageAdmin,
|
||||||
|
fetchDocPageAdmin,
|
||||||
|
fetchDocPagesAllAdmin,
|
||||||
|
saveDocPageAdmin,
|
||||||
|
updateDocPageSettingsAdmin,
|
||||||
|
updateDocPageSortAdmin,
|
||||||
|
uploadAsset,
|
||||||
|
type AdminDocPage,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type PageDraft = Pick<AdminDocPage, "parentId" | "title" | "slug" | "summary" | "content" | "contentType" | "tags">
|
||||||
|
type PageSettingsDraft = Pick<PageDraft, "parentId" | "slug" | "summary">
|
||||||
|
type CreateState = { open: boolean; parentId: number }
|
||||||
|
|
||||||
|
const blankCreate: CreateState = { open: false, parentId: 0 }
|
||||||
|
|
||||||
|
function toDraft(page: AdminDocPage): PageDraft {
|
||||||
|
return {
|
||||||
|
parentId: page.parentId ?? 0,
|
||||||
|
title: page.title,
|
||||||
|
slug: page.slug,
|
||||||
|
summary: page.summary ?? "",
|
||||||
|
content: page.content ?? "",
|
||||||
|
contentType: page.contentType || "markdown",
|
||||||
|
tags: page.tags ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendantIds(pages: AdminDocPage[], id: number) {
|
||||||
|
const result = new Set<number>()
|
||||||
|
const visit = (parentId: number) => pages.filter((page) => page.parentId === parentId).forEach((page) => {
|
||||||
|
result.add(page.id)
|
||||||
|
visit(page.id)
|
||||||
|
})
|
||||||
|
visit(id)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function childPages(pages: AdminDocPage[], parentId: number) {
|
||||||
|
return pages
|
||||||
|
.filter((page) => page.parentId === parentId)
|
||||||
|
.sort((left, right) => left.sortNo - right.sortNo || left.id - right.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderSiblings(pages: AdminDocPage[], parentId: number, activeId: number, overId: number) {
|
||||||
|
const siblings = childPages(pages, parentId)
|
||||||
|
const oldIndex = siblings.findIndex((page) => page.id === activeId)
|
||||||
|
const newIndex = siblings.findIndex((page) => page.id === overId)
|
||||||
|
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null
|
||||||
|
const ordered = arrayMove(siblings, oldIndex, newIndex)
|
||||||
|
const sortByID = new Map(ordered.map((page, index) => [page.id, index]))
|
||||||
|
return {
|
||||||
|
ids: ordered.map((page) => page.id),
|
||||||
|
pages: pages.map((page) => page.parentId === parentId ? { ...page, sortNo: sortByID.get(page.id) ?? page.sortNo } : page),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagePath(pages: AdminDocPage[], page: AdminDocPage | null) {
|
||||||
|
if (!page) return []
|
||||||
|
const path: Array<Pick<AdminDocPage, "id" | "title">> = []
|
||||||
|
let parentId = page.parentId
|
||||||
|
while (parentId) {
|
||||||
|
const parent = pages.find((item) => item.id === parentId)
|
||||||
|
if (!parent) break
|
||||||
|
path.unshift({ id: parent.id, title: parent.title })
|
||||||
|
parentId = parent.parentId
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportDocsWorkbench() {
|
||||||
|
const t = useI18n()
|
||||||
|
const handleApiError = useApiErrorHandler()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const [pages, setPages] = useState<AdminDocPage[]>([])
|
||||||
|
const [selected, setSelected] = useState<AdminDocPage | null>(null)
|
||||||
|
const [draft, setDraft] = useState<PageDraft | null>(null)
|
||||||
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [sorting, setSorting] = useState(false)
|
||||||
|
const [activeDragId, setActiveDragId] = useState<number | null>(null)
|
||||||
|
const [createState, setCreateState] = useState<CreateState>(blankCreate)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const selectionRequest = useRef(0)
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
)
|
||||||
|
|
||||||
|
const dirty = Boolean(selected && draft && JSON.stringify(toDraft(selected)) !== JSON.stringify(draft))
|
||||||
|
const sortingDisabled = Boolean(query.trim()) || loading || sorting
|
||||||
|
const activeDragPage = activeDragId ? pages.find((page) => page.id === activeDragId) : undefined
|
||||||
|
|
||||||
|
const siblingCollisionDetection = useCallback<CollisionDetection>((args) => {
|
||||||
|
const activePage = pages.find((page) => page.id === Number(args.active.id))
|
||||||
|
if (!activePage) return []
|
||||||
|
const siblingIDs = new Set(childPages(pages, activePage.parentId).map((page) => page.id))
|
||||||
|
return closestCenter({
|
||||||
|
...args,
|
||||||
|
droppableContainers: args.droppableContainers.filter((container) => siblingIDs.has(Number(container.id))),
|
||||||
|
})
|
||||||
|
}, [pages])
|
||||||
|
|
||||||
|
const statusOptions = useMemo(() => [
|
||||||
|
{ value: "draft", label: t("supportHelpWorkbench.statusDraft") },
|
||||||
|
{ value: "published", label: t("supportHelpWorkbench.statusPublished") },
|
||||||
|
{ value: "hidden", label: t("supportHelpWorkbench.statusHidden") },
|
||||||
|
], [t])
|
||||||
|
|
||||||
|
const load = useCallback(async (preferredId?: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await fetchDocPagesAllAdmin()
|
||||||
|
setPages(result)
|
||||||
|
const requestedId = preferredId ?? selected?.id
|
||||||
|
const id = requestedId && result.some((page) => page.id === requestedId)
|
||||||
|
? requestedId
|
||||||
|
: result[0]?.id
|
||||||
|
if (!id) {
|
||||||
|
setSelected(null)
|
||||||
|
setDraft(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const detail = await fetchDocPageAdmin(id)
|
||||||
|
setSelected(detail)
|
||||||
|
setDraft(toDraft(detail))
|
||||||
|
const ancestors = new Set<number>()
|
||||||
|
let parentId = detail.parentId
|
||||||
|
while (parentId) {
|
||||||
|
ancestors.add(parentId)
|
||||||
|
parentId = result.find((page) => page.id === parentId)?.parentId ?? 0
|
||||||
|
}
|
||||||
|
setExpanded((current) => new Set([...current, ...ancestors]))
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [handleApiError, selected?.id])
|
||||||
|
|
||||||
|
useEffect(() => { void load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
||||||
|
event.preventDefault()
|
||||||
|
void save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handler)
|
||||||
|
return () => window.removeEventListener("keydown", handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectPage(page: AdminDocPage) {
|
||||||
|
if (page.id === selected?.id) return
|
||||||
|
if (dirty) {
|
||||||
|
const leave = await confirm({
|
||||||
|
title: t("supportHelpWorkbench.unsavedLeaveTitle"),
|
||||||
|
description: t("supportHelpWorkbench.unsavedLeaveConfirm"),
|
||||||
|
confirmText: t("supportHelpWorkbench.discardChanges"),
|
||||||
|
cancelText: t("supportHelpWorkbench.continueEditing"),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
if (!leave) return
|
||||||
|
}
|
||||||
|
const requestId = ++selectionRequest.current
|
||||||
|
try {
|
||||||
|
const detail = await fetchDocPageAdmin(page.id)
|
||||||
|
if (requestId !== selectionRequest.current) return
|
||||||
|
setSelected(detail)
|
||||||
|
setDraft(toDraft(detail))
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === selectionRequest.current) handleApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!selected || !draft || !draft.title.trim() || !draft.slug.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const saved = await saveDocPageAdmin({
|
||||||
|
id: selected.id,
|
||||||
|
...draft,
|
||||||
|
status: selected.status,
|
||||||
|
sortNo: draft.parentId === selected.parentId
|
||||||
|
? pages.find((page) => page.id === selected.id)?.sortNo ?? selected.sortNo
|
||||||
|
: childPages(pages, draft.parentId).length,
|
||||||
|
title: draft.title.trim(),
|
||||||
|
slug: draft.slug.trim(),
|
||||||
|
summary: draft.summary.trim(),
|
||||||
|
})
|
||||||
|
setSelected(saved)
|
||||||
|
setDraft(toDraft(saved))
|
||||||
|
setPages((items) => items.map((item) => item.id === saved.id ? saved : item))
|
||||||
|
toast.success(t("supportHelpWorkbench.saved"))
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(settings: PageSettingsDraft) {
|
||||||
|
if (!selected || !draft || !settings.slug.trim() || saving) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const saved = await updateDocPageSettingsAdmin({
|
||||||
|
id: selected.id,
|
||||||
|
parentId: settings.parentId,
|
||||||
|
slug: settings.slug.trim(),
|
||||||
|
summary: settings.summary.trim(),
|
||||||
|
})
|
||||||
|
setSelected(saved)
|
||||||
|
setDraft({ ...draft, parentId: saved.parentId, slug: saved.slug, summary: saved.summary })
|
||||||
|
setPages((items) => items.map((item) => item.id === saved.id ? saved : item))
|
||||||
|
setSettingsOpen(false)
|
||||||
|
toast.success(t("supportHelpWorkbench.settingsSaved"))
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeStatus(status: "draft" | "published" | "hidden") {
|
||||||
|
if (!selected || saving) return
|
||||||
|
if (dirty) {
|
||||||
|
toast.info(t("supportHelpWorkbench.saveBeforeStatusChange"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: t(`supportHelpWorkbench.${status}ConfirmTitle`, { title: selected.title }),
|
||||||
|
description: t(`supportHelpWorkbench.${status}ConfirmDescription`),
|
||||||
|
confirmText: t(`supportHelpWorkbench.${status}Action`),
|
||||||
|
cancelText: t("supportHelpWorkbench.cancel"),
|
||||||
|
variant: status === "hidden" ? "destructive" : "default",
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const changed = await changeDocPageStatusAdmin(selected.id, status)
|
||||||
|
setSelected(changed)
|
||||||
|
setDraft(toDraft(changed))
|
||||||
|
setPages((items) => items.map((item) => item.id === changed.id ? changed : item))
|
||||||
|
toast.success(t(`supportHelpWorkbench.${status}Success`))
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(page: AdminDocPage) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: t("supportHelpWorkbench.deleteConfirmTitle", { title: page.title }),
|
||||||
|
description: t("supportHelpWorkbench.deleteConfirm", { title: page.title }),
|
||||||
|
confirmText: t("supportHelpWorkbench.deletePage"),
|
||||||
|
cancelText: t("supportHelpWorkbench.cancel"),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
try {
|
||||||
|
await deleteDocPageAdmin(page.id)
|
||||||
|
selectionRequest.current += 1
|
||||||
|
toast.success(t("supportHelpWorkbench.deleted"))
|
||||||
|
await load(selected?.id === page.id ? undefined : selected?.id)
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDragEnd(event: DragEndEvent) {
|
||||||
|
setActiveDragId(null)
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id || sortingDisabled) return
|
||||||
|
const activePage = pages.find((page) => page.id === Number(active.id))
|
||||||
|
const overPage = pages.find((page) => page.id === Number(over.id))
|
||||||
|
if (!activePage || !overPage) return
|
||||||
|
if (activePage.parentId !== overPage.parentId) {
|
||||||
|
toast.info(t("supportHelpWorkbench.sameLevelOnly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reordered = reorderSiblings(pages, activePage.parentId, activePage.id, overPage.id)
|
||||||
|
if (!reordered) return
|
||||||
|
const previous = pages
|
||||||
|
setPages(reordered.pages)
|
||||||
|
setSorting(true)
|
||||||
|
try {
|
||||||
|
await updateDocPageSortAdmin({ parentId: activePage.parentId, ids: reordered.ids })
|
||||||
|
toast.success(t("supportHelpWorkbench.sortSaved"))
|
||||||
|
} catch (error) {
|
||||||
|
setPages(previous)
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
setSorting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleIds = useMemo(() => {
|
||||||
|
const keyword = query.trim().toLowerCase()
|
||||||
|
if (!keyword) return null
|
||||||
|
const ids = new Set<number>()
|
||||||
|
for (const page of pages) {
|
||||||
|
if (keyword && !`${page.title} ${page.slug} ${page.summary}`.toLowerCase().includes(keyword)) continue
|
||||||
|
ids.add(page.id)
|
||||||
|
let parentId = page.parentId
|
||||||
|
while (parentId) {
|
||||||
|
ids.add(parentId)
|
||||||
|
parentId = pages.find((item) => item.id === parentId)?.parentId ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}, [pages, query])
|
||||||
|
|
||||||
|
const roots = childPages(pages, 0).filter((page) => !visibleIds || visibleIds.has(page.id))
|
||||||
|
const path = pagePath(pages, selected)
|
||||||
|
const statusLabel = statusOptions.find((item) => item.value === selected?.status)?.label ?? selected?.status
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 overflow-hidden bg-background">
|
||||||
|
<aside className="flex w-72 shrink-0 flex-col border-r bg-muted/20">
|
||||||
|
<div className="flex h-14 items-center justify-between border-b px-3">
|
||||||
|
<span className="font-semibold">{t("supportHelpWorkbench.title")}</span>
|
||||||
|
<Button size="icon" variant="ghost" title={t("supportHelpWorkbench.createPage")} aria-label={t("supportHelpWorkbench.createPage")} onClick={() => setCreateState({ open: true, parentId: 0 })}>
|
||||||
|
<FilePlus2Icon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||||
|
<Input value={query} onChange={(event) => setQuery(event.target.value)} className="pl-9" placeholder={t("supportHelpWorkbench.searchPlaceholder")} />
|
||||||
|
</div>
|
||||||
|
{query.trim() ? <p className="mt-2 text-xs text-muted-foreground">{t("supportHelpWorkbench.sortDisabledDuringSearch")}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={siblingCollisionDetection}
|
||||||
|
onDragStart={(event) => setActiveDragId(Number(event.active.id))}
|
||||||
|
onDragCancel={() => setActiveDragId(null)}
|
||||||
|
onDragEnd={(event) => void handleDragEnd(event)}
|
||||||
|
>
|
||||||
|
<SortableContext items={roots.map((page) => page.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{roots.map((page) => (
|
||||||
|
<PageTreeNode
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
pages={pages}
|
||||||
|
depth={0}
|
||||||
|
selectedId={selected?.id}
|
||||||
|
expanded={expanded}
|
||||||
|
forceExpanded={Boolean(query)}
|
||||||
|
visibleIds={visibleIds}
|
||||||
|
sortingDisabled={sortingDisabled}
|
||||||
|
onToggle={(id) => setExpanded((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})}
|
||||||
|
onSelect={selectPage}
|
||||||
|
onCreate={(parentId) => setCreateState({ open: true, parentId })}
|
||||||
|
onDelete={remove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeDragPage ? <PageDragOverlay page={activeDragPage} /> : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
{!loading && pages.length === 0 ? <div className="px-3 py-10 text-center text-sm text-muted-foreground">{t("supportHelpWorkbench.empty")}</div> : null}
|
||||||
|
{loading ? <div className="px-3 py-10 text-center text-sm text-muted-foreground">{t("supportHelpWorkbench.loading")}</div> : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main key={selected?.id ?? "empty"} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{draft && selected ? <>
|
||||||
|
<div className="sticky top-0 z-20 flex h-14 items-center justify-between gap-4 border-b bg-background px-4">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{path.map((item, index) => <span key={item.id} className="flex min-w-0 items-center gap-2">{index > 0 ? <ChevronRightIcon className="size-3.5 shrink-0" /> : null}<span className="truncate">{item.title}</span></span>)}
|
||||||
|
{path.length > 0 ? <ChevronRightIcon className="size-3.5 shrink-0" /> : null}
|
||||||
|
<Input
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||||
|
className="h-8 min-w-32 max-w-xl flex-1 border-border/60 bg-muted/30 px-2 text-sm font-medium text-foreground shadow-none hover:border-border hover:bg-muted/50 focus-visible:bg-background"
|
||||||
|
placeholder={t("supportHelpWorkbench.pageTitle")}
|
||||||
|
aria-label={t("supportHelpWorkbench.pageTitle")}
|
||||||
|
/>
|
||||||
|
<Badge variant="outline" className="ml-1 shrink-0">{statusLabel}</Badge>
|
||||||
|
{selected.status === "published" && dirty ? <span className="hidden text-xs text-amber-600 xl:inline dark:text-amber-400">{t("supportHelpWorkbench.publishedSaveWarning")}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className={cn("hidden text-xs sm:inline", dirty ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")}>{dirty ? t("supportHelpWorkbench.unsaved") : t("supportHelpWorkbench.savedState")}</span>
|
||||||
|
<Button size="icon" variant="ghost" title={t("supportHelpWorkbench.pageSettings")} aria-label={t("supportHelpWorkbench.pageSettings")} onClick={() => setSettingsOpen(true)}><Settings2Icon /></Button>
|
||||||
|
{selected.status === "published" ? <Button size="icon" variant="ghost" nativeButton={false} title={t("supportHelpWorkbench.openPublicPage")} aria-label={t("supportHelpWorkbench.openPublicPage")} render={<a href={`/support/docs/${selected.slug}`} target="_blank" rel="noreferrer" />}><ExternalLinkIcon /></Button> : null}
|
||||||
|
<Button variant={selected.status === "published" ? "default" : "outline"} onClick={() => void save()} disabled={!dirty || saving} title={t("supportHelpWorkbench.save")}><SaveIcon /><span className="hidden xl:inline">{saving ? t("supportHelpWorkbench.saving") : t("supportHelpWorkbench.save")}</span></Button>
|
||||||
|
{selected.status === "published" ? <Button variant="outline" onClick={() => void changeStatus("draft")} disabled={saving} title={t("supportHelpWorkbench.withdrawAction")}><EyeOffIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.withdrawAction")}</span></Button> : selected.status !== "hidden" ? <Button variant="outline" onClick={() => void changeStatus("hidden")} disabled={saving} title={t("supportHelpWorkbench.hiddenAction")}><EyeOffIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.hiddenAction")}</span></Button> : null}
|
||||||
|
{selected.status !== "published" ? <Button onClick={() => void changeStatus("published")} disabled={saving || dirty} title={t("supportHelpWorkbench.publishedAction")}><RocketIcon /><span className="hidden xl:inline">{t("supportHelpWorkbench.publishedAction")}</span></Button> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col bg-card">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<ContentEditor
|
||||||
|
value={{ mode: draft.contentType === "html" ? "html" : "markdown", raw: draft.content }}
|
||||||
|
onChange={(content) => setDraft({ ...draft, content: content.raw, contentType: content.mode })}
|
||||||
|
onUploadImage={async (file) => {
|
||||||
|
const asset = await uploadAsset(file, "support/docs")
|
||||||
|
return { url: asset.url, alt: asset.filename, title: asset.filename }
|
||||||
|
}}
|
||||||
|
placeholder={t("supportHelpWorkbench.contentPlaceholder")}
|
||||||
|
height="100%"
|
||||||
|
className="min-h-0 flex-1 [&>div]:h-full [&>div]:rounded-none [&>div]:border-0 [&>div]:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</> : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("supportHelpWorkbench.selectPrompt")}</div>}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<CreatePageDialog key={`${createState.open}-${createState.parentId}`} state={createState} pages={pages} onOpenChange={(open) => setCreateState((current) => ({ ...current, open }))} onCreated={async (id) => { setCreateState(blankCreate); await load(id) }} />
|
||||||
|
<PageSettingsDialog key={`${selected?.id ?? 0}-${settingsOpen}`} open={settingsOpen} onOpenChange={setSettingsOpen} pages={pages} selected={selected} draft={draft} saving={saving} onSave={saveSettings} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageTreeNodeProps = {
|
||||||
|
page: AdminDocPage
|
||||||
|
pages: AdminDocPage[]
|
||||||
|
depth: number
|
||||||
|
selectedId?: number
|
||||||
|
expanded: Set<number>
|
||||||
|
forceExpanded: boolean
|
||||||
|
visibleIds: Set<number> | null
|
||||||
|
sortingDisabled: boolean
|
||||||
|
onToggle: (id: number) => void
|
||||||
|
onSelect: (page: AdminDocPage) => void
|
||||||
|
onCreate: (parentId: number) => void
|
||||||
|
onDelete: (page: AdminDocPage) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageTreeNode(props: PageTreeNodeProps) {
|
||||||
|
const { page, pages, depth, selectedId, expanded, forceExpanded, visibleIds, sortingDisabled, onToggle, onSelect, onCreate, onDelete } = props
|
||||||
|
const t = useI18n()
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: page.id, disabled: sortingDisabled })
|
||||||
|
const children = childPages(pages, page.id).filter((item) => !visibleIds || visibleIds.has(item.id))
|
||||||
|
const open = forceExpanded || expanded.has(page.id)
|
||||||
|
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition: isDragging ? undefined : transition }
|
||||||
|
|
||||||
|
return <div ref={setNodeRef} style={style} className={cn(isDragging && "relative z-10 opacity-30")}>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger className="block">
|
||||||
|
<div className={cn("group flex h-9 items-center rounded-md pr-1 text-sm", selectedId === page.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/60", isDragging && "shadow-sm")} style={{ paddingLeft: 4 + depth * 16 }}>
|
||||||
|
<button type="button" className="flex size-7 shrink-0 items-center justify-center" onClick={() => children.length && onToggle(page.id)} aria-label={open ? t("supportHelpWorkbench.collapse") : t("supportHelpWorkbench.expand")}>
|
||||||
|
<ChevronRightIcon className={cn("size-4 transition-transform", !children.length && "opacity-0", open && "rotate-90")} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="min-w-0 flex-1 truncate text-left" onClick={() => void onSelect(page)}>{page.title}</button>
|
||||||
|
<span className={cn("mr-1 size-1.5 rounded-full", page.status === "published" ? "bg-emerald-500" : page.status === "hidden" ? "bg-amber-500" : "bg-muted-foreground/40")} />
|
||||||
|
<button type="button" className="flex size-7 shrink-0 cursor-grab items-center justify-center text-muted-foreground touch-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-35" disabled={sortingDisabled} aria-label={t("supportHelpWorkbench.dragPage", { title: page.title })} title={sortingDisabled ? t("supportHelpWorkbench.dragDisabled") : t("supportHelpWorkbench.dragPage", { title: page.title })} {...attributes} {...listeners}>
|
||||||
|
<GripVerticalIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={<Button size="icon-sm" variant="ghost" className="opacity-0 group-hover:opacity-100 focus:opacity-100" aria-label={t("supportHelpWorkbench.pageActions", { title: page.title })} />}><MoreHorizontalIcon /></DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => onCreate(page.id)}><FilePlus2Icon />{t("supportHelpWorkbench.createChildPage")}</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={() => void onDelete(page)}><Trash2Icon />{t("supportHelpWorkbench.deletePage")}</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-40">
|
||||||
|
<ContextMenuItem onClick={() => onCreate(page.id)}><FilePlus2Icon />{t("supportHelpWorkbench.createChildPage")}</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem variant="destructive" onClick={() => void onDelete(page)}><Trash2Icon />{t("supportHelpWorkbench.deletePage")}</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
{open ? <SortableContext items={children.map((child) => child.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{children.map((child) => <PageTreeNode key={child.id} {...props} page={child} depth={depth + 1} />)}
|
||||||
|
</SortableContext> : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageDragOverlay({ page }: { page: AdminDocPage }) {
|
||||||
|
return <div className="flex h-9 w-64 items-center rounded-md border bg-background px-3 text-sm shadow-lg">
|
||||||
|
<GripVerticalIcon className="mr-2 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-medium">{page.title}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatePageDialog({ state, pages, onOpenChange, onCreated }: { state: CreateState; pages: AdminDocPage[]; onOpenChange: (open: boolean) => void; onCreated: (id: number) => void }) {
|
||||||
|
const t = useI18n()
|
||||||
|
const handleApiError = useApiErrorHandler()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [slug, setSlug] = useState("")
|
||||||
|
const [parentId, setParentId] = useState(state.parentId)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!title.trim() || !slug.trim() || creating) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const page = await saveDocPageAdmin({
|
||||||
|
parentId,
|
||||||
|
title: title.trim(),
|
||||||
|
slug: slug.trim(),
|
||||||
|
summary: "",
|
||||||
|
contentType: "markdown",
|
||||||
|
content: "",
|
||||||
|
tags: [],
|
||||||
|
status: "draft",
|
||||||
|
sortNo: childPages(pages, parentId).length,
|
||||||
|
})
|
||||||
|
toast.success(t("supportHelpWorkbench.created"))
|
||||||
|
onCreated(page.id)
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dialog open={state.open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{t("supportHelpWorkbench.createPage")}</DialogTitle><DialogDescription>{t("supportHelpWorkbench.createDescription")}</DialogDescription></DialogHeader>
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.pageTitle")}</Label><Input autoFocus value={title} onChange={(event) => { setTitle(event.target.value); if (!slug) setSlug(normalizeSupportSlug(event.target.value)) }} /></div>
|
||||||
|
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.slug")}</Label><Input value={slug} onChange={(event) => setSlug(normalizeSupportSlug(event.target.value))} placeholder="getting-started" /><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugFormatHint")}</p></div>
|
||||||
|
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.parentPage")}</Label><OptionCombobox value={String(parentId)} onChange={(value) => setParentId(Number(value))} placeholder={t("supportHelpWorkbench.selectParentPage")} options={[{ value: "0", label: t("supportHelpWorkbench.rootDirectory") }, ...pages.map((page) => ({ value: String(page.id), label: page.title }))]} /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button variant="outline" disabled={creating} onClick={() => onOpenChange(false)}>{t("supportHelpWorkbench.cancel")}</Button><Button disabled={!title.trim() || !slug.trim() || creating} onClick={() => void create()}>{creating ? t("supportHelpWorkbench.creating") : t("supportHelpWorkbench.createAndEdit")}</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageSettingsDialog({ open, onOpenChange, pages, selected, draft, saving, onSave }: { open: boolean; onOpenChange: (open: boolean) => void; pages: AdminDocPage[]; selected: AdminDocPage | null; draft: PageDraft | null; saving: boolean; onSave: (settings: PageSettingsDraft) => void }) {
|
||||||
|
const t = useI18n()
|
||||||
|
const [settings, setSettings] = useState<PageSettingsDraft>(() => ({ parentId: draft?.parentId ?? 0, slug: draft?.slug ?? "", summary: draft?.summary ?? "" }))
|
||||||
|
if (!draft || !selected) return null
|
||||||
|
const excluded = descendantIds(pages, selected.id)
|
||||||
|
excluded.add(selected.id)
|
||||||
|
const options = [{ value: "0", label: t("supportHelpWorkbench.rootDirectory") }, ...pages.filter((page) => !excluded.has(page.id)).map((page) => ({ value: String(page.id), label: page.title }))]
|
||||||
|
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{t("supportHelpWorkbench.pageSettings")}</DialogTitle></DialogHeader>
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.parentPage")}</Label><OptionCombobox value={String(settings.parentId)} onChange={(value) => setSettings({ ...settings, parentId: Number(value) })} placeholder={t("supportHelpWorkbench.selectParentPage")} options={options} /></div>
|
||||||
|
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.slug")}</Label><Input value={settings.slug} onChange={(event) => setSettings({ ...settings, slug: normalizeSupportSlug(event.target.value) })} /><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugFormatHint")}</p><p className="text-xs leading-5 text-muted-foreground">{t("supportHelpWorkbench.slugChangeWarning")}</p></div>
|
||||||
|
<div className="grid gap-2"><Label>{t("supportHelpWorkbench.summary")}</Label><Textarea value={settings.summary} onChange={(event) => setSettings({ ...settings, summary: event.target.value })} rows={4} placeholder={t("supportHelpWorkbench.summaryPlaceholder")} /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button variant="outline" disabled={saving} onClick={() => onOpenChange(false)}>{t("supportHelpWorkbench.cancel")}</Button><Button disabled={!settings.slug.trim() || saving} onClick={() => onSave(settings)}>{saving ? t("supportHelpWorkbench.saving") : t("supportHelpWorkbench.save")}</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { MessageSquareTextIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { DashboardCrudPage } from "@/components/dashboard/crud"
|
||||||
|
import { DashboardPage } from "@/components/dashboard-page"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
acceptCommunityCommentAdmin,
|
||||||
|
createCommunityCommentAdmin,
|
||||||
|
deleteCommunityCategoryAdmin,
|
||||||
|
fetchCommunityPostAdmin,
|
||||||
|
fetchCommunityCategoriesAllAdmin,
|
||||||
|
fetchCommunityCategoriesAdmin,
|
||||||
|
fetchCommunityPostsAdmin,
|
||||||
|
moderateCommunityPostAdmin,
|
||||||
|
saveCommunityCategoryAdmin,
|
||||||
|
updateCommunityCategorySortAdmin,
|
||||||
|
type AdminCategory,
|
||||||
|
type AdminPost,
|
||||||
|
type AdminPostDetail,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { formatDateTime } from "@/lib/utils"
|
||||||
|
import { normalizeSupportSlug, supportSlugPattern } from "@/lib/support-slug"
|
||||||
|
|
||||||
|
function getPostStatusOptions(t: (key: string) => string) {
|
||||||
|
return [
|
||||||
|
{ value: "all", label: t("supportCommunityAdmin.allStatuses") },
|
||||||
|
{ value: "pending", label: t("supportCommunityAdmin.statusPending") },
|
||||||
|
{ value: "normal", label: t("supportCommunityAdmin.statusNormal") },
|
||||||
|
{ value: "resolved", label: t("supportCommunityAdmin.statusResolved") },
|
||||||
|
{ value: "closed", label: t("supportCommunityAdmin.statusClosed") },
|
||||||
|
{ value: "hidden", label: t("supportCommunityAdmin.statusHidden") },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryPayload = Pick<
|
||||||
|
AdminCategory,
|
||||||
|
"name" | "slug" | "description" | "status" | "remark"
|
||||||
|
>
|
||||||
|
|
||||||
|
function postStatusLabel(status: string, t: (key: string) => string) {
|
||||||
|
return getPostStatusOptions(t).find((item) => item.value === status)?.label ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSupportCommunityCategoryAdmin() {
|
||||||
|
const t = useI18n()
|
||||||
|
const categoryStatusOptions = [
|
||||||
|
{ value: "all", label: t("supportCommunityCategory.allStatuses") },
|
||||||
|
{ value: "0", label: t("supportCommunityCategory.enabled") },
|
||||||
|
{ value: "1", label: t("supportCommunityCategory.disabled") },
|
||||||
|
]
|
||||||
|
const crudFormLabels = {
|
||||||
|
create: t("supportCommunityCategory.create"),
|
||||||
|
save: t("supportCommunityCategory.save"),
|
||||||
|
saving: t("supportCommunityCategory.saving"),
|
||||||
|
cancel: t("supportCommunityCategory.cancel"),
|
||||||
|
loadingDetail: t("supportCommunityCategory.loadingDetail"),
|
||||||
|
required: t("supportCommunityCategory.required"),
|
||||||
|
invalidNumber: t("supportCommunityCategory.invalidNumber"),
|
||||||
|
minValue: (min: number) => t("supportCommunityCategory.minValue", { min }),
|
||||||
|
maxValue: (max: number) => t("supportCommunityCategory.maxValue", { max }),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCrudPage<AdminCategory, CategoryPayload>
|
||||||
|
filters={[
|
||||||
|
{ name: "name", label: t("supportCommunityCategory.name"), placeholder: t("supportCommunityCategory.searchName"), defaultValue: "", trim: true, className: "w-full sm:w-72" },
|
||||||
|
{ name: "status", label: t("supportCommunityCategory.allStatuses"), placeholder: t("supportCommunityCategory.allStatuses"), type: "select", defaultValue: "all", allValue: "all", valueType: "number", options: categoryStatusOptions, className: "w-full sm:w-36" },
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{ key: "name", label: t("supportCommunityCategory.name"), render: (item) => <div><div className="font-medium">{item.name}</div><div className="mt-1 text-sm text-muted-foreground">{item.description || t("supportCommunityCategory.noDescription")}</div></div> },
|
||||||
|
{ key: "slug", label: "Slug", className: "w-48", render: (item) => <span className="font-mono text-sm">{item.slug}</span> },
|
||||||
|
{ key: "status", label: t("supportCommunityCategory.status"), className: "w-28", render: (item) => <Badge variant={item.status === 0 ? "default" : "outline"}>{item.status === 0 ? t("supportCommunityCategory.enabled") : t("supportCommunityCategory.disabled")}</Badge> },
|
||||||
|
]}
|
||||||
|
fetchList={fetchCommunityCategoriesAdmin}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
createItem={(payload) => saveCommunityCategoryAdmin(payload)}
|
||||||
|
updateItem={(item, payload) => saveCommunityCategoryAdmin({ id: item.id, ...payload })}
|
||||||
|
deleteItem={(item) => deleteCommunityCategoryAdmin(item.id)}
|
||||||
|
deleteConfirm={(item) => ({ title: t("supportCommunityCategory.confirmDeleteTitle"), description: t("supportCommunityCategory.confirmDeleteDescription", { name: item.name }), confirmText: t("supportCommunityCategory.delete"), variant: "destructive" })}
|
||||||
|
sort={{
|
||||||
|
enabled: true,
|
||||||
|
onReorder: (items) => updateCommunityCategorySortAdmin(items.map((item) => item.id)),
|
||||||
|
successMessage: t("supportCommunityCategory.sortUpdated"),
|
||||||
|
errorMessage: t("supportCommunityCategory.sortUpdateFailed"),
|
||||||
|
handleLabel: t("supportCommunityCategory.dragSort", { name: "" }),
|
||||||
|
}}
|
||||||
|
form={{
|
||||||
|
fields: [
|
||||||
|
{ name: "name", label: t("supportCommunityCategory.name"), placeholder: t("supportCommunityCategory.namePlaceholder"), required: true, trim: true },
|
||||||
|
{ name: "slug", label: "Slug", placeholder: t("supportCommunityCategory.slugPlaceholder"), required: true, trim: true, normalizeInput: normalizeSupportSlug, pattern: supportSlugPattern, patternMessage: t("supportCommunityCategory.slugPatternMessage") },
|
||||||
|
{ name: "description", label: t("supportCommunityCategory.description"), placeholder: t("supportCommunityCategory.descriptionPlaceholder"), type: "textarea", rows: 3, trim: true },
|
||||||
|
{ name: "status", label: t("supportCommunityCategory.status"), type: "select", defaultValue: "0", valueType: "number", required: true, options: categoryStatusOptions.filter((item) => item.value !== "all"), valueFromItem: (item) => String(item.status) },
|
||||||
|
{ name: "remark", label: t("supportCommunityCategory.remark"), placeholder: t("supportCommunityCategory.remarkPlaceholder"), type: "textarea", rows: 3, trim: true },
|
||||||
|
],
|
||||||
|
transformSubmitValues: (values) => ({ name: String(values.name), slug: normalizeSupportSlug(String(values.slug)), description: String(values.description ?? ""), status: Number(values.status), remark: String(values.remark ?? "") }),
|
||||||
|
labels: { ...crudFormLabels, createTitle: t("supportCommunityCategory.createTitle"), editTitle: t("supportCommunityCategory.editTitle") },
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
refresh: t("supportCommunityCategory.refresh"), create: t("supportCommunityCategory.new"), query: t("supportCommunityCategory.query"), loading: t("supportCommunityCategory.loading"), empty: t("supportCommunityCategory.empty"), actions: t("supportCommunityCategory.actions"), edit: t("supportCommunityCategory.edit"), delete: t("supportCommunityCategory.delete"), processing: t("supportCommunityCategory.processing"), moreActions: (item) => t("supportCommunityCategory.moreActions", { name: item.name }), loadFailed: t("supportCommunityCategory.loadFailed"), saveFailed: t("supportCommunityCategory.saveFailed"), deleteFailed: t("supportCommunityCategory.deleteFailed"), created: (payload) => t("supportCommunityCategory.created", { name: payload.name }), updated: (item) => t("supportCommunityCategory.updated", { name: item.name }), deleted: (item) => t("supportCommunityCategory.deleted", { name: item.name }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSupportCommunityAdmin() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [categories, setCategories] = useState<AdminCategory[]>([])
|
||||||
|
const [posts, setPosts] = useState<AdminPost[]>([])
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "all">("all")
|
||||||
|
const [status, setStatus] = useState("all")
|
||||||
|
const [detail, setDetail] = useState<AdminPostDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [comment, setComment] = useState("")
|
||||||
|
|
||||||
|
const reloadPosts = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const page = await fetchCommunityPostsAdmin({ categoryId: categoryId === "all" ? undefined : categoryId, status: status === "all" ? undefined : status, limit: 50 })
|
||||||
|
setPosts(page.results)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [categoryId, status])
|
||||||
|
|
||||||
|
useEffect(() => { void fetchCommunityCategoriesAllAdmin().then(setCategories) }, [])
|
||||||
|
useEffect(() => { void reloadPosts() }, [reloadPosts])
|
||||||
|
|
||||||
|
const openPost = async (id: number) => setDetail(await fetchCommunityPostAdmin(id))
|
||||||
|
const refreshDetail = async () => { if (detail) setDetail(await fetchCommunityPostAdmin(detail.post.id)) }
|
||||||
|
|
||||||
|
const moderate = async (nextStatus: string) => {
|
||||||
|
if (!detail) return
|
||||||
|
await moderateCommunityPostAdmin(detail.post.id, nextStatus)
|
||||||
|
toast.success(t("supportCommunityAdmin.postStatusUpdated"))
|
||||||
|
await Promise.all([refreshDetail(), reloadPosts()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const postStatusOptions = getPostStatusOptions(t)
|
||||||
|
|
||||||
|
const reply = async () => {
|
||||||
|
if (!detail || !comment.trim()) return
|
||||||
|
await createCommunityCommentAdmin(detail.post.id, comment.trim())
|
||||||
|
setComment("")
|
||||||
|
toast.success(t("supportCommunityAdmin.commentSuccess"))
|
||||||
|
await Promise.all([refreshDetail(), reloadPosts()])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPage>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
|
||||||
|
<div className="w-full sm:w-44">
|
||||||
|
<OptionCombobox
|
||||||
|
value={String(categoryId)}
|
||||||
|
onChange={(value) => setCategoryId(value === "all" ? "all" : Number(value))}
|
||||||
|
placeholder={t("supportCommunityAdmin.allCategories")}
|
||||||
|
options={[{ value: "all", label: t("supportCommunityAdmin.allCategories") }, ...categories.map((item) => ({ value: String(item.id), label: item.name }))]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-36">
|
||||||
|
<OptionCombobox
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
placeholder={t("supportCommunityAdmin.allStatuses")}
|
||||||
|
options={postStatusOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => void reloadPosts()} disabled={loading}>{t("supportCommunityAdmin.refresh")}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<button key={post.id} type="button" className="flex w-full items-center gap-4 border-b px-4 py-3 text-left last:border-b-0 hover:bg-muted/50" onClick={() => void openPost(post.id)}>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted"><MessageSquareTextIcon className="size-4 text-muted-foreground" /></span>
|
||||||
|
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{post.title}</span><span className="mt-1 block text-sm text-muted-foreground">{post.userName || t("supportCommunityAdmin.defaultUser")} · {post.categoryName || t("supportCommunityAdmin.uncategorized")} · {formatDateTime(post.createdAt)}</span></span>
|
||||||
|
<span className="hidden text-sm text-muted-foreground sm:block">{t("supportCommunityAdmin.commentCount", { count: post.commentCount })}</span>
|
||||||
|
<Badge variant={post.status === "resolved" ? "default" : "outline"}>{postStatusLabel(post.status, t)}</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!loading && posts.length === 0 ? <div className="py-16 text-center text-sm text-muted-foreground">{t("supportCommunityAdmin.noMatchingPosts")}</div> : null}
|
||||||
|
{loading ? <div className="py-16 text-center text-sm text-muted-foreground">{t("supportCommunityAdmin.loadingPosts")}</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectDialog open={Boolean(detail)} onOpenChange={(open) => { if (!open) setDetail(null) }} title={detail?.post.title ?? t("supportCommunityAdmin.moderateDialogTitle")} description={detail ? `${detail.post.userName || t("supportCommunityAdmin.defaultUser")} · ${detail.post.categoryName || t("supportCommunityAdmin.uncategorized")} · ${formatDateTime(detail.post.createdAt)}` : undefined} size="xl" allowFullscreen>
|
||||||
|
{detail ? (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2"><Badge variant={detail.post.status === "resolved" ? "default" : "outline"}>{postStatusLabel(detail.post.status, t)}</Badge><span className="text-sm text-muted-foreground">{t("supportCommunityAdmin.likesAndViews", { likes: detail.post.reactionCount, views: detail.post.viewCount })}</span></div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-7">{detail.post.content}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 border-y py-3">
|
||||||
|
<Button variant="outline" onClick={() => void moderate("normal")}>{t("supportCommunityAdmin.restoreNormalAction")}</Button>
|
||||||
|
<Button variant="outline" onClick={() => void moderate("closed")}>{t("supportCommunityAdmin.closePostAction")}</Button>
|
||||||
|
<Button variant="destructive" onClick={() => void moderate("hidden")}>{t("supportCommunityAdmin.hidePostAction")}</Button>
|
||||||
|
</div>
|
||||||
|
<Card><CardHeader><CardTitle className="text-base">{t("supportCommunityAdmin.commentsTitle", { count: detail.comments.length })}</CardTitle></CardHeader><CardContent className="grid gap-3">{detail.comments.map((item) => <div key={item.id} className="rounded-md border p-3"><div className="flex items-center justify-between gap-3"><span className="text-sm font-medium">{item.authorName || item.authorType}</span>{item.isAccepted ? <Badge>{t("supportCommunityAdmin.acceptedBadge")}</Badge> : <Button size="sm" variant="outline" onClick={() => void acceptCommunityCommentAdmin(detail.post.id, item.id).then(refreshDetail)}>{t("supportCommunityAdmin.acceptAnswerAction")}</Button>}</div><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{item.content}</p></div>)}</CardContent></Card>
|
||||||
|
<div className="grid gap-2"><Textarea value={comment} onChange={(event) => setComment(event.target.value)} rows={5} placeholder={t("supportCommunityAdmin.commentPlaceholder")} /><div className="flex justify-end"><Button onClick={() => void reply()} disabled={!comment.trim()}>{t("supportCommunityAdmin.publishCommentAction")}</Button></div></div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ProjectDialog>
|
||||||
|
</DashboardPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import { ExternalLinkIcon, GripVerticalIcon, PlusIcon, RefreshCwIcon, SaveIcon, Trash2Icon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar } from "@/components/dashboard-page"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import {
|
||||||
|
fetchSupportConfigAdmin,
|
||||||
|
saveSupportConfigAdmin,
|
||||||
|
type SupportNavigationMenuItem,
|
||||||
|
} from "@/lib/api/admin"
|
||||||
|
import { isApiRequestError } from "@/lib/api/client"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type ConfigFieldError = {
|
||||||
|
path: string
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavigationMenuRowProps = {
|
||||||
|
item: SupportNavigationMenuItem
|
||||||
|
disabled: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
onChange: (id: string, values: Partial<SupportNavigationMenuItem>) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMenuItem = (): SupportNavigationMenuItem => ({
|
||||||
|
id: `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
title: "",
|
||||||
|
url: "/support",
|
||||||
|
openInNewWindow: false,
|
||||||
|
visible: true,
|
||||||
|
sortNo: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeRows(rows: SupportNavigationMenuItem[]) {
|
||||||
|
return rows.map((item, index) => ({ ...item, sortNo: (index + 1) * 10 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRows(rows: SupportNavigationMenuItem[]) {
|
||||||
|
return JSON.stringify(
|
||||||
|
rows.map(({ id, title, url, openInNewWindow, visible }) => ({
|
||||||
|
id,
|
||||||
|
title: title.trim(),
|
||||||
|
url: url.trim(),
|
||||||
|
openInNewWindow,
|
||||||
|
visible,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportConfigPanel() {
|
||||||
|
const t = useI18n()
|
||||||
|
const [items, setItems] = useState<SupportNavigationMenuItem[]>([])
|
||||||
|
const [savedSnapshot, setSavedSnapshot] = useState("")
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<ConfigFieldError[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor),
|
||||||
|
useSensor(TouchSensor),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
)
|
||||||
|
|
||||||
|
const dirty = useMemo(() => serializeRows(items) !== savedSnapshot, [items, savedSnapshot])
|
||||||
|
const canDelete = items.length > 1
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const config = await fetchSupportConfigAdmin()
|
||||||
|
const nextItems = normalizeRows(config.navigationMenu)
|
||||||
|
setItems(nextItems)
|
||||||
|
setSavedSnapshot(serializeRows(nextItems))
|
||||||
|
setFieldErrors([])
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("supportConfig.loadFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConfig()
|
||||||
|
}, [loadConfig])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ""
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
}, [dirty])
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
setItems((current) => normalizeRows([...current, newMenuItem()]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(id: string, values: Partial<SupportNavigationMenuItem>) {
|
||||||
|
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...values } : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
const item = items.find((candidate) => candidate.id === id)
|
||||||
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!window.confirm(t("supportConfig.confirmDelete", { title: item.title || t("supportConfig.untitled") }))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setItems((current) => normalizeRows(current.filter((candidate) => candidate.id !== id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setItems((current) => {
|
||||||
|
const oldIndex = current.findIndex((item) => item.id === active.id)
|
||||||
|
const newIndex = current.findIndex((item) => item.id === over.id)
|
||||||
|
if (oldIndex < 0 || newIndex < 0) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
return normalizeRows(arrayMove(current, oldIndex, newIndex))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
const config = await saveSupportConfigAdmin({ navigationMenu: items })
|
||||||
|
const saved = normalizeRows(config.navigationMenu)
|
||||||
|
setItems(saved)
|
||||||
|
setSavedSnapshot(serializeRows(saved))
|
||||||
|
setFieldErrors([])
|
||||||
|
toast.success(t("supportConfig.saved"))
|
||||||
|
} catch (error) {
|
||||||
|
if (isApiRequestError(error)) {
|
||||||
|
setFieldErrors(extractConfigFieldErrors(error.data))
|
||||||
|
}
|
||||||
|
toast.error(error instanceof Error ? error.message : t("supportConfig.saveFailed"))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPage>
|
||||||
|
<DashboardToolbar
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={() => void loadConfig()} disabled={loading || saving}>
|
||||||
|
<RefreshCwIcon className={cn((loading || saving) && "animate-spin")} />
|
||||||
|
{t("supportConfig.refresh")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void handleSave()} disabled={loading || saving || !dirty}>
|
||||||
|
<SaveIcon />
|
||||||
|
{saving ? t("supportConfig.saving") : t("supportConfig.save")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">{t("supportConfig.title")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t("supportConfig.description")}</p>
|
||||||
|
</div>
|
||||||
|
</DashboardToolbar>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium">{t("supportConfig.navigationTitle")}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t("supportConfig.navigationDescription")}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAdd} disabled={loading || saving}>
|
||||||
|
<PlusIcon />
|
||||||
|
{t("supportConfig.addNavigation")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fieldErrors.length > 0 ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
<div className="font-medium">{t("supportConfig.validationFailed")}</div>
|
||||||
|
<ul className="mt-1 list-disc space-y-1 pl-5">
|
||||||
|
{fieldErrors.map((error) => (
|
||||||
|
<li key={`${error.path}-${error.code}`}>{error.path ? `${error.path}: ${error.message}` : error.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DashboardTableShell>
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">{t("supportConfig.sort")}</TableHead>
|
||||||
|
<TableHead className="min-w-44">{t("supportConfig.menuTitle")}</TableHead>
|
||||||
|
<TableHead className="min-w-64">{t("supportConfig.menuURL")}</TableHead>
|
||||||
|
<TableHead className="w-40">{t("supportConfig.target")}</TableHead>
|
||||||
|
<TableHead className="w-28">{t("supportConfig.visible")}</TableHead>
|
||||||
|
<TableHead className="w-20 text-right">{t("supportConfig.actions")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<DashboardTableStateRow colSpan={6} loading loadingText={t("supportConfig.loading")} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<DashboardTableStateRow colSpan={6} emptyText={t("supportConfig.emptyNavigation")} />
|
||||||
|
) : (
|
||||||
|
<SortableContext items={items.map((item) => item.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavigationMenuRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
disabled={saving}
|
||||||
|
canDelete={canDelete}
|
||||||
|
onChange={handleChange}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DndContext>
|
||||||
|
</DashboardTableShell>
|
||||||
|
</section>
|
||||||
|
</DashboardPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractConfigFieldErrors(data: unknown): ConfigFieldError[] {
|
||||||
|
if (!data || typeof data !== "object" || !("errors" in data)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const errors = (data as { errors?: unknown }).errors
|
||||||
|
if (!Array.isArray(errors)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return errors.filter((item): item is ConfigFieldError => {
|
||||||
|
return Boolean(
|
||||||
|
item &&
|
||||||
|
typeof item === "object" &&
|
||||||
|
"path" in item &&
|
||||||
|
"code" in item &&
|
||||||
|
"message" in item &&
|
||||||
|
typeof (item as ConfigFieldError).path === "string" &&
|
||||||
|
typeof (item as ConfigFieldError).code === "string" &&
|
||||||
|
typeof (item as ConfigFieldError).message === "string"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuRow({ item, disabled, canDelete, onChange, onDelete }: NavigationMenuRowProps) {
|
||||||
|
const t = useI18n()
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: item.id,
|
||||||
|
disabled,
|
||||||
|
})
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow ref={setNodeRef} style={style} className={cn(isDragging && "relative z-10 bg-muted/60 shadow-sm")}>
|
||||||
|
<TableCell className="w-12">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="size-8 cursor-grab rounded-md active:cursor-grabbing"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t("supportConfig.dragNavigation", { title: item.title || t("supportConfig.untitled") })}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-44 whitespace-normal">
|
||||||
|
<Label className="sr-only" htmlFor={`support-nav-title-${item.id}`}>{t("supportConfig.menuTitle")}</Label>
|
||||||
|
<Input
|
||||||
|
id={`support-nav-title-${item.id}`}
|
||||||
|
value={item.title}
|
||||||
|
onChange={(event) => onChange(item.id, { title: event.target.value })}
|
||||||
|
placeholder={t("supportConfig.titlePlaceholder")}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-64 whitespace-normal">
|
||||||
|
<Label className="sr-only" htmlFor={`support-nav-url-${item.id}`}>{t("supportConfig.menuURL")}</Label>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id={`support-nav-url-${item.id}`}
|
||||||
|
value={item.url}
|
||||||
|
onChange={(event) => onChange(item.id, { url: event.target.value })}
|
||||||
|
placeholder="/support/docs"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{item.url ? (
|
||||||
|
<a
|
||||||
|
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={t("supportConfig.openURL")}
|
||||||
|
title={t("supportConfig.openURL")}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-40">
|
||||||
|
<Select value={item.openInNewWindow ? "_blank" : "_self"} onValueChange={(value) => onChange(item.id, { openInNewWindow: value === "_blank" })} disabled={disabled}>
|
||||||
|
<SelectTrigger className="w-36 rounded-md">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_self">{t("supportConfig.targetSelf")}</SelectItem>
|
||||||
|
<SelectItem value="_blank">{t("supportConfig.targetBlank")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-28">
|
||||||
|
<Switch
|
||||||
|
checked={item.visible}
|
||||||
|
onCheckedChange={(checked) => onChange(item.id, { visible: checked })}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t("supportConfig.toggleNavigation", { title: item.title || t("supportConfig.untitled") })}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-20 text-right">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="size-8 rounded-md text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
disabled={disabled || !canDelete}
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
aria-label={t("supportConfig.deleteNavigation", { title: item.title || t("supportConfig.untitled") })}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardSupportCommunityCategoryAdmin } from "../_components/support-admin"
|
||||||
|
|
||||||
|
export default function DashboardSupportCommunityCategoriesPage() {
|
||||||
|
return <DashboardSupportCommunityCategoryAdmin />
|
||||||
|
}
|
||||||
5
web/app/(dashboard)/dashboard/support/community/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/community/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardSupportCommunityAdmin } from "../_components/support-admin"
|
||||||
|
|
||||||
|
export default function DashboardSupportCommunityPage() {
|
||||||
|
return <DashboardSupportCommunityAdmin />
|
||||||
|
}
|
||||||
5
web/app/(dashboard)/dashboard/support/config/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/config/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SupportConfigPanel } from "../_components/support-config-panel"
|
||||||
|
|
||||||
|
export default function DashboardSupportConfigPage() {
|
||||||
|
return <SupportConfigPanel />
|
||||||
|
}
|
||||||
5
web/app/(dashboard)/dashboard/support/docs/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/docs/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SupportDocsWorkbench } from "../_components/help-workbench"
|
||||||
|
|
||||||
|
export default function DashboardDocPage() {
|
||||||
|
return <SupportDocsWorkbench />
|
||||||
|
}
|
||||||
5
web/app/(dashboard)/dashboard/support/page.tsx
Normal file
5
web/app/(dashboard)/dashboard/support/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default function DashboardSupportPage() {
|
||||||
|
redirect("/dashboard/support/docs")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user