Feat: 新增首页和编辑器页面,支持导出导出
This commit is contained in:
parent
d2ba984a75
commit
f2acb91f95
|
|
@ -15,6 +15,9 @@ export default defineConfig([
|
|||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
"lucide-react": "^0.552.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.9.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
|
|
|
|||
434
pnpm-lock.yaml
434
pnpm-lock.yaml
|
|
@ -8,6 +8,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog':
|
||||
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)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||
|
|
@ -29,6 +32,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: ^19.1.1
|
||||
version: 19.2.0(react@19.2.0)
|
||||
react-router:
|
||||
specifier: ^7.9.5
|
||||
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
|
|
@ -399,6 +405,9 @@ packages:
|
|||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
|
|
@ -408,6 +417,111 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
||||
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-dismissable-layer@1.1.11':
|
||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7':
|
||||
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
|
||||
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-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
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-presence@1.1.5':
|
||||
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
|
||||
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-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
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':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
|
|
@ -417,6 +531,51 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
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-controllable-state@1.2.2':
|
||||
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
||||
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-effect-event@0.0.2':
|
||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||
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-escape-keydown@1.1.1':
|
||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||
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-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.43':
|
||||
resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==}
|
||||
|
||||
|
|
@ -734,6 +893,10 @@ packages:
|
|||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
|
|
@ -787,6 +950,10 @@ packages:
|
|||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie@1.0.2:
|
||||
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -810,6 +977,9 @@ packages:
|
|||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
electron-to-chromium@1.5.244:
|
||||
resolution: {integrity: sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==}
|
||||
|
||||
|
|
@ -936,6 +1106,10 @@ packages:
|
|||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-nonce@1.0.1:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -1206,6 +1380,46 @@ packages:
|
|||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-remove-scroll-bar@2.3.8:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-remove-scroll@2.7.1:
|
||||
resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-router@7.9.5:
|
||||
resolution: {integrity: sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react@19.2.0:
|
||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -1238,6 +1452,9 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -1282,6 +1499,9 @@ packages:
|
|||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
|
|
@ -1313,6 +1533,26 @@ packages:
|
|||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
use-callback-ref@1.3.3:
|
||||
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sidecar@1.1.3:
|
||||
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
vite@7.1.12:
|
||||
resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1649,12 +1889,108 @@ snapshots:
|
|||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.19.1
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@radix-ui/react-dialog@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)':
|
||||
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-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-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-slot': 1.2.3(@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)
|
||||
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-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:
|
||||
'@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-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-escape-keydown': 1.1.1(@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)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 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)
|
||||
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-id@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/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:
|
||||
'@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-layout-effect': 1.1.1(@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-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)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@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)
|
||||
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-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)':
|
||||
dependencies:
|
||||
'@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-slot@1.2.3(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
|
|
@ -1662,6 +1998,40 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@radix-ui/react-use-callback-ref@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-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@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)
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@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/react-use-escape-keydown@1.1.1(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.43': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.52.5':
|
||||
|
|
@ -1959,6 +2329,10 @@ snapshots:
|
|||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
baseline-browser-mapping@2.8.22: {}
|
||||
|
|
@ -2009,6 +2383,8 @@ snapshots:
|
|||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie@1.0.2: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
@ -2025,6 +2401,8 @@ snapshots:
|
|||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
electron-to-chromium@1.5.244: {}
|
||||
|
||||
enhanced-resolve@5.18.3:
|
||||
|
|
@ -2188,6 +2566,8 @@ snapshots:
|
|||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -2394,6 +2774,41 @@ snapshots:
|
|||
|
||||
react-refresh@0.18.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.2)(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
react-remove-scroll@2.7.1(@types/react@19.2.2)(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react-remove-scroll-bar: 2.3.8(@types/react@19.2.2)(react@19.2.0)
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||
tslib: 2.8.1
|
||||
use-callback-ref: 1.3.3(@types/react@19.2.2)(react@19.2.0)
|
||||
use-sidecar: 1.1.3(@types/react@19.2.2)(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
cookie: 1.0.2
|
||||
react: 19.2.0
|
||||
set-cookie-parser: 2.7.2
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.2.0):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
react: 19.2.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
react@19.2.0: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
|
@ -2438,6 +2853,8 @@ snapshots:
|
|||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
|
@ -2471,6 +2888,8 @@ snapshots:
|
|||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
|
|
@ -2502,6 +2921,21 @@ snapshots:
|
|||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-callback-ref@1.3.3(@types/react@19.2.2)(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
use-sidecar@1.1.3(@types/react@19.2.2)(react@19.2.0):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
react: 19.2.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.11
|
||||
|
|
|
|||
42
src/App.css
42
src/App.css
|
|
@ -1,42 +0,0 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
35
src/App.tsx
35
src/App.tsx
|
|
@ -1,35 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { unflattenValues } from "@/lib/i18n-structure";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
languages: string[];
|
||||
valuesByLang: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) {
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? ""));
|
||||
}
|
||||
}, [open, languages]);
|
||||
|
||||
const jsonText = useMemo(() => {
|
||||
if (!selected) return "";
|
||||
const flat = valuesByLang[selected] ?? {};
|
||||
const nested = unflattenValues(flat);
|
||||
try {
|
||||
return JSON.stringify(nested, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}, [selected, valuesByLang]);
|
||||
|
||||
function handleDownload() {
|
||||
if (!selected) return;
|
||||
const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${selected}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导出语言 JSON</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无可导出的语言</div>
|
||||
) : (
|
||||
languages.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setSelected(lang)}
|
||||
className={`px-3 h-8 rounded border text-sm ${selected === lang ? "bg-accent" : ""}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<textarea className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre" readOnly>
|
||||
{jsonText}
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
<Button onClick={handleDownload} disabled={!selected || !jsonText}>导出 JSON</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ExportLanguageModal = memo(ExportLanguageModalImpl);
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { memo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { buildStructureFromObject, flattenValues } from "@/lib/i18n-structure";
|
||||
import { upsertLanguageTranslations, upsertStructure } from "@/lib/db";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
projectId: string;
|
||||
hasStructure: boolean;
|
||||
onImported: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function ImportLanguageModalImpl({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
hasStructure,
|
||||
onImported,
|
||||
}: Props) {
|
||||
const [lang, setLang] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!fileRef.current ||
|
||||
!fileRef.current.files ||
|
||||
fileRef.current.files.length === 0
|
||||
) {
|
||||
setError("请选择 JSON 文件");
|
||||
return;
|
||||
}
|
||||
const language = lang.trim();
|
||||
if (!language) {
|
||||
setError("请输入语言代号,如 en 或 zh-CN");
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const file = fileRef.current.files[0]!;
|
||||
const text = await file.text();
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("JSON 解析失败,请检查文件内容");
|
||||
}
|
||||
if (!hasStructure) {
|
||||
const root = buildStructureFromObject(json);
|
||||
await upsertStructure({ projectId, root });
|
||||
}
|
||||
const values = flattenValues(json);
|
||||
await upsertLanguageTranslations(projectId, language, values);
|
||||
setLang("");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
onOpenChange(false);
|
||||
await onImported();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "导入失败");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!importing) {
|
||||
onOpenChange(v);
|
||||
if (!v) setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasStructure ? "导入语言 JSON" : "导入翻译 JSON 并构建结构"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">
|
||||
选择 JSON 文件
|
||||
</label>
|
||||
<Input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="mt-1 block w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">
|
||||
语言代号(如 en、ja、zh-CN)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="en"
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
aria-label="语言代号"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={importing}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={importing}>
|
||||
{importing ? "导入中..." : "确认导入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ImportLanguageModal = memo(ImportLanguageModalImpl);
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
|
@ -2,20 +2,25 @@ import * as React from "react";
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { upsertLanguageTranslations } from "@/lib/db";
|
||||
|
||||
export type ValuesByLang = Record<string, Record<string, string>>;
|
||||
|
||||
function makeKey(path: string, lang: string) {
|
||||
return `${path}||${lang}`;
|
||||
}
|
||||
|
||||
export function useTranslationInlineEdit(opts: {
|
||||
projectId?: string;
|
||||
valuesByLang: ValuesByLang;
|
||||
setValuesByLang: React.Dispatch<React.SetStateAction<ValuesByLang>>;
|
||||
onError?: (message: string) => void;
|
||||
}) {
|
||||
const { projectId, valuesByLang, setValuesByLang, onError } = opts;
|
||||
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingValue, setEditingValue] = useState<string>("");
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
function startEdit(path: string, lang: string) {
|
||||
const key = makeKey(path, lang);
|
||||
const current = valuesByLang[lang]?.[path] ?? "";
|
||||
setEditingKey(key);
|
||||
setEditingValue(current);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingKey(null);
|
||||
setEditingValue("");
|
||||
}
|
||||
|
||||
function isEditingCell(path: string, lang: string) {
|
||||
return editingKey === makeKey(path, lang);
|
||||
}
|
||||
|
||||
function isSavingCell(path: string, lang: string) {
|
||||
return savingKey === makeKey(path, lang);
|
||||
}
|
||||
|
||||
function getDisplayValue(path: string, lang: string) {
|
||||
return valuesByLang[lang]?.[path] ?? "";
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!projectId || !editingKey) return;
|
||||
if (savingKey === editingKey) return;
|
||||
const [path, lang] = editingKey.split("||");
|
||||
const prev = valuesByLang[lang] ?? {};
|
||||
const next = { ...prev, [path]: editingValue };
|
||||
setSavingKey(editingKey);
|
||||
try {
|
||||
await upsertLanguageTranslations(projectId, lang, next);
|
||||
setValuesByLang((old) => ({ ...old, [lang]: next }));
|
||||
cancelEdit();
|
||||
} catch (e) {
|
||||
onError?.((e as Error)?.message ?? "保存失败");
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void saveEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
editingKey,
|
||||
editingValue,
|
||||
savingKey,
|
||||
inputRef,
|
||||
// selectors
|
||||
isEditingCell,
|
||||
isSavingCell,
|
||||
getDisplayValue,
|
||||
// actions
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
setEditingValue,
|
||||
saveEdit,
|
||||
handleKeyDown,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -4,18 +4,6 @@
|
|||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
|
|
@ -50,60 +38,6 @@
|
|||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number; // ms timestamp
|
||||
updatedAt: number; // ms timestamp
|
||||
};
|
||||
|
||||
export type StructureNode = {
|
||||
key: string;
|
||||
type: "group" | "entry";
|
||||
children?: StructureNode[];
|
||||
};
|
||||
|
||||
export type ProjectStructure = {
|
||||
projectId: string;
|
||||
root: StructureNode;
|
||||
};
|
||||
|
||||
export type ProjectLanguageTranslations = {
|
||||
id: string; // `${projectId}:${language}`
|
||||
projectId: string;
|
||||
language: string; // e.g. en, zh-CN, ja
|
||||
values: Record<string, string>; // path -> value
|
||||
};
|
||||
|
||||
const DB_NAME = "i18n-translate-it";
|
||||
const DB_VERSION = 2;
|
||||
const STORE_PROJECTS = "projects";
|
||||
const STORE_STRUCTURES = "structures"; // keyPath: projectId
|
||||
const STORE_TRANSLATIONS = "translations"; // keyPath: id, index by projectId
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_PROJECTS)) {
|
||||
db.createObjectStore(STORE_PROJECTS, { keyPath: "id" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_STRUCTURES)) {
|
||||
db.createObjectStore(STORE_STRUCTURES, { keyPath: "projectId" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_TRANSLATIONS)) {
|
||||
const store = db.createObjectStore(STORE_TRANSLATIONS, { keyPath: "id" });
|
||||
store.createIndex("byProject", "projectId", { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () => {
|
||||
reject(new Error("IndexedDB blocked: please close other tabs."));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function generateId(length = 12): string {
|
||||
const alphabet =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const bytes = new Uint8Array(length);
|
||||
if (globalThis.crypto && globalThis.crypto.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < length; i += 1)
|
||||
bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
let id = "";
|
||||
for (let i = 0; i < length; i += 1)
|
||||
id += alphabet[bytes[i] % alphabet.length];
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_PROJECTS, "readonly");
|
||||
const store = tx.objectStore(STORE_PROJECTS);
|
||||
const getAllReq = store.getAll();
|
||||
const result = await promisifyRequest<Project[]>(getAllReq);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
// sort by createdAt desc by default when listing
|
||||
return (result ?? []).sort((a, b) => b.createdAt - a.createdAt);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project | undefined> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_PROJECTS, "readonly");
|
||||
const store = tx.objectStore(STORE_PROJECTS);
|
||||
const req = store.get(id);
|
||||
const result = await promisifyRequest<Project | undefined>(
|
||||
req as IDBRequest<Project | undefined>
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(name: string): Promise<Project> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("项目名称不能为空");
|
||||
const project: Project = {
|
||||
id: generateId(12),
|
||||
name: trimmed,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_PROJECTS, "readwrite");
|
||||
const store = tx.objectStore(STORE_PROJECTS);
|
||||
const putReq = store.add(project);
|
||||
await promisifyRequest(putReq);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
return project;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_PROJECTS, "readwrite");
|
||||
const store = tx.objectStore(STORE_PROJECTS);
|
||||
const delReq = store.delete(id);
|
||||
await promisifyRequest(delReq);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Structures -----
|
||||
export async function getStructure(projectId: string): Promise<ProjectStructure | undefined> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_STRUCTURES, "readonly");
|
||||
const store = tx.objectStore(STORE_STRUCTURES);
|
||||
const req = store.get(projectId);
|
||||
const result = await promisifyRequest<ProjectStructure | undefined>(req as IDBRequest<ProjectStructure | undefined>);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertStructure(structure: ProjectStructure): Promise<void> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_STRUCTURES, "readwrite");
|
||||
const store = tx.objectStore(STORE_STRUCTURES);
|
||||
const putReq = store.put(structure);
|
||||
await promisifyRequest(putReq);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Translations -----
|
||||
export async function upsertLanguageTranslations(projectId: string, language: string, values: Record<string, string>): Promise<void> {
|
||||
const id = `${projectId}:${language}`;
|
||||
const record: ProjectLanguageTranslations = { id, projectId, language, values };
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_TRANSLATIONS, "readwrite");
|
||||
const store = tx.objectStore(STORE_TRANSLATIONS);
|
||||
const putReq = store.put(record);
|
||||
await promisifyRequest(putReq);
|
||||
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 getLanguageTranslations(projectId: string, language: string): Promise<ProjectLanguageTranslations | undefined> {
|
||||
const id = `${projectId}:${language}`;
|
||||
const db = await openDb();
|
||||
try {
|
||||
const tx = db.transaction(STORE_TRANSLATIONS, "readonly");
|
||||
const store = tx.objectStore(STORE_TRANSLATIONS);
|
||||
const req = store.get(id);
|
||||
const result = await promisifyRequest<ProjectLanguageTranslations | undefined>(req as IDBRequest<ProjectLanguageTranslations | undefined>);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLanguages(projectId: string): Promise<string[]> {
|
||||
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[]>);
|
||||
const langs = new Set<string>();
|
||||
for (const r of records) langs.add(r.language);
|
||||
return Array.from(langs);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { type StructureNode } from "@/lib/db";
|
||||
|
||||
export type FlatEntry = { path: string; name: string };
|
||||
|
||||
export function buildStructureFromObject(obj: unknown): StructureNode {
|
||||
const root: StructureNode = { key: "$root", type: "group", children: [] };
|
||||
function walk(currentKey: string, value: unknown): StructureNode {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
const children: StructureNode[] = [];
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
children.push(walk(k, v));
|
||||
}
|
||||
return { key: currentKey, type: "group", children };
|
||||
}
|
||||
return { key: currentKey, type: "entry" };
|
||||
}
|
||||
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
root.children!.push(walk(k, v));
|
||||
}
|
||||
} else {
|
||||
root.children!.push({ key: "value", type: "entry" });
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export function flattenEntries(root: StructureNode): FlatEntry[] {
|
||||
const out: FlatEntry[] = [];
|
||||
function dfs(node: StructureNode, parentPath: string[]) {
|
||||
if (node.type === "entry") {
|
||||
const pathArr = [...parentPath, node.key].filter(Boolean);
|
||||
out.push({ path: pathArr.join("."), name: node.key });
|
||||
return;
|
||||
}
|
||||
const next = node.key === "$root" ? parentPath : [...parentPath, node.key];
|
||||
for (const child of node.children ?? []) dfs(child, next);
|
||||
}
|
||||
dfs(root, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function flattenValues(obj: unknown): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
function dfs(value: unknown, parentPath: string[]) {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
dfs(v, [...parentPath, k]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const path = parentPath.join(".");
|
||||
map[path] = typeof value === "string" ? value : String(value);
|
||||
}
|
||||
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) dfs(v, [k]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function unflattenValues(values: Record<string, string>): Record<string, unknown> {
|
||||
const root: Record<string, unknown> = {};
|
||||
for (const [path, val] of Object.entries(values)) {
|
||||
if (!path) continue;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
|
||||
30
src/main.tsx
30
src/main.tsx
|
|
@ -1,10 +1,24 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter } from "react-router";
|
||||
import { RouterProvider } from "react-router/dom";
|
||||
import "./index.css";
|
||||
import Home from "./pages/home.tsx";
|
||||
import Editor from "./pages/editor.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/editor/:id",
|
||||
element: <Editor />,
|
||||
},
|
||||
]);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
<RouterProvider router={router} />,
|
||||
</StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
getProject,
|
||||
getStructure,
|
||||
listLanguages,
|
||||
type Project,
|
||||
type ProjectStructure,
|
||||
getLanguageTranslations,
|
||||
} from "@/lib/db";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
||||
import { flattenEntries, type FlatEntry } from "@/lib/i18n-structure";
|
||||
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
||||
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
||||
|
||||
export default function Editor() {
|
||||
const { id: projectId } = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [structure, setStructure] = useState<ProjectStructure | null>(null);
|
||||
const [languages, setLanguages] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
setPageError(null);
|
||||
try {
|
||||
const p = await getProject(projectId);
|
||||
if (!p) throw new Error("项目不存在");
|
||||
setProject(p);
|
||||
const s = await getStructure(projectId);
|
||||
if (s) setStructure(s);
|
||||
const langs = await listLanguages(projectId);
|
||||
setLanguages(langs);
|
||||
} catch (e) {
|
||||
setPageError((e as Error)?.message ?? "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
const entries: FlatEntry[] = useMemo(() => {
|
||||
if (!structure) return [];
|
||||
return flattenEntries(structure.root);
|
||||
}, [structure]);
|
||||
|
||||
const [valuesByLang, setValuesByLang] = useState<Record<string, Record<string, string>>>({});
|
||||
const inline = useTranslationInlineEdit({
|
||||
projectId,
|
||||
valuesByLang,
|
||||
setValuesByLang,
|
||||
onError: (m) => setPageError(m),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || languages.length === 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const all: Record<string, Record<string, string>> = {};
|
||||
for (const lang of languages) {
|
||||
const rec = await getLanguageTranslations(projectId, lang);
|
||||
all[lang] = rec?.values ?? {};
|
||||
}
|
||||
if (!cancelled) setValuesByLang(all);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, languages]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="h-14 border-b px-2 md:px-4">
|
||||
<div className="grid grid-cols-3 h-full items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="icon" variant="ghost" aria-label="返回">
|
||||
<Link to="/">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center font-medium truncate">
|
||||
{project?.name ?? "编辑器"}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!structure ? (
|
||||
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => { setImportOpen(true); }}>导入语言 JSON</Button>
|
||||
)}
|
||||
{languages.length > 0 && (
|
||||
<Button variant="outline" onClick={() => setExportOpen(true)}>导出 JSON</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto p-4 md:p-6">
|
||||
{pageError && (
|
||||
<div className="mb-4 text-sm text-red-600" role="alert">{pageError}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground">加载中...</div>
|
||||
) : !structure ? (
|
||||
<div className="rounded-md border border-dashed p-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-auto border rounded-md">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 w-[320px]">翻译条目名称</th>
|
||||
{languages.map((lang) => (
|
||||
<th key={lang} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.path} className="border-t">
|
||||
<td 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}`}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<ImportLanguageModal
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
projectId={projectId ?? ""}
|
||||
hasStructure={!!structure}
|
||||
onImported={refresh}
|
||||
/>
|
||||
<ExportLanguageModal
|
||||
open={exportOpen}
|
||||
onOpenChange={setExportOpen}
|
||||
languages={languages}
|
||||
valuesByLang={valuesByLang}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createProject, deleteProject, listProjects, type Project } from "@/lib/db";
|
||||
|
||||
function formatTime(ts: number) {
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return String(ts);
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
const canSubmit = useMemo(() => projectName.trim().length > 0 && !submitting, [projectName, submitting]);
|
||||
|
||||
async function onCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createProject(projectName.trim());
|
||||
setProjectName("");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "创建失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(id: string) {
|
||||
// 简单确认,避免误删
|
||||
if (!confirm("确认删除该项目?此操作不可恢复")) return;
|
||||
try {
|
||||
await deleteProject(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="text-2xl font-semibold">翻译项目</h1>
|
||||
|
||||
<form onSubmit={onCreate} className="mt-6 flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="输入项目名称"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
aria-label="项目名称"
|
||||
/>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{submitting ? "创建中..." : "新建项目"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 text-lg font-medium">项目列表</h2>
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground">加载中...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无项目,创建一个开始吧。</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{projects.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between rounded-md border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{p.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate">
|
||||
ID: {p.id} · 创建时间: {formatTime(p.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/editor/${p.id}`}>打开</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => onDelete(p.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Loading…
Reference in New Issue