Сделан баланс, проверка чеков, начата система создания серверов
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
ospabhost/frontend/.env
|
||||
ospabhost/backend/.env
|
||||
|
||||
# Build
|
||||
/dist
|
||||
/build
|
||||
11
ospabhost/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
frontend/.env
|
||||
backend/.env
|
||||
|
||||
# Build
|
||||
/dist
|
||||
/build
|
||||
239
ospabhost/backend/package-lock.json
generated
@@ -10,12 +10,14 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -23,6 +25,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.12.12",
|
||||
"prisma": "^6.16.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
@@ -296,10 +299,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.15.tgz",
|
||||
"integrity": "sha512-W3bqcbLsRdFDVcmAM5l6oLlcl67vjevn8j1FPZ4nx+K5jNoWCh+FC/btxFoBPnvQlrHHDwfjp1kjIEDfwJ0Mog==",
|
||||
"version": "20.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -410,6 +423,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
@@ -423,6 +442,23 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -521,9 +557,19 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -617,6 +663,18 @@
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -624,6 +682,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||
@@ -723,6 +796,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -870,6 +952,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -992,6 +1089,42 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1155,6 +1288,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -1478,7 +1626,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -1503,6 +1650,36 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1729,6 +1906,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
@@ -1796,6 +1979,20 @@
|
||||
"destr": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -2045,6 +2242,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -2260,6 +2474,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
@@ -2290,6 +2510,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -2326,7 +2552,6 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -26,6 +28,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.12.12",
|
||||
"prisma": "^6.16.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
|
||||
9
ospabhost/backend/prisma/delete_windows_os.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
await prisma.operatingSystem.deleteMany({ where: { type: 'windows' } });
|
||||
console.log('Все Windows Server ОС удалены!');
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Ticket` ADD CONSTRAINT `Ticket_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Response` ADD CONSTRAINT `Response_operatorId_fkey` FOREIGN KEY (`operatorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `Check` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`amount` DOUBLE NOT NULL,
|
||||
`status` VARCHAR(191) NOT NULL DEFAULT 'pending',
|
||||
`fileUrl` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Check` ADD CONSTRAINT `Check_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `user` ADD COLUMN `balance` DOUBLE NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `Tariff` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`price` DOUBLE NOT NULL,
|
||||
`description` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `Tariff_name_key`(`name`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `OperatingSystem` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`type` VARCHAR(191) NOT NULL,
|
||||
`template` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `OperatingSystem_name_key`(`name`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Server` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`tariffId` INTEGER NOT NULL,
|
||||
`osId` INTEGER NOT NULL,
|
||||
`status` VARCHAR(191) NOT NULL DEFAULT 'stopped',
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Server` ADD CONSTRAINT `Server_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Server` ADD CONSTRAINT `Server_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Server` ADD CONSTRAINT `Server_osId_fkey` FOREIGN KEY (`osId`) REFERENCES `OperatingSystem`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,4 +1,3 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
@@ -10,14 +9,61 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// This is your Prisma schema file,
|
||||
model Tariff {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
price Float
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
servers Server[]
|
||||
}
|
||||
|
||||
model OperatingSystem {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
type String // linux, windows, etc
|
||||
template String? // путь к шаблону для контейнера
|
||||
createdAt DateTime @default(now())
|
||||
servers Server[]
|
||||
}
|
||||
|
||||
model Server {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
tariffId Int
|
||||
osId Int
|
||||
status String @default("stopped") // running, stopped, etc
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
tariff Tariff @relation(fields: [tariffId], references: [id])
|
||||
os OperatingSystem @relation(fields: [osId], references: [id])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
email String @unique
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
plans Plan[]
|
||||
operator Int @default(0) // Добавляем новую колонку operator
|
||||
plans Plan[] @relation("UserPlans")
|
||||
operator Int @default(0)
|
||||
tickets Ticket[] @relation("UserTickets")
|
||||
responses Response[] @relation("OperatorResponses")
|
||||
checks Check[] @relation("UserChecks")
|
||||
balance Float @default(0)
|
||||
servers Server[]
|
||||
}
|
||||
|
||||
model Check {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
amount Float
|
||||
status String @default("pending") // pending, approved, rejected
|
||||
fileUrl String
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation("UserChecks", fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model Plan {
|
||||
@@ -28,8 +74,8 @@ model Plan {
|
||||
isCustom Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
userId Int
|
||||
owner User @relation(fields: [userId], references: [id])
|
||||
services Service[]
|
||||
owner User @relation("UserPlans", fields: [userId], references: [id])
|
||||
services Service[] @relation("PlanServices")
|
||||
}
|
||||
|
||||
model Service {
|
||||
@@ -37,7 +83,7 @@ model Service {
|
||||
name String @unique
|
||||
price Float
|
||||
planId Int?
|
||||
plan Plan? @relation(fields: [planId], references: [id])
|
||||
plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
@@ -48,10 +94,8 @@ model Ticket {
|
||||
status String @default("open")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
responses Response[] // связь
|
||||
|
||||
// Если нужна связь с User:
|
||||
// user User @relation(fields: [userId], references: [id])
|
||||
responses Response[] @relation("TicketResponses")
|
||||
user User? @relation("UserTickets", fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model Response {
|
||||
@@ -60,5 +104,6 @@ model Response {
|
||||
operatorId Int
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id]) // <-- обратная связь
|
||||
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id])
|
||||
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
|
||||
}
|
||||
27
ospabhost/backend/prisma/seed.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const tariffs = [
|
||||
{ name: 'Минимальный', price: 150, description: '1 ядро, 1ГБ RAM, 20ГБ SSD' },
|
||||
{ name: 'Базовый', price: 300, description: '2 ядра, 2ГБ RAM, 40ГБ SSD' },
|
||||
{ name: 'Старт', price: 500, description: '2 ядра, 4ГБ RAM, 60ГБ SSD' },
|
||||
{ name: 'Оптимальный', price: 700, description: '4 ядра, 4ГБ RAM, 80ГБ SSD' },
|
||||
{ name: 'Профи', price: 1000, description: '4 ядра, 8ГБ RAM, 120ГБ SSD' },
|
||||
{ name: 'Бизнес', price: 1500, description: '8 ядер, 16ГБ RAM, 200ГБ SSD' },
|
||||
{ name: 'Корпоративный', price: 2000, description: '12 ядер, 24ГБ RAM, 300ГБ SSD' },
|
||||
{ name: 'Премиум', price: 2500, description: '16 ядер, 32ГБ RAM, 400ГБ SSD' },
|
||||
{ name: 'Энтерпрайз', price: 2800, description: '24 ядра, 48ГБ RAM, 500ГБ SSD' },
|
||||
{ name: 'Максимум', price: 3000, description: '32 ядра, 64ГБ RAM, 1ТБ SSD' },
|
||||
];
|
||||
for (const t of tariffs) {
|
||||
await prisma.tariff.upsert({
|
||||
where: { name: t.name },
|
||||
update: t,
|
||||
create: t,
|
||||
});
|
||||
}
|
||||
console.log('Тарифы успешно добавлены!');
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
24
ospabhost/backend/prisma/seed_os.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const oses = [
|
||||
{ name: 'Ubuntu 22.04', type: 'linux', template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst' },
|
||||
{ name: 'Debian 12', type: 'linux', template: 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst' },
|
||||
{ name: 'CentOS 9', type: 'linux', template: 'local:vztmpl/centos-9-stream-default_20240828_amd64.tar.xz' },
|
||||
{ name: 'AlmaLinux 9', type: 'linux', template: 'local:vztmpl/almalinux-9-default_20240911_amd64.tar.xz' },
|
||||
{ name: 'Rocky Linux 9', type: 'linux', template: 'local:vztmpl/rockylinux-9-default_20240912_amd64.tar.xz' },
|
||||
{ name: 'Arch Linux', type: 'linux', template: 'local:vztmpl/archlinux-base_20240911-1_amd64.tar.zst' },
|
||||
{ name: 'Fedora 41', type: 'linux', template: 'local:vztmpl/fedora-41-default_20241118_amd64.tar.xz' },
|
||||
];
|
||||
for (const os of oses) {
|
||||
await prisma.operatingSystem.upsert({
|
||||
where: { name: os.name },
|
||||
update: os,
|
||||
create: os,
|
||||
});
|
||||
}
|
||||
console.log('ОС успешно добавлены!');
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
2
ospabhost/backend/proxmox/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Импорт и экспорт функций для работы с Proxmox
|
||||
export * from './proxmoxApi';
|
||||
18
ospabhost/backend/proxmox/proxmox.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { createContainer } from './proxmoxApi';
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
// Маршрут для создания контейнера
|
||||
router.post('/container', async (req, res) => {
|
||||
try {
|
||||
const { vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize } = req.body;
|
||||
const result = await createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : err });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
45
ospabhost/backend/proxmox/proxmoxApi.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const PROXMOX_API = `https://${process.env.PROXMOX_HOST}:${process.env.PROXMOX_PORT}/api2/json`;
|
||||
|
||||
function getProxmoxHeaders() {
|
||||
return {
|
||||
Authorization: `PVEAPIToken=${process.env.PROXMOX_API_TOKEN_ID}=${process.env.PROXMOX_API_TOKEN_SECRET}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Создание контейнера (LXC) из шаблона
|
||||
export interface CreateContainerParams {
|
||||
vmid: number;
|
||||
hostname: string;
|
||||
password: string;
|
||||
ostemplate: string;
|
||||
storage: string;
|
||||
cores: number;
|
||||
memory: number;
|
||||
rootfsSize: number;
|
||||
}
|
||||
|
||||
export async function createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize }: CreateContainerParams) {
|
||||
const url = `${PROXMOX_API}/nodes/${process.env.PROXMOX_NODE}/lxc`;
|
||||
const data = {
|
||||
vmid,
|
||||
hostname,
|
||||
password,
|
||||
ostemplate, // например: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst'
|
||||
storage, // например: 'local'
|
||||
cores, // количество ядер
|
||||
memory, // RAM в МБ
|
||||
rootfs: `${storage}:${rootfsSize}`, // например: 'local:8'
|
||||
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
|
||||
// Дополнительные параметры по необходимости
|
||||
};
|
||||
try {
|
||||
const res = await axios.post(url, data, { headers: getProxmoxHeaders() });
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
throw new Error('Ошибка создания контейнера: ' + (err instanceof Error ? err.message : err));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,12 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './modules/auth/auth.routes';
|
||||
import ticketRoutes from './modules/ticket/ticket.routes';
|
||||
import checkRoutes from './modules/check/check.routes';
|
||||
import proxmoxRoutes from '../proxmox/proxmox.routes';
|
||||
import tariffRoutes from './modules/tariff';
|
||||
import osRoutes from './modules/os';
|
||||
import serverRoutes from './modules/server';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -30,7 +36,19 @@ app.get('/', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Статические файлы чеков
|
||||
import path from 'path';
|
||||
app.use('/uploads/checks', express.static(path.join(__dirname, '../uploads/checks')));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/ticket', ticketRoutes);
|
||||
app.use('/api/check', checkRoutes);
|
||||
app.use('/api/proxmox', proxmoxRoutes);
|
||||
app.use('/api/tariff', tariffRoutes);
|
||||
app.use('/api/os', osRoutes);
|
||||
app.use('/api/server', serverRoutes);
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
|
||||
@@ -67,11 +67,10 @@ export const login = async (req: Request, res: Response) => {
|
||||
|
||||
export const getMe = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Не авторизован.' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -79,18 +78,19 @@ export const getMe = async (req: Request, res: Response) => {
|
||||
username: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
operator: true, // Добавляем поле operator
|
||||
operator: true,
|
||||
balance: true,
|
||||
servers: true,
|
||||
tickets: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('API /api/auth/me user:', user);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'Пользователь не найден.' });
|
||||
}
|
||||
|
||||
res.status(200).json({ user });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении данных пользователя:', error);
|
||||
res.status(500).json({ message: 'Ошибка сервера.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
||||
|
||||
export const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
@@ -20,9 +18,10 @@ export const authMiddleware = (req: AuthRequest, res: Response, next: NextFuncti
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||
req.userId = decoded.id;
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
|
||||
if (!user) return res.status(401).json({ message: 'Пользователь не найден.' });
|
||||
req.user = user;
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка в мидлваре аутентификации:', error);
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
|
||||
66
ospabhost/backend/src/modules/check/check.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import { Multer } from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Тип расширенного запроса с Multer
|
||||
interface MulterRequest extends Request {
|
||||
file?: Express.Multer.File;
|
||||
}
|
||||
|
||||
// Загрузка чека клиентом (с файлом)
|
||||
export async function uploadCheck(req: MulterRequest, res: Response) {
|
||||
const userId = req.user?.id;
|
||||
const { amount } = req.body;
|
||||
const file = req.file;
|
||||
if (!userId || !amount || !file) return res.status(400).json({ error: 'Данные не заполнены или файл не загружен' });
|
||||
|
||||
// Сохраняем путь к файлу
|
||||
const fileUrl = `/uploads/checks/${file.filename}`;
|
||||
|
||||
const check = await prisma.check.create({
|
||||
data: { userId, amount: Number(amount), fileUrl }
|
||||
});
|
||||
res.json(check);
|
||||
}
|
||||
|
||||
// Получить все чеки (оператор)
|
||||
export async function getChecks(req: Request, res: Response) {
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
if (!isOperator) return res.status(403).json({ error: 'Нет прав' });
|
||||
const checks = await prisma.check.findMany({
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
res.json(checks);
|
||||
}
|
||||
|
||||
// Подтвердить чек и пополнить баланс
|
||||
export async function approveCheck(req: Request, res: Response) {
|
||||
const { checkId } = req.body;
|
||||
// Найти чек
|
||||
const check = await prisma.check.findUnique({ where: { id: checkId } });
|
||||
if (!check) return res.status(404).json({ error: 'Чек не найден' });
|
||||
// Обновить статус
|
||||
await prisma.check.update({ where: { id: checkId }, data: { status: 'approved' } });
|
||||
// Пополнить баланс пользователя
|
||||
await prisma.user.update({
|
||||
where: { id: check.userId },
|
||||
data: {
|
||||
balance: {
|
||||
increment: check.amount
|
||||
}
|
||||
}
|
||||
});
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// Отклонить чек
|
||||
export async function rejectCheck(req: Request, res: Response) {
|
||||
const { checkId } = req.body;
|
||||
await prisma.check.update({ where: { id: checkId }, data: { status: 'rejected' } });
|
||||
res.json({ success: true });
|
||||
}
|
||||
48
ospabhost/backend/src/modules/check/check.routes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express';
|
||||
import { uploadCheck, getChecks, approveCheck, rejectCheck } from './check.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
import multer, { MulterError } from 'multer';
|
||||
import path from 'path';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Настройка Multer для загрузки чеков
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) {
|
||||
cb(null, path.join(__dirname, '../../../uploads/checks'));
|
||||
},
|
||||
filename: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/jpg'
|
||||
];
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
// Кастомная ошибка для Multer
|
||||
const err: any = new Error('Недопустимый формат файла. Разрешены только изображения: jpg, jpeg, png, gif, webp.');
|
||||
err.code = 'LIMIT_FILE_FORMAT';
|
||||
cb(err, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.post('/upload', upload.single('file'), uploadCheck);
|
||||
router.get('/', getChecks);
|
||||
router.post('/approve', approveCheck);
|
||||
router.post('/reject', rejectCheck);
|
||||
|
||||
export default router;
|
||||
2
ospabhost/backend/src/modules/os/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import osRoutes from './os.routes';
|
||||
export default osRoutes;
|
||||
18
ospabhost/backend/src/modules/os/os.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET /api/os — получить все ОС
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const oses = await prisma.operatingSystem.findMany();
|
||||
res.json(oses);
|
||||
} catch (err) {
|
||||
console.error('Ошибка получения ОС:', err);
|
||||
res.status(500).json({ error: 'Ошибка получения ОС' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
2
ospabhost/backend/src/modules/server/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import serverRoutes from './server.routes';
|
||||
export default serverRoutes;
|
||||
70
ospabhost/backend/src/modules/server/server.routes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Router } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
// import { createProxmoxContainer } from '../../proxmox/proxmoxApi'; // если есть интеграция
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// POST /api/server/create — создать сервер (контейнер)
|
||||
router.post('/create', async (req, res) => {
|
||||
try {
|
||||
const { tariffId, osId } = req.body;
|
||||
// TODO: получить userId из авторизации (req.user)
|
||||
const userId = 1; // временно, заменить на реального пользователя
|
||||
|
||||
// Получаем тариф и ОС
|
||||
const tariff = await prisma.tariff.findUnique({ where: { id: tariffId } });
|
||||
const os = await prisma.operatingSystem.findUnique({ where: { id: osId } });
|
||||
if (!tariff || !os) {
|
||||
return res.status(400).json({ error: 'Тариф или ОС не найдены' });
|
||||
}
|
||||
|
||||
// TODO: интеграция с Proxmox для создания контейнера
|
||||
// Если интеграция с Proxmox есть, то только при успешном создании контейнера создавать запись в БД
|
||||
// Например:
|
||||
// let proxmoxResult;
|
||||
// try {
|
||||
// proxmoxResult = await createProxmoxContainer({ ... });
|
||||
// } catch (proxmoxErr) {
|
||||
// console.error('Ошибка Proxmox:', proxmoxErr);
|
||||
// return res.status(500).json({ error: 'Ошибка создания контейнера на Proxmox' });
|
||||
// }
|
||||
|
||||
// Если всё успешно — создаём запись сервера в БД
|
||||
const server = await prisma.server.create({
|
||||
data: {
|
||||
userId,
|
||||
tariffId,
|
||||
osId,
|
||||
status: 'creating',
|
||||
},
|
||||
});
|
||||
res.json({ success: true, server });
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания сервера:', err);
|
||||
// Не создавать сервер, если есть ошибка
|
||||
return res.status(500).json({ error: 'Ошибка создания сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/server — получить все серверы пользователя
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// TODO: получить userId из авторизации (req.user)
|
||||
const userId = 1; // временно
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
os: true,
|
||||
tariff: true,
|
||||
},
|
||||
});
|
||||
console.log('API /api/server ответ:', servers);
|
||||
res.json(servers);
|
||||
} catch (err) {
|
||||
console.error('Ошибка получения серверов:', err);
|
||||
res.status(500).json({ error: 'Ошибка получения серверов' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
2
ospabhost/backend/src/modules/tariff/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import tariffRoutes from './tariff.routes';
|
||||
export default tariffRoutes;
|
||||
18
ospabhost/backend/src/modules/tariff/tariff.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET /api/tariff — получить все тарифы
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const tariffs = await prisma.tariff.findMany();
|
||||
res.json(tariffs);
|
||||
} catch (err) {
|
||||
console.error('Ошибка получения тарифов:', err);
|
||||
res.status(500).json({ error: 'Ошибка получения тарифов' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
93
ospabhost/backend/src/modules/ticket/ticket.controller.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Расширяем тип Request для user
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: {
|
||||
id: number;
|
||||
operator?: number;
|
||||
// можно добавить другие поля при необходимости
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Создать тикет
|
||||
export async function createTicket(req: Request, res: Response) {
|
||||
const { title, message } = req.body;
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
try {
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: { title, message, userId },
|
||||
});
|
||||
res.json(ticket);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка создания тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить тикеты (клиент — свои, оператор — все)
|
||||
export async function getTickets(req: Request, res: Response) {
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
try {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: isOperator ? {} : { userId },
|
||||
include: {
|
||||
responses: { include: { operator: true } },
|
||||
user: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(tickets);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка получения тикетов' });
|
||||
}
|
||||
}
|
||||
|
||||
// Ответить на тикет (только оператор)
|
||||
export async function respondTicket(req: Request, res: Response) {
|
||||
const { ticketId, message } = req.body;
|
||||
const operatorId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
if (!operatorId || !isOperator) return res.status(403).json({ error: 'Нет прав' });
|
||||
try {
|
||||
const response = await prisma.response.create({
|
||||
data: { ticketId, operatorId, message },
|
||||
});
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: { status: 'answered' },
|
||||
});
|
||||
res.json(response);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка ответа на тикет' });
|
||||
}
|
||||
}
|
||||
|
||||
// Закрыть тикет (клиент или оператор)
|
||||
export async function closeTicket(req: Request, res: Response) {
|
||||
const { ticketId } = req.body;
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
|
||||
if (!isOperator && ticket.userId !== userId) return res.status(403).json({ error: 'Нет прав' });
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: { status: 'closed' },
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка закрытия тикета' });
|
||||
}
|
||||
}
|
||||
14
ospabhost/backend/src/modules/ticket/ticket.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { createTicket, getTickets, respondTicket, closeTicket } from './ticket.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.post('/create', createTicket);
|
||||
router.get('/', getTickets);
|
||||
router.post('/respond', respondTicket);
|
||||
router.post('/close', closeTicket);
|
||||
|
||||
export default router;
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -5,7 +5,7 @@ import Homepage from './pages/index';
|
||||
import Dashboard from './pages/dashboard/mainpage';
|
||||
import Loginpage from './pages/login';
|
||||
import Registerpage from './pages/register';
|
||||
import Tariffspage from './pages/tariffs';
|
||||
import TariffsPage from './pages/tariffs';
|
||||
import Aboutpage from './pages/about';
|
||||
import Privateroute from './components/privateroute';
|
||||
import { AuthProvider } from './context/authcontext';
|
||||
@@ -17,7 +17,7 @@ function App() {
|
||||
<Routes>
|
||||
{/* Обычные страницы с footer */}
|
||||
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
|
||||
<Route path="/tariffs" element={<Pagetempl><Tariffspage /></Pagetempl>} />
|
||||
<Route path="/tariffs" element={<Pagetempl><TariffsPage /></Pagetempl>} />
|
||||
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
|
||||
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
|
||||
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import useAuth from '../context/useAuth';
|
||||
|
||||
@@ -8,7 +8,8 @@ interface PrivateRouteProps {
|
||||
|
||||
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
return isLoggedIn ? children : <Navigate to="/login" replace />;
|
||||
const location = useLocation();
|
||||
return isLoggedIn ? children : <Navigate to="/login" replace state={{ from: location }} />;
|
||||
};
|
||||
|
||||
export default PrivateRoute;
|
||||
@@ -2,8 +2,12 @@
|
||||
import { createContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { UserData } from '../pages/dashboard/types';
|
||||
|
||||
interface AuthContextType {
|
||||
isLoggedIn: boolean;
|
||||
userData: UserData | null;
|
||||
setUserData: (data: UserData | null) => void;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
@@ -11,6 +15,8 @@ interface AuthContextType {
|
||||
// Создаем контекст с начальными значениями
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isLoggedIn: false,
|
||||
userData: null,
|
||||
setUserData: () => {},
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
@@ -22,11 +28,12 @@ interface AuthProviderProps {
|
||||
// Создаем провайдер, который будет управлять состоянием
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
|
||||
// Проверяем статус входа при загрузке приложения
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
setIsLoggedIn(!!token);
|
||||
// Можно добавить загрузку userData при наличии токена
|
||||
}, []);
|
||||
|
||||
const login = (token: string) => {
|
||||
@@ -37,10 +44,11 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
setIsLoggedIn(false);
|
||||
setUserData(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isLoggedIn, login, logout }}>
|
||||
<AuthContext.Provider value={{ isLoggedIn, userData, setUserData, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import App from './app.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
// 3. Исправляем frontend/src/pages/dashboard/billing.tsx
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
|
||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
|
||||
|
||||
|
||||
const Billing = () => {
|
||||
const [amount, setAmount] = useState(0);
|
||||
const [isPaymentGenerated, setIsPaymentGenerated] = useState(false);
|
||||
const [copyStatus, setCopyStatus] = useState('');
|
||||
|
||||
// ИСПРАВЛЕНО: используем правильные переменные окружения для Vite
|
||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER || '';
|
||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL || '';
|
||||
const [checkFile, setCheckFile] = useState<File | null>(null);
|
||||
const [checkStatus, setCheckStatus] = useState('');
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
|
||||
const handleGeneratePayment = () => {
|
||||
if (amount <= 0) {
|
||||
alert('Пожалуйста, введите сумму больше нуля.');
|
||||
return;
|
||||
}
|
||||
if (!cardNumber || !sbpUrl) {
|
||||
alert('Данные для оплаты не настроены. Пожалуйста, обратитесь к администратору.');
|
||||
return;
|
||||
}
|
||||
setIsPaymentGenerated(true);
|
||||
if (amount > 0) setIsPaymentGenerated(true);
|
||||
};
|
||||
|
||||
const handleCopyCard = () => {
|
||||
if (cardNumber) {
|
||||
navigator.clipboard.writeText(cardNumber);
|
||||
setCopyStatus('Номер карты скопирован!');
|
||||
setCopyStatus('Скопировано!');
|
||||
setTimeout(() => setCopyStatus(''), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckUpload = async () => {
|
||||
if (!checkFile || amount <= 0) return;
|
||||
setUploadLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', checkFile);
|
||||
formData.append('amount', String(amount));
|
||||
const response = await axios.post('http://localhost:5000/api/check/upload', formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// 'Content-Type' не указываем вручную для FormData!
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
setCheckStatus('Чек успешно загружен! Ожидайте проверки.');
|
||||
setCheckFile(null);
|
||||
console.log('Чек успешно загружен:', response.data);
|
||||
} catch (error) {
|
||||
setCheckStatus('Ошибка загрузки чека.');
|
||||
console.error('Ошибка загрузки чека:', error);
|
||||
}
|
||||
setUploadLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-2xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Пополнение баланса</h2>
|
||||
|
||||
{/* Только QR-код и карта, без реквизитов */}
|
||||
{!isPaymentGenerated ? (
|
||||
<div>
|
||||
<p className="text-lg text-gray-500 mb-4">
|
||||
@@ -61,52 +82,53 @@ const Billing = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
Для пополнения баланса переведите <strong>₽{amount}</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Ваш заказ будет обработан вручную после проверки чека.
|
||||
</p>
|
||||
|
||||
{sbpUrl && (
|
||||
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCode value={sbpUrl} size={256} />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Отсканируйте QR-код через мобильное приложение вашего банка.
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
Для пополнения баланса переведите <strong>₽{amount}</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Ваш заказ будет обработан вручную после проверки чека.
|
||||
</p>
|
||||
</div>
|
||||
{/* QR-код для оплаты по СБП */}
|
||||
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE-QR-LINK'} size={256} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardNumber && (
|
||||
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по номеру карты</h3>
|
||||
<p className="text-2xl font-bold text-gray-800 select-all">{cardNumber}</p>
|
||||
<button
|
||||
onClick={handleCopyCard}
|
||||
className="mt-4 px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-gray-700"
|
||||
>
|
||||
Скопировать номер карты
|
||||
</button>
|
||||
{copyStatus && <p className="mt-2 text-sm text-green-500">{copyStatus}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Отсканируйте QR-код через мобильное приложение вашего банка.
|
||||
</p>
|
||||
</div>
|
||||
{/* Номер карты с кнопкой копирования */}
|
||||
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по номеру карты</h3>
|
||||
<p className="text-2xl font-bold text-gray-800 select-all">{cardNumber || '0000 0000 0000 0000'}</p>
|
||||
<button
|
||||
onClick={handleCopyCard}
|
||||
className="mt-4 px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-gray-700"
|
||||
>
|
||||
Скопировать номер карты
|
||||
</button>
|
||||
{copyStatus && <p className="mt-2 text-sm text-green-500">{copyStatus}</p>}
|
||||
</div>
|
||||
{/* Форма загрузки чека и инструкции */}
|
||||
<div className="bg-blue-50 p-6 rounded-2xl border-l-4 border-blue-500 text-left mb-6">
|
||||
<p className="font-bold text-blue-800">Загрузите чек для проверки:</p>
|
||||
<input type="file" accept="image/*,application/pdf" onChange={e => setCheckFile(e.target.files?.[0] || null)} className="mt-2" />
|
||||
<button onClick={handleCheckUpload} disabled={!checkFile || uploadLoading} className="mt-2 bg-blue-500 text-white px-4 py-2 rounded">
|
||||
{uploadLoading ? 'Загрузка...' : 'Отправить чек'}
|
||||
</button>
|
||||
{checkStatus && <div className="mt-2 text-green-600">{checkStatus}</div>}
|
||||
</div>
|
||||
<div className="bg-red-50 p-6 rounded-2xl border-l-4 border-red-500 text-left mb-6">
|
||||
<p className="font-bold text-red-800">Важно:</p>
|
||||
<p className="text-sm text-red-700">
|
||||
После оплаты сделайте скриншот или сохраните чек и отправьте его нам в тикет поддержки.
|
||||
После оплаты сделайте скриншот или сохраните чек и загрузите его для проверки.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-gray-600">
|
||||
После подтверждения ваш баланс будет пополнен. Перейдите в раздел{' '}
|
||||
<Link to="/dashboard/tickets" className="text-ospab-primary font-bold hover:underline">
|
||||
Тикеты
|
||||
</Link>
|
||||
, чтобы отправить нам чек.
|
||||
После подтверждения ваш баланс будет пополнен. Ожидайте проверки чека оператором.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
122
ospabhost/frontend/src/pages/dashboard/checkout.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Tariff {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface OperatingSystem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
interface CheckoutProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const Checkout: React.FC<CheckoutProps> = ({ onSuccess }) => {
|
||||
const [tariffs, setTariffs] = useState<Tariff[]>([]);
|
||||
const [oses, setOses] = useState<OperatingSystem[]>([]);
|
||||
const [selectedTariff, setSelectedTariff] = useState<number | null>(null);
|
||||
const [selectedOs, setSelectedOs] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Загрузка тарифов и ОС
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [tariffRes, osRes] = await Promise.all([
|
||||
axios.get('http://localhost:5000/api/tariff'),
|
||||
axios.get('http://localhost:5000/api/os'),
|
||||
]);
|
||||
setTariffs(tariffRes.data);
|
||||
setOses(osRes.data);
|
||||
// Автовыбор тарифа из query
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tariffId = params.get('tariff');
|
||||
if (tariffId) {
|
||||
setSelectedTariff(Number(tariffId));
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка загрузки тарифов или ОС');
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [location.search]);
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!selectedTariff || !selectedOs) {
|
||||
setError('Выберите тариф и ОС');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
console.log('Покупка сервера:', { tariffId: selectedTariff, osId: selectedOs });
|
||||
const res = await axios.post('http://localhost:5000/api/server/create', {
|
||||
tariffId: selectedTariff,
|
||||
osId: selectedOs,
|
||||
});
|
||||
console.log('Ответ сервера:', res.data);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Ошибка покупки сервера:', err);
|
||||
setError('Ошибка покупки сервера');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-4">Покупка сервера</h2>
|
||||
{error && <p className="text-red-500 mb-2">{error}</p>}
|
||||
<div className="mb-4">
|
||||
<label className="block font-semibold mb-2">Тариф:</label>
|
||||
<select
|
||||
className="w-full border rounded px-3 py-2"
|
||||
value={selectedTariff ?? ''}
|
||||
onChange={e => setSelectedTariff(Number(e.target.value))}
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map(t => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} — ₽{t.price}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block font-semibold mb-2">Операционная система:</label>
|
||||
<select
|
||||
className="w-full border rounded px-3 py-2"
|
||||
value={selectedOs ?? ''}
|
||||
onChange={e => setSelectedOs(Number(e.target.value))}
|
||||
>
|
||||
<option value="">Выберите ОС</option>
|
||||
{oses.map(os => (
|
||||
<option key={os.id} value={os.id}>
|
||||
{os.name} ({os.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="bg-ospab-primary text-white px-6 py-2 rounded font-bold w-full"
|
||||
onClick={handleBuy}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Покупка...' : 'Купить сервер'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkout;
|
||||
@@ -1,8 +1,140 @@
|
||||
const CheckVerification = () => {
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface IUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ICheck {
|
||||
id: number;
|
||||
userId: number;
|
||||
amount: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
fileUrl: string;
|
||||
createdAt: string;
|
||||
user?: IUser;
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:5000/api/check';
|
||||
|
||||
const CheckVerification: React.FC = () => {
|
||||
const [checks, setChecks] = useState<ICheck[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChecks = async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get<ICheck[]>(API_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
withCredentials: true,
|
||||
});
|
||||
setChecks(res.data);
|
||||
} catch {
|
||||
setError('Ошибка загрузки чеков');
|
||||
setChecks([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchChecks();
|
||||
}, []);
|
||||
|
||||
const handleAction = async (checkId: number, action: 'approve' | 'reject'): Promise<void> => {
|
||||
setActionLoading(checkId);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post(`${API_URL}/${action}`, { checkId }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
withCredentials: true,
|
||||
});
|
||||
setChecks((prevChecks: ICheck[]) => prevChecks.map((c: ICheck) => c.id === checkId ? { ...c, status: action === 'approve' ? 'approved' : 'rejected' } : c));
|
||||
// Если подтверждение — обновить баланс пользователя
|
||||
if (action === 'approve') {
|
||||
try {
|
||||
const userToken = localStorage.getItem('access_token') || token;
|
||||
const headers = { Authorization: `Bearer ${userToken}` };
|
||||
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
||||
// Глобально обновить userData через типизированное событие (для Dashboard)
|
||||
window.dispatchEvent(new CustomEvent<import('./types').UserData>('userDataUpdate', {
|
||||
detail: {
|
||||
user: userRes.data.user,
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
servers: userRes.data.user.servers ?? [],
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления userData:', error);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка действия');
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Проверка чеков</h2>
|
||||
<p className="text-lg text-gray-500">Здесь будут отображаться чеки для проверки.</p>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500">Загрузка чеков...</p>
|
||||
) : error ? (
|
||||
<p className="text-lg text-red-500">{error}</p>
|
||||
) : checks.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">Нет чеков для проверки.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{checks.map((check: ICheck) => (
|
||||
<div key={check.id} className="border rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between bg-gray-50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Пользователь:</span> <span className="text-gray-700">{check.user?.username || check.user?.email}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Сумма:</span> <span className="text-gray-700">₽{check.amount}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Статус:</span> <span className={`font-bold ${check.status === 'pending' ? 'text-yellow-600' : check.status === 'approved' ? 'text-green-600' : 'text-red-600'}`}>{check.status === 'pending' ? 'На проверке' : check.status === 'approved' ? 'Подтверждён' : 'Отклонён'}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Дата:</span> <span className="text-gray-700">{new Date(check.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 md:ml-8">
|
||||
<a href={`http://localhost:5000${check.fileUrl}`} target="_blank" rel="noopener noreferrer" className="block mb-2">
|
||||
<img src={`http://localhost:5000${check.fileUrl}`} alt="Чек" className="w-32 h-32 object-contain rounded-xl border" />
|
||||
</a>
|
||||
{check.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleAction(check.id, 'approve')}
|
||||
disabled={actionLoading === check.id}
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-full font-bold mb-2"
|
||||
>
|
||||
{actionLoading === check.id ? 'Подтверждение...' : 'Подтвердить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(check.id, 'reject')}
|
||||
disabled={actionLoading === check.id}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-bold"
|
||||
>
|
||||
{actionLoading === check.id ? 'Отклонение...' : 'Отклонить'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,16 +7,18 @@ import { useContext } from 'react';
|
||||
|
||||
// Импортируем компоненты для вкладок
|
||||
import Summary from './summary';
|
||||
import Servers from './servers';
|
||||
import Tickets from './tickets';
|
||||
import ServerManagementPage from './servermanagement';
|
||||
import TicketsPage from './tickets';
|
||||
import Billing from './billing';
|
||||
import Settings from './settings';
|
||||
import CheckVerification from './checkverification';
|
||||
import TicketResponse from './ticketresponse';
|
||||
import Checkout from './checkout';
|
||||
import TariffsPage from '../tariffs';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [userData, setUserData] = useState<import('./types').UserData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { logout } = useContext(AuthContext);
|
||||
@@ -44,15 +46,13 @@ const Dashboard = () => {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
||||
|
||||
setUserData({
|
||||
user: userRes.data.user,
|
||||
balance: 1500,
|
||||
servers: [],
|
||||
tickets: [],
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
servers: userRes.data.user.servers ?? [],
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки данных:', err);
|
||||
@@ -67,35 +67,48 @@ const Dashboard = () => {
|
||||
fetchData();
|
||||
}, [logout, navigate]);
|
||||
|
||||
// Функция для обновления userData из API
|
||||
const updateUserData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
||||
setUserData({
|
||||
user: userRes.data.user,
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
servers: userRes.data.user.servers ?? [],
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления userData:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleUserDataUpdate = () => {
|
||||
try {
|
||||
updateUserData();
|
||||
} catch (err) {
|
||||
console.error('Ошибка в обработчике userDataUpdate:', err);
|
||||
}
|
||||
};
|
||||
window.addEventListener('userDataUpdate', handleUserDataUpdate);
|
||||
return () => {
|
||||
window.removeEventListener('userDataUpdate', handleUserDataUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isOperator = userData?.user?.operator === 1;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary mx-auto mb-4"></div>
|
||||
<h1 className="text-2xl text-gray-800">Загрузка...</h1>
|
||||
</div>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<span className="text-gray-500 text-lg">Загрузка...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userData || !userData.user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl text-gray-800 mb-4">Ошибка загрузки данных</h1>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg"
|
||||
>
|
||||
Перезагрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOperator = userData.user.operator === 1;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
{/* Sidebar - фиксированный слева */}
|
||||
@@ -103,7 +116,7 @@ const Dashboard = () => {
|
||||
{/* Заголовок сайдбара */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
Привет, {userData.user.username}!
|
||||
Привет, {userData?.user?.username || 'Гость'}!
|
||||
</h2>
|
||||
{isOperator && (
|
||||
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full mt-1">
|
||||
@@ -111,7 +124,7 @@ const Dashboard = () => {
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
Баланс: <span className="font-semibold text-ospab-primary">₽{userData.balance}</span>
|
||||
Баланс: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +137,6 @@ const Dashboard = () => {
|
||||
activeTab === 'summary' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">📊</span>
|
||||
Сводка
|
||||
</Link>
|
||||
<Link
|
||||
@@ -133,7 +145,6 @@ const Dashboard = () => {
|
||||
activeTab === 'servers' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">🖥️</span>
|
||||
Серверы
|
||||
</Link>
|
||||
<Link
|
||||
@@ -142,7 +153,6 @@ const Dashboard = () => {
|
||||
activeTab === 'tickets' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">🎫</span>
|
||||
Тикеты
|
||||
</Link>
|
||||
<Link
|
||||
@@ -151,7 +161,6 @@ const Dashboard = () => {
|
||||
activeTab === 'billing' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">💳</span>
|
||||
Пополнить баланс
|
||||
</Link>
|
||||
<Link
|
||||
@@ -160,7 +169,6 @@ const Dashboard = () => {
|
||||
activeTab === 'settings' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">⚙️</span>
|
||||
Настройки
|
||||
</Link>
|
||||
</div>
|
||||
@@ -177,7 +185,6 @@ const Dashboard = () => {
|
||||
activeTab === 'checkverification' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">✅</span>
|
||||
Проверка чеков
|
||||
</Link>
|
||||
<Link
|
||||
@@ -186,7 +193,6 @@ const Dashboard = () => {
|
||||
activeTab === 'ticketresponse' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">💬</span>
|
||||
Ответы на тикеты
|
||||
</Link>
|
||||
</div>
|
||||
@@ -207,7 +213,7 @@ const Dashboard = () => {
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Хлебные крошки/заголовок */}
|
||||
<div className="bg-white border-b border-gray-200 px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 capitalize">
|
||||
{activeTab === 'summary' ? 'Сводка' :
|
||||
@@ -228,32 +234,22 @@ const Dashboard = () => {
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Быстрые действия */}
|
||||
<div className="flex space-x-3">
|
||||
<Link
|
||||
to="/dashboard/billing"
|
||||
className="px-4 py-2 bg-green-100 text-green-800 rounded-lg text-sm font-medium hover:bg-green-200 transition-colors"
|
||||
>
|
||||
💰 Пополнить
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/tickets"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
🆘 Поддержка
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент страницы */}
|
||||
<div className="flex-1 p-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Summary userData={userData} />} />
|
||||
<Route path="servers" element={<Servers servers={userData.servers} />} />
|
||||
<Route path="tickets" element={<Tickets tickets={userData.tickets} />} />
|
||||
<Route path="billing" element={<Billing />} />
|
||||
<Route path="/" element={<Summary userData={userData ?? { user: { username: '', operator: 0 }, balance: 0, servers: [], tickets: [] }} />} />
|
||||
<Route path="servers" element={<ServerManagementPage />} />
|
||||
<Route path="checkout" element={<Checkout onSuccess={() => window.location.reload()} />} />
|
||||
<Route path="tariffs" element={<TariffsPage />} />
|
||||
{userData && (
|
||||
<Route path="tickets" element={<TicketsPage setUserData={setUserData} />} />
|
||||
)}
|
||||
{userData && (
|
||||
<Route path="billing" element={<Billing />} />
|
||||
)}
|
||||
<Route path="settings" element={<Settings />} />
|
||||
|
||||
{isOperator && (
|
||||
|
||||
96
ospabhost/frontend/src/pages/dashboard/servermanagement.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuth from '../../context/useAuth';
|
||||
|
||||
interface Server {
|
||||
id: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tariff: { name: string; price: number };
|
||||
os: { name: string; type: string };
|
||||
}
|
||||
|
||||
const ServerManagementPage = () => {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const { isLoggedIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await axios.get('http://localhost:5000/api/server', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setServers(res.data);
|
||||
} catch {
|
||||
setError('Ошибка загрузки серверов');
|
||||
setServers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchServers();
|
||||
}, [isLoggedIn, navigate]);
|
||||
|
||||
// TODO: добавить управление сервером (включить, выключить, перезагрузить, переустановить ОС)
|
||||
|
||||
try {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-4xl font-bold text-center mb-10 text-gray-900">Управление серверами</h1>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500 text-center">Загрузка...</p>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||
<p className="text-lg text-red-500">{error}</p>
|
||||
<button className="mt-4 px-4 py-2 rounded bg-ospab-primary text-white font-bold" onClick={() => window.location.reload()}>Перезагрузить</button>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<p className="text-lg text-gray-500 text-center">У вас нет серверов.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{servers.map(server => (
|
||||
<div key={server.id} className="bg-white p-8 rounded-2xl shadow-xl flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">{server.tariff.name}</h2>
|
||||
<p className="text-lg text-gray-600">ОС: {server.os.name} ({server.os.type})</p>
|
||||
<p className="text-lg text-gray-600">Статус: <span className="font-bold">{server.status}</span></p>
|
||||
<p className="text-sm text-gray-400">Создан: {new Date(server.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
{/* TODO: Кнопки управления сервером */}
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button className="px-4 py-2 rounded bg-ospab-primary text-white font-bold hover:bg-ospab-accent">Включить</button>
|
||||
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Выключить</button>
|
||||
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Перезагрузить</button>
|
||||
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Переустановить ОС</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Ошибка отображения страницы</h2>
|
||||
<p className="text-gray-700 mb-4">Произошла критическая ошибка. Попробуйте перезагрузить страницу.</p>
|
||||
<button className="px-4 py-2 rounded bg-ospab-primary text-white font-bold" onClick={() => window.location.reload()}>Перезагрузить</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ServerManagementPage;
|
||||
@@ -1,17 +1,70 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ServersProps {
|
||||
servers: unknown[];
|
||||
interface Server {
|
||||
id: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
os: { name: string; type: string };
|
||||
tariff: { name: string; price: number };
|
||||
}
|
||||
|
||||
const Servers: React.FC<ServersProps> = ({ servers }) => {
|
||||
const Servers: React.FC = () => {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const res = await axios.get('http://localhost:5000/api/server');
|
||||
console.log('Ответ API серверов:', res.data);
|
||||
// Защита от получения HTML вместо JSON
|
||||
if (typeof res.data === 'string' && res.data.startsWith('<!doctype html')) {
|
||||
setError('Ошибка соединения с backend: получен HTML вместо JSON. Проверьте адрес и порт.');
|
||||
setServers([]);
|
||||
} else if (Array.isArray(res.data)) {
|
||||
setServers(res.data);
|
||||
} else {
|
||||
setError('Некорректный формат данных серверов');
|
||||
setServers([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки серверов:', err);
|
||||
setError('Ошибка загрузки серверов');
|
||||
setServers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchServers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Серверы</h2>
|
||||
{servers.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">У вас пока нет активных серверов.</p>
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold text-gray-800">Серверы</h2>
|
||||
{/* Кнопка 'Купить сервер' только если серверов нет */}
|
||||
{servers.length === 0 && !loading && !error && (
|
||||
<a href="/tariffs" className="bg-ospab-primary text-white px-4 py-2 rounded font-bold hover:bg-ospab-primary-dark transition">Купить сервер</a>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500">Загрузка...</p>
|
||||
) : error ? (
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-red-500 mb-4">{error}</p>
|
||||
<button className="bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition" onClick={() => window.location.reload()}>Перезагрузить страницу</button>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-gray-500 mb-4">У вас пока нет активных серверов.</p>
|
||||
<a href="/tariffs" className="inline-block bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition">Посмотреть тарифы</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-lg text-gray-500">Список ваших серверов будет здесь...</p>
|
||||
<div className="text-center">
|
||||
<a href="/dashboard/servermanagement" className="bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition">Перейти к управлению серверами</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { UserData } from './types';
|
||||
import type { UserData, Ticket, Server } from './types';
|
||||
|
||||
interface SummaryProps {
|
||||
userData: UserData;
|
||||
}
|
||||
|
||||
const Summary = ({ userData }: SummaryProps) => {
|
||||
// Фильтрация открытых тикетов и активных серверов
|
||||
const openTickets = Array.isArray(userData.tickets)
|
||||
? userData.tickets.filter((t: Ticket) => t.status !== 'closed')
|
||||
: [];
|
||||
const activeServers = Array.isArray(userData.servers)
|
||||
? userData.servers.filter((s: Server) => s.status === 'active')
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Сводка по аккаунту</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
||||
<p className="text-xl font-medium text-gray-700">Баланс:</p>
|
||||
<p className="text-4xl font-extrabold text-ospab-primary mt-2">₽ {userData.balance.toFixed(2)}</p>
|
||||
<p className="text-4xl font-extrabold text-ospab-primary mt-2">₽ {userData.balance?.toFixed ? userData.balance.toFixed(2) : Number(userData.balance).toFixed(2)}</p>
|
||||
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">Пополнить баланс →</Link>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
||||
<p className="text-xl font-medium text-gray-700">Активные серверы:</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{userData.servers.length}</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{activeServers.length}</p>
|
||||
<Link to="/dashboard/servers" className="text-sm text-gray-500 hover:underline mt-2">Управлять →</Link>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
||||
<p className="text-xl font-medium text-gray-700">Открытые тикеты:</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{userData.tickets.length}</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{openTickets.length}</p>
|
||||
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">Служба поддержки →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,155 @@
|
||||
const TicketResponse = () => {
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Response {
|
||||
id: number;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
operator?: { username: string };
|
||||
}
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
responses: Response[];
|
||||
user?: { username: string };
|
||||
}
|
||||
|
||||
const TicketResponse: React.FC = () => {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [responseMsg, setResponseMsg] = useState<{ [key: number]: string }>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get('http://localhost:5000/api/ticket', {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
setTickets(res.data);
|
||||
} else {
|
||||
setTickets([]);
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка загрузки тикетов');
|
||||
setTickets([]);
|
||||
}
|
||||
};
|
||||
|
||||
const respondTicket = async (ticketId: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/respond', {
|
||||
ticketId,
|
||||
message: responseMsg[ticketId]
|
||||
}, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
setResponseMsg(prev => ({ ...prev, [ticketId]: '' }));
|
||||
fetchTickets();
|
||||
} catch {
|
||||
setError('Ошибка отправки ответа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция закрытия тикета
|
||||
const closeTicket = async (ticketId: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
fetchTickets();
|
||||
} catch {
|
||||
setError('Ошибка закрытия тикета');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Ответы на тикеты</h2>
|
||||
<p className="text-lg text-gray-500">Здесь будут отображаться тикеты для ответов.</p>
|
||||
{error && <div className="text-red-500 mb-4">{error}</div>}
|
||||
{tickets.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">Нет тикетов для ответа.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{tickets.map(ticket => (
|
||||
<div key={ticket.id} className="border rounded-xl p-4 shadow flex flex-col">
|
||||
<div className="font-bold text-lg mb-1">{ticket.title}</div>
|
||||
<div className="text-gray-600 mb-2">{ticket.message}</div>
|
||||
<div className="text-sm text-gray-400 mb-2">Статус: {ticket.status} | Автор: {ticket.user?.username} | {new Date(ticket.createdAt).toLocaleString()}</div>
|
||||
{/* Чат сообщений */}
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="bg-blue-100 text-blue-900 px-3 py-2 rounded-xl max-w-xl">
|
||||
<span className="font-semibold">{ticket.user?.username || 'Клиент'}:</span> {ticket.message}
|
||||
</div>
|
||||
</div>
|
||||
{(ticket.responses || []).map(r => (
|
||||
<div key={r.id} className="flex items-start gap-2">
|
||||
<div className="bg-green-100 text-green-900 px-3 py-2 rounded-xl max-w-xl ml-8">
|
||||
<span className="font-semibold">{r.operator?.username || 'Оператор'}:</span> {r.message}
|
||||
<span className="text-gray-400 ml-2 text-xs">{new Date(r.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Форма ответа и кнопка закрытия */}
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 mt-2">
|
||||
<input
|
||||
value={responseMsg[ticket.id] || ''}
|
||||
onChange={e => setResponseMsg(prev => ({ ...prev, [ticket.id]: e.target.value }))}
|
||||
placeholder="Ваш ответ..."
|
||||
className="border rounded p-2 flex-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respondTicket(ticket.id)}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition"
|
||||
disabled={loading || !(responseMsg[ticket.id] && responseMsg[ticket.id].trim())}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Ответить'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeTicket(ticket.id)}
|
||||
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Закрыть тикет
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ticket.status === 'closed' && (
|
||||
<div className="text-red-600 font-bold mt-2">Тикет закрыт</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,220 @@
|
||||
import React from 'react';
|
||||
import type { UserData, Ticket } from './types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useAuth from '../../context/useAuth';
|
||||
import axios from 'axios';
|
||||
|
||||
interface TicketsProps {
|
||||
tickets: unknown[];
|
||||
}
|
||||
// Глобальный логгер ошибок для axios
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response) {
|
||||
console.error('Ошибка ответа:', error.response.data);
|
||||
} else if (error.request) {
|
||||
console.error('Нет ответа от сервера:', error.request);
|
||||
} else {
|
||||
console.error('Ошибка запроса:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
type TicketsPageProps = {
|
||||
setUserData: (data: UserData) => void;
|
||||
};
|
||||
|
||||
const TicketsPage: React.FC<TicketsPageProps> = ({ setUserData }) => {
|
||||
const { user } = useAuth() as { user?: { username: string; operator?: number } };
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formSuccess, setFormSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [responseMsg, setResponseMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get('http://localhost:5000/api/ticket', {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
setTickets(res.data);
|
||||
} else {
|
||||
setTickets([]);
|
||||
}
|
||||
} catch {
|
||||
setTickets([]);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token') || localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
||||
setUserData({
|
||||
user: userRes.data.user,
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
servers: userRes.data.user.servers ?? [],
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления userData после тикета:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const createTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
if (!title.trim() || !message.trim()) {
|
||||
setFormError('Заполните тему и сообщение');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/create', { title, message }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
setTitle('');
|
||||
setMessage('');
|
||||
setFormSuccess('Тикет успешно создан!');
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
} catch {
|
||||
setFormError('Ошибка создания тикета');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const respondTicket = async (ticketId: number) => {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/respond', { ticketId, message: responseMsg }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
setResponseMsg('');
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
};
|
||||
|
||||
const closeTicket = async (ticketId: number) => {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
};
|
||||
|
||||
const Tickets: React.FC<TicketsProps> = ({ tickets }) => {
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Тикеты поддержки</h2>
|
||||
{tickets.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">У вас пока нет открытых тикетов.</p>
|
||||
) : (
|
||||
<p className="text-lg text-gray-500">Список ваших тикетов будет здесь...</p>
|
||||
)}
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Мои тикеты</h2>
|
||||
<form onSubmit={createTicket} className="mb-8 max-w-xl bg-gray-50 rounded-2xl shadow p-6 flex flex-col gap-4">
|
||||
<label className="font-semibold text-lg">Тема тикета</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Введите тему..."
|
||||
className="border rounded-xl p-3 focus:outline-blue-400 text-base"
|
||||
/>
|
||||
<label className="font-semibold text-lg">Сообщение</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="Опишите проблему или вопрос..."
|
||||
className="border rounded-xl p-3 min-h-[80px] resize-y focus:outline-blue-400 text-base"
|
||||
/>
|
||||
{formError && <div className="text-red-500 text-sm">{formError}</div>}
|
||||
{formSuccess && <div className="text-green-600 text-sm">{formSuccess}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className={`bg-blue-500 text-white px-6 py-3 rounded-xl hover:bg-blue-600 transition text-lg font-semibold ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Создать тикет'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="space-y-8">
|
||||
{tickets.map(ticket => (
|
||||
<div key={ticket.id} className="border rounded-2xl p-6 shadow flex flex-col">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-2">
|
||||
<div className="font-bold text-xl text-blue-900">{ticket.title}</div>
|
||||
<div className="text-sm text-gray-500">Статус: <span className={ticket.status === 'closed' ? 'text-red-600 font-bold' : 'text-green-600 font-bold'}>{ticket.status === 'closed' ? 'Закрыт' : 'Открыт'}</span></div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mb-2">Автор: {ticket.user?.username} | {new Date(ticket.createdAt).toLocaleString()}</div>
|
||||
{/* Чат сообщений */}
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="bg-blue-100 text-blue-900 px-3 py-2 rounded-xl max-w-xl">
|
||||
<span className="font-semibold">{ticket.user?.username || 'Клиент'}:</span> {ticket.message}
|
||||
</div>
|
||||
</div>
|
||||
{((Array.isArray(ticket.responses) ? ticket.responses : []) as {
|
||||
id: number;
|
||||
operator?: { username?: string };
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}[]).map((r) => (
|
||||
<div key={r.id} className="flex items-start gap-2">
|
||||
<div className="bg-green-100 text-green-900 px-3 py-2 rounded-xl max-w-xl ml-8">
|
||||
<span className="font-semibold">{r.operator?.username || 'Оператор'}:</span> {r.message}
|
||||
<span className="text-gray-400 ml-2 text-xs">{new Date(r.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Форма ответа и кнопка закрытия */}
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 mt-2">
|
||||
{user?.operator === 1 && (
|
||||
<>
|
||||
<input
|
||||
value={responseMsg}
|
||||
onChange={e => setResponseMsg(e.target.value)}
|
||||
placeholder="Ваш ответ..."
|
||||
className="border rounded-xl p-2 flex-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respondTicket(ticket.id)}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded-xl hover:bg-green-600 transition"
|
||||
disabled={loading || !(responseMsg && responseMsg.trim())}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Ответить'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeTicket(ticket.id)}
|
||||
className="bg-red-500 text-white px-4 py-2 rounded-xl hover:bg-red-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Закрыть тикет
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ticket.status === 'closed' && (
|
||||
<div className="text-red-600 font-bold mt-2">Тикет закрыт</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tickets;
|
||||
export default TicketsPage;
|
||||
|
||||
@@ -3,9 +3,26 @@ export interface User {
|
||||
operator: number;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
responses: unknown[];
|
||||
user?: { username: string };
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
// можно добавить другие поля по необходимости
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
user: User;
|
||||
balance: number;
|
||||
servers: unknown[];
|
||||
tickets: unknown[];
|
||||
servers: Server[];
|
||||
tickets: Ticket[];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import useAuth from '../context/useAuth';
|
||||
|
||||
@@ -9,6 +9,7 @@ const LoginPage = () => {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
@@ -22,9 +23,13 @@ const LoginPage = () => {
|
||||
password: password,
|
||||
});
|
||||
|
||||
login(response.data.token);
|
||||
// ИСПРАВЛЕНО: правильный путь к дашборду
|
||||
navigate('/dashboard');
|
||||
localStorage.setItem('token', response.data.token);
|
||||
login(response.data.token);
|
||||
// Возврат на исходную страницу, если был редирект
|
||||
type LocationState = { from?: { pathname?: string } };
|
||||
const state = location.state as LocationState | null;
|
||||
const from = state?.from?.pathname || '/dashboard';
|
||||
navigate(from);
|
||||
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
|
||||
@@ -1,130 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AuthContext from '../context/authcontext';
|
||||
|
||||
const TariffsPage = () => {
|
||||
const [cpu, setCpu] = useState(1);
|
||||
const [ram, setRam] = useState(1);
|
||||
const [storage, setStorage] = useState(50);
|
||||
|
||||
const cpuPrice = 500;
|
||||
const ramPrice = 300;
|
||||
const storagePrice = 5;
|
||||
const [tariffs, setTariffs] = useState<Array<{id:number;name:string;price:number;description?:string}>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { isLoggedIn } = useContext(AuthContext);
|
||||
|
||||
const total = cpu * cpuPrice + ram * ramPrice + storage * storagePrice;
|
||||
useEffect(() => {
|
||||
const fetchTariffs = async () => {
|
||||
try {
|
||||
const res = await axios.get('http://localhost:5000/api/tariff');
|
||||
console.log('Ответ API тарифов:', res.data);
|
||||
if (Array.isArray(res.data)) {
|
||||
setTariffs(res.data);
|
||||
} else {
|
||||
setError('Некорректный формат данных тарифов');
|
||||
setTariffs([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки тарифов:', err);
|
||||
setError('Ошибка загрузки тарифов');
|
||||
setTariffs([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchTariffs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-center mb-16 text-gray-900">Выберите подходящий тариф</h1>
|
||||
|
||||
{/* Basic Tariffs Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
|
||||
{/* Tariff Card 1 */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl text-center transition-transform hover:scale-105 duration-300">
|
||||
<h2 className="text-3xl font-bold text-gray-800">Базовый</h2>
|
||||
<p className="mt-4 text-4xl font-extrabold text-ospab-primary">₽1500<span className="text-lg font-normal text-gray-500">/мес</span></p>
|
||||
<ul className="mt-4 text-gray-600 space-y-2">
|
||||
<li>1 ядро CPU</li>
|
||||
<li>2 ГБ RAM</li>
|
||||
<li>100 ГБ SSD</li>
|
||||
<li>Неограниченный трафик</li>
|
||||
</ul>
|
||||
<button className="mt-8 px-6 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
|
||||
Выбрать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tariff Card 2 */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl text-center border-4 border-ospab-primary transition-transform hover:scale-105 duration-300">
|
||||
<h2 className="text-3xl font-bold text-gray-800">Профессиональный</h2>
|
||||
<p className="mt-4 text-4xl font-extrabold text-ospab-primary">₽4000<span className="text-lg font-normal text-gray-500">/мес</span></p>
|
||||
<ul className="mt-4 text-gray-600 space-y-2">
|
||||
<li>4 ядра CPU</li>
|
||||
<li>8 ГБ RAM</li>
|
||||
<li>250 ГБ SSD</li>
|
||||
<li>Приоритетная поддержка</li>
|
||||
</ul>
|
||||
<button className="mt-8 px-6 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
|
||||
Выбрать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tariff Card 3 */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl text-center transition-transform hover:scale-105 duration-300">
|
||||
<h2 className="text-3xl font-bold text-gray-800">Бизнес</h2>
|
||||
<p className="mt-4 text-4xl font-extrabold text-ospab-primary">₽8000<span className="text-lg font-normal text-gray-500">/мес</span></p>
|
||||
<ul className="mt-4 text-gray-600 space-y-2">
|
||||
<li>8 ядер CPU</li>
|
||||
<li>16 ГБ RAM</li>
|
||||
<li>500 ГБ SSD</li>
|
||||
<li>24/7 Мониторинг</li>
|
||||
</ul>
|
||||
<button className="mt-8 px-6 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
|
||||
Выбрать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const handleBuy = (tariffId: number) => {
|
||||
if (!isLoggedIn) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
navigate(`/dashboard/checkout?tariff=${tariffId}`);
|
||||
};
|
||||
|
||||
{/* Server Constructor Section */}
|
||||
<div className="bg-white p-10 rounded-3xl shadow-2xl max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center mb-8 text-gray-900">Соберите свой сервер</h2>
|
||||
<div className="space-y-6">
|
||||
{/* CPU Slider */}
|
||||
<div>
|
||||
<label htmlFor="cpu" className="block text-lg font-medium text-gray-700">Ядра CPU: {cpu}</label>
|
||||
<input
|
||||
type="range"
|
||||
id="cpu"
|
||||
min="1"
|
||||
max="16"
|
||||
value={cpu}
|
||||
onChange={(e) => setCpu(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">Цена: ₽{cpu * cpuPrice}</p>
|
||||
</div>
|
||||
|
||||
{/* RAM Slider */}
|
||||
<div>
|
||||
<label htmlFor="ram" className="block text-lg font-medium text-gray-700">Оперативная память (ГБ): {ram}</label>
|
||||
<input
|
||||
type="range"
|
||||
id="ram"
|
||||
min="1"
|
||||
max="32"
|
||||
value={ram}
|
||||
onChange={(e) => setRam(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">Цена: ₽{ram * ramPrice}</p>
|
||||
</div>
|
||||
|
||||
{/* Storage Slider */}
|
||||
<div>
|
||||
<label htmlFor="storage" className="block text-lg font-medium text-gray-700">Диск (ГБ): {storage}</label>
|
||||
<input
|
||||
type="range"
|
||||
id="storage"
|
||||
min="50"
|
||||
max="2000"
|
||||
step="50"
|
||||
value={storage}
|
||||
onChange={(e) => setStorage(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">Цена: ₽{storage * storagePrice}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-2xl font-bold text-gray-800">Итого: ₽{total}<span className="text-lg font-normal text-gray-500">/мес</span></p>
|
||||
<button className="mt-4 px-8 py-4 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
|
||||
Собрать сервер
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-center mb-16 text-gray-900">Тарифы</h1>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500 text-center">Загрузка...</p>
|
||||
) : error ? (
|
||||
<p className="text-lg text-red-500 text-center">{error}</p>
|
||||
) : tariffs.length === 0 ? (
|
||||
<p className="text-lg text-gray-500 text-center">Нет доступных тарифов.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
|
||||
{tariffs.map(tariff => (
|
||||
<div key={tariff.id} className="bg-white p-12 rounded-3xl shadow-2xl text-left flex flex-col justify-between transition-transform hover:scale-105 duration-300 min-h-[340px]">
|
||||
<div>
|
||||
<h2 className="text-4xl font-bold text-gray-800 mb-4">{tariff.name}</h2>
|
||||
<p className="mb-4 text-5xl font-extrabold text-ospab-primary">₽{tariff.price}<span className="text-xl font-normal text-gray-500">/мес</span></p>
|
||||
{tariff.description && (
|
||||
<ul className="text-lg text-gray-700 mb-6 list-disc list-inside">
|
||||
{tariff.description.split(',').map((desc, i) => (
|
||||
<li key={i}>{desc.trim()}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="mt-4 px-8 py-4 rounded-full text-white font-bold text-xl transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent"
|
||||
onClick={() => handleBuy(tariff.id)}
|
||||
>
|
||||
Купить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TariffsPage;
|
||||
export default TariffsPage;
|
||||
|
||||
|
||||
|
||||