feat: enhance channel configuration UI and validation
- Updated ChannelInstructionsPanel to include a button for viewing documentation, improving user guidance. - Enhanced ChannelTokenField to support showing/hiding secret values with appropriate labels and icons. - Refined ChannelTypeSelector to display connection type icons and improved layout for better user experience. - Added new messages for documentation links, validation feedback, and secret management in i18n. - Extended ChannelMeta to include optional documentation URLs for better context on configuration fields. - Implemented credential validation logic in ChannelsPage to ensure user inputs are validated before saving. - Introduced ChannelLogo component to display channel icons in the UI. - Added tests for channel credential validation to ensure proper error handling and feedback.
1
src/assets/channels/dingtalk.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1773137539538" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9954" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M511.68 0.64a511.552 511.552 0 1 0 0 1023.104 511.552 511.552 0 0 0 0-1023.104z m237.056 443.52a113.92 113.92 0 0 1-7.36 18.944h0.064l-0.384 0.704c-21.504 45.952-77.568 136.064-77.568 136.064s0-0.192-0.256-0.576l-16.384 28.544h78.912l-150.72 200.448 34.176-136.32h-62.08l21.568-90.24a880.64 880.64 0 0 0-62.528 17.856s-33.088 19.392-95.296-37.248c0 0-41.92-36.928-17.6-46.208 10.368-3.904 50.176-8.896 81.6-13.12 42.368-5.76 68.48-8.768 68.48-8.768s-130.752 1.92-161.728-2.944c-31.04-4.864-70.4-56.64-78.72-102.08 0 0-12.992-24.96 27.84-13.184 40.832 11.84 209.856 46.08 209.856 46.08s-219.776-67.392-234.432-83.84c-14.592-16.448-43.008-89.728-39.36-134.784 0 0 1.6-11.2 13.12-8.192 0 0 162.496 74.24 273.6 114.88 111.104 40.64 207.68 61.312 195.2 113.92z" fill="#2c2c2c" p-id="9955"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
src/assets/channels/discord.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/assets/channels/feishu.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1773137473652" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8906" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M48.761905 435.809524v9.508571l0.731428 248.198095-0.365714 49.371429 0.365714 21.162667 0.365715 7.411809 0.731428 4.924953v0.707047c0 0.341333 0 0.707048 0.341334 1.048381 1.462857 5.997714 5.095619 10.947048 10.532571 15.872 4.705524 4.242286 10.50819 7.753143 19.577905 12.678095 43.154286 22.918095 84.528762 39.862857 126.976 51.492572 43.544381 11.995429 88.892952 18.334476 137.508571 19.382857 50.41981 1.072762 97.206857-3.876571 142.921143-15.506286 43.885714-10.922667 87.430095-27.501714 133.485714-50.761143 34.133333-17.286095 71.484952-44.422095 105.569524-76.507428l11.971048-11.654095 11.629714-11.971048 4.705524-5.290667-2.535619 1.414096-9.801143 4.924952-3.632762 1.779809c-38.448762 17.261714-78.360381 22.186667-122.977524 16.920381l-13.409524-1.779809-13.409523-2.438095-3.291429-0.731429-3.267048-0.707048-14.140952-3.169523-3.632762-1.048381-3.974095-1.072762-16.335238-4.583619-2.925715-0.707048c-1.77981-0.682667-3.974095-1.048381-5.778285-1.755428l-29.744762-9.167239-14.506667-4.583619-42.447238-14.799238-21.040762-7.753143-19.236571-6.339047-9.435429-3.900953-10.142476-4.583619-1.097143-0.341333c-1.097143-0.365714-1.80419-1.072762-2.925714-1.755428l-13.775238-6.704762-35.913143-16.579048-22.479238-10.922667-7.972572-4.242285c-40.277333-20.431238-79.823238-45.104762-119.369142-72.972191a1362.651429 1362.651429 0 0 1-113.542096-90.940952l-18.870857-17.310476L48.761905 435.833905z" p-id="8907" fill="#2c2c2c"></path><path d="M851.456 390.095238l-14.750476 0.390095-5.680762 0.390096a320.853333 320.853333 0 0 0-66.243048 10.800761c-19.69981 5.412571-39.009524 12.726857-57.539047 22.381715-18.944 9.654857-36.717714 20.845714-53.394286 33.962666l-9.45981 7.338667-2.267428 1.950476-2.267429 1.901715-9.069714 8.118857-9.48419 8.874666-10.581334 10.044953-49.980952 49.395809-3.413334 3.096381c-24.210286 23.161905-42.008381 38.595048-62.829714 53.248l-8.313905 5.802667-21.577142 14.287238-3.023239 1.926095c-4.169143 2.681905-7.94819 5.022476-11.751619 7.314286l-10.971428 6.192762 15.896381 6.558476 46.567619 17.749333 28.769524 10.435048 32.548571 10.800762 18.529524 5.778286 16.286476 4.632381 3.803429 1.170285c3.779048 1.145905 7.558095 1.926095 10.971428 2.681905l13.994667 3.096381 3.413333 0.78019 3.413334 0.780191 13.238857 2.31619 3.413333 0.365715 3.413333 0.390095c43.885714 5.412571 82.895238 0 120.368762-18.529524 48.079238-23.527619 70.777905-47.85981 102.204953-106.910476l10.215619-19.675429 16.286476-32.426666 7.192381-13.897143 2.267428-4.242286c21.942857-42.471619 38.595048-68.315429 61.318096-92.257524l2.267428-2.31619-5.290666-1.926095-6.436572-2.316191-13.653333-4.632381-11.702857-3.462095-5.315048-1.560381a319.195429 319.195429 0 0 0-66.243048-9.654857l-15.11619-1.145905zM219.428571 170.666667l12.434286 9.947428 17.871238 14.360381 7.314286 5.973334a1493.162667 1493.162667 0 0 1 59.14819 51.785142 1033.581714 1033.581714 0 0 1 77.04381 81.261715 1258.032762 1258.032762 0 0 1 60.269714 75.312762 1085.68381 1085.68381 0 0 1 40.911238 58.953142l15.335619 24.697905L534.601143 536.380952l15.701333-15.945142 23.722667-24.697905 13.507047-13.945905 17.89562-17.92 4.022857-3.974095c11.312762-11.166476 23.381333-21.528381 35.425523-31.47581 9.849905-7.972571 21.552762-15.945143 34.694096-23.503238 9.142857-5.193143 18.261333-9.97181 27.745524-14.336l15.701333-6.777905 8.411428-3.169523-0.365714-1.219048-0.365714-1.999238a224.792381 224.792381 0 0 0-9.849905-31.451429l-6.217143-16.749714-1.462857-3.584c-9.849905-24.697905-21.552762-50.200381-30.305524-66.121143l-17.895619-29.891047-9.874285-15.530667-1.804191-2.389333c-13.165714-19.919238-23.747048-32.280381-32.52419-36.644572-5.851429-3.193905-11.312762-3.998476-20.431239-4.388571H219.428571z" p-id="8908" fill="#2c2c2c"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/channels/qq.svg
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
3
src/assets/channels/telegram.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
1
src/assets/channels/wechat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774167686596" class="icon" viewBox="0 0 1309 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2533" xmlns:xlink="http://www.w3.org/1999/xlink" width="255.6640625" height="200"><path d="M1147.26896 912.681417l34.90165 111.318583-127.165111-66.823891a604.787313 604.787313 0 0 1-139.082747 22.263717c-220.607239 0-394.296969-144.615936-394.296969-322.758409s173.526026-322.889372 394.296969-322.889372C1124.219465 333.661082 1309.630388 478.669907 1309.630388 656.550454c0 100.284947-69.344929 189.143369-162.361428 256.130963zM788.070086 511.869037a49.11114 49.11114 0 0 0-46.360916 44.494692 48.783732 48.783732 0 0 0 46.360916 44.494693 52.090549 52.090549 0 0 0 57.983885-44.494693 52.385216 52.385216 0 0 0-57.983885-44.494692z m254.985036 0a48.881954 48.881954 0 0 0-46.09899 44.494692 48.620028 48.620028 0 0 0 46.09899 44.494693 52.385216 52.385216 0 0 0 57.983886-44.494693 52.58166 52.58166 0 0 0-57.951145-44.494692z m-550.568615 150.018161a318.567592 318.567592 0 0 0 14.307712 93.212943c-14.307712 1.080445-28.746387 1.768001-43.283284 1.768001a827.293516 827.293516 0 0 1-162.394168-22.296458l-162.001279 77.955749 46.328175-133.811485C69.410411 600.858422 0 500.507993 0 378.38496 0 166.683208 208.689602 0 463.510935 0c227.908428 0 427.594322 133.18941 467.701752 312.379588a427.463358 427.463358 0 0 0-44.625655-2.619261c-220.24709 0-394.100524 157.74498-394.100525 352.126871zM312.90344 189.143369a64.270111 64.270111 0 0 0-69.803299 55.659291 64.532037 64.532037 0 0 0 69.803299 55.659292 53.694846 53.694846 0 0 0 57.852923-55.659292 53.465661 53.465661 0 0 0-57.852923-55.659291z m324.428188 0a64.040926 64.040926 0 0 0-69.574114 55.659291 64.302852 64.302852 0 0 0 69.574114 55.659292 53.694846 53.694846 0 0 0 57.951145-55.659292 53.465661 53.465661 0 0 0-57.951145-55.659291z" p-id="2534" fill="#515151"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
src/assets/channels/wecom.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1773137309921" class="icon" viewBox="0 0 1254 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4960" xmlns:xlink="http://www.w3.org/1999/xlink" width="244.921875" height="200"><path d="M1035.517652 464.288862a83.284664 83.284664 0 0 0-84.836564-12.152221c-2.377848-21.728608-3.302788-42.534275-7.166541-62.74398a341.096145 341.096145 0 0 0-152.270244-222.047773 494.092342 494.092342 0 0 0-576.881038 25.791347 313.125937 313.125937 0 0 0 6.208602 501.720854 61.819039 61.819039 0 0 1 25.923339 80.113867 560.007119 560.007119 0 0 0-16.280956 61.65405q3.004807 2.212858 6.043612 4.35972a317.385664 317.385664 0 0 0 36.919635-20.870663 180.174456 180.174456 0 0 1 167.592262-30.943017 419.823101 419.823101 0 0 0 224.392623-12.879175 206.626761 206.626761 0 0 0 15.026037 40.453408 229.511295 229.511295 0 0 0 30.513045 32.494918 554.591466 554.591466 0 0 1-162.110613 30.645037 623.213069 623.213069 0 0 1-176.739676-17.038909 47.123981 47.123981 0 0 0-29.226128 3.532774c-44.779131 20.936659-89.162287 42.930249-133.181467 65.154826a39.627461 39.627461 0 0 1-60.928096-44.581144 1435.624016 1435.624016 0 0 0 14.332082-107.986081 34.541787 34.541787 0 0 0-11.162285-23.512494 468.53298 468.53298 0 0 1-133.28146-200.682142c-46.464023-157.684897-3.566771-293.906169 113.830706-406.513953 213.230338-204.743882 587.812338-195.827453 792.55622 16.840921a386.932208 386.932208 0 0 1 114.325674 280.268042c0 4.325723 0 8.717441-0.494968 13.209154a24.50343 24.50343 0 0 1-3.103801 5.712634z m-57.328327 19.384758a69.745531 69.745531 0 0 1 69.348557 53.794553 240.738575 240.738575 0 0 0 68.325622 129.38471 47.387964 47.387964 0 0 1 2.739825 24.305443 49.798809 49.798809 0 0 1-24.998399-2.146863 252.758805 252.758805 0 0 0-134.107407-67.73066 67.795656 67.795656 0 0 1-52.010668-74.070254 72.651345 72.651345 0 0 1 70.70247-63.536929z m275.974318 270.228686a67.433679 67.433679 0 0 1-56.536378 68.555607 233.705026 233.705026 0 0 0-128.095792 65.08883c-8.189475 8.057484-18.162836 14.134094-25.791348 2.609833a24.701417 24.701417 0 0 1 3.301789-23.744479 244.371343 244.371343 0 0 0 66.707726-125.48796 71.032449 71.032449 0 0 1 140.414003 12.978169z m-205.106858 200.120178a70.07551 70.07551 0 0 1-61.720046 69.183567 68.258627 68.258627 0 0 1-76.31711-49.831807A247.674131 247.674131 0 0 0 840.846125 839.795802a41.113366 41.113366 0 0 1-2.773822-21.002654 46.001053 46.001053 0 0 1 23.115519 0.362977 271.219622 271.219622 0 0 0 138.334136 69.942518 64.427872 64.427872 0 0 1 49.435833 64.923841z m-344.794909-201.837068a66.706726 66.706726 0 0 1 53.563568-64.230885 246.088233 246.088233 0 0 0 132.35652-66.507739 47.816936 47.816936 0 0 1 22.951529-3.302788 43.921186 43.921186 0 0 1-3.302788 22.356568 250.249966 250.249966 0 0 0-68.291624 129.648693 66.937711 66.937711 0 0 1-76.547096 51.714686 69.513546 69.513546 0 0 1-60.829102-69.678535z" p-id="4961" fill="#2c2c2c"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
1
src/assets/channels/whatsapp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1773137626598" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10958" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M872.992 148.8C777.184 52.8 649.792 0 513.984 0 234.368 0 6.784 227.584 6.784 507.392c0 89.408 23.392 176.8 67.808 253.6L2.592 1024l268.992-70.592a507.456 507.456 0 0 0 242.4 61.792h0.192c279.616 0 507.392-227.616 507.392-507.392 0-135.616-52.8-263.008-148.608-359.008z m-358.784 780.8a421.44 421.44 0 0 1-214.816-58.784l-15.392-9.216-159.584 41.792 42.592-155.616-10.016-16a418.752 418.752 0 0 1-64.608-224.384c0-232.608 189.184-421.792 422.016-421.792 112.608 0 218.592 44 298.208 123.584a419.456 419.456 0 0 1 123.392 298.4c-0.192 232.8-189.408 422.016-421.792 422.016z m231.2-316c-12.608-6.4-75.008-36.992-86.592-41.216s-20-6.4-28.608 6.4c-8.384 12.608-32.8 41.216-40.192 49.792-7.392 8.384-14.784 9.6-27.392 3.2s-53.6-19.808-102.016-63.008c-37.6-33.6-63.2-75.2-70.592-87.808s-0.8-19.616 5.6-25.792c5.792-5.6 12.608-14.816 19.008-22.208s8.384-12.608 12.608-21.184c4.192-8.384 2.208-15.808-0.992-22.208s-28.608-68.8-39.008-94.208c-10.208-24.8-20.8-21.408-28.608-21.792-7.392-0.384-15.808-0.384-24.192-0.384s-22.208 3.2-33.792 15.808c-11.616 12.608-44.384 43.392-44.384 105.792s45.408 122.592 51.808 131.2c6.4 8.384 89.408 136.608 216.608 191.392 30.208 12.992 53.792 20.8 72.192 26.784 30.4 9.6 58.016 8.192 79.808 4.992 24.384-3.616 75.008-30.592 85.6-60.192s10.592-55.008 7.392-60.192c-3.008-5.6-11.392-8.8-24.192-15.2z" p-id="10959" fill="#2c2c2c"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,3 +1,5 @@
|
||||
import { Hash } from 'lucide-react';
|
||||
|
||||
type ChannelAccountIdFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -16,18 +18,23 @@ export default function ChannelAccountIdField({
|
||||
disabled,
|
||||
}: ChannelAccountIdFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground/80">
|
||||
<Hash className="h-3.5 w-3.5 text-foreground/55" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
className="h-[44px] w-full rounded-[14px] border border-black/10 bg-white px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-foreground/35 focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
|
||||
{helpText ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
|
||||
<div className="text-[12px] leading-[18px] text-foreground/55 dark:text-gray-500">{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
import { Check, Loader2, ShieldCheck } from 'lucide-react';
|
||||
|
||||
type ChannelConfigActionsProps = {
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
onClose: () => void;
|
||||
validateLabel?: string;
|
||||
validatingLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onValidate?: () => void;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
validating?: boolean;
|
||||
};
|
||||
|
||||
export default function ChannelConfigActions({
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
onClose,
|
||||
validateLabel,
|
||||
validatingLabel,
|
||||
onConfirm,
|
||||
onValidate,
|
||||
disabled,
|
||||
submitting,
|
||||
validating,
|
||||
}: ChannelConfigActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#EDECE4] px-5 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#222225] dark:text-gray-200"
|
||||
onClick={onClose}
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<div className="flex flex-col gap-3 border-t border-black/10 pt-6 sm:flex-row sm:justify-end dark:border-white/10">
|
||||
{onValidate ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[54px] items-center justify-center rounded-full border border-black/10 bg-[#fbf8f1] px-6 text-[15px] font-semibold text-[#1f2937] transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:hover:bg-white/10"
|
||||
onClick={onValidate}
|
||||
disabled={disabled || submitting || validating}
|
||||
>
|
||||
{validating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{validatingLabel ?? validateLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{validateLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#2B7FFF] px-5 text-[13px] font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex h-[54px] items-center justify-center rounded-full bg-[#8ea8ff] px-7 text-[15px] font-semibold text-white transition-colors hover:bg-[#7f9afb] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#8ea8ff] dark:text-white dark:hover:bg-[#7f9afb]"
|
||||
onClick={onConfirm}
|
||||
disabled={disabled || submitting}
|
||||
disabled={disabled || submitting || validating}
|
||||
>
|
||||
{submitting ? `${confirmLabel}...` : confirmLabel}
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{confirmLabel}...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{confirmLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import ChannelAccountIdField from './ChannelAccountIdField';
|
||||
import ChannelTokenField from './ChannelTokenField';
|
||||
|
||||
type ChannelConfigFieldsProps = {
|
||||
@@ -8,9 +7,12 @@ type ChannelConfigFieldsProps = {
|
||||
values: ChannelConfigFieldValueMap;
|
||||
onValueChange: (key: string, value: string) => void;
|
||||
disabled?: boolean;
|
||||
accountIdLabel: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
showSecretMap?: Record<string, boolean>;
|
||||
onToggleSecret?: (key: string) => void;
|
||||
emptyStateText?: string;
|
||||
envVarLabel?: string;
|
||||
showSecretLabel?: string;
|
||||
hideSecretLabel?: string;
|
||||
};
|
||||
|
||||
function renderField({
|
||||
@@ -18,66 +20,116 @@ function renderField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
showSecretMap,
|
||||
onToggleSecret,
|
||||
envVarLabel,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
}: {
|
||||
field: ChannelConfigFieldMeta;
|
||||
value: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
disabled?: boolean;
|
||||
accountIdLabel: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
showSecretMap?: Record<string, boolean>;
|
||||
onToggleSecret?: (key: string) => void;
|
||||
envVarLabel?: string;
|
||||
showSecretLabel?: string;
|
||||
hideSecretLabel?: string;
|
||||
}): ReactNode {
|
||||
const commonProps = {
|
||||
label: field.label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder: field.placeholder,
|
||||
helpText: field.description,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (field.key === 'accountId') {
|
||||
return (
|
||||
<ChannelAccountIdField
|
||||
{...commonProps}
|
||||
label={field.label || accountIdLabel}
|
||||
helpText={field.description ?? accountIdHelpText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'token' || field.kind === 'password') {
|
||||
return <ChannelTokenField {...commonProps} helpText={field.description ?? tokenHelpText} />;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ChannelTokenField
|
||||
label={field.label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder}
|
||||
helpText={field.description}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
showSecret={Boolean(showSecretMap?.[field.key])}
|
||||
showSecretLabel={showSecretLabel}
|
||||
hideSecretLabel={hideSecretLabel}
|
||||
onToggleSecret={
|
||||
onToggleSecret ? () => onToggleSecret(field.key) : undefined
|
||||
}
|
||||
/>
|
||||
{renderFieldMeta(field, envVarLabel)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'textarea') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{field.label}</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
rows={field.rows ?? 4}
|
||||
className="w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{field.description}</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{field.label}
|
||||
{field.required ? <span className="ml-1 text-[#d14343]">*</span> : null}
|
||||
</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
rows={field.rows ?? 4}
|
||||
className="min-h-[144px] w-full rounded-[18px] border border-black/10 bg-[#fbf8f1] px-5 py-4 text-[15px] text-[#171717] outline-none transition-colors placeholder:text-[#98a2b3] focus:border-[#8ea8ff] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="text-[14px] leading-7 text-[#667085] dark:text-gray-400">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{renderFieldMeta(field, envVarLabel)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelTokenField
|
||||
{...commonProps}
|
||||
label={field.label}
|
||||
helpText={field.description ?? tokenHelpText}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{field.label}
|
||||
{field.required ? <span className="ml-1 text-[#d14343]">*</span> : null}
|
||||
</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[56px] w-full rounded-[18px] border border-black/10 bg-[#fbf8f1] px-5 text-[15px] text-[#171717] outline-none transition-colors placeholder:text-[#98a2b3] focus:border-[#8ea8ff] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="text-[14px] leading-7 text-[#667085] dark:text-gray-400">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{renderFieldMeta(field, envVarLabel)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldMeta(
|
||||
field: ChannelConfigFieldMeta,
|
||||
envVarLabel?: string,
|
||||
): ReactNode {
|
||||
const envVars = Array.isArray(field.envVar)
|
||||
? field.envVar.filter(Boolean)
|
||||
: field.envVar
|
||||
? [field.envVar]
|
||||
: [];
|
||||
|
||||
if (envVars.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-[12px] leading-6 text-[#667085] dark:text-gray-400">
|
||||
{envVars.length > 0 ? (
|
||||
<span className="font-mono tracking-[0.02em]">
|
||||
{`${envVarLabel ?? 'Environment variable'}: ${envVars.join(', ')}`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,16 +138,23 @@ export default function ChannelConfigFields({
|
||||
values,
|
||||
onValueChange,
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
showSecretMap,
|
||||
onToggleSecret,
|
||||
emptyStateText,
|
||||
envVarLabel,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
}: ChannelConfigFieldsProps) {
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
return (
|
||||
<div className="rounded-[20px] border border-dashed border-black/10 bg-[#fbf8f1] px-5 py-4 text-[14px] leading-7 text-[#667085] dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-300">
|
||||
{emptyStateText ?? 'No additional fields are required for this channel.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-8">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{renderField({
|
||||
@@ -103,9 +162,11 @@ export default function ChannelConfigFields({
|
||||
value: values[field.key] ?? '',
|
||||
onChange: (nextValue) => onValueChange(field.key, nextValue),
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
showSecretMap,
|
||||
onToggleSecret,
|
||||
envVarLabel,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,157 +1,284 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, X } from 'lucide-react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import { getChannelMeta, getChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
|
||||
import DialogSurface from '../../pages/Home/components/DialogSurface';
|
||||
import ChannelAccountIdField from './ChannelAccountIdField';
|
||||
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import { getChannelMeta, type ChannelMeta } from '../../lib/channel-meta';
|
||||
import ChannelConfigActions from './ChannelConfigActions';
|
||||
import ChannelConfigFields from './ChannelConfigFields';
|
||||
import ChannelInstructionsPanel from './ChannelInstructionsPanel';
|
||||
import ChannelTypeSelector from './ChannelTypeSelector';
|
||||
|
||||
export type ChannelFieldValidationResult = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
required: boolean;
|
||||
provided: boolean;
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ChannelCredentialsValidationResult = {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
details?: {
|
||||
channelType?: string;
|
||||
accountId?: string;
|
||||
channelName?: string;
|
||||
connectionType?: string;
|
||||
fields?: ChannelFieldValidationResult[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelConfigModalProps = {
|
||||
open: boolean;
|
||||
selectedChannelType: string;
|
||||
channelTypes?: ChannelMeta[];
|
||||
values: ChannelConfigFieldValueMap;
|
||||
accountId: string;
|
||||
disableChannelType?: boolean;
|
||||
disableAccountId?: boolean;
|
||||
onChannelTypeChange: (value: string) => void;
|
||||
onValueChange: (key: string, value: string) => void;
|
||||
onAccountIdChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onValidate?: () => void | Promise<void>;
|
||||
error?: string | null;
|
||||
submitting?: boolean;
|
||||
validating?: boolean;
|
||||
validationResult?: ChannelCredentialsValidationResult | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
typeLabel?: string;
|
||||
accountIdLabel?: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
instructionsTitle?: string;
|
||||
docsLabel?: string;
|
||||
diagnosticsNote?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
widthClassName?: string;
|
||||
validateLabel?: string;
|
||||
validatingLabel?: string;
|
||||
};
|
||||
|
||||
function withTranslatedMeta(
|
||||
meta: ChannelMeta,
|
||||
translate: (path: string, fallback: string) => string,
|
||||
): ChannelMeta {
|
||||
return {
|
||||
...meta,
|
||||
name: translate(`channels.meta.${meta.type}.name`, meta.name),
|
||||
description: translate(`channels.meta.${meta.type}.description`, meta.description),
|
||||
docsUrl: meta.docsUrl
|
||||
? translate(`channels.meta.${meta.type}.docsUrl`, meta.docsUrl)
|
||||
: meta.docsUrl,
|
||||
instructions: meta.instructions.map((instruction, index) => (
|
||||
translate(`channels.meta.${meta.type}.instructions.${index}`, instruction)
|
||||
)),
|
||||
configFields: meta.configFields.map((field) => withTranslatedField(meta.type, field, translate)),
|
||||
};
|
||||
}
|
||||
|
||||
function withTranslatedField(
|
||||
channelType: string,
|
||||
field: ChannelConfigFieldMeta,
|
||||
translate: (path: string, fallback: string) => string,
|
||||
): ChannelConfigFieldMeta {
|
||||
return {
|
||||
...field,
|
||||
label: translate(`channels.meta.${channelType}.fields.${field.key}.label`, field.label),
|
||||
placeholder: field.placeholder
|
||||
? translate(`channels.meta.${channelType}.fields.${field.key}.placeholder`, field.placeholder)
|
||||
: field.placeholder,
|
||||
description: field.description
|
||||
? translate(`channels.meta.${channelType}.fields.${field.key}.description`, field.description)
|
||||
: field.description,
|
||||
docsUrl: field.docsUrl
|
||||
? translate(`channels.meta.${channelType}.fields.${field.key}.docsUrl`, field.docsUrl)
|
||||
: field.docsUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ChannelConfigModal({
|
||||
open,
|
||||
selectedChannelType,
|
||||
channelTypes,
|
||||
values,
|
||||
accountId,
|
||||
disableChannelType,
|
||||
disableAccountId,
|
||||
onChannelTypeChange,
|
||||
onValueChange,
|
||||
onAccountIdChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onValidate,
|
||||
error,
|
||||
submitting,
|
||||
validating,
|
||||
validationResult,
|
||||
title,
|
||||
description,
|
||||
typeLabel,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
instructionsTitle,
|
||||
docsLabel,
|
||||
diagnosticsNote,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
widthClassName,
|
||||
validateLabel,
|
||||
validatingLabel,
|
||||
}: ChannelConfigModalProps) {
|
||||
const { t } = useI18n();
|
||||
const options = channelTypes ?? getChannelOptions();
|
||||
const activeMeta = options.find((item) => item.type === selectedChannelType) ?? getChannelMeta(selectedChannelType);
|
||||
const accountFieldMeta = activeMeta.configFields.find((field) => field.key === 'accountId');
|
||||
const accountLabel = accountIdLabel ?? accountFieldMeta?.label ?? t('channels.modal.accountIdLabel');
|
||||
const accountHelp = accountIdHelpText ?? accountFieldMeta?.description;
|
||||
const typeSelectLabel = typeLabel ?? t('channels.modal.typeLabel');
|
||||
const docsText = docsLabel ?? t('channels.modal.docsLabel');
|
||||
const instructionsHeading = instructionsTitle ?? t('channels.modal.instructionsTitle');
|
||||
const confirmText = confirmLabel ?? t('channels.modal.confirm');
|
||||
const cancelText = cancelLabel ?? t('dialog.cancel');
|
||||
const notesText = diagnosticsNote ?? t('channels.modal.diagnosticsNote');
|
||||
const descriptionText = description ?? t('channels.modal.description');
|
||||
const { t, hasMessage } = useI18n();
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const firstFieldRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const translate = useCallback((path: string, fallback: string) => (
|
||||
hasMessage(path) ? t(path) : fallback
|
||||
), [hasMessage, t]);
|
||||
|
||||
const activeMeta = useMemo(
|
||||
() => withTranslatedMeta(getChannelMeta(selectedChannelType), translate),
|
||||
[selectedChannelType, translate],
|
||||
);
|
||||
const supportedFields = activeMeta.configFields.filter((field) => field.key !== 'accountId');
|
||||
const canValidate = Boolean(onValidate) && activeMeta.connectionType !== 'qr';
|
||||
|
||||
const resolvedTitle = title ?? t('channels.modal.configureTitle', { name: activeMeta.name });
|
||||
const descriptionText = description ?? activeMeta.description;
|
||||
const instructionsHeading = t('channels.modal.howTo');
|
||||
const confirmText = confirmLabel ?? (canValidate ? t('channels.modal.saveAndConnect') : t('channels.modal.confirm'));
|
||||
const validateText = validateLabel ?? t('channels.modal.validateConfig');
|
||||
const validatingText = validatingLabel ?? t('channels.modal.validating');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setShowSecrets({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowSecrets({});
|
||||
}, [selectedChannelType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && firstFieldRef.current) {
|
||||
firstFieldRef.current.focus();
|
||||
}
|
||||
}, [open, selectedChannelType]);
|
||||
|
||||
const toggleSecretVisibility = useCallback((key: string) => {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<DialogSurface
|
||||
open={open}
|
||||
title={title ?? t('channels.modal.title')}
|
||||
widthClassName={widthClassName ?? 'max-w-[920px]'}
|
||||
onClose={onClose}
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="text-[13px] leading-[20px] text-[#525866] dark:text-gray-300">
|
||||
{descriptionText}
|
||||
<div
|
||||
className="flex max-h-[92vh] w-full max-w-280 flex-col overflow-hidden rounded-[34px] border border-black/10 bg-white shadow-[0_32px_90px_-24px_rgba(15,23,42,0.35)] dark:border-white/10 dark:bg-[#1c1c20]"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-6 px-10 pb-2 pt-9">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className="text-[54px] leading-[0.98] font-normal tracking-tight text-[#0f172a] dark:text-[#f3f4f6]"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{resolvedTitle}
|
||||
</h2>
|
||||
<p className="mt-5 max-w-4xl text-[16px] leading-8 text-[#667085] dark:text-gray-300">
|
||||
{descriptionText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[#98a2b3] transition-colors hover:bg-black/5 hover:text-[#0f172a] dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-100"
|
||||
onClick={onClose}
|
||||
aria-label={t('window.close')}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[12px] border border-[#f2c7cd] bg-[#fff5f6] px-4 py-3 text-[13px] text-[#c24150] dark:border-[#4b2229] dark:bg-[#2b1c1f] dark:text-[#ffb4bf]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-10 pb-8 pt-6">
|
||||
<div className="space-y-8">
|
||||
{error ? (
|
||||
<div className="rounded-[22px] border border-[#efb3b3] bg-[#fff1f1] px-5 py-4 text-[14px] leading-7 text-[#b42318] dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="space-y-4">
|
||||
<ChannelTypeSelector
|
||||
label={typeSelectLabel}
|
||||
value={activeMeta.type}
|
||||
options={options}
|
||||
onChange={onChannelTypeChange}
|
||||
disabled={disableChannelType || submitting}
|
||||
helpText={activeMeta.connectionType}
|
||||
<ChannelInstructionsPanel
|
||||
meta={activeMeta}
|
||||
title={instructionsHeading}
|
||||
viewDocsLabel={t('channels.modal.viewDocs')}
|
||||
/>
|
||||
|
||||
<ChannelAccountIdField
|
||||
label={accountLabel}
|
||||
value={accountId}
|
||||
onChange={onAccountIdChange}
|
||||
placeholder={accountFieldMeta?.placeholder}
|
||||
helpText={accountHelp}
|
||||
disabled={disableAccountId || submitting}
|
||||
/>
|
||||
|
||||
<ChannelConfigFields
|
||||
fields={supportedFields}
|
||||
values={values}
|
||||
onValueChange={onValueChange}
|
||||
disabled={submitting}
|
||||
accountIdLabel={accountLabel}
|
||||
accountIdHelpText={accountHelp}
|
||||
tokenHelpText={tokenHelpText ?? t('channels.modal.tokenHelpText')}
|
||||
/>
|
||||
|
||||
<div className="rounded-[16px] border border-dashed border-[#E5E8EE] bg-white px-4 py-3 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
|
||||
{t('channels.modal.todoNote')}
|
||||
<div ref={firstFieldRef} tabIndex={-1} className="space-y-8 outline-none">
|
||||
<ChannelConfigFields
|
||||
fields={supportedFields}
|
||||
values={values}
|
||||
onValueChange={onValueChange}
|
||||
disabled={submitting || validating}
|
||||
showSecretMap={showSecrets}
|
||||
onToggleSecret={toggleSecretVisibility}
|
||||
emptyStateText={t('channels.modal.noAdditionalFields')}
|
||||
envVarLabel={t('channels.modal.envVar')}
|
||||
showSecretLabel={t('channels.modal.showSecret')}
|
||||
hideSecretLabel={t('channels.modal.hideSecret')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validationResult ? (
|
||||
<div
|
||||
className={[
|
||||
'rounded-[22px] border px-5 py-4 text-[14px] leading-7',
|
||||
validationResult.valid
|
||||
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300'
|
||||
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{validationResult.valid ? (
|
||||
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="mt-1 h-5 w-5 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold">
|
||||
{validationResult.valid ? t('channels.modal.credentialsVerified') : t('channels.modal.validationFailed')}
|
||||
</div>
|
||||
|
||||
{validationResult.errors.length > 0 ? (
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{validationResult.errors.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{validationResult.warnings.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<div className="text-[12px] font-semibold uppercase tracking-[0.12em] opacity-80">
|
||||
{t('channels.modal.warnings')}
|
||||
</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{validationResult.warnings.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ChannelConfigActions
|
||||
confirmLabel={confirmText}
|
||||
validateLabel={validateText}
|
||||
validatingLabel={validatingText}
|
||||
onConfirm={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
onValidate={canValidate ? () => {
|
||||
void onValidate?.();
|
||||
} : undefined}
|
||||
disabled={false}
|
||||
submitting={submitting}
|
||||
validating={validating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelInstructionsPanel
|
||||
meta={activeMeta}
|
||||
title={instructionsHeading}
|
||||
docsLabel={docsText}
|
||||
diagnosticsNote={notesText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelConfigActions
|
||||
cancelLabel={cancelText}
|
||||
confirmLabel={confirmText}
|
||||
onClose={onClose}
|
||||
onConfirm={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={false}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
</DialogSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,68 @@
|
||||
import { BookOpen, ExternalLink } from 'lucide-react';
|
||||
import type { ChannelMeta } from '../../lib/channel-meta';
|
||||
|
||||
type ChannelInstructionsPanelProps = {
|
||||
meta: ChannelMeta;
|
||||
title: string;
|
||||
docsLabel: string;
|
||||
diagnosticsNote: string;
|
||||
viewDocsLabel: string;
|
||||
};
|
||||
|
||||
export default function ChannelInstructionsPanel({
|
||||
meta,
|
||||
title,
|
||||
docsLabel,
|
||||
diagnosticsNote,
|
||||
viewDocsLabel,
|
||||
}: ChannelInstructionsPanelProps) {
|
||||
return (
|
||||
<section className="rounded-[16px] border border-[#E5E8EE] bg-[#FAFBFC] p-4 dark:border-[#2a2a2d] dark:bg-[#17171a]">
|
||||
<div className="text-[13px] font-medium text-[#171717] dark:text-gray-100">{title}</div>
|
||||
<div className="mt-1 text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">
|
||||
{docsLabel}
|
||||
</div>
|
||||
const canOpenDocs = Boolean(meta.docsUrl);
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-[12px] border border-dashed border-[#DCE5F1] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300">
|
||||
{meta.description}
|
||||
function openDocs(): void {
|
||||
if (!meta.docsUrl) return;
|
||||
|
||||
try {
|
||||
if (window.electron?.openExternal) {
|
||||
window.electron.openExternal(meta.docsUrl);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to window.open below.
|
||||
}
|
||||
|
||||
window.open(meta.docsUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-black/10 bg-[#f7f3eb] px-8 py-7 shadow-[0_10px_30px_rgba(15,23,42,0.06)] dark:border-white/10 dark:bg-[#232327]">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[18px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-3 max-w-3xl text-[15px] leading-7 text-[#667085] dark:text-gray-300">
|
||||
{meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meta.instructions.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{meta.instructions.map((instruction) => (
|
||||
<li
|
||||
key={instruction}
|
||||
className="rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300"
|
||||
>
|
||||
{instruction}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{canOpenDocs ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-12 shrink-0 items-center gap-2 rounded-full border border-black/10 bg-[#fbf8f1] px-5 text-[15px] font-semibold text-[#1f2937] transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-[#2a2a2f] dark:text-gray-100 dark:hover:bg-white/10"
|
||||
onClick={openDocs}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>{viewDocsLabel}</span>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[12px] bg-[#EFF6FF] px-3 py-2 text-[12px] leading-[18px] text-[#2B4E8C] dark:bg-[#1d2633] dark:text-[#93c5fd]">
|
||||
{diagnosticsNote}
|
||||
</div>
|
||||
{meta.instructions.length > 0 ? (
|
||||
<ol className="mt-7 space-y-3 pl-7 text-[15px] leading-8 text-[#667085] dark:text-gray-300">
|
||||
{meta.instructions.map((instruction) => (
|
||||
<li key={instruction} className="list-decimal marker:font-semibold marker:text-[#4b5563] dark:marker:text-gray-300">
|
||||
{instruction}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
type ChannelTokenFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -5,6 +7,11 @@ type ChannelTokenFieldProps = {
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
disabled?: boolean;
|
||||
showSecret?: boolean;
|
||||
onToggleSecret?: () => void;
|
||||
showSecretLabel?: string;
|
||||
hideSecretLabel?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export default function ChannelTokenField({
|
||||
@@ -14,20 +21,49 @@ export default function ChannelTokenField({
|
||||
placeholder,
|
||||
helpText,
|
||||
disabled,
|
||||
showSecret,
|
||||
onToggleSecret,
|
||||
showSecretLabel,
|
||||
hideSecretLabel,
|
||||
required,
|
||||
}: ChannelTokenFieldProps) {
|
||||
const isSecretField = typeof showSecret === 'boolean' && typeof onToggleSecret === 'function';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-[#d14343]">*</span> : null}
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
type={isSecretField && !showSecret ? 'password' : 'text'}
|
||||
className="h-[56px] w-full rounded-[18px] border border-black/10 bg-[#fbf8f1] px-5 text-[15px] text-[#171717] outline-none transition-colors placeholder:text-[#98a2b3] focus:border-[#8ea8ff] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSecretField ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSecret}
|
||||
disabled={disabled}
|
||||
className="inline-flex h-[56px] w-[56px] shrink-0 items-center justify-center rounded-[18px] border border-black/10 bg-[#fbf8f1] text-[#667085] transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-100"
|
||||
aria-label={showSecret ? (hideSecretLabel ?? 'Hide secret') : (showSecretLabel ?? 'Show secret')}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{helpText ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
|
||||
<div className="text-[14px] leading-7 text-[#667085] dark:text-gray-400">{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadgeCheck, ChevronDown, LayoutGrid, Plug, QrCode, Webhook } from 'lucide-react';
|
||||
import type { ChannelMeta } from '../../lib/channel-meta';
|
||||
|
||||
type ChannelTypeSelectorProps = {
|
||||
@@ -9,6 +10,19 @@ type ChannelTypeSelectorProps = {
|
||||
helpText?: string;
|
||||
};
|
||||
|
||||
function getConnectionIcon(connectionType?: string) {
|
||||
switch (connectionType) {
|
||||
case 'qr':
|
||||
return QrCode;
|
||||
case 'webhook':
|
||||
return Webhook;
|
||||
case 'plugin':
|
||||
return Plug;
|
||||
default:
|
||||
return LayoutGrid;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChannelTypeSelector({
|
||||
label,
|
||||
value,
|
||||
@@ -18,29 +32,46 @@ export default function ChannelTypeSelector({
|
||||
helpText,
|
||||
}: ChannelTypeSelectorProps) {
|
||||
const selected = options.find((item) => item.type === value);
|
||||
const ConnectionIcon = getConnectionIcon(selected?.connectionType ?? helpText);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<section className="rounded-[22px] border border-black/10 bg-[#faf9f3] p-4 shadow-sm dark:border-white/10 dark:bg-[#222228]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground/80">
|
||||
<BadgeCheck className="h-3.5 w-3.5 text-foreground/55" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[36px] rounded-[12px] border border-dashed border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
|
||||
<div className="font-medium text-[#171717] dark:text-gray-100">
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-black/10 bg-white px-2.5 py-1 text-[11px] font-medium text-foreground/65 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||||
<ConnectionIcon className="h-3 w-3" />
|
||||
<span>{selected?.connectionType ?? helpText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-[44px] w-full appearance-none rounded-[14px] border border-black/10 bg-white px-3 pr-10 text-[13px] text-foreground outline-none transition-colors focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#101013] dark:text-gray-100"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground/45" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[16px] border border-dashed border-black/10 bg-white/80 px-4 py-3 text-[12px] leading-[18px] text-foreground/65 dark:border-white/10 dark:bg-[#17171a] dark:text-gray-300">
|
||||
<div className="font-medium text-foreground dark:text-gray-100">
|
||||
{selected?.name ?? value}
|
||||
</div>
|
||||
<div className="mt-1">{selected?.description ?? helpText}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,10 +55,28 @@ export const messages: I18nMessages = {
|
||||
modal: {
|
||||
title: 'Channel configuration',
|
||||
description: 'Choose a channel type, then fill in the connection fields that are available for this template.',
|
||||
configureTitle: 'Configure {name}',
|
||||
typeLabel: 'Channel type',
|
||||
accountIdLabel: 'Account ID',
|
||||
docsLabel: 'Docs and instructions',
|
||||
instructionsTitle: 'Setup instructions',
|
||||
howTo: 'How to connect',
|
||||
viewDocs: 'View docs',
|
||||
validate: 'Validate',
|
||||
validateConfig: 'Validate configuration',
|
||||
validating: 'Validating...',
|
||||
saveAndConnect: 'Save and connect',
|
||||
envVar: 'Environment variable',
|
||||
envVars: 'Environment variables',
|
||||
fieldDocs: 'Field reference',
|
||||
docsUrl: 'Documentation URL',
|
||||
envVarHint: 'These values can also be provided from environment variables when needed.',
|
||||
credentialsVerified: 'Credentials verified',
|
||||
validationFailed: 'Validation failed',
|
||||
warnings: 'Warnings',
|
||||
noAdditionalFields: 'No additional fields are required for this channel.',
|
||||
showSecret: 'Show secret',
|
||||
hideSecret: 'Hide secret',
|
||||
tokenHelpText: 'Paste the token, secret, or access credential required by the selected channel.',
|
||||
diagnosticsNote: 'QR code and diagnostics hooks will be wired up later when the backend contract is ready.',
|
||||
todoNote: 'TODO: QR and diagnostics are placeholders for now; the modal stays props-driven until the channel APIs land.',
|
||||
@@ -272,10 +290,28 @@ export const messages: I18nMessages = {
|
||||
modal: {
|
||||
title: '渠道配置',
|
||||
description: '先选择渠道类型,再填写这个模板可用的连接字段。',
|
||||
configureTitle: '配置 {name}',
|
||||
typeLabel: '渠道类型',
|
||||
accountIdLabel: 'Account ID',
|
||||
docsLabel: '文档与说明',
|
||||
instructionsTitle: '接入说明',
|
||||
howTo: '如何连接',
|
||||
viewDocs: '查看文档',
|
||||
validate: '校验',
|
||||
validateConfig: '验证配置',
|
||||
validating: '验证中...',
|
||||
saveAndConnect: '保存并连接',
|
||||
envVar: '环境变量',
|
||||
envVars: '环境变量',
|
||||
fieldDocs: '字段说明',
|
||||
docsUrl: '文档链接',
|
||||
envVarHint: '这些值也可以通过环境变量提前提供。',
|
||||
credentialsVerified: '凭证已验证',
|
||||
validationFailed: '验证失败',
|
||||
warnings: '警告',
|
||||
noAdditionalFields: '当前频道不需要额外字段。',
|
||||
showSecret: '显示密钥',
|
||||
hideSecret: '隐藏密钥',
|
||||
tokenHelpText: '请填写所选渠道所需的 token、密钥或访问凭证。',
|
||||
diagnosticsNote: '二维码和诊断能力会在后端契约完成后再接入。',
|
||||
todoNote: 'TODO:二维码和诊断目前仅保留占位,modal 先保持 props 驱动,等待渠道 API 落地。',
|
||||
@@ -489,10 +525,28 @@ export const messages: I18nMessages = {
|
||||
modal: {
|
||||
title: 'チャンネル設定',
|
||||
description: 'まずチャンネル種類を選び、このテンプレートで使える接続項目を入力します。',
|
||||
configureTitle: '{name} を設定',
|
||||
typeLabel: 'チャンネル種類',
|
||||
accountIdLabel: 'Account ID',
|
||||
docsLabel: 'ドキュメントと手順',
|
||||
instructionsTitle: '接続手順',
|
||||
howTo: '接続方法',
|
||||
viewDocs: 'ドキュメントを見る',
|
||||
validate: '検証',
|
||||
validateConfig: '設定を検証',
|
||||
validating: '検証中...',
|
||||
saveAndConnect: '保存して接続',
|
||||
envVar: '環境変数',
|
||||
envVars: '環境変数',
|
||||
fieldDocs: '項目の説明',
|
||||
docsUrl: 'ドキュメント URL',
|
||||
envVarHint: '必要に応じて、これらの値は環境変数からも指定できます。',
|
||||
credentialsVerified: '認証情報を確認しました',
|
||||
validationFailed: '検証に失敗しました',
|
||||
warnings: '警告',
|
||||
noAdditionalFields: 'このチャンネルに追加項目はありません。',
|
||||
showSecret: 'シークレットを表示',
|
||||
hideSecret: 'シークレットを隠す',
|
||||
tokenHelpText: '選択したチャンネルに必要な token、secret、またはアクセス資格情報を入力してください。',
|
||||
diagnosticsNote: 'QR コードと診断の導線は、バックエンド契約が整ってから接続します。',
|
||||
todoNote: 'TODO: QR と診断は現在プレースホルダです。modal は引き続き props 駆動のままにしておきます。',
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ChannelMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
connectionType: ChannelConnectionType;
|
||||
docsUrl?: string;
|
||||
configFields: ChannelConfigFieldMeta[];
|
||||
isPlugin: boolean;
|
||||
instructions: string[];
|
||||
@@ -20,6 +21,7 @@ function createField(
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
options?: Pick<ChannelConfigFieldMeta, 'docsUrl' | 'envVar'>,
|
||||
): ChannelConfigFieldMeta {
|
||||
return {
|
||||
key,
|
||||
@@ -27,6 +29,8 @@ function createField(
|
||||
kind,
|
||||
placeholder,
|
||||
description,
|
||||
docsUrl: options?.docsUrl,
|
||||
envVar: options?.envVar,
|
||||
required,
|
||||
autoComplete: 'off',
|
||||
};
|
||||
@@ -38,8 +42,9 @@ function createTokenField(
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
options?: Pick<ChannelConfigFieldMeta, 'docsUrl' | 'envVar'>,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'token', placeholder, description, required);
|
||||
return createField(key, label, 'token', placeholder, description, required, options);
|
||||
}
|
||||
|
||||
function createTextField(
|
||||
@@ -48,8 +53,9 @@ function createTextField(
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
options?: Pick<ChannelConfigFieldMeta, 'docsUrl' | 'envVar'>,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'text', placeholder, description, required);
|
||||
return createField(key, label, 'text', placeholder, description, required, options);
|
||||
}
|
||||
|
||||
function createPasswordField(
|
||||
@@ -58,8 +64,9 @@ function createPasswordField(
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
options?: Pick<ChannelConfigFieldMeta, 'docsUrl' | 'envVar'>,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'password', placeholder, description, required);
|
||||
return createField(key, label, 'password', placeholder, description, required, options);
|
||||
}
|
||||
|
||||
function createTextareaField(
|
||||
@@ -68,9 +75,10 @@ function createTextareaField(
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
options?: Pick<ChannelConfigFieldMeta, 'docsUrl' | 'envVar'>,
|
||||
): ChannelConfigFieldMeta {
|
||||
return {
|
||||
...createField(key, label, 'textarea', placeholder, description, required),
|
||||
...createField(key, label, 'textarea', placeholder, description, required, options),
|
||||
rows: 5,
|
||||
};
|
||||
}
|
||||
@@ -81,8 +89,9 @@ function createUrlField(
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
options?: Pick<ChannelConfigFieldMeta, 'docsUrl' | 'envVar'>,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'url', placeholder, description, required);
|
||||
return createField(key, label, 'url', placeholder, description, required, options);
|
||||
}
|
||||
|
||||
export const PRIMARY_CHANNEL_TYPES = [
|
||||
@@ -102,15 +111,36 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'Telegram',
|
||||
description: '使用 @BotFather 提供的机器人令牌连接 Telegram。',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'https://core.telegram.org/bots',
|
||||
configFields: [
|
||||
createPasswordField('botToken', 'Bot Token', '123456:telegram-bot-token', '从 @BotFather 获取的机器人令牌。', true),
|
||||
createTextField('allowedUsers', 'Allowed Users', '12345678,98765432', '可选。限制允许与机器人对话的 Telegram 用户 ID。'),
|
||||
createPasswordField(
|
||||
'botToken',
|
||||
'Bot Token',
|
||||
'123456:telegram-bot-token',
|
||||
'从 @BotFather 获取的机器人令牌。',
|
||||
true,
|
||||
{
|
||||
docsUrl: 'https://core.telegram.org/bots#botfather',
|
||||
envVar: 'TELEGRAM_BOT_TOKEN',
|
||||
},
|
||||
),
|
||||
createTextField(
|
||||
'allowedUsers',
|
||||
'Allowed Users',
|
||||
'12345678,98765432',
|
||||
'可选。限制允许与机器人对话的 Telegram 用户 ID。',
|
||||
false,
|
||||
{
|
||||
envVar: 'TELEGRAM_ALLOWED_USERS',
|
||||
},
|
||||
),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'在 Telegram 中使用 @BotFather 创建机器人并复制 Bot Token。',
|
||||
'如需限制访问范围,可把用户 ID 通过环境变量或配置项预先准备好。',
|
||||
'如果需要限制测试范围,可填写允许访问的用户 ID 列表。',
|
||||
'保存后在消息频道页确认默认账号、归属 Agent 和运行态状态。',
|
||||
'保存后在消息频道页确认默认账号、归属 Agent 和运行态状态,完成 save and connect。',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -118,15 +148,45 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'Discord',
|
||||
description: '使用开发者门户提供的机器人令牌连接 Discord。',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'https://discord.com/developers/docs/intro',
|
||||
configFields: [
|
||||
createPasswordField('token', 'Bot Token', 'discord-bot-token', 'Discord Bot Token。', true),
|
||||
createTextField('guildId', 'Guild ID', '123456789012345678', '推荐填写,便于定位绑定的服务器。', true),
|
||||
createTextField('channelId', 'Channel ID', '123456789012345678', '可选。指定默认投递频道。'),
|
||||
createPasswordField(
|
||||
'token',
|
||||
'Bot Token',
|
||||
'discord-bot-token',
|
||||
'Discord Bot Token。',
|
||||
true,
|
||||
{
|
||||
docsUrl: 'https://discord.com/developers/applications',
|
||||
envVar: 'DISCORD_BOT_TOKEN',
|
||||
},
|
||||
),
|
||||
createTextField(
|
||||
'guildId',
|
||||
'Guild ID',
|
||||
'123456789012345678',
|
||||
'推荐填写,便于定位绑定的服务器。',
|
||||
true,
|
||||
{
|
||||
envVar: 'DISCORD_GUILD_ID',
|
||||
},
|
||||
),
|
||||
createTextField(
|
||||
'channelId',
|
||||
'Channel ID',
|
||||
'123456789012345678',
|
||||
'可选。指定默认投递频道。',
|
||||
false,
|
||||
{
|
||||
envVar: 'DISCORD_CHANNEL_ID',
|
||||
},
|
||||
),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'在 Discord Developer Portal 创建应用并启用 Bot。',
|
||||
'复制 Token,并在目标服务器中邀请该机器人。',
|
||||
'把 Token 和可选的 Guild / Channel 标识整理成环境变量后,便于本地与部署环境复用。',
|
||||
'如果需要固定默认频道,可补充填写 Guild ID / Channel ID。',
|
||||
],
|
||||
},
|
||||
@@ -135,10 +195,12 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'WhatsApp',
|
||||
description: '通过扫描二维码连接 WhatsApp(无需手机号)。',
|
||||
connectionType: 'qr',
|
||||
docsUrl: 'https://developers.facebook.com/docs/whatsapp',
|
||||
configFields: [],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'保存后等待 runtime 侧二维码能力接入。',
|
||||
'如果后续切换到服务化接入,可把账号凭据改为环境变量管理。',
|
||||
'当前 `zn-ai` 已预留配置和状态位,二维码拉起仍是后续波次。',
|
||||
'在完全对齐 ClawX 前,建议先使用默认账号完成路由与绑定链路验证。',
|
||||
],
|
||||
@@ -148,10 +210,12 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'WeChat',
|
||||
description: '通过腾讯官方 OpenClaw 插件扫码连接个人微信。',
|
||||
connectionType: 'qr',
|
||||
docsUrl: 'https://developers.weixin.qq.com/doc/',
|
||||
configFields: [],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'该频道依赖插件与二维码链路,当前页面已保留入口。',
|
||||
'扫码接入通常不需要额外字段,但后续如果插件暴露 envVar,可直接在 modal 中复用。',
|
||||
'后续需要补齐扫码事件、成功回调和账号自动发现。',
|
||||
'Agent 绑定、默认账号和删除流程已可先行对齐。',
|
||||
],
|
||||
@@ -161,16 +225,28 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'DingTalk',
|
||||
description: '通过 OpenClaw 渠道插件连接钉钉(Stream 模式)。',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'https://open.dingtalk.com/document',
|
||||
configFields: [
|
||||
createTextField('clientId', 'Client ID', 'ding-app-key', '钉钉应用的 AppKey。', true),
|
||||
createPasswordField('clientSecret', 'Client Secret', 'ding-app-secret', '钉钉应用的 AppSecret。', true),
|
||||
createTextField('robotCode', 'Robot Code', 'dingxxxx', '可选。机器人编码。'),
|
||||
createTextField('corpId', 'Corp ID', 'dingcorp123', '可选。企业 corpId。'),
|
||||
createTextField('agentId', 'Agent ID', '123456789', '可选。机器人 AgentId。'),
|
||||
createTextField('clientId', 'Client ID', 'ding-app-key', '钉钉应用的 AppKey。', true, {
|
||||
envVar: 'DINGTALK_CLIENT_ID',
|
||||
}),
|
||||
createPasswordField('clientSecret', 'Client Secret', 'ding-app-secret', '钉钉应用的 AppSecret。', true, {
|
||||
envVar: 'DINGTALK_CLIENT_SECRET',
|
||||
}),
|
||||
createTextField('robotCode', 'Robot Code', 'dingxxxx', '可选。机器人编码。', false, {
|
||||
envVar: 'DINGTALK_ROBOT_CODE',
|
||||
}),
|
||||
createTextField('corpId', 'Corp ID', 'dingcorp123', '可选。企业 corpId。', false, {
|
||||
envVar: 'DINGTALK_CORP_ID',
|
||||
}),
|
||||
createTextField('agentId', 'Agent ID', '123456789', '可选。机器人 AgentId。', false, {
|
||||
envVar: 'DINGTALK_AGENT_ID',
|
||||
}),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在钉钉开发者后台创建机器人并获取 AppKey / AppSecret。',
|
||||
'如果你更习惯部署时注入配置,可以把这些值放到环境变量里再填入 modal。',
|
||||
'按需补充 Robot Code、Corp ID 或 Agent ID 以兼容不同部署方式。',
|
||||
'保存后优先验证默认账号、频道归属和 gateway 重载是否收敛。',
|
||||
],
|
||||
@@ -180,13 +256,19 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'Feishu / Lark',
|
||||
description: '通过飞书官方推出的 OpenClaw 插件连接飞书/Lark 机器人。',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'https://open.feishu.cn/document',
|
||||
configFields: [
|
||||
createTextField('appId', 'App ID', 'cli_xxxxxxxxx', '飞书应用的 App ID。', true),
|
||||
createPasswordField('appSecret', 'App Secret', 'app-secret', '飞书应用的 App Secret。', true),
|
||||
createTextField('appId', 'App ID', 'cli_xxxxxxxxx', '飞书应用的 App ID。', true, {
|
||||
envVar: 'FEISHU_APP_ID',
|
||||
}),
|
||||
createPasswordField('appSecret', 'App Secret', 'app-secret', '飞书应用的 App Secret。', true, {
|
||||
envVar: 'FEISHU_APP_SECRET',
|
||||
}),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在飞书开放平台创建机器人应用并开启事件订阅。',
|
||||
'App ID 与 App Secret 可以直接从环境变量注入,方便本地、测试与生产环境统一。',
|
||||
'填入 App ID 与 App Secret,保存后完成默认账号与 Agent 绑定。',
|
||||
'这是 ClawX 中验证最完整的频道之一,建议作为首个联调样板。',
|
||||
],
|
||||
@@ -196,13 +278,19 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'WeCom',
|
||||
description: '通过插件连接企业微信机器人。',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'https://developer.work.weixin.qq.com/document',
|
||||
configFields: [
|
||||
createTextField('botId', 'Bot ID', 'wecom-bot-id', '企业微信机器人或应用标识。', true),
|
||||
createPasswordField('secret', 'Secret', 'wecom-secret', '企业微信机器人密钥。', true),
|
||||
createTextField('botId', 'Bot ID', 'wecom-bot-id', '企业微信机器人或应用标识。', true, {
|
||||
envVar: 'WECOM_BOT_ID',
|
||||
}),
|
||||
createPasswordField('secret', 'Secret', 'wecom-secret', '企业微信机器人密钥。', true, {
|
||||
envVar: 'WECOM_BOT_SECRET',
|
||||
}),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在企业微信管理后台创建机器人并复制关键信息。',
|
||||
'如果你已经在部署层维护 env vars,可直接用环境变量名作为对照来填充字段。',
|
||||
'建议先用默认账号联通消息,再扩展多账号分工。',
|
||||
'保存后使用消息频道页统一管理默认账号和 Agent 归属。',
|
||||
],
|
||||
@@ -212,13 +300,19 @@ export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
name: 'QQ Bot',
|
||||
description: '连接 QQ 机器人频道(OpenClaw 3.31 起内置)。',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'https://bot.q.qq.com/wiki',
|
||||
configFields: [
|
||||
createTextField('appId', 'App ID', 'qq-app-id', 'QQ Bot App ID。', true),
|
||||
createPasswordField('clientSecret', 'Client Secret', 'qq-client-secret', 'QQ Bot Client Secret。', true),
|
||||
createTextField('appId', 'App ID', 'qq-app-id', 'QQ Bot App ID。', true, {
|
||||
envVar: 'QQBOT_APP_ID',
|
||||
}),
|
||||
createPasswordField('clientSecret', 'Client Secret', 'qq-client-secret', 'QQ Bot Client Secret。', true, {
|
||||
envVar: 'QQBOT_CLIENT_SECRET',
|
||||
}),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'在 QQ Bot 平台创建机器人并获取 App ID 与密钥。',
|
||||
'App ID 和 Client Secret 都可以通过环境变量形式提前约定,方便和 runtime 对齐。',
|
||||
'如需多账号场景,新增账号时建议使用清晰的 accountId 规则。',
|
||||
'对齐完成后,Cron、目标选择和路由会直接复用这里的配置。',
|
||||
],
|
||||
@@ -338,6 +432,7 @@ export const DEFAULT_CHANNEL_META: ChannelMeta = {
|
||||
name: 'Custom Channel',
|
||||
description: '通用频道模板,用于承接尚未内建 schema 的自定义渠道或插件。',
|
||||
connectionType: 'plugin',
|
||||
docsUrl: undefined,
|
||||
configFields: [
|
||||
createTokenField('token', 'Token', 'token-or-secret', '粘贴该渠道要求的 token、secret 或访问凭据。'),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ChannelConfigFieldMeta {
|
||||
kind: ChannelConfigFieldKind;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
docsUrl?: string;
|
||||
envVar?: string | string[];
|
||||
required?: boolean;
|
||||
rows?: number;
|
||||
autoComplete?: string;
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import ChannelConfigModal from '../../components/channels/ChannelConfigModal';
|
||||
import ChannelConfigModal, { type ChannelCredentialsValidationResult } from '../../components/channels/ChannelConfigModal';
|
||||
import { getChannelMeta, getPrimaryChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
|
||||
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import { hostApiFetch } from '../../lib/host-api';
|
||||
import telegramIcon from '../../assets/channels/telegram.svg';
|
||||
import discordIcon from '../../assets/channels/discord.svg';
|
||||
import whatsappIcon from '../../assets/channels/whatsapp.svg';
|
||||
import wechatIcon from '../../assets/channels/wechat.svg';
|
||||
import dingtalkIcon from '../../assets/channels/dingtalk.svg';
|
||||
import feishuIcon from '../../assets/channels/feishu.svg';
|
||||
import wecomIcon from '../../assets/channels/wecom.svg';
|
||||
import qqIcon from '../../assets/channels/qq.svg';
|
||||
|
||||
function cn(...tokens: Array<string | false | null | undefined>): string {
|
||||
return tokens.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getChannelMonogram(channelType: string): string {
|
||||
const meta = getChannelMeta(channelType);
|
||||
const initials = meta.name
|
||||
.split(/[\s/]+/)
|
||||
.map((part) => part.trim().charAt(0))
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return initials || channelType.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function buildSyntheticChannelUrl(channelType: string, accountId: string): string {
|
||||
return `channel://${encodeURIComponent(channelType)}/${encodeURIComponent(accountId || 'default')}`;
|
||||
}
|
||||
@@ -72,10 +67,11 @@ export default function ChannelsPage() {
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalChannelType, setModalChannelType] = useState<string>('telegram');
|
||||
const [modalAccountId, setModalAccountId] = useState<string>('');
|
||||
const [modalValues, setModalValues] = useState<ChannelConfigFieldValueMap>({});
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
const [modalSubmitting, setModalSubmitting] = useState(false);
|
||||
const [modalValidating, setModalValidating] = useState(false);
|
||||
const [modalValidationResult, setModalValidationResult] = useState<ChannelCredentialsValidationResult | null>(null);
|
||||
|
||||
function loadSupportedChannels(nextFeedback?: string): void {
|
||||
const channels = getPrimaryChannelOptions();
|
||||
@@ -89,17 +85,18 @@ export default function ChannelsPage() {
|
||||
function resetModalState(): void {
|
||||
setModalOpen(false);
|
||||
setModalChannelType(supportedChannels[0]?.type ?? 'telegram');
|
||||
setModalAccountId('');
|
||||
setModalValues({});
|
||||
setModalError(null);
|
||||
setModalSubmitting(false);
|
||||
setModalValidating(false);
|
||||
setModalValidationResult(null);
|
||||
}
|
||||
|
||||
function openCreateChannelModal(channelType: string): void {
|
||||
setModalChannelType(channelType);
|
||||
setModalAccountId('');
|
||||
setModalValues({});
|
||||
setModalError(null);
|
||||
setModalValidationResult(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
@@ -111,15 +108,62 @@ export default function ChannelsPage() {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function handleValidateModal(): Promise<ChannelCredentialsValidationResult | null> {
|
||||
setModalValidating(true);
|
||||
setModalError(null);
|
||||
|
||||
try {
|
||||
const config = Object.fromEntries(
|
||||
Object.entries(modalValues)
|
||||
.map(([key, value]) => [key, value.trim()])
|
||||
.filter(([, value]) => value.length > 0),
|
||||
);
|
||||
|
||||
const response = await hostApiFetch<ChannelCredentialsValidationResult & { success?: boolean }>(
|
||||
'/api/channels/credentials/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelType: modalChannelType,
|
||||
config,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const result: ChannelCredentialsValidationResult = {
|
||||
valid: response.valid,
|
||||
errors: response.errors ?? [],
|
||||
warnings: response.warnings ?? [],
|
||||
details: response.details,
|
||||
};
|
||||
setModalValidationResult(result);
|
||||
return result;
|
||||
} catch (requestError) {
|
||||
const message = requestError instanceof Error ? requestError.message : String(requestError);
|
||||
setModalError(message);
|
||||
setModalValidationResult(null);
|
||||
return null;
|
||||
} finally {
|
||||
setModalValidating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveModal(): Promise<void> {
|
||||
const trimmedAccountId = modalAccountId.trim();
|
||||
const accountIdForSave = trimmedAccountId || 'default';
|
||||
const accountIdForSave = 'default';
|
||||
const meta = getChannelMeta(modalChannelType);
|
||||
const requiredError = validateRequiredFields(modalChannelType, modalValues);
|
||||
if (requiredError) {
|
||||
setModalError(requiredError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.connectionType !== 'qr') {
|
||||
const validation = await handleValidateModal();
|
||||
if (!validation?.valid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setModalSubmitting(true);
|
||||
setModalError(null);
|
||||
|
||||
@@ -134,7 +178,6 @@ export default function ChannelsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelType: modalChannelType,
|
||||
accountId: trimmedAccountId || undefined,
|
||||
channelLabel: getChannelMeta(modalChannelType).name,
|
||||
accountName: buildSavedAccountName(modalChannelType, accountIdForSave),
|
||||
channelUrl: deriveChannelUrl(modalChannelType, accountIdForSave, config),
|
||||
@@ -173,7 +216,7 @@ export default function ChannelsPage() {
|
||||
支持的频道
|
||||
</h1>
|
||||
<div className="mt-1 text-[13px] text-[#667085] dark:text-gray-500">
|
||||
统一管理消息频道、账号、账号与智能体的绑定关系,以及频道默认账号
|
||||
当前页面聚焦支持的频道模块、配置入口与刷新能力。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,7 +249,7 @@ export default function ChannelsPage() {
|
||||
}}
|
||||
>
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-black/5 bg-[#F7F4EB] text-[16px] font-semibold text-[#0F172A] shadow-sm dark:border-white/10 dark:bg-[#17171a] dark:text-gray-100">
|
||||
{getChannelMonogram(meta.type)}
|
||||
<ChannelLogo type={meta.type} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -246,34 +289,46 @@ export default function ChannelsPage() {
|
||||
<ChannelConfigModal
|
||||
open={modalOpen}
|
||||
selectedChannelType={modalChannelType}
|
||||
channelTypes={supportedChannels}
|
||||
accountId={modalAccountId}
|
||||
values={modalValues}
|
||||
onChannelTypeChange={(value) => {
|
||||
setModalChannelType(value);
|
||||
setModalAccountId('');
|
||||
setModalValues({});
|
||||
setModalError(null);
|
||||
}}
|
||||
onValueChange={(key, value) => {
|
||||
setModalValues((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}));
|
||||
if (modalError) setModalError(null);
|
||||
}}
|
||||
onAccountIdChange={(value) => {
|
||||
setModalAccountId(value);
|
||||
if (modalError) setModalError(null);
|
||||
if (modalValidationResult) setModalValidationResult(null);
|
||||
}}
|
||||
onClose={resetModalState}
|
||||
onValidate={handleValidateModal}
|
||||
onConfirm={handleSaveModal}
|
||||
error={modalError}
|
||||
submitting={modalSubmitting}
|
||||
title="配置频道"
|
||||
description="选择一个支持的频道模块并填写接入信息。"
|
||||
confirmLabel="保存频道"
|
||||
validating={modalValidating}
|
||||
validationResult={modalValidationResult}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelLogo({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case 'telegram':
|
||||
return <img src={telegramIcon} alt="Telegram" className="h-5.5 w-5.5 dark:invert" />;
|
||||
case 'discord':
|
||||
return <img src={discordIcon} alt="Discord" className="h-5.5 w-5.5 dark:invert" />;
|
||||
case 'whatsapp':
|
||||
return <img src={whatsappIcon} alt="WhatsApp" className="h-5.5 w-5.5 dark:invert" />;
|
||||
case 'wechat':
|
||||
return <img src={wechatIcon} alt="WeChat" className="h-5.5 w-5.5 dark:invert" />;
|
||||
case 'dingtalk':
|
||||
return <img src={dingtalkIcon} alt="DingTalk" className="h-5.5 w-5.5 dark:invert" />;
|
||||
case 'feishu':
|
||||
return <img src={feishuIcon} alt="Feishu" className="h-5.5 w-5.5 dark:invert" />;
|
||||
case 'wecom':
|
||||
return <img src={wecomIcon} alt="WeCom" className="h-5.5 w-5.5 dark:invert" />;
|
||||
case 'qqbot':
|
||||
return <img src={qqIcon} alt="QQ" className="h-5.5 w-5.5 dark:invert" />;
|
||||
default:
|
||||
return <span className="text-[22px] font-semibold">{type.slice(0, 1).toUpperCase()}</span>;
|
||||
}
|
||||
}
|
||||
|
||||