feat: Implement desktop environment with responsive design and window management
All checks were successful
Build and Push Container Image, Deploy to Host / build-and-push-and-deploy (push) Successful in 2m4s

- Refactored layout to include device context and internationalization support.
- Created a Desktop component that conditionally renders PC and mobile versions.
- Developed Background, PCDesktop, MobileDesktop, Content, Dock, and TopBar components for the desktop UI.
- Added window management context to handle multiple windows with operations like open, close, minimize, and update.
- Implemented BaseWindow component for draggable and resizable windows.
- Integrated datetime and icon widgets into the TopBar.
- Configured application metadata and environment variables for dynamic title, description, and icon.
- Established device context for theme and background management with local storage persistence.
- Added i18n support for localization based on user preferences.
This commit is contained in:
2025-08-22 04:59:42 +08:00
parent d2ccc17fd6
commit 7a027841a0
22 changed files with 1083 additions and 109 deletions

View File

@ -1,8 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = {
output: 'standalone',
/* config options here */
};
export default nextConfig;
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

View File

@ -9,19 +9,25 @@
"lint": "next lint"
},
"dependencies": {
"deepmerge": "^4.3.1",
"framer-motion": "^12.23.12",
"next": "15.4.6",
"next-intl": "^4.3.5",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.4.6"
"react-rnd": "^10.5.2"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.4.6",
"@eslint/eslintrc": "^3"
"eslint-plugin-react-hooks": "^5.2.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

225
pnpm-lock.yaml generated
View File

@ -8,15 +8,30 @@ importers:
.:
dependencies:
deepmerge:
specifier: ^4.3.1
version: 4.3.1
framer-motion:
specifier: ^12.23.12
version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next:
specifier: 15.4.6
version: 15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-intl:
specifier: ^4.3.5
version: 4.3.5(next@15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react:
specifier: 19.1.0
version: 19.1.0
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
react-rnd:
specifier: ^10.5.2
version: 10.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
devDependencies:
'@eslint/eslintrc':
specifier: ^3
@ -39,6 +54,9 @@ importers:
eslint-config-next:
specifier: 15.4.6
version: 15.4.6(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2)
eslint-plugin-react-hooks:
specifier: ^5.2.0
version: 5.2.0(eslint@9.33.0(jiti@2.5.1))
tailwindcss:
specifier: ^4
version: 4.1.11
@ -103,6 +121,24 @@ packages:
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@formatjs/ecma402-abstract@2.3.4':
resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/icu-messageformat-parser@2.11.2':
resolution: {integrity: sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==}
'@formatjs/icu-skeleton-parser@1.8.14':
resolution: {integrity: sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-localematcher@0.6.1':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -341,6 +377,9 @@ packages:
'@rushstack/eslint-patch@1.12.0':
resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@ -727,6 +766,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -783,9 +826,16 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@ -1016,6 +1066,20 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
framer-motion@12.23.12:
resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@ -1114,6 +1178,9 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
intl-messageformat@10.7.16:
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@ -1385,6 +1452,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
motion-dom@12.23.12:
resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -1401,6 +1474,26 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-intl@4.3.5:
resolution: {integrity: sha512-tT3SltfpPOCAQ9kVNr+8t6FUtVf8G0WFlJcVc8zj4WCMfuF8XFk4gZCN/MtjgDgkUISw5aKamOClJB4EsV95WQ==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.4.6:
resolution: {integrity: sha512-us++E/Q80/8+UekzB3SAGs71AlLDsadpFMXVNM/uQ0BMwsh9m3mr0UNQIfjKed8vpWXsASe+Qifrnu1oLIcKEQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@ -1522,14 +1615,32 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
re-resizable@6.11.2:
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom@19.1.0:
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
peerDependencies:
react: ^19.1.0
react-draggable@4.4.6:
resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-rnd@10.5.2:
resolution: {integrity: sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==}
peerDependencies:
react: '>=16.3.0'
react-dom: '>=16.3.0'
react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
@ -1723,6 +1834,9 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -1764,6 +1878,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-intl@4.3.5:
resolution: {integrity: sha512-qyL1TZNesVbzj/75ZbYsi+xzNSiFqp5rIVsiAN0JT8rPMSjX0/3KQz76aJIrngI1/wIQdVYFVdImWh5yAv+dWA==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -1866,6 +1985,36 @@ snapshots:
'@eslint/core': 0.15.2
levn: 0.4.1
'@formatjs/ecma402-abstract@2.3.4':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.1
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.2':
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
'@formatjs/icu-skeleton-parser': 1.8.14
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.14':
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.1':
dependencies:
tslib: 2.8.1
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@ -2038,6 +2187,8 @@ snapshots:
'@rushstack/eslint-patch@1.12.0': {}
'@schummar/icu-type-parser@1.21.5': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@ -2434,6 +2585,8 @@ snapshots:
client-only@0.0.1: {}
clsx@1.2.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -2490,8 +2643,12 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@ -2877,6 +3034,15 @@ snapshots:
dependencies:
is-callable: 1.2.7
framer-motion@12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
motion-dom: 12.23.12
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@ -2978,6 +3144,13 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
intl-messageformat@10.7.16:
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.2
tslib: 2.8.1
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@ -3231,6 +3404,12 @@ snapshots:
mkdirp@3.0.1: {}
motion-dom@12.23.12:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
@ -3239,6 +3418,23 @@ snapshots:
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
next-intl@4.3.5(next@15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
next: 15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
use-intl: 4.3.5(react@19.1.0)
optionalDependencies:
typescript: 5.9.2
next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
next@15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.4.6
@ -3369,13 +3565,33 @@ snapshots:
queue-microtask@1.2.3: {}
re-resizable@6.11.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-dom@19.1.0(react@19.1.0):
dependencies:
react: 19.1.0
scheduler: 0.26.0
react-draggable@4.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
clsx: 1.2.1
prop-types: 15.8.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-is@16.13.1: {}
react-rnd@10.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
re-resizable: 6.11.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-draggable: 4.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tslib: 2.6.2
react@19.1.0: {}
reflect.getprototypeof@1.0.10:
@ -3643,6 +3859,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@2.6.2: {}
tslib@2.8.1: {}
type-check@0.4.0:
@ -3721,6 +3939,13 @@ snapshots:
dependencies:
punycode: 2.3.1
use-intl@4.3.5(react@19.1.0):
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@schummar/icu-type-parser': 1.21.5
intl-messageformat: 10.7.16
react: 19.1.0
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0

View File

@ -1,7 +1,13 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import config from "@/config";
import "./globals.css";
import { DeviceProvider } from "@/contexts/device";
import {NextIntlClientProvider} from 'next-intl';
import {getLocale} from 'next-intl/server';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@ -13,21 +19,29 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: config.title,
description: config.description,
icons: {
icon: config.icon,
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale();
return (
<html lang="en">
<html lang={locale}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<NextIntlClientProvider locale={locale}>
<DeviceProvider>
{children}
</DeviceProvider>
</NextIntlClientProvider>
</body>
</html>
);

View File

@ -1,103 +1,9 @@
import Image from "next/image";
import Desktop from "@/components/desktop";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
<>
<Desktop />
</>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { useDevice } from "@/contexts/device";
import { useEffect, useState } from "react";
export default function Background() {
const { background } = useDevice();
const [isLoaded, setIsLoaded] = useState(false);
// 只在客户端渲染,避免水合错误
useEffect(() => {
setIsLoaded(true);
}, []);
if (!isLoaded) {
return null; // 服务端渲染时不显示任何内容
}
console.log("Background component rendered with background:", background);
return (
<div
className="fixed inset-0 z-[-1] transition-opacity duration-500"
style={{
backgroundImage: background ? `url(${background})` : "",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
opacity: background ? 1 : 0,
pointerEvents: "none" // 不阻挡鼠标事件
}}
/>
);
}

View File

@ -0,0 +1,7 @@
export default function MobileDesktop() {
return (
<div className="block md:hidden">
<h1>Mobile Desktop Component</h1>
</div>
);
}

View File

@ -0,0 +1,22 @@
import Content from "./content";
import Dock from "./dock";
import TopBar from "./topbar";
export default function PCDesktop() {
return (
<div
className="hidden md:flex flex-col min-h-screen"
>
<div className="h-8 w-full">
<TopBar />
</div>
<div className="flex-1 w-full">
<Content />
</div>
<div className="h-16 w-full flex justify-center">
<Dock />
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
export default function Content() {
return (
<div className="content">
<h1>Desktop Content Component</h1>
{/* Other content can be added here */}
</div>
);
}

View File

@ -0,0 +1,9 @@
export default function Dock() {
return (
<div
className={`bg-slate-100/90 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl`}
>
Dock Component
</div>
);
}

View File

@ -0,0 +1,14 @@
import PCDesktop from "./PCDesktop";
import MobileDesktop from "./MobileDesktop";
import { WindowsManagerProvider } from "@/contexts/windows";
import Background from "./Background";
export default function Desktop() {
return (
<WindowsManagerProvider>
<Background />
<PCDesktop />
<MobileDesktop />
</WindowsManagerProvider>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import Datetime from "./widgets/Datetime";
import Icon from "./widgets/Icon";
import WindowTitle from "./widgets/WindowTitle";
function TopBarLeftMenu() {
return (
<div className="flex items-center gap-2 h-full">
<Icon />
<WindowTitle />
</div>
);
}
function TopBarRightMenu() {
return (
<div className="flex items-center gap-2 h-full">
<Datetime />
</div>
);
}
export default function TopBar() {
return (
<div className="flex justify-between items-center h-full px-2 py-1.5 text-slate-700 dark:text-slate-300 text-sm bg-white/50 dark:bg-gray-800/50 shadow-md">
<TopBarLeftMenu />
<TopBarRightMenu />
</div>
);
}

View File

@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from 'next-intl';
export default function Datetime() {
const [time, setTime] = useState<Date | null>(null);
const locale = useLocale();
const t = useTranslations();
useEffect(() => {
setTime(new Date());
const timer = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
const formatTime = (date: Date) => {
return date.toLocaleTimeString("zh-CN", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
};
const formatDate = (date: Date) => {
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
weekday: "short",
});
};
if (!time) return null;
return (
<div className="font-bold">
<span className="mt-0.5">
{formatDate(time)}
</span>
<span className="mx-1"></span>
<span className="leading-none">
{formatTime(time)}
</span>
</div>
);
}

View File

@ -0,0 +1,9 @@
import config from "@/config";
export default function Icon() {
return (
<div className="h-full flex items-center justify-center overflow-hidden">
<img src={config.icon} alt="Icon" className="max-w-full max-h-full object-contain" />
</div>
);
}

View File

@ -0,0 +1,16 @@
import { useWindowsManager } from "@/contexts/windows";
import { useEffect, useState } from "react";
export default function WindowTitle() {
const { getTopWindow } = useWindowsManager();
const [title, setTitle] = useState("");
useEffect(() => {
setTitle(getTopWindow()?.title ?? "");
}, [getTopWindow]);
return (
<div className="flex-1 h-full flex items-center justify-center overflow-hidden">
<span className="text-sm font-bold text-slate-700 dark:text-slate-300">{title}</span>
</div>
);
}

View File

@ -0,0 +1,288 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Rnd, RndDragCallback, RndResizeCallback } from "react-rnd";
import type { Rnd as RndType } from "react-rnd";
import { useWindowsManager } from "@/contexts/windows";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslations } from "next-intl";
export interface BaseWindowProps {
id?: string;
x?: number;
y?: number;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
zIndex?: number;
draggable?: boolean;
resizable?: boolean;
maximized?: boolean;
visible?: boolean;
onDragStart?: RndDragCallback;
onDragStop?: RndDragCallback;
onResizeStop?: RndResizeCallback;
onClick?: React.MouseEventHandler<HTMLDivElement>;
dockHeight?: number; // 可选的停靠栏高度
windowMargin?: number; // 窗口边距
dragHandleClassName?: string;
children?: React.ReactNode;
}
interface PreMaximizeState {
size: { width: number; height: number };
position: { x: number; y: number };
mouseOffset: { x: number; y: number };
}
export const BaseWindow: React.FC<BaseWindowProps> = ({
id,
x = 100,
y = 100,
width = 400,
height = 300,
minWidth = 220,
minHeight = 120,
zIndex = 100,
draggable = true,
resizable = true,
maximized = false,
visible = true,
onDragStart,
onDragStop,
onResizeStop,
onClick,
dragHandleClassName,
dockHeight = 0, // 默认停靠栏顶部绝对高度
windowMargin = 0, // 窗口边距
children,
}) => {
const windowsManager = useWindowsManager?.();
const win =
id && windowsManager
? windowsManager.windows.find((w) => w.id === id)
: undefined;
const [position, setPosition] = useState({ x, y });
const [size, setSize] = useState({ width, height });
const [isMobile, setIsMobile] = useState(false);
const t = useTranslations('HomePage');
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
};
window.addEventListener("resize", handleResize);
handleResize();
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
useEffect(() => {
if (!win) setPosition({ x, y });
}, [x, y, win]);
useEffect(() => {
if (!win) setSize({ width, height });
}, [width, height, win]);
const [shouldRender, setShouldRender] = useState(visible);
useEffect(() => {
if (visible) {
setShouldRender(true);
} else {
const timer = setTimeout(() => setShouldRender(false), 250);
return () => clearTimeout(timer);
}
}, [visible]);
const maximizedStyle =
(win?.maximized ?? maximized)
? {
x: windowMargin,
y: windowMargin,
width: window.innerWidth - windowMargin * 2,
height: window.innerHeight - windowMargin * 2 - dockHeight,
}
: {
x: win?.position?.x ?? position.x,
y: win?.position?.y ?? position.y,
width: win?.size?.width ?? size.width,
height: win?.size?.height ?? size.height,
};
const [showMaximizeHint, setShowMaximizeHint] = useState(false);
const [preMaximize, setPreMaximize] = useState<PreMaximizeState | null>(null);
const rndRef = useRef<RndType | null>(null);
const handleDrag: RndDragCallback = (_e, d) => {
if (win?.maximized ?? maximized) return;
if (d.y <= windowMargin) {
setShowMaximizeHint(true);
} else {
setShowMaximizeHint(false);
}
};
const handleDragStop: RndDragCallback = (e, d) => {
if (showMaximizeHint) {
setShowMaximizeHint(false);
if (id && windowsManager) {
windowsManager.updateWindow(id, { maximized: true });
}
return;
}
if (id && windowsManager) {
windowsManager.updateWindow(id, { position: { x: d.x, y: d.y } });
} else {
setPosition({ x: d.x, y: d.y });
}
onDragStop?.(e, d);
};
const handleDragStart: RndDragCallback = (e, d) => {
if (win?.maximized ?? maximized) {
const mouseX = (e as MouseEvent).clientX;
const mouseY = (e as MouseEvent).clientY;
const widthVal = win?.size?.width ?? size.width;
const heightVal = win?.size?.height ?? size.height;
setPreMaximize({
size: { width: widthVal, height: heightVal },
position: { x: d.x, y: d.y },
mouseOffset: {
x: mouseX - (maximizedStyle.x ?? 0),
y: mouseY - (maximizedStyle.y ?? 0),
},
});
if (id && windowsManager) {
windowsManager.updateWindow(id, { maximized: false });
}
}
onDragStart?.(e, d);
};
useEffect(() => {
if (!win?.maximized && preMaximize && rndRef.current) {
const mouse = preMaximize.mouseOffset;
const newX = Math.max(
0,
window.innerWidth / 2 - preMaximize.size.width / 2,
);
const newY = Math.max(
windowMargin,
mouse.y - preMaximize.size.height / 2,
);
rndRef.current.updatePosition({ x: newX, y: newY });
rndRef.current.updateSize({
width: preMaximize.size.width,
height: preMaximize.size.height,
});
setPreMaximize(null);
if (id && windowsManager) {
windowsManager.updateWindow(id, {
position: { x: newX, y: newY },
size: {
width: preMaximize.size.width,
height: preMaximize.size.height,
},
});
} else {
setPosition({ x: newX, y: newY });
setSize({
width: preMaximize.size.width,
height: preMaximize.size.height,
});
}
}
}, [
win?.maximized,
preMaximize,
id,
windowsManager,
size.width,
size.height,
windowMargin,
]);
if (!shouldRender) return null;
// 移动端下直接全屏
const rndSize = isMobile
? { width: "100vw", height: "100vh" }
: { width: maximizedStyle.width, height: maximizedStyle.height };
const rndPosition = isMobile
? { x: 0, y: 0 }
: { x: maximizedStyle.x, y: maximizedStyle.y };
return (
<AnimatePresence>
{visible && (
<Rnd
ref={rndRef}
size={rndSize}
position={rndPosition}
minWidth={minWidth}
minHeight={minHeight}
bounds="parent"
disableDragging={
isMobile || !draggable || (win?.maximized ?? maximized)
}
enableResizing={
!isMobile && resizable && !(win?.maximized ?? maximized)
}
style={{ zIndex: win?.zIndex ?? zIndex }}
className="base-window"
dragHandleClassName={dragHandleClassName}
onDrag={handleDrag}
onDragStart={handleDragStart}
onDragStop={handleDragStop}
onMouseDown={() => {
if (id && windowsManager) {
windowsManager.placeOnTop(id);
}
}}
onResizeStop={(e, dir, ref, delta, pos) => {
if (id && windowsManager) {
windowsManager.updateWindow(id, {
size: {
width: parseInt(ref.style.width, 10),
height: parseInt(ref.style.height, 10),
},
position: { x: pos.x, y: pos.y },
});
} else {
setSize({
width: parseInt(ref.style.width, 10),
height: parseInt(ref.style.height, 10),
});
setPosition({ x: pos.x, y: pos.y });
}
onResizeStop?.(e, dir, ref, delta, pos);
}}
onClick={onClick}
>
<motion.div
layout
className="w-full h-full relative"
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.7, y: -30 }}
transition={{ duration: 0.4, type: "spring" }}
>
{showMaximizeHint && (
<div
className="
base-window-container
absolute top-0 left-1/2 -translate-x-1/2
bg-blue-500/50 text-white px-4 py-1 rounded-lg z-[9999] text
text-sm pointer-events-none shadow-md select-none w-full h-full flex justify-center items-start
"
>
{t("ui.releaseToMaximize")}
</div>
)}
{children}
</motion.div>
</Rnd>
)}
</AnimatePresence>
);
};
export default BaseWindow;

View File

5
src/config.ts Normal file
View File

@ -0,0 +1,5 @@
export default {
title: process.env.NEXT_PUBLIC_APP_TITLE || "Snowykami OS",
description: process.env.NEXT_PUBLIC_APP_DESCRIPTION || "A modern OS built with Next.js",
icon: process.env.NEXT_PUBLIC_APP_ICON || "https://cdn.liteyuki.org/snowykami/avatar_alpha.png"
}

93
src/contexts/device.tsx Normal file
View File

@ -0,0 +1,93 @@
"use client"
import { createContext, useContext, useEffect, useState } from "react";
const THEME_STORAGE_KEY = "theme";
const BACKGROUND_STORAGE_KEY = "background";
type Theme = "light" | "dark" | "system";
interface DeviceContextProps {
isMobile: boolean;
theme: Theme;
setTheme: (theme: Theme) => void;
background: string;
setBackground: (background: string) => void;
}
const DeviceContext = createContext<DeviceContextProps>({
background: 'https://cdn.liteyuki.org/blog/background.png',
isMobile: false,
theme: "system",
setTheme: () => { },
setBackground: () => { },
});
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isMobile, setIsMobile] = useState(false);
const [theme, setTheme] = useState<Theme>("system");
const [background, setBackground] = useState<string>('https://cdn.liteyuki.org/blog/background.png');
// 挂载后读取 localStorage
useEffect(() => {
if (typeof window !== "undefined") {
const savedTheme = window.localStorage.getItem(THEME_STORAGE_KEY) as Theme;
if (savedTheme) setTheme(savedTheme);
const savedBg = window.localStorage.getItem(BACKGROUND_STORAGE_KEY);
if (savedBg) setBackground(savedBg);
}
}, []);
// 监听系统主题变化
useEffect(() => {
if (theme === "system") {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
document.documentElement.classList.toggle("dark", media.matches);
};
handler();
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}
}, [theme]);
// 监听配置主题变化
useEffect(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem("theme", theme);
if (theme !== "system") {
document.documentElement.classList.toggle("dark", theme === "dark");
}
}
}, [theme]);
// 监听背景变化并持久化
useEffect(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem(BACKGROUND_STORAGE_KEY, background);
}
}, [background]);
// 响应式判断 isMobile
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return (
<DeviceContext.Provider value={{ isMobile, theme, setTheme, background, setBackground }}>
{children}
</DeviceContext.Provider>
);
};
export const useDevice = () => {
const context = useContext(DeviceContext);
if (!context) {
throw new Error("useDevice must be used within a DeviceProvider");
}
return context;
};

183
src/contexts/windows.tsx Normal file
View File

@ -0,0 +1,183 @@
"use client"
import { WindowProps } from "@/types/window";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
const WINDOWS_STORAGE_KEY = "windows";
interface WindowsManagerContextProps {
windows: WindowProps[];
// window operations
openWindow: (id: string, props: Partial<WindowProps>) => void;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
placeOnTop: (id: string) => void;
updateWindow: (id: string, props: Partial<WindowProps>) => void;
// get and global
getWindowById: (id: string) => WindowProps | undefined;
getTopWindow: () => WindowProps | undefined;
resetWindowsOfLocalStorage?: () => void;
}
const WindowManagerContext = createContext<WindowsManagerContextProps | null>(
null
);
export const WindowsManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// 彻底关闭窗口(移除)
const closeWindow = useCallback((id: string) => {
setWindows(ws => ws.filter(w => w.id !== id));
}, []);
// 最小化窗口(只设置 visible=false不移除
const minimizeWindow = useCallback((id: string) => {
setWindows(ws => ws.map(w => w.id === id ? { ...w, visible: false } : w));
}, []);
const [windows, setWindows] = useState<WindowProps[]>(() => {
try {
const saved = localStorage.getItem(WINDOWS_STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
useEffect(() => {
localStorage.setItem(WINDOWS_STORAGE_KEY, JSON.stringify(windows));
}, [windows]);
const openWindow = useCallback(
(id: string, initial: Partial<WindowProps> = {}) => {
setWindows((ws) => {
const exist = ws.find((w) => w.id === id);
if (exist) {
return ws.map((w) =>
w.id === id
? {
...w,
visible: true,
minimized: false,
...initial,
}
: w,
);
}
if (ws.find((w) => w.id === id)) return ws;
const maxZ = ws.reduce((max, w) => Math.max(max, w.zIndex), 100);
const size = initial.size ?? { width: 400, height: 300 };
const position =
initial.position ?? calculateNewWindowPosition(ws.length, size);
return [
...ws,
{
id,
title: initial.title ?? "",
visible: true,
zIndex: maxZ + 1,
maximized: false,
minimized: false,
position,
size,
...initial,
},
];
});
},
[],
);
const placeOnTop = useCallback(
(id: string) => {
setWindows((ws) => {
const win = ws.find((w) => w.id === id);
if (!win) return ws;
const maxZ = ws.reduce((max, w) => Math.max(max, w.zIndex), 100);
return ws.map((w) =>
w.id === id
? { ...w, zIndex: maxZ + 1 }
: w,
);
});
},
[],
);
const updateWindow = useCallback(
(id: string, props: Partial<WindowProps>) => {
setWindows((ws) => {
const win = ws.find((w) => w.id === id);
if (!win) return ws;
return ws.map((w) =>
w.id === id ? { ...w, ...props } : w,
);
});
},
[],
);
const getWindowById = useCallback(
(id: string) => {
return windows.find((w) => w.id === id);
},
[windows],
);
const getTopWindow = useCallback(() => {
if (windows.length === 0) return undefined;
return windows.reduce((top, w) => (w.zIndex > top.zIndex ? w : top), windows[0]);
}, [windows]);
const resetWindowsOfLocalStorage = useCallback(() => {
if (typeof window !== "undefined") {
window.localStorage.removeItem("windows");
}
}, []);
return (
<WindowManagerContext.Provider
value={{
windows,
openWindow,
closeWindow,
minimizeWindow,
placeOnTop,
updateWindow,
getTopWindow,
getWindowById,
resetWindowsOfLocalStorage,
}}
>
{children}
</WindowManagerContext.Provider>
);
};
export const useWindowsManager = () => {
const ctx = useContext(WindowManagerContext);
if (!ctx)
throw new Error(
"useWindowsManager must be used within WindowsManagerProvider",
);
return ctx;
};
const calculateNewWindowPosition = (
count: number,
size = { width: 400, height: 300 },
) => {
// 屏幕中心
const centerX = Math.max(window.innerWidth / 2 - size.width / 2, 0);
const centerY = Math.max(window.innerHeight / 2 - size.height / 2, 0);
const offset = 20;
let x = centerX + offset * (count % 6);
let y = centerY + offset * (count % 6);
// 限制窗口不超出右下边界
x = Math.min(x, window.innerWidth - size.width);
y = Math.min(y, window.innerHeight - size.height);
// 限制窗口不超出左上边界
x = Math.max(x, 0);
y = Math.max(y, 0);
return { x, y };
};

37
src/i18n/request.ts Normal file
View File

@ -0,0 +1,37 @@
import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
import deepmerge from 'deepmerge';
export default getRequestConfig(async () => {
const locales = await getUserLocales();
const messages = await Promise.all(
locales.map(async (locale) => {
try {
return (await import(`@/locales/${locale}.json`)).default;
} catch (err) {
console.debug(`Failed to load locale ${locale}: ${err}`);
return {};
}
})
).then((msgs) => msgs.reduce((acc, msg) => deepmerge(acc, msg), {}));
return {
locale: locales[0],
messages
};
});
export async function getUserLocales(): Promise<string[]> {
const defaultLocales = ["zh-CN", "zh", "en-US", "en"];
const cookieStore = await cookies();
const languageInCookie = cookieStore.get('language')?.value;
let locales: string[] = [];
if (languageInCookie) {
locales.push(languageInCookie);
locales.push(languageInCookie.split('-')[0]);
}
locales.push(...defaultLocales);
// 去重,保留顺序
locales = Array.from(new Set(locales));
return locales;
}

18
src/types/window.ts Normal file
View File

@ -0,0 +1,18 @@
export interface WindowProps {
id: string;
title: string;
appId?: string;
visible: boolean;
zIndex: number;
maximized: boolean;
minimized: boolean;
position: { x: number; y: number };
size: { width: number; height: number };
showClose?: boolean;
showMinimize?: boolean;
showMaximize?: boolean;
className?: string;
onClose?: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
}