Feat: 加入虚拟列表,导出 JSON 增加排序功能
This commit is contained in:
parent
f2acb91f95
commit
e1a5be8722
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router": "^7.9.5",
|
"react-router": "^7.9.5",
|
||||||
|
"react-virtuoso": "^4.7.11",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.16"
|
"tailwindcss": "^4.1.16"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
276
pnpm-lock.yaml
276
pnpm-lock.yaml
|
|
@ -11,6 +11,9 @@ importers:
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.15
|
specifier: ^1.1.15
|
||||||
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-dropdown-menu':
|
||||||
|
specifier: ^2.1.16
|
||||||
|
version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
version: 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
|
@ -35,6 +38,9 @@ importers:
|
||||||
react-router:
|
react-router:
|
||||||
specifier: ^7.9.5
|
specifier: ^7.9.5
|
||||||
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
react-virtuoso:
|
||||||
|
specifier: ^4.7.11
|
||||||
|
version: 4.14.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
|
|
@ -361,6 +367,21 @@ packages:
|
||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||||
|
|
||||||
|
'@floating-ui/react-dom@2.1.6':
|
||||||
|
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10':
|
||||||
|
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
|
|
@ -408,6 +429,32 @@ packages:
|
||||||
'@radix-ui/primitive@1.1.3':
|
'@radix-ui/primitive@1.1.3':
|
||||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
|
|
||||||
|
'@radix-ui/react-arrow@1.1.7':
|
||||||
|
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-collection@1.1.7':
|
||||||
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-compose-refs@1.1.2':
|
'@radix-ui/react-compose-refs@1.1.2':
|
||||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -439,6 +486,15 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-direction@1.1.1':
|
||||||
|
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-dismissable-layer@1.1.11':
|
'@radix-ui/react-dismissable-layer@1.1.11':
|
||||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -452,6 +508,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-dropdown-menu@2.1.16':
|
||||||
|
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-focus-guards@1.1.3':
|
'@radix-ui/react-focus-guards@1.1.3':
|
||||||
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -483,6 +552,32 @@ packages:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-menu@2.1.16':
|
||||||
|
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-popper@1.2.8':
|
||||||
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-portal@1.1.9':
|
'@radix-ui/react-portal@1.1.9':
|
||||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -522,6 +617,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-roving-focus@1.1.11':
|
||||||
|
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.2.3':
|
'@radix-ui/react-slot@1.2.3':
|
||||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -576,6 +684,27 @@ packages:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-rect@1.1.1':
|
||||||
|
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-size@1.1.1':
|
||||||
|
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/rect@1.1.1':
|
||||||
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.43':
|
'@rolldown/pluginutils@1.0.0-beta.43':
|
||||||
resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==}
|
resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==}
|
||||||
|
|
||||||
|
|
@ -1420,6 +1549,12 @@ packages:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-virtuoso@4.14.1:
|
||||||
|
resolution: {integrity: sha512-NRUF1ak8lY+Tvc6WN9cce59gU+lilzVtOozP+pm9J7iHshLGGjsiAB4rB2qlBPHjFbcXOQpT+7womNHGDUql8w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16 || >=17 || >= 18 || >= 19'
|
||||||
|
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
||||||
|
|
||||||
react@19.2.0:
|
react@19.2.0:
|
||||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -1847,6 +1982,23 @@ snapshots:
|
||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.3
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.4
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
|
|
@ -1891,6 +2043,27 @@ snapshots:
|
||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
|
|
||||||
|
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)':
|
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
@ -1925,6 +2098,12 @@ snapshots:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
|
||||||
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
|
@ -1938,6 +2117,21 @@ snapshots:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.2)(react@19.2.0)':
|
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.2)(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
@ -1962,6 +2156,50 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
|
|
||||||
|
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/rect': 1.1.1
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
|
@ -1991,6 +2229,23 @@ snapshots:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.2.0)':
|
'@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
|
@ -2032,6 +2287,22 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
|
|
||||||
|
'@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/rect': 1.1.1
|
||||||
|
react: 19.2.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
|
||||||
|
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.2)(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
|
||||||
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.43': {}
|
'@rolldown/pluginutils@1.0.0-beta.43': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.52.5':
|
'@rollup/rollup-android-arm-eabi@4.52.5':
|
||||||
|
|
@ -2809,6 +3080,11 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
|
|
||||||
|
react-virtuoso@4.14.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
|
||||||
react@19.2.0: {}
|
react@19.2.0: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { memo, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
onConfirm: (name: string) => void | Promise<void>;
|
||||||
|
validate?: (name: string) => string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EntryNameModalImpl({ open, onOpenChange, title, placeholder, defaultValue, confirmText = "确认", onConfirm, validate }: Props) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(defaultValue ?? "");
|
||||||
|
setErr(null);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [open, defaultValue]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return setErr("名称不能为空");
|
||||||
|
if (trimmed.includes(".")) return setErr("名称不能包含 '.'");
|
||||||
|
if (validate) {
|
||||||
|
const msg = validate(trimmed);
|
||||||
|
if (msg) return setErr(msg);
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onConfirm(trimmed);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? "操作失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!saving) onOpenChange(v); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
aria-label="名称"
|
||||||
|
/>
|
||||||
|
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{confirmText}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EntryNameModal = memo(EntryNameModalImpl);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,31 +14,84 @@ type Props = {
|
||||||
onOpenChange: (next: boolean) => void;
|
onOpenChange: (next: boolean) => void;
|
||||||
languages: string[];
|
languages: string[];
|
||||||
valuesByLang: Record<string, Record<string, string>>;
|
valuesByLang: Record<string, Record<string, string>>;
|
||||||
|
orderedPaths?: string[]; // 优先按照结构顺序导出
|
||||||
};
|
};
|
||||||
|
|
||||||
function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) {
|
function ExportLanguageModalImpl({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
languages,
|
||||||
|
valuesByLang,
|
||||||
|
orderedPaths,
|
||||||
|
}: Props) {
|
||||||
const [selected, setSelected] = useState<string>("");
|
const [selected, setSelected] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? ""));
|
setSelected((prev) =>
|
||||||
|
prev && languages.includes(prev) ? prev : languages[0] ?? ""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [open, languages]);
|
}, [open, languages]);
|
||||||
|
|
||||||
const jsonText = useMemo(() => {
|
const jsonText = useMemo(() => {
|
||||||
if (!selected) return "";
|
if (!selected) return "";
|
||||||
const flat = valuesByLang[selected] ?? {};
|
const flat = valuesByLang[selected] ?? {};
|
||||||
const nested = unflattenValues(flat);
|
|
||||||
|
// 若提供结构顺序,则按结构顺序构建嵌套对象,保证键序与表格一致
|
||||||
|
if (orderedPaths && orderedPaths.length > 0) {
|
||||||
|
const root: Record<string, unknown> = {};
|
||||||
|
const added = new Set<string>();
|
||||||
|
const setByPath = (path: string, val: string) => {
|
||||||
|
const parts = path.split(".");
|
||||||
|
let cur: Record<string, unknown> = root;
|
||||||
|
for (let i = 0; i < parts.length; i += 1) {
|
||||||
|
const key = parts[i]!;
|
||||||
|
const isLeaf = i === parts.length - 1;
|
||||||
|
if (isLeaf) {
|
||||||
|
cur[key] = val;
|
||||||
|
} else {
|
||||||
|
const next = cur[key];
|
||||||
|
if (next && typeof next === "object" && !Array.isArray(next)) {
|
||||||
|
cur = next as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
cur[key] = obj;
|
||||||
|
cur = obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const p of orderedPaths) {
|
||||||
|
const v = Object.prototype.hasOwnProperty.call(flat, p) ? flat[p] : "";
|
||||||
|
setByPath(p, v);
|
||||||
|
added.add(p);
|
||||||
|
}
|
||||||
|
// 追加结构外的剩余键,避免丢数据
|
||||||
|
for (const p of Object.keys(flat)) {
|
||||||
|
if (!added.has(p)) setByPath(p, flat[p]!);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(root, null, 2);
|
||||||
|
} catch {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:无结构顺序时使用普通反扁平
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(nested, null, 2);
|
return JSON.stringify(unflattenValues(flat), null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return "{}";
|
return "{}";
|
||||||
}
|
}
|
||||||
}, [selected, valuesByLang]);
|
}, [selected, valuesByLang, orderedPaths]);
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" });
|
const blob = new Blob([jsonText || "{}"], {
|
||||||
|
type: "application/json;charset=utf-8",
|
||||||
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
@ -58,14 +111,18 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
|
||||||
<div className="space-y-3 min-w-0">
|
<div className="space-y-3 min-w-0">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{languages.length === 0 ? (
|
{languages.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">暂无可导出的语言</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
暂无可导出的语言
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
languages.map((lang) => (
|
languages.map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected(lang)}
|
onClick={() => setSelected(lang)}
|
||||||
className={`px-3 h-8 rounded border text-sm ${selected === lang ? "bg-accent" : ""}`}
|
className={`px-3 h-8 rounded border text-sm ${
|
||||||
|
selected === lang ? "bg-accent" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{lang}
|
{lang}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -73,14 +130,20 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-md overflow-hidden">
|
<div className="border rounded-md overflow-hidden">
|
||||||
<textarea className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre" readOnly>
|
<textarea
|
||||||
{jsonText}
|
className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre"
|
||||||
</textarea>
|
readOnly
|
||||||
|
value={jsonText}
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
<Button onClick={handleDownload} disabled={!selected || !jsonText}>导出 JSON</Button>
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleDownload} disabled={!selected || !jsonText}>
|
||||||
|
导出 JSON
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -88,5 +151,3 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExportLanguageModal = memo(ExportLanguageModalImpl);
|
export const ExportLanguageModal = memo(ExportLanguageModalImpl);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
|
|
@ -250,3 +250,76 @@ export async function listLanguages(projectId: string): Promise<string[]> {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllLanguageTranslations(projectId: string): Promise<ProjectLanguageTranslations[]> {
|
||||||
|
const db = await openDb();
|
||||||
|
try {
|
||||||
|
const tx = db.transaction(STORE_TRANSLATIONS, "readonly");
|
||||||
|
const store = tx.objectStore(STORE_TRANSLATIONS);
|
||||||
|
const index = store.index("byProject");
|
||||||
|
const allReq = index.getAll(IDBKeyRange.only(projectId));
|
||||||
|
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
return records;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEntryFromAllLanguages(projectId: string, path: string): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
try {
|
||||||
|
const tx = db.transaction(STORE_TRANSLATIONS, "readwrite");
|
||||||
|
const store = tx.objectStore(STORE_TRANSLATIONS);
|
||||||
|
const index = store.index("byProject");
|
||||||
|
const allReq = index.getAll(IDBKeyRange.only(projectId));
|
||||||
|
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
|
||||||
|
for (const rec of records) {
|
||||||
|
if (rec.values && Object.prototype.hasOwnProperty.call(rec.values, path)) {
|
||||||
|
const { [path]: _, ...rest } = rec.values;
|
||||||
|
rec.values = rest;
|
||||||
|
await promisifyRequest(store.put(rec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameEntryInAllLanguages(projectId: string, oldPath: string, newPath: string): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
try {
|
||||||
|
const tx = db.transaction(STORE_TRANSLATIONS, "readwrite");
|
||||||
|
const store = tx.objectStore(STORE_TRANSLATIONS);
|
||||||
|
const index = store.index("byProject");
|
||||||
|
const allReq = index.getAll(IDBKeyRange.only(projectId));
|
||||||
|
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
|
||||||
|
for (const rec of records) {
|
||||||
|
const values = rec.values ?? {};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(values, oldPath)) {
|
||||||
|
const val = values[oldPath];
|
||||||
|
const updated = { ...values };
|
||||||
|
delete updated[oldPath];
|
||||||
|
updated[newPath] = val;
|
||||||
|
rec.values = updated;
|
||||||
|
await promisifyRequest(store.put(rec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,4 +83,74 @@ export function unflattenValues(values: Record<string, string>): Record<string,
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSegments(path: string): string[] {
|
||||||
|
return path ? path.split(".") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneNode(node: StructureNode): StructureNode {
|
||||||
|
if (node.type === "group") {
|
||||||
|
return { key: node.key, type: "group", children: (node.children ?? []).map(cloneNode) };
|
||||||
|
}
|
||||||
|
return { key: node.key, type: "entry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findParentAndIndex(root: StructureNode, path: string): { parent: StructureNode; index: number } | null {
|
||||||
|
const segments = getSegments(path);
|
||||||
|
let parent: StructureNode = root;
|
||||||
|
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||||
|
const seg = segments[i]!;
|
||||||
|
const next = (parent.children ?? []).find((c) => c.key === seg && c.type === "group");
|
||||||
|
if (!next) return null;
|
||||||
|
parent = next;
|
||||||
|
}
|
||||||
|
const leafKey = segments[segments.length - 1];
|
||||||
|
if (leafKey == null) return null;
|
||||||
|
const index = (parent.children ?? []).findIndex((c) => c.key === leafKey);
|
||||||
|
if (index < 0) return null;
|
||||||
|
return { parent, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertEntrySibling(
|
||||||
|
root: StructureNode,
|
||||||
|
targetPath: string,
|
||||||
|
position: "above" | "below",
|
||||||
|
newKey: string
|
||||||
|
): StructureNode {
|
||||||
|
const cloned = cloneNode(root);
|
||||||
|
const info = findParentAndIndex(cloned, targetPath);
|
||||||
|
if (!info) return cloned;
|
||||||
|
const { parent, index } = info;
|
||||||
|
if (!parent.children) parent.children = [];
|
||||||
|
const exists = parent.children.some((c) => c.key === newKey);
|
||||||
|
if (exists) throw new Error("同级已存在同名条目");
|
||||||
|
const insertIndex = position === "above" ? index : index + 1;
|
||||||
|
parent.children.splice(insertIndex, 0, { key: newKey, type: "entry" });
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEntryAtPath(root: StructureNode, path: string): StructureNode {
|
||||||
|
const cloned = cloneNode(root);
|
||||||
|
const info = findParentAndIndex(cloned, path);
|
||||||
|
if (!info) return cloned;
|
||||||
|
const { parent, index } = info;
|
||||||
|
parent.children?.splice(index, 1);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameEntryAtPath(root: StructureNode, path: string, newKey: string): { root: StructureNode; newPath: string } {
|
||||||
|
const cloned = cloneNode(root);
|
||||||
|
const info = findParentAndIndex(cloned, path);
|
||||||
|
if (!info) return { root: cloned, newPath: path };
|
||||||
|
const { parent, index } = info;
|
||||||
|
const siblings = parent.children ?? [];
|
||||||
|
if (siblings.some((c, i) => i !== index && c.key === newKey)) {
|
||||||
|
throw new Error("同级已存在同名条目");
|
||||||
|
}
|
||||||
|
const old = siblings[index]!;
|
||||||
|
siblings[index] = old.type === "group" ? { ...old, key: newKey } : { key: newKey, type: "entry" };
|
||||||
|
const segments = getSegments(path);
|
||||||
|
segments[segments.length - 1] = newKey;
|
||||||
|
return { root: cloned, newPath: segments.join(".") };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type React from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -9,12 +10,24 @@ import {
|
||||||
type Project,
|
type Project,
|
||||||
type ProjectStructure,
|
type ProjectStructure,
|
||||||
getLanguageTranslations,
|
getLanguageTranslations,
|
||||||
|
upsertStructure,
|
||||||
|
deleteEntryFromAllLanguages,
|
||||||
|
renameEntryInAllLanguages,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
||||||
import { flattenEntries, type FlatEntry } from "@/lib/i18n-structure";
|
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
|
||||||
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
||||||
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
||||||
|
import { EntryNameModal } from "@/components/biz/entry-name-modal";
|
||||||
|
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const { id: projectId } = useParams();
|
const { id: projectId } = useParams();
|
||||||
|
|
@ -25,6 +38,50 @@ export default function Editor() {
|
||||||
const [pageError, setPageError] = useState<string | null>(null);
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null);
|
||||||
|
const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null);
|
||||||
|
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||||
|
const scrollerRootRef = useRef<HTMLElement | Window | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||||
|
const [fullMatch, setFullMatch] = useState(false);
|
||||||
|
|
||||||
|
function highlightRow(index: number) {
|
||||||
|
const tryFindAndAnimate = (attempt = 0) => {
|
||||||
|
const root = scrollerRootRef.current as HTMLElement | null;
|
||||||
|
if (!root) return;
|
||||||
|
const row = root.querySelector(`tr[data-item-index="${index}"]`) as HTMLTableRowElement | null;
|
||||||
|
if (row) {
|
||||||
|
row.animate(
|
||||||
|
[
|
||||||
|
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
|
||||||
|
{ backgroundColor: "transparent" },
|
||||||
|
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
|
||||||
|
{ backgroundColor: "transparent" },
|
||||||
|
],
|
||||||
|
{ duration: 1200, easing: "ease-in-out" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt < 10) {
|
||||||
|
setTimeout(() => tryFindAndAnimate(attempt + 1), 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 等一帧,确保滚动定位后的 DOM 稳定
|
||||||
|
requestAnimationFrame(() => tryFindAndAnimate(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToQuery() {
|
||||||
|
if (!query) return;
|
||||||
|
const idx = entries.findIndex((e) => {
|
||||||
|
const hay = caseSensitive ? e.path : e.path.toLowerCase();
|
||||||
|
const needle = caseSensitive ? query : query.toLowerCase();
|
||||||
|
return fullMatch ? hay === needle : hay.includes(needle);
|
||||||
|
});
|
||||||
|
if (idx >= 0) {
|
||||||
|
virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
@ -63,6 +120,107 @@ export default function Editor() {
|
||||||
onError: (m) => setPageError(m),
|
onError: (m) => setPageError(m),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const colWidth = useMemo(() => `${100 / (languages.length + 2)}%`, [languages.length]);
|
||||||
|
|
||||||
|
const virtuosoComponents = useMemo(() => ({
|
||||||
|
Table: (props: React.TableHTMLAttributes<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} />,
|
||||||
|
TableHead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead className="bg-muted" {...props} />,
|
||||||
|
TableRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr className="border-t" {...props} />,
|
||||||
|
TableBody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />,
|
||||||
|
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const headerContent = useCallback(() => (
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: colWidth }} className="text-left px-3 py-2">翻译条目名称</th>
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
|
||||||
|
))}
|
||||||
|
<th style={{ width: "5em" }} className="text-left px-3 py-2">操作</th>
|
||||||
|
</tr>
|
||||||
|
), [languages, colWidth]);
|
||||||
|
|
||||||
|
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td style={{ width: colWidth }} className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
|
||||||
|
{languages.map((lang) => {
|
||||||
|
const isEditing = inline.isEditingCell(entry.path, lang);
|
||||||
|
const isSaving = inline.isSavingCell(entry.path, lang);
|
||||||
|
const displayValue = inline.getDisplayValue(entry.path, lang);
|
||||||
|
return (
|
||||||
|
<td key={`${entry.path}:${lang}`} style={{ width: colWidth }} className="px-3 py-2 text-foreground/90 align-top">
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
ref={inline.inputRef}
|
||||||
|
value={inline.editingValue}
|
||||||
|
onChange={(e) => inline.setEditingValue(e.target.value)}
|
||||||
|
onBlur={inline.saveEdit}
|
||||||
|
onKeyDown={inline.handleKeyDown}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
|
||||||
|
onClick={() => inline.startEdit(entry.path, lang)}
|
||||||
|
title="点击编辑"
|
||||||
|
>
|
||||||
|
{displayValue || <span className="text-muted-foreground">点击编辑</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td style={{ width: "5em" }} className="px-3 py-2 align-top">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline">操作</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
|
||||||
|
在下面新增
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
|
||||||
|
在上面新增
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
if (!projectId || !structure) return;
|
||||||
|
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
|
||||||
|
try {
|
||||||
|
const nextRoot = removeEntryAtPath(structure.root, entry.path);
|
||||||
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
|
await deleteEntryFromAllLanguages(projectId, entry.path);
|
||||||
|
setStructure({ projectId, root: nextRoot });
|
||||||
|
setValuesByLang((old) => {
|
||||||
|
const copy: typeof old = {};
|
||||||
|
for (const [langKey, vals] of Object.entries(old)) {
|
||||||
|
const rest = { ...vals } as Record<string, string>;
|
||||||
|
delete rest[entry.path];
|
||||||
|
copy[langKey] = rest;
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setPageError((e as Error)?.message ?? "删除失败");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
|
||||||
|
重命名
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId || languages.length === 0) return;
|
if (!projectId || languages.length === 0) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -124,56 +282,35 @@ export default function Editor() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="overflow-auto border rounded-md">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<table className="min-w-full text-sm">
|
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
<thead className="bg-muted/50">
|
<Button
|
||||||
<tr>
|
variant={fullMatch ? "default" : "outline"}
|
||||||
<th className="text-left px-3 py-2 w-[320px]">翻译条目名称</th>
|
onClick={() => setFullMatch((v) => !v)}
|
||||||
{languages.map((lang) => (
|
title="切换全量匹配/模糊匹配"
|
||||||
<th key={lang} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
|
>
|
||||||
))}
|
{fullMatch ? "全量匹配" : "模糊匹配"}
|
||||||
</tr>
|
</Button>
|
||||||
</thead>
|
<Button
|
||||||
<tbody>
|
variant={caseSensitive ? "default" : "outline"}
|
||||||
{entries.map((entry) => (
|
onClick={() => setCaseSensitive((v) => !v)}
|
||||||
<tr key={entry.path} className="border-t">
|
title="切换大小写敏感"
|
||||||
<td className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
|
>
|
||||||
{languages.map((lang) => {
|
{caseSensitive ? "区分大小写" : "忽略大小写"}
|
||||||
const isEditing = inline.isEditingCell(entry.path, lang);
|
</Button>
|
||||||
const isSaving = inline.isSavingCell(entry.path, lang);
|
<Button variant="outline" onClick={scrollToQuery}>定位</Button>
|
||||||
const displayValue = inline.getDisplayValue(entry.path, lang);
|
</div>
|
||||||
return (
|
<div className="border rounded-md">
|
||||||
<td
|
<TableVirtuoso
|
||||||
key={`${entry.path}:${lang}`}
|
ref={virtuosoRef}
|
||||||
className="px-3 py-2 text-foreground/90 align-top"
|
data={entries}
|
||||||
>
|
style={{ height: "60vh" }}
|
||||||
{isEditing ? (
|
fixedHeaderContent={headerContent}
|
||||||
<Input
|
itemContent={renderItemContent}
|
||||||
ref={inline.inputRef}
|
components={virtuosoComponents}
|
||||||
value={inline.editingValue}
|
computeItemKey={(_index, entry) => entry.path}
|
||||||
onChange={(e) => inline.setEditingValue(e.target.value)}
|
scrollerRef={(el) => { scrollerRootRef.current = el; }}
|
||||||
onBlur={inline.saveEdit}
|
/>
|
||||||
onKeyDown={inline.handleKeyDown}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
|
|
||||||
onClick={() => inline.startEdit(entry.path, lang)}
|
|
||||||
title="点击编辑"
|
|
||||||
>
|
|
||||||
{displayValue || <span className="text-muted-foreground">点击编辑</span>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -191,6 +328,56 @@ export default function Editor() {
|
||||||
onOpenChange={setExportOpen}
|
onOpenChange={setExportOpen}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
valuesByLang={valuesByLang}
|
valuesByLang={valuesByLang}
|
||||||
|
orderedPaths={entries.map((e) => e.path)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EntryNameModal
|
||||||
|
open={!!addModal?.open}
|
||||||
|
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
|
title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"}
|
||||||
|
placeholder="请输入条目名称(不含点)"
|
||||||
|
onConfirm={async (name) => {
|
||||||
|
if (!projectId || !structure || !addModal) return;
|
||||||
|
const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name);
|
||||||
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
|
setStructure({ projectId, root: nextRoot });
|
||||||
|
}}
|
||||||
|
validate={(name) => {
|
||||||
|
// 同级重名校验在结构函数中也会做,这里做基础提示即可
|
||||||
|
if (name.includes('.')) return "名称不能包含 '.'";
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EntryNameModal
|
||||||
|
open={!!renameModal?.open}
|
||||||
|
onOpenChange={(v) => setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
|
title="重命名条目"
|
||||||
|
placeholder="请输入新名称(不含点)"
|
||||||
|
defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''}
|
||||||
|
onConfirm={async (newName) => {
|
||||||
|
if (!projectId || !structure || !renameModal) return;
|
||||||
|
const { root: nextRoot, newPath } = renameEntryAtPath(structure.root, renameModal.path, newName);
|
||||||
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
|
await renameEntryInAllLanguages(projectId, renameModal.path, newPath);
|
||||||
|
setStructure({ projectId, root: nextRoot });
|
||||||
|
setValuesByLang((old) => {
|
||||||
|
const copy: typeof old = {};
|
||||||
|
for (const [langKey, vals] of Object.entries(old)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(vals, renameModal.path)) {
|
||||||
|
const { [renameModal.path]: val, ...rest } = vals;
|
||||||
|
copy[langKey] = { ...rest, [newPath]: val };
|
||||||
|
} else {
|
||||||
|
copy[langKey] = vals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
validate={(name) => {
|
||||||
|
if (name.includes('.')) return "名称不能包含 '.'";
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue